diff --git a/.gitignore b/.gitignore index e92b785..83f718c 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,6 @@ crashlytics-build.properties /Packages/com.mibdev.fullscreen-editor/ /Assets/Textures/PSD~/ + +# DeepBump CLI (local ML tool: cloned repo + machine-local venv, see TextureNormalMapGenerator.cs) +/Tools/DeepBump/ diff --git a/Assets/Scripts/Editor/TextureNormalMapGenerator.cs b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs new file mode 100644 index 0000000..bdafdbe --- /dev/null +++ b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs @@ -0,0 +1,241 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using UnityEditor; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace Chameleon.Editor +{ + /// + /// Right-click a texture asset -> Process -> Generate Normal + Height Map (DeepBump). + /// Runs the DeepBump ML CLI (Tools/DeepBump, own venv) twice: color_to_normals estimates a + /// normal map from the color/albedo image, normals_to_height then derives a height/displacement + /// map from that normal map (DeepBump has no direct color->height path). Both land next to the + /// original as _normal.png (Texture Type = Normal Map) and _height.png (linear, sRGB off). + /// DeepBump outputs OpenGL convention, which matches Unity. + /// + public static class TextureNormalMapGenerator + { + const string MenuPath = "Assets/Process/Generate Normal + Height Map (DeepBump)"; + const string DeepBumpDir = "Tools/DeepBump"; + // Inputs above this get Lanczos-downscaled first: DeepBump cost is quadratic in resolution + // (256px tiles) and fewer tiles also means fewer visible tile seams. Normal-map detail is + // low-frequency anyway; strength is tuned via _BumpScale. + const int MaxInputSize = 2048; + + static string ProjectRoot => Path.GetDirectoryName(Application.dataPath); + + [MenuItem(MenuPath, true)] + static bool Validate() + => Selection.GetFiltered(SelectionMode.Assets).Length > 0; + + [MenuItem(MenuPath, false, 2001)] + static void Process() + { + string deepBump = Path.Combine(ProjectRoot, DeepBumpDir); + string python = Path.Combine(deepBump, "venv/Scripts/python.exe"); + if (!File.Exists(python)) + { + Debug.LogError($"[DeepBump] venv python not found at '{python}'. " + + "Set up Tools/DeepBump (clone + venv with numpy/onnxruntime/imageio) first."); + return; + } + + var mainThread = SynchronizationContext.Current; + foreach (var texture in Selection.GetFiltered(SelectionMode.Assets)) + ProcessTexture(texture, deepBump, python, mainThread); + } + + static void ProcessTexture(Texture2D texture, string deepBump, string python, SynchronizationContext mainThread) + { + string assetPath = AssetDatabase.GetAssetPath(texture); + if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) + { + Debug.LogError($"[DeepBump] No source file for '{texture.name}'."); + return; + } + + string directory = Path.GetDirectoryName(assetPath)?.Replace('\\', '/'); + string baseName = Path.GetFileNameWithoutExtension(assetPath); + string normalAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{baseName}_normal.png"); + string heightAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{directory}/{baseName}_height.png"); + + string inputFull = Path.GetFullPath(assetPath); + string normalFull = Path.GetFullPath(normalAssetPath); + string heightFull = Path.GetFullPath(heightAssetPath); + string tempInput = DownscaleIfNeeded(assetPath, inputFull, python, deepBump); + string itemName = Path.GetFileName(assetPath); + + RunCli(python, deepBump, tempInput ?? inputFull, normalFull, "color_to_normals", + "DeepBump Normal Map", itemName, mainThread, (normalOk, normalLog) => + { + DeleteTemp(tempInput); + if (!normalOk) + { + if (File.Exists(normalFull)) File.Delete(normalFull); + Debug.LogError($"[DeepBump] Normal map generation failed for '{normalAssetPath}':\n{normalLog}"); + return; + } + ImportNormalMap(normalAssetPath); + + // Height is derived from the just-generated normal map (full-res PNG on disk, before + // Unity's import size cap can shrink it). + RunCli(python, deepBump, normalFull, heightFull, "normals_to_height", + "DeepBump Height Map", itemName, mainThread, (heightOk, heightLog) => + { + if (!heightOk) + { + if (File.Exists(heightFull)) File.Delete(heightFull); + Debug.LogError($"[DeepBump] Height map generation failed for '{heightAssetPath}' " + + $"(the normal map was created fine):\n{heightLog}"); + return; + } + ImportHeightMap(heightAssetPath); + }); + }); + } + + /// + /// Runs one DeepBump CLI module as a background process with its own progress item; + /// onDone(success, stderr) is invoked on the main thread. + /// + static void RunCli(string python, string deepBump, string inputFull, string outputFull, string module, + string progressTitle, string itemName, SynchronizationContext mainThread, Action onDone) + { + int progressId = Progress.Start(progressTitle, itemName); + + var startInfo = new ProcessStartInfo + { + FileName = python, + Arguments = $"cli.py \"{inputFull}\" \"{outputFull}\" {module} --verbose", + WorkingDirectory = deepBump, // cli.py imports its sibling modules relative to cwd + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + // cli.py prints a Unicode arrow; the default cp1252 console encoding would crash it. + startInfo.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; + + var log = new StringBuilder(); + + try + { + var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; + process.OutputDataReceived += (_, e) => + { + if (e.Data == null) return; + // Verbose progress lines are "current/total" per tile. + int slash = e.Data.IndexOf('/'); + if (slash > 0 + && float.TryParse(e.Data.Substring(0, slash), out float current) + && float.TryParse(e.Data.Substring(slash + 1), out float total) && total > 0) + Progress.Report(progressId, current / total); + }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) log.AppendLine(e.Data); }; + process.Exited += (_, _) => + { + bool success = process.ExitCode == 0; + process.Dispose(); + mainThread.Post(_ => + { + Progress.Remove(progressId); + onDone(success, log.ToString()); + }, null); + }; + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + } + catch (Exception e) + { + Progress.Remove(progressId); + onDone(false, $"Failed to start python: {e.Message}"); + } + } + + /// + /// Returns a temp-file path with the downscaled image if the source exceeds + /// , otherwise null (use the original). Runs synchronously + /// (sub-second even for large photos). + /// + static string DownscaleIfNeeded(string assetPath, string inputFull, string python, string deepBump) + { + int width = 0, height = 0; + if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer) + importer.GetSourceTextureWidthAndHeight(out width, out height); + if (Mathf.Max(width, height) <= MaxInputSize) + return null; + + string temp = Path.Combine(Path.GetTempPath(), $"deepbump_{Guid.NewGuid():N}.png"); + string code = "from PIL import Image; im = Image.open(r'" + inputFull + "').convert('RGB'); " + + $"im.thumbnail(({MaxInputSize}, {MaxInputSize}), Image.LANCZOS); " + + "im.save(r'" + temp + "')"; + var startInfo = new ProcessStartInfo + { + FileName = python, + Arguments = $"-c \"{code}\"", + WorkingDirectory = deepBump, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + }; + try + { + // Fully qualified: bare "Process" resolves to this class's Process() menu method. + using var process = System.Diagnostics.Process.Start(startInfo); + string error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + if (process.ExitCode == 0 && File.Exists(temp)) + return temp; + Debug.LogWarning($"[DeepBump] Pre-resize failed, falling back to full resolution:\n{error}"); + } + catch (Exception e) + { + Debug.LogWarning($"[DeepBump] Pre-resize failed, falling back to full resolution: {e.Message}"); + } + DeleteTemp(temp); + return null; + } + + static void DeleteTemp(string tempInput) + { + if (tempInput != null && File.Exists(tempInput)) + File.Delete(tempInput); + } + + static void ImportNormalMap(string assetPath) + { + AssetDatabase.ImportAsset(assetPath); + if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer + && importer.textureType != TextureImporterType.NormalMap) + { + importer.textureType = TextureImporterType.NormalMap; + importer.SaveAndReimport(); + } + Ping(assetPath); + } + + static void ImportHeightMap(string assetPath) + { + AssetDatabase.ImportAsset(assetPath); + // Grayscale displacement data, not color: linear sampling. + if (AssetImporter.GetAtPath(assetPath) is TextureImporter importer && importer.sRGBTexture) + { + importer.sRGBTexture = false; + importer.SaveAndReimport(); + } + Ping(assetPath); + } + + static void Ping(string assetPath) + { + var imported = AssetDatabase.LoadAssetAtPath(assetPath); + EditorGUIUtility.PingObject(imported); + Debug.Log($"[DeepBump] Created '{assetPath}'.", imported); + } + } +} diff --git a/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta new file mode 100644 index 0000000..bd21155 --- /dev/null +++ b/Assets/Scripts/Editor/TextureNormalMapGenerator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fbe40bb187a4ccc45b2c74267cbc4259 \ No newline at end of file