C# makes it easy to create large graphs of objects connected by their fields. The larger this graph grows, the more complex it is to deal with objects in the graph. It’s hard to look at code or set a breakpoint in a debugger and get an intuitive sense of all these connections. So today we’ll write a small tool to visualize an object graph!

Planning

The goal here is to visualize a graph of managed objects such as string and List<T> that are connected by their fields. Properties, indexers, and events don’t count, as these are just functions that happen to be typically “backed” by fields. Access specifiers should be ignored too since there’s always at least an indirect way to have them influence the behavior of the code.

This tool executes at runtime on a particular object. This is different than other tools such as Visual Studio’s code maps which display the relationship between types and assemblies while the app isn’t running. Tools like this display the potential connections between objects while today’s tool displays the actual connections such as by omitting connections to null objects. Both tools are useful, but for different purposes.

Now that we’ve defined the input—an object—to the visualizer, let’s discuss the output. Unity has many ways to display graphics, but we don’t want to interfere with a running game so we won’t render anything to the screen. We could render to an editor window, but that would limit usage to just running in the editor. The amount of code required to render the graph is also non-trivial and we’d like to get a visualization up and running quickly.

So we’ll opt to render the object graph to the DOT language. DOT is designed to describe a graph and is consumed by programs like Graphviz that render the graph. This choice allows us to simply describe the graph and let Graphviz do all the work to render it. It’s a free, open source program available on all major operating systems so it should be easy to integrate into an analysis workflow.

Implementation

C# and .NET feature a powerful, easy-to-use reflection system that works well with IL2CPP. This allows us to inspect the types of variables at runtime and, crucially, enumerate their fields regardless of access specifier. We can use this to create the nodes of the graph consisting of an ID, the object’s type, and its links to other nodes. The links are a Dictionary<string, int> mapping the field name to the linked node’s ID.

There are two tricky parts to the implementation. First is the handling of arrays. Since these don’t have fields, they don’t fall into the general case of enumerating fields. Instead, we must enumerate their elements. This is trivial for single-dimensional arrays, but more complex for arrays with arbitrary dimensions. See LinkArray below for details. The second tricky part is handling inheritance. Type.GetFields only returns the fields of the type in question, so we must traverse up the hierarchy of base types until reaching the root: object. See EnumerateInstanceFieldInfos for more.

The visualizer exists in a single static class with a nested Node class. This is all in one file that’s 400 lines long and largely made up of thorough xml-doc to help understand, maintain, and improve it. Here’s the code:

using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
 
/// <summary>
/// Utility functions to visualize a graph of <see cref="object"/>
/// </summary>
/// 
/// <author>
/// Jackson Dunstan, https://JacksonDunstan.com/articles/5034
/// </author>
/// 
/// <license>
/// MIT
/// </license>
public static class ObjectGraphVisualizer
{
    /// <summary>
    /// A node of the graph
    /// </summary>
    private sealed class Node
    {
        /// <summary>
        /// Type of object the node represents
        /// </summary>
        public readonly string TypeName;
 
        /// <summary>
        /// Links from the node to other nodes. Keys are field names. Values are
        /// node IDs.
        /// </summary>
        public readonly Dictionary<string, int> Links;
 
        /// <summary>
        /// ID of the node. Unique to its graph.
        /// </summary>
        public readonly int Id;
 
        /// <summary>
        /// Create a node
        /// </summary>
        /// 
        /// <param name="typeName">
        /// Type of object the node represents
        /// </param>
        /// 
        /// <param name="id">
        /// ID of the node. Must be unique to its graph.
        /// </param>
        public Node(string typeName, int id)
        {
            TypeName = typeName;
            Links = new Dictionary<string, int>(16);
            Id = id;
        }
    }
 
    /// <summary>
    /// Add a node to a graph to represent an object
    /// </summary>
    /// 
    /// <returns>
    /// The added node or the existing node if one already exists for the object
    /// </returns>
    /// 
    /// <param name="nodes">
    /// Graph to add to
    /// </param>
    /// 
    /// <param name="obj">
    /// Object to add a node for
    /// </param>
    /// 
    /// <param name="tempBuilder">
    /// String builder to use only temporarily
    /// </param>
    /// 
    /// <param name="nextNodeId">
    /// ID to assign to the next node. Incremented after assignment.
    /// </param>
    private static Node AddObject(
        Dictionary<object, Node> nodes,
        object obj,
        StringBuilder tempBuilder,
        ref int nextNodeId)
    {
        // Check if there is already a node for the object
        Node node;
        if (nodes.TryGetValue(obj, out node))
        {
            return node;
        }
 
        // Add a node for the object
        Type objType = obj.GetType();
        node = new Node(objType.Name, nextNodeId);
        nextNodeId++;
        nodes.Add(obj, node);
 
        // Add linked nodes for all fields
        foreach (FieldInfo fieldInfo in EnumerateInstanceFieldInfos(objType))
        {
            // Only add reference types
            Type fieldType = fieldInfo.FieldType;
            if (!fieldType.IsPointer && !IsUnmanagedType(fieldType))
            {
                object field = fieldInfo.GetValue(obj);
                if (fieldType.IsArray)
                {
                    LinkArray(
                        nodes,
                        node,
                        (Array)field,
                        fieldInfo.Name,
                        tempBuilder,
                        ref nextNodeId);
                }
                else
                {
                    LinkNode(
                        nodes,
                        node,
                        field,
                        fieldInfo.Name,
                        tempBuilder,
                        ref nextNodeId);
                }
            }
        }
        return node;
    }
 
    /// <summary>
    /// Add new linked nodes for the elements of an array
    /// </summary>
    /// 
    /// <param name="nodes">
    /// Graph to add to
    /// </param>
    /// 
    /// <param name="node">
    /// Node to link from
    /// </param>
    /// 
    /// <param name="array">
    /// Array whose elements should be linked
    /// </param>
    /// 
    /// <param name="arrayName">
    /// Name of the array field
    /// </param>
    /// 
    /// <param name="tempBuilder">
    /// String builder to use only temporarily
    /// </param>
    /// 
    /// <param name="nextNodeId">
    /// ID to assign to the next node. Incremented after assignment.
    /// </param>
    private static void LinkArray(
        Dictionary<object, Node> nodes,
        Node node,
        Array array,
        string arrayName,
        StringBuilder tempBuilder,
        ref int nextNodeId)
    {
        // Don't link null arrays
        if (ReferenceEquals(array, null))
        {
            return;
        }
 
        // Create an array of lengths of each rank
        int rank = array.Rank;
        int[] lengths = new int[rank];
        for (int i = 0; i < lengths.Length; ++i)
        {
            lengths[i] = array.GetLength(i);
        }
 
        // Create an array of indices into each rank
        int[] indices = new int[rank];
        indices[rank - 1] = -1;
 
        // Iterate over all elements of all ranks
        while (true)
        {
            // Increment the indices
            for (int i = rank - 1; i >= 0; --i)
            {
                indices[i]++;
 
                // No overflow, so we can link
                if (indices[i] < lengths[i])
                {
                    goto link;
                }
 
                // Overflow, so carry.
                indices[i] = 0;
            }
            break;
 
        link:
            // Build the field name: "name[1, 2, 3]"
            tempBuilder.Length = 0;
            tempBuilder.Append(arrayName);
            tempBuilder.Append('[');
            for (int i = 0; i < indices.Length; ++i)
            {
                tempBuilder.Append(indices[i]);
                if (i != indices.Length - 1)
                {
                    tempBuilder.Append(", ");
                }
            }
            tempBuilder.Append(']');
 
            // Link the element as a node
            object element = array.GetValue(indices);
            string elementName = tempBuilder.ToString();
            LinkNode(
                nodes,
                node,
                element,
                elementName,
                tempBuilder,
                ref nextNodeId);
        }
    }
 
    /// <summary>
    /// Add a new linked node
    /// </summary>
    /// 
    /// <param name="nodes">
    /// Graph to add to
    /// </param>
    /// 
    /// <param name="node">
    /// Node to link from
    /// </param>
    /// 
    /// <param name="obj">
    /// Object to link a node for
    /// </param>
    /// 
    /// <param name="name">
    /// Name of the object
    /// </param>
    /// 
    /// <param name="tempBuilder">
    /// String builder to use only temporarily
    /// </param>
    /// 
    /// <param name="nextNodeId">
    /// ID to assign to the next node. Incremented after assignment.
    /// </param>
    private static void LinkNode(
        Dictionary<object, Node> nodes,
        Node node,
        object obj,
        string name,
        StringBuilder tempBuilder,
        ref int nextNodeId)
    {
        // Don't link null objects
        if (ReferenceEquals(obj, null))
        {
            return;
        }
 
        // Add a node for the object
        Node linkedNode = AddObject(nodes, obj, tempBuilder, ref nextNodeId);
        node.Links[name] = linkedNode.Id;
    }
 
    /// <summary>
    /// Check if a type is unmanaged, i.e. isn't and contains no managed types
    /// at any level of nesting.
    /// </summary>
    /// 
    /// <returns>
    /// Whether the given type is unmanaged or not
    /// </returns>
    /// 
    /// <param name="type">
    /// Type to check
    /// </param>
    private static bool IsUnmanagedType(Type type)
    {
        if (!type.IsValueType)
        {
            return false;
        }
        if (type.IsPrimitive || type.IsEnum)
        {
            return true;
        }
        foreach (FieldInfo field in EnumerateInstanceFieldInfos(type))
        {
            if (!IsUnmanagedType(field.FieldType))
            {
                return false;
            }
        }
        return true;
    }
 
    /// <summary>
    /// Enumerate the instance fields of a type and all its base types
    /// </summary>
    /// 
    /// <returns>
    /// The fields of the given type and all its base types
    /// </returns>
    /// 
    /// <param name="type">
    /// Type to enumerate
    /// </param>
    private static IEnumerable<FieldInfo> EnumerateInstanceFieldInfos(Type type)
    {
        const BindingFlags bindingFlags =
            BindingFlags.Instance
            | BindingFlags.NonPublic
            | BindingFlags.Public;
        while (type != null)
        {
            foreach (FieldInfo fieldInfo in type.GetFields(bindingFlags))
            {
                yield return fieldInfo;
            }
            type = type.BaseType;
        }
    }
 
    /// <summary>
    /// Visualize the given object by generating DOT which can be rendered with
    /// GraphViz.
    /// </summary>
    /// 
    /// <example>
    /// // 0) Install Graphviz if not already installed
    /// 
    /// // 1) Generate a DOT file for an object
    /// File.WriteAllText("object.dot", ObjectGraphVisualizer.Visualize(obj));
    /// 
    /// // 2) Generate a graph for the object
    /// dot -Tpng object.dot -o object.png
    /// 
    /// // 3) View the graph by opening object.png
    /// </example>
    /// 
    /// <returns>
    /// DOT, which can be rendered with GraphViz
    /// </returns>
    /// 
    /// <param name="obj">
    /// Object to visualize
    /// </param>
    public static string Visualize(object obj)
    {
        // Build the graph
        Dictionary<object, Node> nodes = new Dictionary<object, Node>(1024);
        int nextNodeId = 1;
        StringBuilder output = new StringBuilder(1024 * 64);
        AddObject(nodes, obj, output, ref nextNodeId);
 
        // Write the header
        output.Length = 0;
        output.Append("digraph\n");
        output.Append("{\n");
 
        // Write the mappings from ID to label
        foreach (Node node in nodes.Values)
        {
            output.Append("    ");
            output.Append(node.Id);
            output.Append(" [ label=\"");
            output.Append(node.TypeName);
            output.Append("\" ];\n");
        }
 
        // Write the node connections
        foreach (Node node in nodes.Values)
        {
            foreach (KeyValuePair<string, int> pair in node.Links)
            {
                output.Append("    ");
                output.Append(node.Id);
                output.Append(" -> ");
                output.Append(pair.Value);
                output.Append(" [ label=\"");
                output.Append(pair.Key);
                output.Append("\" ];\n");
            }
        }
 
        // Write the footer
        output.Append("}\n");
 
        return output.ToString();
    }
}
Usage

To use the visualizer, follow these steps:

  1. Install Graphviz if it’s not already installed
  2. Paste the above code into a new C# file in a Unity project. It’s not an editor script, so don’t put it in an Editor directory. It works in older versions of Unity such as 2017.4.
  3. Call File.WriteAllText("object.dot", ObjectGraphVisualizer.Visualize(obj)); at some point during your game to visualize obj to the file object.dot
  4. Open a terminal and run dot -Tpng object.dot -o object.png to render the DOT file to a PNG image
  5. View the graph by opening object.png in an image viewer

Because the output of ObjectGraphVisualizer.Visualize is just a string, it's very flexible and easy for more advanced usage. Here are some examples of other ways to use it:

  • Call EditorGUIUtility.systemCopyBuffer = ObjectGraphVisualizer.Visualize(obj); to copy the DOT to the clipboard
  • Send the string over a network and have the receiver save it to a file
  • Log the string with Debug.Log or to an analytics system like Splunk
  • Display the string in a runtime debugging GUI or editor window
  • Run the visualizer outside of Unity. It's pure C# code with no dependency on Unity.
Example

To try out the visualizer, let's look at a little script with some complex types. These are designed to test various different cases such as the handling of arrays, generics, primitives, enums, pointers, managed and unmanaged structs, cycles, delegates, dynamic variables, private fields, and base types.

using System;
using UnityEditor;
using UnityEngine;
 
class BaseClass
{
    public string BaseField;
}
 
unsafe class TestClass : BaseClass
{
    public string Str;
    private readonly OtherClass Other;
    public OtherClass[] Others;
    public OtherClass[,] OthersMulti;
    public GenericClass<TestClass> Generic;
    public dynamic Dynamic;
    public int Primitive;
    public TestStructUnmanaged StructUnmanaged;
    public TestEnum Enum;
    public TestStruct? Nullable;
    public TestStruct? NullableIsNull;
    public TestStructUnmanaged? NullableUnmanaged;
    public TestStructUnmanaged* Pointer;
    public Action SingleDelegate;
    public Action MultiDelegate;
    public Action<TestClass> GenericDelegate;
    public TestClass Self;
 
    public TestClass(OtherClass other)
    {
        Other = other;
    }
}
 
class OtherClass
{
    public TestClass Test1;
    public TestClass Test2;
    public TestStruct Struct;
}
 
class GenericClass<T>
{
    public T Value;
}
 
struct TestStruct
{
    public TestClass Test;
}
 
struct TestStructUnmanaged
{
    public int Primitive;
}
 
enum TestEnum
{
    A,
    B,
    C
}
 
public class TestScript : MonoBehaviour
{
    unsafe void Start()
    {
        OtherClass otherClass = new OtherClass();
        TestClass testClass = new TestClass(otherClass);
        testClass.BaseField = "hello";
        testClass.Str = "world";
        testClass.Others = new OtherClass[] { otherClass, null, otherClass };
        testClass.OthersMulti = new OtherClass[,] {
            { otherClass, null, otherClass },
            { null, otherClass, null }};
        GenericClass<TestClass> genericClass = new GenericClass<TestClass>();
        genericClass.Value = testClass;
        testClass.Generic = genericClass;
        testClass.Dynamic = otherClass;
        testClass.Primitive = 123;
        TestStructUnmanaged testStructUnmanaged = new TestStructUnmanaged();
        testStructUnmanaged.Primitive = 456;
        testClass.StructUnmanaged = testStructUnmanaged;
        testClass.Enum = TestEnum.B;
        testClass.Pointer = &testStructUnmanaged;
        otherClass.Test1 = null;
        otherClass.Test2 = testClass;
        TestStruct testStruct = new TestStruct();
        testStruct.Test = testClass;
        otherClass.Struct = testStruct;
        testClass.Nullable = testStruct;
        testClass.NullableIsNull = default;
        testClass.NullableUnmanaged = testStructUnmanaged;
        testClass.SingleDelegate = () => print(testClass);
        testClass.MultiDelegate = () => print(testClass);
        testClass.MultiDelegate += () => print(testStruct);
        testClass.GenericDelegate = x => print(testStruct);
        testClass.Self = testClass;
 
        string report = ObjectGraphVisualizer.Visualize(testClass);
        print(report);
        EditorGUIUtility.systemCopyBuffer = report;
        EditorApplication.isPlaying = false;
    }
}

To run this, just paste it into a new C# file in a Unity project and attach it to a game object in the scene. It'll run at startup and then exit play mode with the DOT being written to the log and copied to the clipboard. Here's the resulting DOT:

digraph
{
    1 [ label="TestClass" ];
    2 [ label="String" ];
    3 [ label="OtherClass" ];
    4 [ label="TestStruct" ];
    5 [ label="GenericClass`1" ];
    6 [ label="Action" ];
    7 [ label="<>c__DisplayClass0_0" ];
    8 [ label="MonoMethod" ];
    9 [ label="RuntimeType" ];
    10 [ label="Action" ];
    11 [ label="Action" ];
    12 [ label="MonoMethod" ];
    13 [ label="Action" ];
    14 [ label="MonoMethod" ];
    15 [ label="Action`1" ];
    16 [ label="MonoMethod" ];
    17 [ label="String" ];
    1 -> 2 [ label="Str" ];
    1 -> 3 [ label="Other" ];
    1 -> 3 [ label="Others[0]" ];
    1 -> 3 [ label="Others[2]" ];
    1 -> 3 [ label="OthersMulti[0, 0]" ];
    1 -> 3 [ label="OthersMulti[0, 2]" ];
    1 -> 3 [ label="OthersMulti[1, 1]" ];
    1 -> 5 [ label="Generic" ];
    1 -> 3 [ label="Dynamic" ];
    1 -> 4 [ label="Nullable" ];
    1 -> 6 [ label="SingleDelegate" ];
    1 -> 10 [ label="MultiDelegate" ];
    1 -> 15 [ label="GenericDelegate" ];
    1 -> 1 [ label="Self" ];
    1 -> 17 [ label="BaseField" ];
    3 -> 1 [ label="Test2" ];
    3 -> 4 [ label="Struct" ];
    4 -> 1 [ label="Test" ];
    5 -> 1 [ label="Value" ];
    6 -> 7 [ label="m_target" ];
    6 -> 8 [ label="method_info" ];
    7 -> 1 [ label="testClass" ];
    7 -> 4 [ label="testStruct" ];
    8 -> 9 [ label="reftype" ];
    10 -> 11 [ label="delegates[0]" ];
    10 -> 13 [ label="delegates[1]" ];
    11 -> 7 [ label="m_target" ];
    11 -> 12 [ label="method_info" ];
    12 -> 9 [ label="reftype" ];
    13 -> 7 [ label="m_target" ];
    13 -> 14 [ label="method_info" ];
    14 -> 9 [ label="reftype" ];
    15 -> 7 [ label="m_target" ];
    15 -> 16 [ label="method_info" ];
    16 -> 9 [ label="reftype" ];
}

After running dot, we see the following graph: (click to view full size)

Object Graph Visualizer Rendering

The object being visualized is at the top with type TestClass. Its connections are directly typed into the class, so it's obvious what fields and array elements are connecting. The second-level connections, however, start to show more complex underpinnings. In this example, we see the internals of the Action delegate type: its array of delegates, its "targets" pointing to compiler-generated types like <>c__DisplayClass0_0, its method_info references to a MonoMethod, and then the MonoMethod references to a RuntimeType. We get a lot of transparency into an otherwise opaque system!

Conclusion

This object graph visualizer can be very useful in analyzing the complexity and performance of runtime data. A large graph indicates increased complexity and traversing its nodes indicates a higher likelihood of CPU cache misses due to non-determinate memory layout. It's good to pair this tool with others such as Visual Studio's "code maps" feature that analyze offline at the type level rather than at runtime at the object level. Hopefully you'll find it useful!