Asset type and serializer for Prism shader graph files (.prism). Declares a GameResource that stores each top-level Prism document section as JsonNode properties so the asset inspector can load and save the file verbatim, and provides normalization and skeleton-writing logic to ensure safe round-trip serialization and canonical ordering.
using System;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace Sandbox.Prism;
/// <summary>
/// Registers the <c>.prism</c> extension with the asset system.
/// <para>
/// A <c>.prism</c> file is a schema-v1 Prism document authored by the Prism editor, not a property
/// bag, and nothing loads it at runtime — the generated <c>.shader</c> beside it is what the game
/// consumes. The type exists so the asset browser knows the extension: it supplies the icon, the
/// colour strip, the <i>New ▸ Shader</i> entry and the double-click route into <c>IAssetEditor</c>.
/// </para>
/// <para>
/// It must live in the <b>game</b> assembly. <c>ResourceLibrary.TryGetType</c> resolves asset types
/// through <c>Game.TypeLibrary</c>, which only contains game assemblies, so an <c>[AssetType]</c>
/// declared in <c>Editor/</c> registers the extension but then fails every load and save silently.
/// </para>
/// <para>
/// <b>Why the section properties below exist.</b> <c>GameResource.Serialize()</c> is what
/// <c>Asset.SaveToDisk( GameResource )</c> writes over the source file, and it is reachable from the
/// asset inspector's Save button, its <c>CTRL+S</c> shortcut, its discard prompt and the editor's
/// quit dialog. A resource that serializes to less than it loaded therefore destroys the user's
/// document in place, with no undo. Each top-level section of the document is captured verbatim as a
/// <see cref="JsonNode"/> so that load → save is byte-stable and a save from outside Prism is a
/// no-op rather than a truncation.
/// </para>
/// <para>
/// The sections are round-tripped rather than parsed: this assembly cannot reference the editor's
/// serializer, so it deliberately knows only the <i>names</i> of the sections and never their shape.
/// They are non-public with <c>[Property]</c> because s&box's <c>Json.SerializeAsObject</c> and
/// <c>Json.DeserializeToObject</c> are hand-written property walkers that honour <c>[Property]</c>
/// and <c>[JsonPropertyName]</c> — note that <c>[JsonExtensionData]</c> would <i>not</i> work here,
/// as neither walker implements it — while the asset inspector's own property filter skips anything
/// non-public, so none of this shows up as an editable field.
/// </para>
/// </summary>
[AssetType( Name = "Prism Shader Graph", Extension = "prism", Category = "Shader",
Flags = AssetTypeFlags.NoEmbedding, IconColor = "#7C5CFF" )]
public sealed class PrismGraphFile : GameResource
{
/// <summary>Document schema version.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeySchema )]
internal JsonNode SchemaSection { get; set; }
/// <summary>Stable document id.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyId )]
internal JsonNode IdSection { get; set; }
/// <summary>Document kind — <c>shader</c> or <c>subgraph</c>.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyKind )]
internal JsonNode KindSection { get; set; }
/// <summary>Authoring metadata.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyMeta )]
internal JsonNode MetaSection { get; set; }
/// <summary>Graph settings: domain, blend mode, shading model and the rest.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeySettings )]
internal JsonNode SettingsSection { get; set; }
/// <summary>Preview state.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyPreview )]
internal JsonNode PreviewSection { get; set; }
/// <summary>Blackboard parameters.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyParameters )]
internal JsonNode ParametersSection { get; set; }
/// <summary>Blackboard keywords.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyKeywords )]
internal JsonNode KeywordsSection { get; set; }
/// <summary>Every node in the graph.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyNodes )]
internal JsonNode NodesSection { get; set; }
/// <summary>Every connection.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyEdges )]
internal JsonNode EdgesSection { get; set; }
/// <summary>Node groups.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyGroups )]
internal JsonNode GroupsSection { get; set; }
/// <summary>Sticky notes.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyNotes )]
internal JsonNode NotesSection { get; set; }
/// <summary>Saved camera position and zoom.</summary>
[Property, Hide, JsonPropertyName( PrismDocumentSkeleton.KeyView )]
internal JsonNode ViewSection { get; set; }
/// <summary>
/// Normalise the serialized form: drop the empty sections, and fall back to an empty document only
/// when there is genuinely nothing to write.
/// <para>
/// The engine's generic <i>New ▸ Shader ▸ Prism Shader Graph</i> entry creates the file through
/// <c>AssetSystem.CreateResource</c>, which serializes a brand-new instance — every section null.
/// That is the one case where a skeleton is correct. Every other call is a re-save of a document
/// that was loaded from disk, and must give back exactly what it was given.
/// </para>
/// </summary>
protected override void OnJsonSerialize( JsonObject node )
{
PrismDocumentSkeleton.Normalize( node, false );
}
/// <summary>Asset-browser icon: the Prism violet with a gradient glyph.</summary>
protected override Bitmap CreateAssetTypeIcon( int width, int height )
{
return CreateSimpleAssetTypeIcon( "gradient", width, height, "#7C5CFF", "#F2EEFF" );
}
}
/// <summary>
/// The smallest document that reads back as a usable graph, and the canonical section order.
/// <para>
/// Only the keys that cannot be defaulted are written: schema, id, kind and one output node. Every
/// other section is filled in by the reader from its defaults, and the editor rewrites the file in
/// full canonical form the first time it is saved. Keeping this minimal is deliberate — the game
/// assembly cannot reference the serializer, so anything written here is duplicated knowledge.
/// </para>
/// </summary>
internal static class PrismDocumentSkeleton
{
/// <summary>Mirror of <c>PrismConstants.DocumentSchemaVersion</c>, which lives in the editor assembly.</summary>
public const int SchemaVersion = 1;
/// <summary>Mirror of <c>PrismConstants.EditorVersion</c>.</summary>
public const string EditorVersion = "0.1.0";
/// <summary>Stable type id of the surface output node.</summary>
public const string SurfaceOutputTypeId = "prism.output.surface";
/// <summary>Stable type id of the subgraph input node.</summary>
public const string SubgraphInputTypeId = "prism.subgraph.input";
/// <summary>Stable type id of the subgraph output node.</summary>
public const string SubgraphOutputTypeId = "prism.subgraph.output";
// The top-level section names, mirroring the editor serializer's own key constants. These are the
// wire format and are frozen; the reader tolerates any of them being absent.
/// <summary>Schema version key.</summary>
public const string KeySchema = "schema";
/// <summary>Document id key.</summary>
public const string KeyId = "id";
/// <summary>Document kind key.</summary>
public const string KeyKind = "kind";
/// <summary>Metadata key.</summary>
public const string KeyMeta = "meta";
/// <summary>Settings key.</summary>
public const string KeySettings = "settings";
/// <summary>Preview-state key.</summary>
public const string KeyPreview = "preview";
/// <summary>Parameters key.</summary>
public const string KeyParameters = "parameters";
/// <summary>Keywords key.</summary>
public const string KeyKeywords = "keywords";
/// <summary>Nodes key.</summary>
public const string KeyNodes = "nodes";
/// <summary>Edges key.</summary>
public const string KeyEdges = "edges";
/// <summary>Groups key.</summary>
public const string KeyGroups = "groups";
/// <summary>Notes key.</summary>
public const string KeyNotes = "notes";
/// <summary>View key.</summary>
public const string KeyView = "view";
/// <summary>Every top-level section, in the order the editor writes them.</summary>
public static readonly string[] Sections =
{
KeySchema, KeyId, KeyKind, KeyMeta, KeySettings, KeyPreview,
KeyParameters, KeyKeywords, KeyNodes, KeyEdges, KeyGroups, KeyNotes, KeyView
};
/// <summary>
/// Make a serialized document safe to write over its own source file.
/// <para>
/// Sections that round-tripped as null are removed — the property walker emits one per declared
/// section whether or not the document had it, and a file full of <c>null</c>s is noise the reader
/// would have to step over. If nothing survives that has a node in it, the document is empty and
/// gets a skeleton; otherwise it is returned exactly as it came in, reordered canonically so the
/// same document always serializes to the same bytes.
/// </para>
/// </summary>
public static void Normalize( JsonObject node, bool subgraph )
{
if ( node is null ) return;
var ordered = new JsonObject();
foreach ( var key in Sections )
{
if ( !node.TryGetPropertyValue( key, out var value ) ) continue;
if ( value is null ) continue;
node.Remove( key );
ordered[key] = value;
}
// Anything left is not ours — a base-class field or a stray key. Keep it rather than drop it:
// losing a key we do not understand is the exact failure this method exists to prevent.
foreach ( var extra in System.Linq.Enumerable.ToArray( node ) )
{
if ( extra.Value is null ) continue;
node.Remove( extra.Key );
ordered[extra.Key] = extra.Value;
}
node.Clear();
if ( !HasContent( ordered ) )
{
Write( node, subgraph );
return;
}
foreach ( var entry in System.Linq.Enumerable.ToArray( ordered ) )
{
ordered.Remove( entry.Key );
node[entry.Key] = entry.Value;
}
}
/// <summary>True when the document carries at least one node, i.e. it is a real graph.</summary>
static bool HasContent( JsonObject node )
{
return node[KeyNodes] is JsonArray nodes && nodes.Count > 0;
}
/// <summary>Overwrite <paramref name="node"/> with an empty shader graph or subgraph document.</summary>
public static void Write( JsonObject node, bool subgraph )
{
if ( node is null ) return;
node.Clear();
node[KeySchema] = SchemaVersion;
node[KeyId] = NewDocumentId();
node[KeyKind] = subgraph ? "subgraph" : "shader";
node[KeyMeta] = new JsonObject
{
["editorVersion"] = EditorVersion
};
node[KeySettings] = new JsonObject
{
["domain"] = subgraph ? "Subgraph" : "Surface"
};
var nodes = new JsonArray();
if ( subgraph )
{
nodes.Add( NodeEntry( "sgin0001", SubgraphInputTypeId, -320f, 0f ) );
nodes.Add( NodeEntry( "sgout001", SubgraphOutputTypeId, 320f, 0f ) );
}
else
{
nodes.Add( NodeEntry( "out00001", SurfaceOutputTypeId, 0f, 0f ) );
}
node[KeyNodes] = nodes;
}
/// <summary>One entry of the <c>nodes</c> array.</summary>
static JsonObject NodeEntry( string id, string typeId, float x, float y )
{
return new JsonObject
{
["id"] = id,
["type"] = typeId,
["v"] = 1,
["pos"] = new JsonArray { x, y }
};
}
/// <summary>
/// Eight lowercase base36 characters, matching <c>Ids.NewShortId</c>. Guid hex digits are a
/// subset of base36, so this stays well-formed without duplicating the generator.
/// </summary>
static string NewDocumentId()
{
return Guid.NewGuid().ToString( "N" ).Substring( 0, 8 );
}
}