Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Materials authoring commands (CLI-213): read and write a material's shader, shader properties
/// (floats, colors, vectors, textures, ints), keywords, and render queue.
///
/// These sit on the CLI-190 authoring foundation:
/// - the material is addressed with an <see cref="ObjectRef"/> resolved by <see cref="ObjectResolver"/>
/// and confined to the authoring root (a GUID/globalId handle can't edit a material outside the sandbox),
/// - texture property values are <see cref="ObjectRef"/>s likewise confined to the root,
/// - writes are wrapped in an <see cref="AuthoringUndoScope"/> with <see cref="Undo.RecordObject"/>
/// (material property changes ARE covered by Undo.RecordObject, unlike AssetDatabase ops), then
/// <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssetIfDirty"/> persist them,
/// - unknown property names and type mismatches are reported in <c>unknown[]</c> rather than failing
/// the whole call (as long as at least one input applied).
///
/// Material CREATION already exists in <c>create_asset</c> (CLI-221); this command set is the
/// read/write surface on top of an existing material.
/// </summary>
public static class MaterialCommands
{
[CliCommand("get_material_properties",
"Read a material's shader, render queue, enabled keywords, and all shader properties with their current values (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as an object reference).")]
public static MaterialPropertiesResult GetMaterialProperties(
[CliArg("material", "Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId).", Required = true)] ObjectRef material)
{
var mat = ResolveMaterial(material, out var assetPath);
var result = new MaterialPropertiesResult
{
AssetPath = assetPath,
Shader = mat.shader != null ? mat.shader.name : null,
// rawRenderQueue is -1 when the material inherits from the shader and a positive
// integer when explicitly overridden — returning the raw value preserves the
// round-trip contract with set_material_properties renderQueue:-1 (inherit).
RenderQueue = GetRawRenderQueue(mat),
EnabledKeywords = GetEnabledKeywords(mat),
};
var shader = mat.shader;
if (shader != null)
{
// Enumerate the shader's declared properties via the Shader instance APIs (single index
// space, matching get_shader_properties), so name/type/range-limits always line up.
int count = shader.GetPropertyCount();
for (int i = 0; i < count; i++)
{
var name = shader.GetPropertyName(i);
var propType = shader.GetPropertyType(i);
var entry = new MaterialPropertyValue
{
Name = name,
Type = PublicValueType(propType),
Value = MaterialValueConverter.ReadValue(mat, name, propType),
};
if (propType == ShaderPropertyType.Range)
{
var limits = shader.GetPropertyRangeLimits(i);
entry.Range = new MaterialRange
{
Min = limits.x,
Max = limits.y,
};
}
result.Properties.Add(entry);
}
}
return result;
}
[CliCommand("set_material_properties",
"Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or \"#RRGGBBAA\" hex; Vector=[x,y,z,w]; Texture=an object reference or null to clear), optionally reassign the shader, set the render queue, and toggle keywords. Unknown names / type mismatches are reported in unknown[].")]
public static SetMaterialPropertiesResult SetMaterialProperties(
[CliArg("material", "Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId).", Required = true)] ObjectRef material,
[CliArg("shader", "Reassign the material's shader by name (e.g. \"Standard\", \"Universal Render Pipeline/Lit\", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader.")] string shader = null,
[CliArg("properties", "JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null.")] JObject properties = null,
[CliArg("renderQueue", "Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged.")] int? renderQueue = null,
[CliArg("enableKeywords", "Shader keywords to enable (e.g. _NORMALMAP, _EMISSION).")] string[] enableKeywords = null,
[CliArg("disableKeywords", "Shader keywords to disable.")] string[] disableKeywords = null,
[CliArg("confirm", "Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required.")] bool confirm = false,
[CliArg("dry_run", "If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything.")] bool dryRun = false)
{
var mat = ResolveMaterial(material, out var assetPath);
// Resolve the (optional) replacement shader UP FRONT so dry_run validates it too, and a
// shader_not_found writes nothing (acceptance #9). Shader.Find only finds compiled/included
// shaders — surface that in the hint.
Shader newShader = null;
if (shader != null)
{
newShader = Shader.Find(shader);
if (newShader == null)
throw new ShaderNotFoundException(
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
}
var result = new SetMaterialPropertiesResult();
// ---- dry_run: validate against the would-be shader, never mutate ----------------------
if (dryRun)
{
var effectiveShader = newShader ?? mat.shader;
ValidateProperties(effectiveShader, properties, result);
if (renderQueue.HasValue)
result.Applied.Add("renderQueue");
AddKeywordTokens(result.Applied, enableKeywords, disableKeywords);
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
CopyIdentity(mat, assetPath, result);
result.Shader = effectiveShader != null ? effectiveShader.name : null;
return result;
}
// ---- apply --------------------------------------------------------------------------
using (new AuthoringUndoScope("Set Material Properties"))
{
// Material property/shader/keyword/renderQueue changes are all covered by Undo.RecordObject.
Undo.RecordObject(mat, "Set Material Properties");
if (newShader != null)
mat.shader = newShader;
if (properties != null)
{
foreach (var pair in properties)
{
if (pair.Key == "token") // injected by the server; never a material property.
continue;
var name = pair.Key;
if (!mat.HasProperty(name))
{
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(mat.shader != null ? mat.shader.name : "<none>")}')");
continue;
}
var propType = GetPropertyType(mat.shader, name);
if (propType == null)
{
// HasProperty was true but the type couldn't be read off the shader (e.g. a
// built-in property like unity_Lightmaps not declared in the shader's list).
result.Unknown.Add($"{name}: property is not a settable shader property on '{(mat.shader != null ? mat.shader.name : "<none>")}'");
continue;
}
try
{
MaterialValueConverter.ApplyValue(mat, name, propType.Value, pair.Value);
result.Applied.Add(name);
}
catch (MaterialPropertyTypeMismatch mismatch)
{
result.Unknown.Add(mismatch.Message);
}
}
}
if (renderQueue.HasValue)
{
mat.renderQueue = renderQueue.Value;
result.Applied.Add("renderQueue");
}
ApplyKeywords(mat, enableKeywords, disableKeywords, result.Applied);
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
EditorUtility.SetDirty(mat);
if (!string.IsNullOrEmpty(assetPath))
AssetDatabase.SaveAssetIfDirty(mat);
}
CopyIdentity(mat, assetPath, result);
result.Shader = mat.shader != null ? mat.shader.name : null;
return result;
}
private static int GetRawRenderQueue(Material mat)
{
var serialized = new SerializedObject(mat);
var customRenderQueue = serialized.FindProperty("m_CustomRenderQueue");
return customRenderQueue != null ? customRenderQueue.intValue : mat.renderQueue;
}
/// <summary>
/// Resolve an <see cref="ObjectRef"/> to a <see cref="Material"/>, confining any on-disk asset to
/// the authoring root. Returns the project-relative asset path (null for a non-asset/in-memory
/// material). Throws a clear <see cref="ArgumentException"/> on miss / wrong type / out-of-root.
/// </summary>
internal static Material ResolveMaterial(ObjectRef material, out string assetPath)
{
if (material == null || material.IsEmpty)
throw new ArgumentException("'material' is required.");
if (!ObjectResolver.TryResolve(material, out var obj, out var error))
throw new ArgumentException($"Could not resolve material: {error}");
if (!(obj is Material mat))
throw new ArgumentException($"Reference '{material}' resolved to a {obj.GetType().Name}, not a Material.");
assetPath = AssetDatabase.GetAssetPath(mat);
if (!string.IsNullOrEmpty(assetPath))
{
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"Material '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
assetPath = confined;
}
return mat;
}
/// <summary>
/// Read the material's enabled keyword set. Uses <see cref="Material.enabledKeywords"/>
/// (LocalKeyword[]) which is the supported API on the package's min Unity version (Unity 6 /
/// 6000.0); the legacy <c>shaderKeywords</c> string[] is deprecated.
/// </summary>
private static List<string> GetEnabledKeywords(Material mat)
{
var keywords = new List<string>();
foreach (var keyword in mat.enabledKeywords)
keywords.Add(keyword.name);
keywords.Sort(StringComparer.Ordinal);
return keywords;
}
private static void ApplyKeywords(Material mat, string[] enable, string[] disable, List<string> applied)
{
// Use the LocalKeyword API (Unity 2022.1+) so that Enable/Disable and the
// enabledKeywords reader are talking to the same keyword table. The legacy
// string-based EnableKeyword(string) affects global keywords, whereas
// mat.enabledKeywords returns LocalKeyword[], so they would disagree on round-trip.
if (enable != null)
{
foreach (var keyword in enable)
{
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
continue;
mat.EnableKeyword(new LocalKeyword(mat.shader, keyword));
applied.Add($"+keyword:{keyword}");
}
}
if (disable != null)
{
foreach (var keyword in disable)
{
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
continue;
mat.DisableKeyword(new LocalKeyword(mat.shader, keyword));
applied.Add($"-keyword:{keyword}");
}
}
}
private static void AddKeywordTokens(List<string> applied, string[] enable, string[] disable)
{
if (enable != null)
foreach (var keyword in enable.Where(k => !string.IsNullOrWhiteSpace(k)))
applied.Add($"+keyword:{keyword}");
if (disable != null)
foreach (var keyword in disable.Where(k => !string.IsNullOrWhiteSpace(k)))
applied.Add($"-keyword:{keyword}");
}
/// <summary>
/// dry_run validation: classify each supplied property as applicable (name resolves AND value
/// shape matches the declared type) or unknown, WITHOUT mutating the material.
/// </summary>
private static void ValidateProperties(Shader shader, JObject properties, SetMaterialPropertiesResult result)
{
if (properties == null)
return;
foreach (var pair in properties)
{
if (pair.Key == "token")
continue;
var name = pair.Key;
var propType = shader != null ? GetPropertyType(shader, name) : null;
if (propType == null)
{
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(shader != null ? shader.name : "<none>")}')");
continue;
}
// Validate the value shape against the type by attempting a conversion on a throwaway
// material instance — never the caller's material (dry_run must leave no trace).
Material probe = null;
try
{
probe = new Material(shader);
MaterialValueConverter.ApplyValue(probe, name, propType.Value, pair.Value);
result.Applied.Add(name);
}
catch (MaterialPropertyTypeMismatch mismatch)
{
result.Unknown.Add(mismatch.Message);
}
finally
{
if (probe != null)
Object.DestroyImmediate(probe);
}
}
}
/// <summary>
/// Look up a property's <see cref="UnityEngine.Rendering.ShaderPropertyType"/> by name on a shader.
/// Returns null when the shader has no property with that exact name (case-sensitive; names include
/// the leading underscore).
/// </summary>
internal static ShaderPropertyType? GetPropertyType(Shader shader, string name)
{
if (shader == null)
return null;
int count = shader.GetPropertyCount();
for (int i = 0; i < count; i++)
{
if (shader.GetPropertyName(i) == name)
return shader.GetPropertyType(i);
}
return null;
}
private static void GuardAtLeastOneApplied(SetMaterialPropertiesResult result, bool shaderReassigned)
{
// A shader reassignment is itself a successful change, so it satisfies the "at least one
// thing applied" guard even when every supplied property is unknown for the new shader.
if (!shaderReassigned && result.Applied.Count == 0 && result.Unknown.Count > 0)
throw new ArgumentException(
$"None of the supplied inputs could be applied (unknown property name or type mismatch): {string.Join("; ", result.Unknown)}.");
}
private static void CopyIdentity(Material mat, string assetPath, SetMaterialPropertiesResult result)
{
var identity = ObjectResolver.Describe(mat);
if (identity != null)
{
result.GlobalId = identity.GlobalId;
result.Guid = identity.Guid;
result.FileId = identity.FileId;
result.InstanceId = identity.InstanceId;
result.HierarchyPath = identity.HierarchyPath;
result.Type = identity.Type;
}
else
{
result.Type = nameof(Material);
}
// Prefer the confined asset path computed by ResolveMaterial.
if (!string.IsNullOrEmpty(assetPath))
result.AssetPath = assetPath;
}
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the value-shape label used by get_material_properties.</summary>
private static string PublicValueType(ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color: return "Color";
case ShaderPropertyType.Vector: return "Vector";
case ShaderPropertyType.Float: return "Float";
case ShaderPropertyType.Range: return "Range";
case ShaderPropertyType.Texture: return "Texture";
case ShaderPropertyType.Int: return "Int";
default: return type.ToString();
}
}
}
/// <summary>
/// Thrown when a requested shader name does not resolve via <see cref="Shader.Find"/>. Carries the
/// stable <c>shader_not_found</c> code (also prefixed onto the message, since the server propagates
/// only the exception message over the wire) so a client can distinguish it from other failures.
/// </summary>
public sealed class ShaderNotFoundException : Exception
{
public const string CodeValue = "shader_not_found";
public string Code => CodeValue;
public ShaderNotFoundException(string message) : base($"[{CodeValue}] {message}") { }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a14c4a97ac9e4d61b904de30d1016d92
@@ -0,0 +1,147 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Unity.Pipeline.Models;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Result of <c>get_material_properties</c> (CLI-213): the material's shader, render queue, enabled
/// keywords, and the full list of shader properties with their current values.
/// </summary>
[Serializable]
public class MaterialPropertiesResult
{
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
/// <summary>Shader name, e.g. "Universal Render Pipeline/Lit".</summary>
[JsonProperty("shader")]
public string Shader { get; set; }
/// <summary>Render queue; -1 means "inherit from shader".</summary>
[JsonProperty("renderQueue")]
public int RenderQueue { get; set; }
[JsonProperty("enabledKeywords")]
public List<string> EnabledKeywords { get; set; } = new List<string>();
[JsonProperty("properties")]
public List<MaterialPropertyValue> Properties { get; set; } = new List<MaterialPropertyValue>();
}
/// <summary>A single shader property of a material with its current value.</summary>
[Serializable]
public class MaterialPropertyValue
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>"Float" | "Range" | "Color" | "Vector" | "Texture" | "Int".</summary>
[JsonProperty("type")]
public string Type { get; set; }
/// <summary>
/// number | [r,g,b,a] | [x,y,z,w] | { texture: ObjectRef } | null, per the property type.
/// </summary>
[JsonProperty("value")]
public object Value { get; set; }
/// <summary>Min/max for a Range property; null otherwise.</summary>
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
public MaterialRange Range { get; set; }
}
/// <summary>Inclusive min/max bounds of a Range shader property.</summary>
[Serializable]
public class MaterialRange
{
[JsonProperty("min")]
public float Min { get; set; }
[JsonProperty("max")]
public float Max { get; set; }
}
/// <summary>
/// Result of <c>set_material_properties</c> (CLI-213): the canonical <see cref="AuthoringResult"/>
/// identity of the edited material, extended with which properties applied vs. were unknown (with a
/// reason), and the material's (possibly reassigned) shader name.
/// </summary>
[Serializable]
public class SetMaterialPropertiesResult : AuthoringResult
{
/// <summary>Shader name after any reassignment.</summary>
[JsonProperty("shader")]
public string Shader { get; set; }
/// <summary>Property names (and keyword/renderQueue tokens) that were applied.</summary>
[JsonProperty("applied")]
public List<string> Applied { get; set; } = new List<string>();
/// <summary>
/// Inputs that could not be applied, each as "name: reason" (unknown property name, or a type
/// mismatch like "_Metallic: expected Float, got array").
/// </summary>
[JsonProperty("unknown")]
public List<string> Unknown { get; set; } = new List<string>();
}
/// <summary>A single discovered shader entry from <c>list_shaders</c>.</summary>
[Serializable]
public class ShaderInfo
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>Project asset path for a project shader; null for a built-in/engine shader.</summary>
[JsonProperty("assetPath")]
public string AssetPath { get; set; }
[JsonProperty("isBuiltin")]
public bool IsBuiltin { get; set; }
/// <summary>Reflects <c>Shader.isSupported</c> for the active render pipeline.</summary>
[JsonProperty("isSupported")]
public bool IsSupported { get; set; }
}
/// <summary>Result of <c>get_shader_properties</c> (CLI-213): a shader's declared property list.</summary>
[Serializable]
public class ShaderPropertiesResult
{
[JsonProperty("shader")]
public string Shader { get; set; }
[JsonProperty("properties")]
public List<ShaderPropertyInfo> Properties { get; set; } = new List<ShaderPropertyInfo>();
}
/// <summary>A single declared shader property, introspected via <c>ShaderUtil</c>.</summary>
[Serializable]
public class ShaderPropertyInfo
{
[JsonProperty("name")]
public string Name { get; set; }
/// <summary>ShaderUtil property description (the UI label).</summary>
[JsonProperty("description")]
public string Description { get; set; }
/// <summary>"Color" | "Vector" | "Float" | "Range" | "TexEnv" | "Int".</summary>
[JsonProperty("type")]
public string Type { get; set; }
/// <summary>Min/max for a Range property; null otherwise.</summary>
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
public MaterialRange Range { get; set; }
/// <summary>"Tex2D" | "Cube" | "Tex3D" | "Tex2DArray" for a TexEnv property; null otherwise.</summary>
[JsonProperty("textureDimension", NullValueHandling = NullValueHandling.Ignore)]
public string TextureDimension { get; set; }
/// <summary>Property flags, e.g. "HideInInspector", "Normal", "HDR", "NoScaleOffset".</summary>
[JsonProperty("flags")]
public List<string> Flags { get; set; } = new List<string>();
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52b9d1506e9b4a95982ff554d70179b3
@@ -0,0 +1,310 @@
using System;
using System.Globalization;
using Newtonsoft.Json.Linq;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Bridges agent-supplied JSON values and a <see cref="Material"/>'s shader properties for the
/// get_material_properties / set_material_properties commands (CLI-213).
///
/// Value encoding by shader property type:
/// - Float / Range / Int : a JSON number
/// - Color : <c>[r, g, b, a]</c> (floats 01) or a <c>"#RRGGBB"</c>/<c>"#RRGGBBAA"</c> hex string
/// - Vector : <c>[x, y, z, w]</c>
/// - Texture (TexEnv) : an <see cref="ObjectRef"/> object (resolved + confined to the authoring
/// root), or <c>null</c> to clear
///
/// Reads return the same shapes (Color as <c>[r,g,b,a]</c>, Vector as <c>[x,y,z,w]</c>, Texture as
/// <c>{ texture: ObjectRef }</c> or null) so a read round-trips back through a write.
/// </summary>
internal static class MaterialValueConverter
{
/// <summary>
/// Read a single shader property's current value off the material into a plain object suitable
/// for JSON serialization. The shape matches the <see cref="ShaderPropertyType"/>.
/// </summary>
public static object ReadValue(Material material, string propertyName, ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color:
{
var c = material.GetColor(propertyName);
return new[] { c.r, c.g, c.b, c.a };
}
case ShaderPropertyType.Vector:
{
var v = material.GetVector(propertyName);
return new[] { v.x, v.y, v.z, v.w };
}
case ShaderPropertyType.Float:
case ShaderPropertyType.Range:
return material.GetFloat(propertyName);
case ShaderPropertyType.Int:
return material.GetInteger(propertyName);
case ShaderPropertyType.Texture:
{
var tex = material.GetTexture(propertyName);
// A re-usable handle for the assigned texture, or null when no texture is bound.
var handle = ObjectResolver.Describe(tex);
return handle == null ? null : new { texture = handle };
}
default:
return null;
}
}
/// <summary>
/// Apply a JSON value to a single shader property on the material, converting by the shader's
/// declared property type. Throws <see cref="MaterialPropertyTypeMismatch"/> when the supplied
/// JSON shape does not match the property's type (so the caller can record it in
/// <c>unknown[]</c> with a reason). Texture refs are resolved via <see cref="ObjectResolver"/>
/// and confined to the authoring root; an out-of-root or unresolved ref throws
/// <see cref="ArgumentException"/> (a hard failure — never silently dropped).
/// </summary>
public static void ApplyValue(Material material, string propertyName, ShaderPropertyType type, JToken value)
{
switch (type)
{
case ShaderPropertyType.Float:
material.SetFloat(propertyName, ToFloat(propertyName, "Float", value));
break;
case ShaderPropertyType.Range:
material.SetFloat(propertyName, ToFloat(propertyName, "Range", value));
break;
case ShaderPropertyType.Int:
// Unity 6: Material.SetInteger is the typed setter for an Int shader property.
material.SetInteger(propertyName, ToInt(propertyName, value));
break;
case ShaderPropertyType.Color:
material.SetColor(propertyName, ToColor(propertyName, value));
break;
case ShaderPropertyType.Vector:
material.SetVector(propertyName, ToVector4(propertyName, value));
break;
case ShaderPropertyType.Texture:
material.SetTexture(propertyName, ToTexture(propertyName, value));
break;
default:
throw new MaterialPropertyTypeMismatch(
$"{propertyName}: unsupported shader property type '{type}'.");
}
}
private static float ToFloat(string property, string typeLabel, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
throw new MaterialPropertyTypeMismatch($"{property}: expected {typeLabel}, got null.");
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected {typeLabel} (a number), got {Describe(value)}.");
return value.ToObject<float>();
}
private static int ToInt(string property, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
throw new MaterialPropertyTypeMismatch($"{property}: expected Int, got null.");
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Int (a number), got {Describe(value)}.");
// Reject fractional JSON numbers (e.g. 1.5) rather than silently rounding/truncating.
// An Int shader property must receive a whole number.
var asDouble = value.ToObject<double>();
if (asDouble != Math.Truncate(asDouble))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Int (a whole number), got the non-integer value {value}.");
return value.ToObject<int>();
}
/// <summary>
/// Parse a Color from either <c>[r,g,b,a]</c> (3 or 4 floats) or a hex string
/// (<c>"#RRGGBB"</c> / <c>"#RRGGBBAA"</c>, leading '#' optional). Default alpha is 1.0.
/// </summary>
private static Color ToColor(string property, JToken value)
{
if (value is JArray arr)
{
if (arr.Count != 3 && arr.Count != 4)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] (or [r,g,b]), got an array of length {arr.Count}.");
// Route each component through ToFloat so non-numeric/null elements surface as a clean
// MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
float r = ToFloat(property, "Color", arr[0]);
float g = ToFloat(property, "Color", arr[1]);
float b = ToFloat(property, "Color", arr[2]);
float a = arr.Count == 4 ? ToFloat(property, "Color", arr[3]) : 1f;
return new Color(r, g, b, a);
}
if (value != null && value.Type == JTokenType.String)
{
if (TryParseHexColor(value.ToObject<string>(), out var color))
return color;
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] or a '#RRGGBB'/'#RRGGBBAA' hex string, got '{value}'.");
}
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Color as [r,g,b,a] or a hex string, got {Describe(value)}.");
}
private static Vector4 ToVector4(string property, JToken value)
{
if (!(value is JArray arr))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Vector as [x,y,z,w], got {Describe(value)}.");
if (arr.Count == 0 || arr.Count > 4)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Vector as [x,y,z,w] (14 numbers), got an array of length {arr.Count}.");
// Missing trailing components default to 0 (matches Unity's Vector4 component default),
// so [x,y,z] and [x,y] are accepted as shorthand for a 4-component vector.
// Route each present component through ToFloat so non-numeric/null elements surface as a
// clean MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
float x = arr.Count > 0 ? ToFloat(property, "Vector", arr[0]) : 0f;
float y = arr.Count > 1 ? ToFloat(property, "Vector", arr[1]) : 0f;
float z = arr.Count > 2 ? ToFloat(property, "Vector", arr[2]) : 0f;
float w = arr.Count > 3 ? ToFloat(property, "Vector", arr[3]) : 0f;
return new Vector4(x, y, z, w);
}
/// <summary>
/// Resolve a texture property value: an explicit <c>null</c> clears it, otherwise the value must
/// be an <see cref="ObjectRef"/> that resolves to a <see cref="Texture"/> confined to the
/// authoring root. A non-texture object, an out-of-root asset, or an unresolved handle is a hard
/// failure (never silently dropped).
/// </summary>
private static Texture ToTexture(string property, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
return null;
// ReadValue returns textures as { "texture": <ObjectRef> } so a get->set round-trip works.
// Accept both that wrapper form and a bare ObjectRef supplied directly by the caller.
JToken refToken = value;
if (value is JObject wrapper && wrapper.ContainsKey("texture"))
refToken = wrapper["texture"];
ObjectRef handle;
try
{
handle = refToken.ToObject<ObjectRef>();
}
catch (Exception)
{
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
}
if (handle == null || handle.IsEmpty)
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
throw new ArgumentException($"{property}: could not resolve texture reference: {error}");
if (!(obj is Texture texture))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Texture, got a {obj.GetType().Name}.");
// Confine to the authoring root: a GUID/globalId/path handle must bind a texture that lives
// on disk under the sandbox. An in-memory/scene Texture has an empty asset path; allowing it
// would bypass the root confinement, so reject any ref that doesn't resolve to a persisted
// asset. Mirrors AssetCommands.ResolveAssetPath's confinement.
var assetPath = AssetDatabase.GetAssetPath(texture);
if (string.IsNullOrEmpty(assetPath))
throw new ArgumentException(
$"{property}: texture reference does not resolve to an on-disk asset under the authoring root '{ProjectPaths.AuthoringRoot}'; in-memory or scene textures are not allowed.");
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
if (confined == null)
throw new ArgumentException(
$"{property}: texture '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
return texture;
}
/// <summary>
/// Parse a hex color string ("#RRGGBB", "#RRGGBBAA", or without the leading '#'). Returns false
/// for any malformed input. Channels are 0255 mapped to 01; default alpha is 1.0.
/// </summary>
public static bool TryParseHexColor(string hex, out Color color)
{
color = Color.white;
if (string.IsNullOrWhiteSpace(hex))
return false;
var s = hex.Trim();
if (s.StartsWith("#", StringComparison.Ordinal))
s = s.Substring(1);
if (s.Length != 6 && s.Length != 8)
return false;
if (!byte.TryParse(s.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) ||
!byte.TryParse(s.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) ||
!byte.TryParse(s.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
return false;
byte a = 255;
if (s.Length == 8 &&
!byte.TryParse(s.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a))
return false;
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
return true;
}
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the public type label used in results.</summary>
public static string TypeLabel(ShaderPropertyType type)
{
switch (type)
{
case ShaderPropertyType.Color: return "Color";
case ShaderPropertyType.Vector: return "Vector";
case ShaderPropertyType.Float: return "Float";
case ShaderPropertyType.Range: return "Range";
case ShaderPropertyType.Texture: return "TexEnv";
case ShaderPropertyType.Int: return "Int";
default: return type.ToString();
}
}
private static string Describe(JToken value)
{
if (value == null)
return "null";
switch (value.Type)
{
case JTokenType.Array: return "array";
case JTokenType.Object: return "object";
case JTokenType.Null: return "null";
default: return value.Type.ToString().ToLowerInvariant();
}
}
}
/// <summary>
/// Thrown when a supplied value's JSON shape does not match the shader property's declared type.
/// The command catches this and records the property in <c>unknown[]</c> with the message as the
/// reason, rather than failing the whole call.
/// </summary>
internal sealed class MaterialPropertyTypeMismatch : Exception
{
public MaterialPropertyTypeMismatch(string message) : base(message) { }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc8956be3c264ccb8096787d7146f330
@@ -0,0 +1,186 @@
using System;
using System.Collections.Generic;
using Unity.Pipeline.Commands;
using Unity.Pipeline.Editor.Authoring;
using Unity.Pipeline.Models;
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
namespace Unity.Pipeline.Editor.Commands.Materials
{
/// <summary>
/// Shader discovery / introspection commands (CLI-213): <c>list_shaders</c> so an agent can pick a
/// valid shader name, and <c>get_shader_properties</c> so it knows a shader's settable property
/// names / types / ranges / texture dimensions / flags before authoring a material.
///
/// Property introspection uses <see cref="Shader"/> instance APIs exclusively:
/// <c>GetPropertyCount</c>, <c>GetPropertyName</c>, <c>GetPropertyType</c>,
/// <c>GetPropertyDescription</c>, <c>GetPropertyRangeLimits</c>,
/// <c>GetPropertyTextureDimension</c>, and <c>GetPropertyFlags</c>. The reported type set is
/// <see cref="UnityEngine.Rendering.ShaderPropertyType"/>: Color, Vector, Float, Range, Texture, Int.
/// </summary>
public static class ShaderCommands
{
[CliCommand("list_shaders",
"Discover available shaders so an agent can pick a valid name for set_material_properties / create_asset. Returns [{ name, assetPath|null, isBuiltin, isSupported }].")]
public static List<ShaderInfo> ListShaders(
[CliArg("filter", "Case-insensitive substring matched against the shader name (e.g. \"URP\", \"Lit\").")] string filter = null,
[CliArg("includeBuiltin", "Include built-in/engine shaders (those with no project asset path). Default true.")] bool includeBuiltin = true,
[CliArg("limit", "Maximum number of shaders to return (default 200).")] int limit = 200)
{
if (limit <= 0)
limit = 200;
var results = new List<ShaderInfo>();
var seen = new HashSet<string>(StringComparer.Ordinal);
// AssetDatabase.FindAssets("t:Shader") enumerates project + package shaders (each with an
// asset path); ShaderUtil.GetAllShaderInfo() additionally surfaces built-in engine shaders
// that have no asset path (e.g. "Standard", "Sprites/Default"). Merge both, dedup by name.
foreach (var guid in AssetDatabase.FindAssets("t:Shader"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(path))
continue;
var shader = AssetDatabase.LoadAssetAtPath<Shader>(path);
if (shader == null)
continue;
if (!seen.Add(shader.name))
continue;
if (!PassesFilter(shader.name, filter))
continue;
results.Add(new ShaderInfo
{
Name = shader.name,
AssetPath = path,
IsBuiltin = false,
IsSupported = shader.isSupported,
});
if (results.Count >= limit)
return results;
}
if (includeBuiltin)
{
// GetAllShaderInfo() enumerates every shader the editor knows about, including built-in
// engine shaders that have no project/package asset path. Any name NOT already added by
// the FindAssets("t:Shader") pass above is a built-in (assetPath null). `info.supported`
// already reflects Shader::IsSupported(), so we don't re-run Shader.Find per entry.
foreach (var info in ShaderUtil.GetAllShaderInfo())
{
if (!seen.Add(info.name))
continue;
if (!PassesFilter(info.name, filter))
continue;
results.Add(new ShaderInfo
{
Name = info.name,
AssetPath = null,
IsBuiltin = true,
IsSupported = info.supported,
});
if (results.Count >= limit)
break;
}
}
return results;
}
[CliCommand("get_shader_properties",
"Introspect a shader's declared property list (name, description, type Color|Vector|Float|Range|TexEnv|Int, range, textureDimension, flags). Provide 'shader' (by name) OR 'material' (read the shader off that material).")]
public static ShaderPropertiesResult GetShaderProperties(
[CliArg("shader", "Shader name (e.g. \"Universal Render Pipeline/Lit\"). Provide this OR 'material'.")] string shader = null,
[CliArg("material", "Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'.")] ObjectRef material = null)
{
Shader resolved;
if (!string.IsNullOrWhiteSpace(shader))
{
resolved = Shader.Find(shader);
if (resolved == null)
throw new ShaderNotFoundException(
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
}
else if (material != null && !material.IsEmpty)
{
var mat = MaterialCommands.ResolveMaterial(material, out _);
resolved = mat.shader;
if (resolved == null)
throw new ArgumentException($"Material '{material}' has no shader assigned.");
}
else
{
throw new ArgumentException("Provide either 'shader' (a shader name) or 'material' (a material reference).");
}
var result = new ShaderPropertiesResult { Shader = resolved.name };
int count = resolved.GetPropertyCount();
for (int i = 0; i < count; i++)
{
var propType = resolved.GetPropertyType(i);
var entry = new ShaderPropertyInfo
{
Name = resolved.GetPropertyName(i),
Description = resolved.GetPropertyDescription(i),
Type = MaterialValueConverter.TypeLabel(propType),
Flags = DescribeFlags(resolved.GetPropertyFlags(i)),
};
if (propType == ShaderPropertyType.Range)
{
var limits = resolved.GetPropertyRangeLimits(i);
entry.Range = new MaterialRange
{
Min = limits.x,
Max = limits.y,
};
}
if (propType == ShaderPropertyType.Texture)
entry.TextureDimension = resolved.GetPropertyTextureDimension(i).ToString();
result.Properties.Add(entry);
}
return result;
}
private static bool PassesFilter(string name, string filter)
{
if (string.IsNullOrWhiteSpace(filter))
return true;
return name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
}
/// <summary>
/// Decompose a <see cref="ShaderPropertyFlags"/> bitmask into its individual flag names (the
/// enum is a [Flags] type). <c>None</c> is reported as an empty list rather than ["None"].
/// </summary>
private static List<string> DescribeFlags(ShaderPropertyFlags flags)
{
var list = new List<string>();
if (flags == ShaderPropertyFlags.None)
return list;
foreach (ShaderPropertyFlags flag in Enum.GetValues(typeof(ShaderPropertyFlags)))
{
if (flag == ShaderPropertyFlags.None)
continue;
if ((flags & flag) == flag)
list.Add(flag.ToString());
}
return list;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1007e4472d7b4216a6a866f8aaba76c7