using System; using System.Collections.Generic; using System.Globalization; using System.IO; using NBShaderEditor; using UnityEditor; using UnityEngine; namespace NBShaders2.Editor { [InitializeOnLoad] internal static class ParticleBaseMigrationSettingsProvider { static ParticleBaseMigrationSettingsProvider() { NBFXProjectSettings.RegisterSettingsSection( "ParticleBaseMigration", () => new GUIContent(Text("particleBaseMigration.providerLabel", "ParticleBase Migration")), OnGUI, new[] { "ParticleBase", "NBShader2", "Migration", "Material", "Shader GUID" }, 95); } private static void OnGUI(string searchContext) { EditorGUILayout.HelpBox( Text( "particleBaseMigration.settingsHelp", "Scan material assets that reference the legacy ParticleBase shader GUID and migrate them to NBShader2 after confirmation. Back up assets or commit to version control before converting."), MessageType.Warning); if (GUILayout.Button(ButtonContent("particleBaseMigration.openWindow", "Open ParticleBase Migration Window"))) { ParticleBaseMigrationWindow.Open(); } } private static GUIContent ButtonContent(string key, string fallback) { return NBShaderInspectorLocalization.MakeContent("inspector." + key + ".button", fallback); } private static string Text(string key, string fallback) { return NBShaderInspectorLocalization.GetInspectorText(key, fallback); } } internal sealed class ParticleBaseMigrationWindow : EditorWindow { private const string LegacyShaderGuidFallback = "7184a95c20fc1a441a8815af4c795ccd"; private const string NBShader2Guid = "7787bfdacec31472ca6644d6e3616bd4"; private const string LegacyShaderAssetPath = "Packages/com.xuanxuan.nb.fx/NBShaders/Shader/ParticleBase.shader"; private const string NBShader2AssetPath = "Packages/com.xuanxuan.nb.fx/NBShaders2/Shader/NBShader.shader"; private const string LegacyShaderName = "Effects/NBShader(Legacy)"; private const string UndoNameFallback = "Migrate ParticleBase Materials To NBShader2"; private static readonly string[] LegacyFoldoutProperties = { "_W9ParticleShaderGUIFoldToggle", "_W9ParticleShaderGUIFoldToggle1", "_W9ParticleShaderGUIFoldToggle2" }; private static readonly string[] NBShader2FoldoutProperties = { "_NBShaderGUIFoldToggle", "_NBShaderGUIFoldToggle1", "_NBShaderGUIFoldToggle2" }; private readonly List _entries = new List(); private Vector2 _scrollPosition; private bool _scanned; private string _statusMessage; public static void Open() { var window = GetWindow(Text("particleBaseMigration.windowTitle", "ParticleBase Migration")); window.minSize = new Vector2(760f, 420f); window.Show(); } private void OnGUI() { titleContent = new GUIContent(Text("particleBaseMigration.windowTitle", "ParticleBase Migration")); EditorGUILayout.LabelField(Text("particleBaseMigration.title", "ParticleBase -> NBShader2"), EditorStyles.boldLabel); EditorGUILayout.HelpBox( Text( "particleBaseMigration.windowWarning", "This operation changes material shader assignments and synchronized material state. Back up assets or commit to version control before converting. The tool does not provide an automatic rollback."), MessageType.Warning); DrawToolbar(); DrawStatus(); DrawMaterialList(); DrawConvertButton(); } private void DrawToolbar() { using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button(ButtonContent("particleBaseMigration.scan", "Scan ParticleBase Materials"), GUILayout.Height(24f))) { ScanMaterials(); } EditorGUI.BeginDisabledGroup(_entries.Count == 0); if (GUILayout.Button(ButtonContent("particleBaseMigration.selectAll", "Select All"), GUILayout.Width(96f), GUILayout.Height(24f))) { SetAllSelected(true); } if (GUILayout.Button(ButtonContent("particleBaseMigration.selectNone", "Select None"), GUILayout.Width(96f), GUILayout.Height(24f))) { SetAllSelected(false); } EditorGUI.EndDisabledGroup(); } } private void DrawStatus() { if (!string.IsNullOrEmpty(_statusMessage)) { EditorGUILayout.HelpBox(_statusMessage, MessageType.Info); } else if (!_scanned) { EditorGUILayout.HelpBox( Text( "particleBaseMigration.scanPrompt", "Click Scan to find .mat assets that reference the ParticleBase shader asset GUID. Shader name is only used as a fallback."), MessageType.Info); } else if (_entries.Count == 0) { EditorGUILayout.HelpBox( Text("particleBaseMigration.noMaterials", "No ParticleBase material assets were found."), MessageType.Info); } } private void DrawMaterialList() { if (_entries.Count == 0) { return; } EditorGUILayout.Space(); EditorGUILayout.LabelField( FormatText("particleBaseMigration.listSummary", "Found {0} material asset(s). Selected: {1}", _entries.Count, CountSelected()), EditorStyles.boldLabel); using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { GUILayout.Label("", GUILayout.Width(24f)); GUILayout.Label(Text("particleBaseMigration.column.material", "Material"), GUILayout.Width(240f)); GUILayout.Label(Text("particleBaseMigration.column.match", "Match"), GUILayout.Width(160f)); GUILayout.Label(Text("particleBaseMigration.column.path", "Path"), GUILayout.MinWidth(260f)); } _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); for (int i = 0; i < _entries.Count; i++) { DrawMaterialRow(_entries[i]); } EditorGUILayout.EndScrollView(); } private static void DrawMaterialRow(MaterialMigrationEntry entry) { using (new EditorGUILayout.HorizontalScope()) { entry.selected = EditorGUILayout.Toggle(entry.selected, GUILayout.Width(24f)); EditorGUILayout.ObjectField(entry.material, typeof(Material), false, GUILayout.Width(240f)); GUILayout.Label(entry.matchSource, EditorStyles.miniLabel, GUILayout.Width(160f)); if (GUILayout.Button(ButtonContent("particleBaseMigration.ping", "Ping"), GUILayout.Width(48f))) { PingAsset(entry.assetPath); } EditorGUILayout.SelectableLabel( entry.assetPath, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight), GUILayout.MinWidth(220f)); } } private void DrawConvertButton() { EditorGUILayout.Space(); EditorGUI.BeginDisabledGroup(CountSelected() == 0); if (GUILayout.Button(ButtonContent("particleBaseMigration.convertSelected", "Convert Selected Materials To NBShader2"), GUILayout.Height(30f))) { ConvertSelectedMaterials(); } EditorGUI.EndDisabledGroup(); } private void ScanMaterials() { _entries.Clear(); _scanned = true; _statusMessage = null; string legacyShaderGuid = ResolveShaderGuid(LegacyShaderAssetPath, LegacyShaderGuidFallback); Shader legacyShader = LoadShaderByGuidOrPath(legacyShaderGuid, LegacyShaderAssetPath); string[] materialGuids = AssetDatabase.FindAssets("t:Material"); var seenPaths = new HashSet(StringComparer.Ordinal); bool canceled = false; try { for (int i = 0; i < materialGuids.Length; i++) { if (EditorUtility.DisplayCancelableProgressBar( Text("particleBaseMigration.windowTitle", "ParticleBase Migration"), FormatText("particleBaseMigration.progress.scanning", "Scanning materials {0}/{1}", i + 1, materialGuids.Length), materialGuids.Length > 0 ? (float)i / materialGuids.Length : 1f)) { canceled = true; break; } string path = AssetDatabase.GUIDToAssetPath(materialGuids[i]); if (string.IsNullOrEmpty(path) || !path.EndsWith(".mat", StringComparison.OrdinalIgnoreCase) || !seenPaths.Add(path)) { continue; } Material material = AssetDatabase.LoadAssetAtPath(path); string matchSource; if (!IsLegacyParticleBaseMaterial(path, material, legacyShaderGuid, legacyShader, out matchSource)) { continue; } _entries.Add(new MaterialMigrationEntry { assetPath = path, material = material, selected = true, matchSource = matchSource }); } } finally { EditorUtility.ClearProgressBar(); } _entries.Sort((a, b) => string.CompareOrdinal(a.assetPath, b.assetPath)); _statusMessage = canceled ? FormatText("particleBaseMigration.scanCanceled", "Scan canceled. Found {0} material asset(s) before canceling.", _entries.Count) : FormatText("particleBaseMigration.scanComplete", "Scan complete. Found {0} ParticleBase material asset(s).", _entries.Count); } private void ConvertSelectedMaterials() { List selectedEntries = GetSelectedEntries(); if (selectedEntries.Count == 0) { return; } Shader targetShader = LoadShaderByGuidOrPath(NBShader2Guid, NBShader2AssetPath); if (targetShader == null) { EditorUtility.DisplayDialog( Text("particleBaseMigration.nbShader2Missing.title", "NBShader2 Missing"), Text("particleBaseMigration.nbShader2Missing.message", "Could not load NBShader2 shader asset. Migration cannot continue."), Text("particleBaseMigration.dialog.ok", "OK")); return; } if (!ConfirmConversion(selectedEntries.Count)) { return; } string legacyShaderGuid = ResolveShaderGuid(LegacyShaderAssetPath, LegacyShaderGuidFallback); Shader legacyShader = LoadShaderByGuidOrPath(legacyShaderGuid, LegacyShaderAssetPath); int convertedCount = 0; bool canceled = false; var failures = new List(); int undoGroup = Undo.GetCurrentGroup(); bool assetEditingStarted = false; string undoName = Text("particleBaseMigration.undo", UndoNameFallback); Undo.SetCurrentGroupName(undoName); try { AssetDatabase.StartAssetEditing(); assetEditingStarted = true; for (int i = 0; i < selectedEntries.Count; i++) { MaterialMigrationEntry entry = selectedEntries[i]; if (EditorUtility.DisplayCancelableProgressBar( Text("particleBaseMigration.windowTitle", "ParticleBase Migration"), FormatText("particleBaseMigration.progress.converting", "Converting {0}/{1}: {2}", i + 1, selectedEntries.Count, entry.assetPath), selectedEntries.Count > 0 ? (float)i / selectedEntries.Count : 1f)) { canceled = true; break; } if (ConvertMaterialEntry(entry, targetShader, legacyShaderGuid, legacyShader, undoName, failures)) { convertedCount++; } } } finally { if (assetEditingStarted) { AssetDatabase.StopAssetEditing(); } EditorUtility.ClearProgressBar(); Undo.CollapseUndoOperations(undoGroup); } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); if (failures.Count > 0) { Debug.LogWarning( Text("particleBaseMigration.failuresLogged", "ParticleBase migration completed with failures:") + "\n" + string.Join("\n", failures.ToArray())); } _statusMessage = BuildConversionStatus(convertedCount, selectedEntries.Count, canceled, failures.Count); } private static bool ConvertMaterialEntry( MaterialMigrationEntry entry, Shader targetShader, string legacyShaderGuid, Shader legacyShader, string undoName, List failures) { try { Material material = AssetDatabase.LoadAssetAtPath(entry.assetPath); if (material == null) { failures.Add(FormatText("particleBaseMigration.failure.loadMaterial", "{0}: material could not be loaded.", entry.assetPath)); return false; } string matchSource; if (!IsLegacyParticleBaseMaterial(entry.assetPath, material, legacyShaderGuid, legacyShader, out matchSource)) { failures.Add(FormatText("particleBaseMigration.failure.noLongerParticleBase", "{0}: skipped because it no longer references ParticleBase.", entry.assetPath)); return false; } int[] legacyFoldoutValues = CaptureLegacyFoldoutValues(material, entry.assetPath); Undo.RecordObject(material, undoName); material.shader = targetShader; ApplyNBShader2FoldoutValues(material, legacyFoldoutValues); NBShaderSyncService.SyncMaterialState(material); EditorUtility.SetDirty(material); AssetDatabase.SaveAssetIfDirty(material); entry.material = material; entry.matchSource = Text("particleBaseMigration.match.converted", "Converted"); entry.selected = false; return true; } catch (Exception ex) { failures.Add(FormatText("particleBaseMigration.failure.exception", "{0}: {1}", entry.assetPath, ex.Message)); return false; } } private static bool ConfirmConversion(int count) { return EditorUtility.DisplayDialog( Text("particleBaseMigration.confirm.title", "Confirm ParticleBase Migration"), FormatText( "particleBaseMigration.confirm.message", "You are about to convert {0} material asset(s) from ParticleBase to NBShader2.\n\nBack up assets or commit to version control before continuing. This tool does not provide an automatic rollback.", count), Text("particleBaseMigration.dialog.convert", "Convert"), Text("particleBaseMigration.dialog.cancel", "Cancel")); } private static string BuildConversionStatus(int converted, int selected, bool canceled, int failureCount) { string status = canceled ? FormatText("particleBaseMigration.conversionCanceled", "Conversion canceled. Converted {0}/{1} selected material asset(s).", converted, selected) : FormatText("particleBaseMigration.conversionComplete", "Conversion complete. Converted {0}/{1} selected material asset(s).", converted, selected); if (failureCount > 0) { status += " " + FormatText("particleBaseMigration.conversionFailures", "{0} failure(s) were logged to the Console.", failureCount); } return status; } private static int[] CaptureLegacyFoldoutValues(Material material, string assetPath) { var values = new int[LegacyFoldoutProperties.Length]; for (int i = 0; i < LegacyFoldoutProperties.Length; i++) { values[i] = int.MinValue; if (material != null && material.HasProperty(LegacyFoldoutProperties[i])) { values[i] = material.GetInteger(LegacyFoldoutProperties[i]); continue; } int serializedValue; if (TryReadSerializedIntegerProperty(assetPath, LegacyFoldoutProperties[i], out serializedValue)) { values[i] = serializedValue; } } return values; } private static void ApplyNBShader2FoldoutValues(Material material, int[] legacyValues) { if (material == null || legacyValues == null) { return; } int count = Math.Min(legacyValues.Length, NBShader2FoldoutProperties.Length); for (int i = 0; i < count; i++) { if (legacyValues[i] == int.MinValue || !material.HasProperty(NBShader2FoldoutProperties[i])) { continue; } material.SetInteger(NBShader2FoldoutProperties[i], legacyValues[i]); } } private static bool IsLegacyParticleBaseMaterial( string assetPath, Material material, string legacyShaderGuid, Shader legacyShader, out string matchSource) { if (SerializedMaterialReferencesShaderGuid(assetPath, legacyShaderGuid)) { matchSource = Text("particleBaseMigration.match.shaderGuid", "Shader GUID"); return true; } if (material != null && material.shader != null) { if (legacyShader != null && material.shader == legacyShader) { matchSource = Text("particleBaseMigration.match.shaderObject", "Shader object"); return true; } if (string.Equals(material.shader.name, LegacyShaderName, StringComparison.Ordinal)) { matchSource = Text("particleBaseMigration.match.shaderNameFallback", "Shader name fallback"); return true; } } matchSource = null; return false; } private static bool SerializedMaterialReferencesShaderGuid(string assetPath, string shaderGuid) { if (string.IsNullOrEmpty(assetPath) || string.IsNullOrEmpty(shaderGuid)) { return false; } string physicalPath; if (!TryGetPhysicalPath(assetPath, out physicalPath) || !File.Exists(physicalPath)) { return false; } try { string content = File.ReadAllText(physicalPath); return content.IndexOf("m_Shader:", StringComparison.Ordinal) >= 0 && content.IndexOf("guid: " + shaderGuid, StringComparison.OrdinalIgnoreCase) >= 0; } catch { return false; } } private static bool TryReadSerializedIntegerProperty(string assetPath, string propertyName, out int value) { value = 0; if (string.IsNullOrEmpty(assetPath) || string.IsNullOrEmpty(propertyName)) { return false; } string physicalPath; if (!TryGetPhysicalPath(assetPath, out physicalPath) || !File.Exists(physicalPath)) { return false; } try { string prefix = "- " + propertyName + ":"; using (var reader = new StringReader(File.ReadAllText(physicalPath))) { string line; while ((line = reader.ReadLine()) != null) { string trimmed = line.Trim(); if (!trimmed.StartsWith(prefix, StringComparison.Ordinal)) { continue; } string rawValue = trimmed.Substring(prefix.Length).Trim(); return int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); } } } catch { return false; } return false; } private static bool TryGetPhysicalPath(string assetPath, out string physicalPath) { physicalPath = null; if (string.IsNullOrEmpty(assetPath)) { return false; } string normalizedPath = assetPath.Replace('\\', '/'); if (normalizedPath.StartsWith("Assets/", StringComparison.Ordinal) || string.Equals(normalizedPath, "Assets", StringComparison.Ordinal)) { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); physicalPath = Path.Combine(projectRoot, normalizedPath.Replace('/', Path.DirectorySeparatorChar)); return true; } if (!normalizedPath.StartsWith("Packages/", StringComparison.Ordinal)) { return false; } var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(normalizedPath); if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath)) { return false; } string packageRoot = "Packages/" + packageInfo.name + "/"; if (!normalizedPath.StartsWith(packageRoot, StringComparison.Ordinal)) { return false; } string relativePath = normalizedPath.Substring(packageRoot.Length).Replace('/', Path.DirectorySeparatorChar); physicalPath = Path.Combine(packageInfo.resolvedPath, relativePath); return true; } private static string ResolveShaderGuid(string assetPath, string fallbackGuid) { string guid = AssetDatabase.AssetPathToGUID(assetPath); return string.IsNullOrEmpty(guid) ? fallbackGuid : guid; } private static Shader LoadShaderByGuidOrPath(string guid, string fallbackAssetPath) { string assetPath = string.IsNullOrEmpty(guid) ? null : AssetDatabase.GUIDToAssetPath(guid); if (string.IsNullOrEmpty(assetPath)) { assetPath = fallbackAssetPath; } return AssetDatabase.LoadAssetAtPath(assetPath); } private void SetAllSelected(bool selected) { for (int i = 0; i < _entries.Count; i++) { _entries[i].selected = selected; } } private int CountSelected() { int count = 0; for (int i = 0; i < _entries.Count; i++) { if (_entries[i].selected) { count++; } } return count; } private List GetSelectedEntries() { var selectedEntries = new List(); for (int i = 0; i < _entries.Count; i++) { if (_entries[i].selected) { selectedEntries.Add(_entries[i]); } } return selectedEntries; } private static void PingAsset(string assetPath) { UnityEngine.Object asset = AssetDatabase.LoadMainAssetAtPath(assetPath); if (asset == null) { return; } Selection.activeObject = asset; EditorGUIUtility.PingObject(asset); } private static GUIContent ButtonContent(string key, string fallback) { return NBShaderInspectorLocalization.MakeContent("inspector." + key + ".button", fallback); } private static string Text(string key, string fallback) { return NBShaderInspectorLocalization.GetInspectorText(key, fallback); } private static string FormatText(string key, string fallback, params object[] args) { return string.Format(Text(key, fallback), args); } private sealed class MaterialMigrationEntry { public string assetPath; public Material material; public string matchSource; public bool selected; } } }