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 { /// /// 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 resolved by /// and confined to the authoring root (a GUID/globalId handle can't edit a material outside the sandbox), /// - texture property values are s likewise confined to the root, /// - writes are wrapped in an with /// (material property changes ARE covered by Undo.RecordObject, unlike AssetDatabase ops), then /// + persist them, /// - unknown property names and type mismatches are reported in unknown[] rather than failing /// the whole call (as long as at least one input applied). /// /// Material CREATION already exists in create_asset (CLI-221); this command set is the /// read/write surface on top of an existing material. /// 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 : "")}')"); 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 : "")}'"); 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; } /// /// Resolve an to a , 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 on miss / wrong type / out-of-root. /// 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; } /// /// Read the material's enabled keyword set. Uses /// (LocalKeyword[]) which is the supported API on the package's min Unity version (Unity 6 / /// 6000.0); the legacy shaderKeywords string[] is deprecated. /// private static List GetEnabledKeywords(Material mat) { var keywords = new List(); 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 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 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}"); } /// /// dry_run validation: classify each supplied property as applicable (name resolves AND value /// shape matches the declared type) or unknown, WITHOUT mutating the material. /// 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 : "")}')"); 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); } } } /// /// Look up a property's by name on a shader. /// Returns null when the shader has no property with that exact name (case-sensitive; names include /// the leading underscore). /// 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; } /// Map a to the value-shape label used by get_material_properties. 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(); } } } /// /// Thrown when a requested shader name does not resolve via . Carries the /// stable shader_not_found 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. /// public sealed class ShaderNotFoundException : Exception { public const string CodeValue = "shader_not_found"; public string Code => CodeValue; public ShaderNotFoundException(string message) : base($"[{CodeValue}] {message}") { } } }