Add DeepBump normal + height map generator editor tool
Right-click a texture > Process > Generate Normal + Height Map (DeepBump): runs the DeepBump ML CLI (color_to_normals, then normals_to_height) in the background with progress display. Sources larger than 2048px are downscaled via Pillow/Lanczos first (quadratic tile cost, avoids grid seams). Normal imports as Normal Map, height as linear (sRGB off). Tool lives outside Assets under Tools/DeepBump (gitignored: cloned repo + local venv). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
adcd1db73b
commit
8a5f5fd832
3 files changed
+246
No files matched your search
@@ -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/
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Texture2D>(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<Texture2D>(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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one DeepBump CLI module as a background process with its own progress item;
|
||||
/// onDone(success, stderr) is invoked on the main thread.
|
||||
/// </summary>
|
||||
static void RunCli(string python, string deepBump, string inputFull, string outputFull, string module,
|
||||
string progressTitle, string itemName, SynchronizationContext mainThread, Action<bool, string> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a temp-file path with the downscaled image if the source exceeds
|
||||
/// <see cref="MaxInputSize"/>, otherwise null (use the original). Runs synchronously
|
||||
/// (sub-second even for large photos).
|
||||
/// </summary>
|
||||
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<Texture2D>(assetPath);
|
||||
EditorGUIUtility.PingObject(imported);
|
||||
Debug.Log($"[DeepBump] Created '{assetPath}'.", imported);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbe40bb187a4ccc45b2c74267cbc4259
|
||||
Reference in new issue
Block a user