using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Codely.Newtonsoft.Json;
using Codely.Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityTcp.Editor.Helpers;
namespace UnityTcp.Editor.Tools
{
///
/// Compile shader variants and preview.
/// File create/read/update/delete is handled by exec_editor_script.
///
public static class ManageShader
{
private static readonly Dictionary> ActionHandlers =
new Dictionary>
{
{ "compile", CompileShader },
{ "preview", PreviewShader },
};
///
/// Main handler for shader management actions.
///
public static object HandleCommand(JObject @params)
=> ActionRouter.Route(@params, ActionHandlers);
// --- Shader Compile Methods ---
///
/// Compiles one or more shaders and returns structured error/warning messages.
/// Import + variant warmup run as a so the editor main thread
/// keeps pumping between frames — GPU shader compilation cannot complete if the
/// main thread is blocked (ForceSynchronousImport / Thread.Sleep both deadlock).
///
private static object CompileShader(JObject @params)
{
try
{
var pathsToken = @params["paths"] as JArray;
var namesToken = @params["shader_names"] as JArray;
var matPathsToken = @params["material_paths"] as JArray;
var keywordsToken = @params["keywords"] as JArray;
var passesToken = @params["include_passes"] as JArray;
string variantMode = @params["variant_mode"]?.ToString() ?? "common";
int timeoutSeconds = @params["timeout_seconds"]?.ToObject() ?? 60;
var inputPaths = pathsToken?.Select(t => t.ToString()).ToList() ?? new List();
var shaderNames = namesToken?.Select(t => t.ToString()).ToList() ?? new List();
var matPaths = matPathsToken?.Select(t => t.ToString()).ToList() ?? new List();
if (inputPaths.Count == 0 && shaderNames.Count == 0)
return Response.Error("compile requires at least one of 'paths' or 'shader_names'.");
var shaderAssetPaths = new List();
foreach (var raw in inputPaths)
{
string original = raw.Replace('\\', '/');
string p = original;
if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
p = "Assets/" + p.TrimStart('/');
string ext = Path.GetExtension(p).ToLowerInvariant();
if (ext != ".shader" && ext != ".hlsl" && ext != ".cginc"
&& ext != ".glsl" && ext != ".compute")
{
return Response.Error(
$"Unsupported file type '{ext}' in paths. " +
"Expected .shader, .hlsl, .cginc, .glsl, or .compute.");
}
string fullPath = Path.GetFullPath(Path.Combine(
Application.dataPath, "..", p)).Replace('\\', '/');
if (!File.Exists(fullPath))
return Response.Error($"File not found: '{original}'.");
if (ext == ".shader")
{
shaderAssetPaths.Add(p);
}
else
{
var found = FindShadersIncluding(p);
if (found.Count == 0)
return Response.Error(
$"No .shader assets found that #include '{p}'. " +
"Pass the parent .shader via 'paths' or use 'shader_names'.");
shaderAssetPaths.AddRange(found);
}
}
foreach (var shaderName in shaderNames)
{
Shader s = Shader.Find(shaderName);
if (s == null)
return Response.Error(
$"Shader.Find(\"{shaderName}\") returned null. " +
"Verify the name matches the Shader \"Name/Path\" declaration in the file.");
string assetPath = AssetDatabase.GetAssetPath(s);
if (string.IsNullOrEmpty(assetPath))
return Response.Error($"Could not resolve asset path for shader '{shaderName}'.");
shaderAssetPaths.Add(assetPath);
}
shaderAssetPaths = shaderAssetPaths
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (shaderAssetPaths.Count == 0)
return Response.Error("No shader assets to compile.");
var explicitKeywords = new List>();
if (keywordsToken != null)
{
foreach (var combo in keywordsToken)
{
if (combo is JArray arr)
explicitKeywords.Add(arr.Select(k => k.ToString()).ToList());
}
}
var includePasses = passesToken?.Select(t => t.ToString()).ToList()
?? new List();
return StepJobRunner.Start(
CommandContext.RequestId,
CommandContext.CommandType ?? "manage_shader",
new CompileShaderJob
{
AssetPaths = shaderAssetPaths,
VariantMode = variantMode,
MaterialPaths = matPaths,
ExplicitKeywords = explicitKeywords,
IncludePasses = includePasses,
},
timeoutSeconds: timeoutSeconds);
}
catch (Exception e)
{
return Response.Error($"Shader compilation failed: {e.Message}");
}
}
private const int MaxVariants = 32;
///
/// Builds the list of shader variants to warm up based on variant_mode, material keywords,
/// and optional explicit keyword arrays. Capped at MaxVariants to prevent explosion.
///
private static List BuildVariantList(
Shader shader,
string variantMode,
List matPaths,
List> explicitKeywords,
List includePasses,
out bool truncated)
{
truncated = false;
var result = new List();
// PassType is resolved through ShaderVariantApi so older Tuanjie
// editors missing the type (or individual enum members) still compile.
var passList = new List();
if (includePasses != null)
{
foreach (var p in includePasses)
{
if (ShaderVariantApi.TryResolvePassType(p, out var pt)
&& !passList.Contains(pt))
passList.Add(pt);
}
}
if (passList.Count == 0)
{
foreach (var pt in ShaderVariantApi.DefaultPassTypes())
passList.Add(pt);
}
// Build keyword combinations.
var keywordCombos = new List();
if (variantMode == "explicit")
{
if (explicitKeywords != null)
{
foreach (var combo in explicitKeywords)
{
if (combo != null)
keywordCombos.Add(combo.ToArray());
}
}
// Ensure at least the no-keyword variant.
if (keywordCombos.Count == 0)
keywordCombos.Add(new string[0]);
}
else
{
// Always include the no-keyword (default) variant.
keywordCombos.Add(new string[0]);
// Add keyword combos from materials (used / common both use this).
foreach (var mp in matPaths ?? Enumerable.Empty())
{
var mat = AssetDatabase.LoadAssetAtPath(mp);
if (mat?.shaderKeywords?.Length > 0)
{
string[] kws = mat.shaderKeywords.OrderBy(k => k).ToArray();
string sig = string.Join(",", kws);
if (!keywordCombos.Any(c => string.Join(",", c.OrderBy(k => k)) == sig))
keywordCombos.Add(kws);
}
}
if (variantMode == "common")
{
// Instancing variants.
keywordCombos.Add(new[] { "INSTANCING_ON" });
keywordCombos.Add(new[] { "INSTANCING_OFF" });
// Current SRP default keywords.
var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
if (currentRP != null)
{
string rpName = currentRP.GetType().FullName.ToLowerInvariant();
if (rpName.Contains("universal") || rpName.Contains("urp"))
{
keywordCombos.Add(new[] { "_MAIN_LIGHT_SHADOWS" });
keywordCombos.Add(new[] { "_ADDITIONAL_LIGHTS" });
keywordCombos.Add(new[] { "_MAIN_LIGHT_SHADOWS", "_ADDITIONAL_LIGHTS" });
}
else if (rpName.Contains("highdefinition") || rpName.Contains("hdrp"))
{
keywordCombos.Add(new[] { "SHADOW_LOW" });
keywordCombos.Add(new[] { "SHADOW_MEDIUM" });
}
}
}
}
// Cross-product: passList × keywordCombos, capped at MaxVariants.
foreach (var passType in passList)
{
foreach (var kwCombo in keywordCombos)
{
if (result.Count >= MaxVariants)
{
truncated = true;
return result;
}
if (ShaderVariantApi.TryCreateVariant(shader, passType, kwCombo, out var variant))
result.Add(variant);
}
}
// include_passes may resolve to a PassType the shader does not have
// (e.g. "ForwardLit" -> ForwardBase on a Built-in Unlit / SRP shader).
// Fall back to DefaultPassTypes so we still compile something.
if (result.Count == 0 && includePasses != null && includePasses.Count > 0)
{
foreach (var fallbackPass in ShaderVariantApi.DefaultPassTypes())
{
if (passList.Contains(fallbackPass)) continue;
foreach (var kwCombo in keywordCombos)
{
if (result.Count >= MaxVariants)
{
truncated = true;
return result;
}
if (ShaderVariantApi.TryCreateVariant(shader, fallbackPass, kwCombo, out var variant))
result.Add(variant);
}
}
}
return result;
}
///
/// Tries to read Tuanjie-extended fields (passName, shaderKeywords) from a ShaderMessage.
/// Uses reflection for cross-version compatibility; omits the fields silently if absent.
///
private static void TryAddExtendedMessageFields(ShaderMessage msg, Dictionary entry)
{
try
{
Type t = msg.GetType();
const BindingFlags bf = BindingFlags.Public | BindingFlags.Instance;
// passName — Tuanjie extended field
FieldInfo passField = t.GetField("passName", bf)
?? t.GetField("PassName", bf);
if (passField != null)
{
object val = passField.GetValue(msg);
if (val != null && !string.IsNullOrEmpty(val.ToString()))
entry["pass"] = val.ToString();
}
// shaderKeywords — Tuanjie extended field
FieldInfo kwField = t.GetField("shaderKeywords", bf)
?? t.GetField("ShaderKeywords", bf);
if (kwField != null)
{
object val = kwField.GetValue(msg);
if (val is string[] arr && arr.Length > 0)
entry["keywords"] = arr;
else if (val is string s && !string.IsNullOrEmpty(s))
entry["keywords"] = s.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
}
}
catch
{
// Ignore — extended fields not available in this editor version.
}
if (entry.ContainsKey("pass") && entry.ContainsKey("keywords"))
return;
if (TryParsePassAndKeywordsFromText(msg.message, out string parsedPass, out string[] parsedKws))
{
if (!entry.ContainsKey("pass") && !string.IsNullOrEmpty(parsedPass))
entry["pass"] = parsedPass;
if (!entry.ContainsKey("keywords") && parsedKws != null && parsedKws.Length > 0)
entry["keywords"] = parsedKws;
}
}
private static readonly Regex PassFromTextRegex = new Regex(
@"Pass:\s*(?\w+)|pass\s+name\s+""(?[^""]+)""",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex KeywordsFromTextRegex = new Regex(
@"(?:with|keywords?:)\s*(?.+)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
///
/// Extracts pass / keywords from Unity's embedded ShaderMessage text when
/// Tuanjie-extended fields are not present on ShaderMessage.
///
private static bool TryParsePassAndKeywordsFromText(
string messageText, out string pass, out string[] keywords)
{
pass = null;
keywords = null;
if (string.IsNullOrEmpty(messageText))
return false;
var passMatch = PassFromTextRegex.Match(messageText);
if (passMatch.Success)
pass = passMatch.Groups["pass"].Value;
var kwMatch = KeywordsFromTextRegex.Match(messageText);
if (kwMatch.Success)
{
string raw = kwMatch.Groups["kws"].Value.Trim();
if (raw.IndexOf("", StringComparison.OrdinalIgnoreCase) < 0
&& !string.Equals(raw, "none", StringComparison.OrdinalIgnoreCase))
{
keywords = raw.Split(new[] { ' ', ',' }, StringSplitOptions.RemoveEmptyEntries);
}
}
return pass != null || keywords != null;
}
///
/// Creates a deduplication key from a message entry so that the same error reported
/// both by GetShaderMessages and by the Console log is not counted twice.
///
private static string MakeMessageSig(Dictionary entry)
{
string file = entry.TryGetValue("file", out var f) ? f?.ToString() ?? "" : "";
string line = entry.TryGetValue("line", out var l) ? l?.ToString() ?? "" : "";
string msg = entry.TryGetValue("message", out var m) ? m?.ToString() ?? "" : "";
return $"{file}:{line}:{msg}";
}
// Patterns for shader error lines emitted to the Unity Console during WarmUp.
// Unity does not expose variant-compile errors via ShaderUtil.GetShaderMessages;
// they only appear in the log (Application.logMessageReceived).
private static readonly Regex[] ShaderLogPatterns = new Regex[]
{
// "Shader error in 'NAME': MESSAGE at FILEPATH line N" (most common)
new Regex(
@"Shader\s+(?:error|warning)\s+in\s+'[^']*':\s*(?.+?)\s+at\s+(?\S+)\s+line\s+(?\d+)",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
// "... at FILEPATH(N)" or "... at FILEPATH line N" without leading 'Shader error'
new Regex(
@"(?.+?)\s+at\s+(?[^\s(]+)\((?\d+)\)",
RegexOptions.IgnoreCase | RegexOptions.Compiled),
};
private static readonly Regex ShaderNameFromLogRegex = new Regex(
@"Shader\s+(?:error|warning)\s+in\s+'([^']+)'",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static bool IsShaderRelatedLog(string logMsg)
{
return logMsg.IndexOf("shader", StringComparison.OrdinalIgnoreCase) >= 0
|| logMsg.IndexOf("undeclared identifier", StringComparison.OrdinalIgnoreCase) >= 0
|| logMsg.IndexOf("unexpected token", StringComparison.OrdinalIgnoreCase) >= 0
|| logMsg.IndexOf("cannot convert", StringComparison.OrdinalIgnoreCase) >= 0
|| logMsg.IndexOf("implicit truncation", StringComparison.OrdinalIgnoreCase) >= 0
|| logMsg.IndexOf("use of undeclared", StringComparison.OrdinalIgnoreCase) >= 0;
}
///
/// Attempts to parse a Unity Console log message emitted during shader variant compilation
/// into a structured message entry. Returns null if the message is unrelated to shaders.
///
private static Dictionary TryParseShaderLogMessage(
string logMsg, string fallbackAssetPath, bool isError)
{
if (!IsShaderRelatedLog(logMsg)) return null;
string severity = isError ? "error" : "warning";
foreach (var pattern in ShaderLogPatterns)
{
var m = pattern.Match(logMsg);
if (!m.Success) continue;
return new Dictionary
{
["severity"] = severity,
["shader"] = fallbackAssetPath,
["file"] = m.Groups["file"].Value.Trim(),
["line"] = int.TryParse(m.Groups["line"].Value, out int ln) ? ln : 0,
["message"] = m.Groups["message"].Value.Trim(),
["source"] = "variant_compile",
};
}
// Couldn't extract a location — include the raw message at shader root.
return new Dictionary
{
["severity"] = severity,
["shader"] = fallbackAssetPath,
["file"] = fallbackAssetPath,
["line"] = 0,
["message"] = logMsg.Trim(),
["source"] = "variant_compile",
};
}
// --- Shader Preview Methods ---
///
/// Renders one or more preview frames using PreviewRenderUtility (no Play Mode required).
/// Returns base64-encoded PNG images and an is_error_pink flag per frame.
/// Refuses to render if the shader has compilation errors to prevent GPU compiler hangs.
///
private static object PreviewShader(JObject @params)
{
try
{
// Resolve shader_names (first entry) or material_path.
var shaderNamesToken = @params["shader_names"] as JArray;
string shaderName = shaderNamesToken?.Count > 0 ? shaderNamesToken[0].ToString() : null;
string matPath = @params["material_path"]?.ToString();
string meshType = @params["mesh"]?.ToString() ?? "sphere";
int resolution = Math.Max(64, Math.Min(1024,
@params["resolution"]?.ToObject() ?? 256));
// Parse frames array; default to a single frame at t=0.
var frames = new List<(float t, JObject props)>();
if (@params["frames"] is JArray framesArr && framesArr.Count > 0)
{
foreach (var f in framesArr)
{
float t = f["t"]?.ToObject() ?? 0f;
frames.Add((t, f["props"] as JObject));
}
}
else
{
frames.Add((0f, null));
}
// Load or create a material.
Material material = null;
bool tempMaterial = false;
if (!string.IsNullOrEmpty(matPath))
{
material = AssetDatabase.LoadAssetAtPath(matPath);
if (material == null)
return Response.Error($"Material not found at: {matPath}");
}
else if (!string.IsNullOrEmpty(shaderName))
{
Shader shader = Shader.Find(shaderName);
if (shader == null)
return Response.Error($"Shader.Find(\"{shaderName}\") returned null.");
material = new Material(shader);
tempMaterial = true;
}
else
{
return Response.Error("preview requires 'shader_names' or 'material_path'.");
}
// Safety guard (Bug #3): refuse to render shaders with compilation errors.
// Rendering a broken shader variant submits it to the GPU compiler and can
// hang the entire Unity process when the compiler deadlocks.
Shader previewShader = material.shader;
if (previewShader != null)
{
ShaderMessage[] safetyMsgs = ShaderUtil.GetShaderMessages(previewShader);
int errCount = 0;
foreach (var sm in safetyMsgs)
{
if (string.Equals(sm.severity.ToString(), "Error",
StringComparison.OrdinalIgnoreCase))
errCount++;
}
if (errCount > 0)
return Response.Error(
$"Cannot preview: the shader '{previewShader.name}' has {errCount} " +
"compilation error(s). Run unity_shader { \"action\": \"compile\" } " +
"first and fix all errors before previewing.");
if (!previewShader.isSupported)
return Response.Error(
$"Cannot preview: the shader '{previewShader.name}' is not supported " +
"on this GPU (not finished compiling, or failed to compile). " +
"Run unity_shader { \"action\": \"compile\" } first.");
}
Mesh previewMesh = GetPreviewMesh(meshType);
var pru = new PreviewRenderUtility();
var resultFrames = new List>();
try
{
// Basic camera setup.
pru.camera.transform.position = new Vector3(0f, 0f, -3f);
pru.camera.transform.LookAt(Vector3.zero);
pru.camera.nearClipPlane = 0.01f;
pru.camera.farClipPlane = 100f;
pru.camera.backgroundColor = new Color(0.2f, 0.2f, 0.2f, 1f);
pru.camera.clearFlags = CameraClearFlags.SolidColor;
// Key light.
pru.lights[0].transform.rotation = Quaternion.Euler(40f, 40f, 0f);
pru.lights[0].intensity = 1.4f;
if (pru.lights.Length > 1)
{
pru.lights[1].transform.rotation = Quaternion.Euler(-40f, -40f, 0f);
pru.lights[1].intensity = 0.5f;
}
foreach (var (t, props) in frames)
{
// Unity ignores writes to the built-in _Time / _TimeParameters
// (and may overwrite them every frame). Preview animation is
// driven only by the opt-in _PreviewTime float.
material.SetFloat("_PreviewTime", t);
// Apply per-frame custom float/vector properties.
if (props != null)
{
foreach (var kv in props)
{
switch (kv.Value?.Type)
{
case JTokenType.Float:
case JTokenType.Integer:
material.SetFloat(kv.Key, kv.Value.ToObject());
break;
case JTokenType.Array:
var arr = (JArray)kv.Value;
if (arr.Count >= 4)
material.SetVector(kv.Key, new Vector4(
arr[0].ToObject(), arr[1].ToObject(),
arr[2].ToObject(), arr[3].ToObject()));
else if (arr.Count == 3)
material.SetVector(kv.Key, new Vector4(
arr[0].ToObject(), arr[1].ToObject(),
arr[2].ToObject(), 0f));
break;
}
}
}
pru.BeginStaticPreview(new Rect(0, 0, resolution, resolution));
pru.DrawMesh(previewMesh, Matrix4x4.identity, material, 0);
pru.camera.Render();
Texture2D tex = pru.EndStaticPreview();
// Unity error shaders render as Color.magenta (1, 0, 1).
// Count across the whole image — a corner sample is all background.
Color[] pixels = tex.GetPixels();
int pink = 0;
foreach (var px in pixels)
{
if (px.r == 1f && px.g == 0f && px.b == 1f)
pink++;
}
bool isErrorPink = pixels.Length > 0
&& (pink / (float)pixels.Length) >= 0.05f;
byte[] pngBytes = tex.EncodeToPNG();
UnityEngine.Object.DestroyImmediate(tex);
resultFrames.Add(new Dictionary
{
["t"] = t,
["image_base64"] = Convert.ToBase64String(pngBytes),
["is_error_pink"] = isErrorPink,
});
}
}
finally
{
pru.Cleanup();
if (tempMaterial && material != null)
UnityEngine.Object.DestroyImmediate(material);
}
return Response.Success("Shader preview rendered successfully.", new Dictionary
{
["success"] = true,
["frames"] = resultFrames,
["resolution"] = resolution,
["mesh"] = meshType,
});
}
catch (Exception e)
{
return Response.Error($"Shader preview failed: {e.Message}");
}
}
/// Returns a built-in Unity mesh by type name.
private static Mesh GetPreviewMesh(string meshType)
{
switch (meshType?.ToLowerInvariant())
{
case "quad": return Resources.GetBuiltinResource("Quad.fbx");
case "cube": return Resources.GetBuiltinResource("Cube.fbx");
default: return Resources.GetBuiltinResource("Sphere.fbx");
}
}
///
/// Finds .shader assets that contain a #include directive referencing the given
/// hlsl/include file (matched by base filename). Searches the file's own directory
/// and its parent first for speed; falls back to a full-project scan only when those
/// yield nothing.
///
private static List FindShadersIncluding(string hlslAssetPath)
{
string hlslFileName = Path.GetFileName(hlslAssetPath);
string hlslDir = (Path.GetDirectoryName(hlslAssetPath) ?? "Assets").Replace('\\', '/');
string hlslParentDir = (Path.GetDirectoryName(hlslDir) ?? "Assets").Replace('\\', '/');
// #include pattern: matches both "...file.hlsl" and <...file.hlsl>
var includePattern = new Regex(
@"#include\s+[""<][^""<>]*" + Regex.Escape(hlslFileName) + @"["">""]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Search scopes: narrow first, full project as last resort
var scopes = new[] { new[] { hlslDir }, new[] { hlslParentDir }, null };
foreach (var scope in scopes)
{
string[] guids = scope != null
? AssetDatabase.FindAssets("t:Shader", scope)
: AssetDatabase.FindAssets("t:Shader");
var found = new List();
foreach (var guid in guids)
{
string shaderPath = AssetDatabase.GUIDToAssetPath(guid);
if (string.IsNullOrEmpty(shaderPath)) continue;
try
{
string fullPath = Path.Combine(
Path.GetDirectoryName(Application.dataPath) ?? string.Empty,
shaderPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullPath)) continue;
string content = File.ReadAllText(fullPath);
if (includePattern.IsMatch(content))
found.Add(shaderPath);
}
catch
{
// Skip files that cannot be read
}
}
if (found.Count > 0) return found;
}
return new List();
}
///
/// Frame-driven shader compile: ImportAsset(ForceUpdate) + variant warmup, waiting
/// across editor frames for !ShaderUtil.anythingCompiling so the GPU compiler
/// can deliver completion on the main-thread pump.
///
private sealed class CompileShaderJob : StepJob
{
public List AssetPaths = new List();
public string VariantMode = "common";
public List> ExplicitKeywords = new List>();
public List IncludePasses = new List();
public List MaterialPaths = new List();
public bool ImportStarted;
public bool WarmupStarted;
public int TotalVariants;
public bool AnyTruncated;
[JsonIgnore] private int _importIdle;
[JsonIgnore] private int _warmupIdle;
[JsonIgnore] private bool _warmupFinished;
[JsonIgnore] private bool _listening;
[JsonIgnore] private Application.LogCallback _logHandler;
[JsonIgnore] private readonly List> _consoleLogs
= new List>();
[JsonIgnore] private readonly List> _allMessages
= new List>();
[JsonIgnore] private readonly HashSet _seenSigs = new HashSet();
[JsonIgnore] private readonly HashSet _allowedShaderNames
= new HashSet(StringComparer.OrdinalIgnoreCase);
[JsonIgnore] private readonly List _collections
= new List();
[JsonIgnore] private int _totalErrors;
[JsonIgnore] private int _totalWarnings;
protected override JobStep[] BuildSteps() => new[]
{
new JobStep("setup-import", SetupImport, ImportSettled),
new JobStep("read-and-warmup", ReadAndWarmup, WarmupSettled),
new JobStep("collect", Collect),
};
public override void OnRestored()
{
// Domain reload drops the log subscription and JsonIgnore sets.
// Rebuild the allow-list, then re-attach while import or warmup
// is still in flight so Console errors are not lost.
PopulateAllowedShaderNames();
if (ImportStarted && !_warmupFinished)
AttachLog();
}
private void SetupImport()
{
if (ImportStarted) return;
ImportStarted = true;
// Shader compile errors are emitted to the Console during ImportAsset.
// The handler must be attached before that call, not after.
PopulateAllowedShaderNames();
AttachLog();
foreach (var assetPath in AssetPaths ?? Enumerable.Empty())
{
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
}
}
private bool ImportSettled()
{
if (ShaderUtil.anythingCompiling)
{
_importIdle = 0;
return false;
}
// Stay at least one extra idle frame so a compile that starts on the next
// pump is not mistaken for "already done".
return ++_importIdle >= 2;
}
private void ReadAndWarmup()
{
if (!WarmupStarted)
{
WarmupStarted = true;
KickoffWarmup();
}
else if (_collections.Count == 0 && !_warmupFinished)
{
// Collections are not serialized; rebuild them after a domain reload.
TotalVariants = 0;
AnyTruncated = false;
KickoffWarmup();
}
ContinueWarmup();
}
private void KickoffWarmup()
{
foreach (var assetPath in AssetPaths ?? Enumerable.Empty())
{
try
{
var shader = AssetDatabase.LoadAssetAtPath(assetPath);
if (shader == null)
{
AddMessage(new Dictionary
{
["severity"] = "error",
["shader"] = assetPath,
["file"] = assetPath,
["line"] = 0,
["message"] = $"Failed to load shader asset after import: '{assetPath}'",
});
continue;
}
CollectShaderMessages(shader, assetPath);
var variantList = BuildVariantList(
shader, VariantMode, MaterialPaths,
ExplicitKeywords, IncludePasses, out bool truncated);
AnyTruncated |= truncated;
TotalVariants += variantList.Count;
if (variantList.Count == 0) continue;
var svc = new ShaderVariantCollection();
foreach (var v in variantList)
{
try { svc.Add(v); } catch { /* skip invalid pass/keyword combinations */ }
}
_collections.Add(svc);
}
catch (Exception e)
{
AddMessage(new Dictionary
{
["severity"] = "error",
["shader"] = assetPath,
["file"] = assetPath,
["line"] = 0,
["message"] = $"Exception during shader processing: {e.Message}",
});
}
}
}
private void ContinueWarmup()
{
if (_warmupFinished) return;
if (_collections.Count == 0)
{
_warmupFinished = true;
DetachLog();
return;
}
bool allDone = true;
foreach (var svc in _collections)
{
try
{
if (!TryWarmUpProgressively(svc, 8))
allDone = false;
}
catch (Exception e)
{
string fallback = AssetPaths != null && AssetPaths.Count > 0
? AssetPaths[0] : string.Empty;
AddMessage(new Dictionary
{
["severity"] = "error",
["shader"] = fallback,
["file"] = fallback,
["line"] = 0,
["message"] = $"Exception during variant warmup: {e.Message}",
});
}
}
_warmupFinished = allDone;
if (_warmupFinished)
DetachLog();
}
private bool WarmupSettled()
{
if (!_warmupFinished || ShaderUtil.anythingCompiling)
{
if (ShaderUtil.anythingCompiling) _warmupIdle = 0;
return false;
}
return ++_warmupIdle >= 2;
}
private void Collect()
{
try
{
foreach (var assetPath in AssetPaths ?? Enumerable.Empty())
{
try
{
var shader = AssetDatabase.LoadAssetAtPath(assetPath);
if (shader == null) continue;
CollectShaderMessages(shader, assetPath);
}
catch (Exception e)
{
AddMessage(new Dictionary
{
["severity"] = "error",
["shader"] = assetPath,
["file"] = assetPath,
["line"] = 0,
["message"] = $"Exception collecting shader messages: {e.Message}",
});
}
}
foreach (var entry in _consoleLogs)
AddMessage(entry);
string resultMsg = _totalErrors > 0
? $"Shader compilation completed with {_totalErrors} error(s) and {_totalWarnings} warning(s)."
: _totalWarnings > 0
? $"Shader compilation completed with {_totalWarnings} warning(s)."
: "Shader compilation completed successfully.";
// Top-level success means the compile job finished, not that
// the shader is error-free. Shader errors live in has_errors /
// error_count / messages so the CLI does not swallow the payload.
Complete(Response.Success(resultMsg, new Dictionary
{
["compiled"] = true,
["shaders_compiled"] = AssetPaths?.Count ?? 0,
["error_count"] = _totalErrors,
["warning_count"] = _totalWarnings,
["variants_compiled"] = TotalVariants,
["variants_truncated"] = AnyTruncated,
["has_errors"] = _totalErrors > 0,
["messages"] = _allMessages,
}));
}
catch (Exception e)
{
Fail($"Shader compilation failed: {e.Message}");
}
finally
{
DetachLog();
CleanupCollections();
}
}
private void CollectShaderMessages(Shader shader, string assetPath)
{
ShaderMessage[] msgs = ShaderUtil.GetShaderMessages(shader);
if (msgs == null || msgs.Length == 0) return;
// ShaderUtil.GetShaderMessages may return stale entries from other
// shaders. Keep only messages whose file belongs to this shader
// (the shader itself + its hlsl/cginc/glsl deps).
var ownFiles = new HashSet(StringComparer.OrdinalIgnoreCase) { assetPath };
try
{
foreach (var dep in AssetDatabase.GetDependencies(assetPath, recursive: false))
{
string ext = Path.GetExtension(dep).ToLowerInvariant();
if (ext == ".hlsl" || ext == ".cginc" || ext == ".glsl" || ext == ".shader")
ownFiles.Add(dep);
}
}
catch { /* dependency lookup is best-effort */ }
foreach (var msg in msgs)
{
string msgFile = (msg.file ?? string.Empty).Replace('\\', '/');
if (!string.IsNullOrEmpty(msgFile) && !ownFiles.Contains(msgFile))
continue;
// GetShaderMessages can return stale entries from other shaders
// (often with an empty file). Drop those by the name in the text.
var nameMatch = ShaderNameFromLogRegex.Match(msg.message ?? string.Empty);
if (nameMatch.Success
&& !string.IsNullOrEmpty(shader.name)
&& !string.Equals(
nameMatch.Groups[1].Value, shader.name, StringComparison.Ordinal))
continue;
bool isError = string.Equals(
msg.severity.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
var entry = new Dictionary
{
["severity"] = isError ? "error" : "warning",
["shader"] = assetPath,
["file"] = string.IsNullOrEmpty(msg.file) ? assetPath : msg.file,
["line"] = msg.line,
["message"] = msg.message ?? string.Empty,
};
TryAddExtendedMessageFields(msg, entry);
AddMessage(entry);
}
}
private void AddMessage(Dictionary entry)
{
if (entry == null) return;
if (!_seenSigs.Add(MakeMessageSig(entry))) return;
_allMessages.Add(entry);
bool isError = string.Equals(
entry["severity"]?.ToString(), "error", StringComparison.OrdinalIgnoreCase);
if (isError) _totalErrors++;
else _totalWarnings++;
}
private void PopulateAllowedShaderNames()
{
_allowedShaderNames.Clear();
foreach (var assetPath in AssetPaths ?? Enumerable.Empty())
{
var shader = AssetDatabase.LoadAssetAtPath(assetPath);
if (shader != null && !string.IsNullOrEmpty(shader.name))
_allowedShaderNames.Add(shader.name);
}
}
private bool IsAllowedShaderLog(string logMsg)
{
var m = ShaderNameFromLogRegex.Match(logMsg ?? string.Empty);
if (!m.Success) return true;
if (_allowedShaderNames.Count == 0) return true;
return _allowedShaderNames.Contains(m.Groups[1].Value);
}
private void AttachLog()
{
if (_listening) return;
_listening = true;
_logHandler = (logMsg, stackTrace, logType) =>
{
if (!_listening) return;
if (logType != LogType.Error && logType != LogType.Warning) return;
if (!IsAllowedShaderLog(logMsg)) return;
string fallback = AssetPaths != null && AssetPaths.Count > 0
? AssetPaths[0] : string.Empty;
var parsed = TryParseShaderLogMessage(
logMsg, fallback, logType == LogType.Error);
if (parsed != null) _consoleLogs.Add(parsed);
};
Application.logMessageReceived += _logHandler;
}
private void DetachLog()
{
if (!_listening) return;
_listening = false;
if (_logHandler != null)
Application.logMessageReceived -= _logHandler;
_logHandler = null;
}
private void CleanupCollections()
{
foreach (var svc in _collections)
{
if (svc != null)
UnityEngine.Object.DestroyImmediate(svc);
}
_collections.Clear();
}
///
/// Warms a small batch of variants and returns true when the collection is fully
/// warmed. Falls back to blocking WarmUp only when WarmUpProgressively is absent.
///
private static bool TryWarmUpProgressively(ShaderVariantCollection svc, int count)
{
var method = typeof(ShaderVariantCollection).GetMethod(
"WarmUpProgressively",
BindingFlags.Public | BindingFlags.Instance,
null,
new[] { typeof(int) },
null);
if (method != null)
return (bool)method.Invoke(svc, new object[] { count });
svc.WarmUp();
return true;
}
}
}
}