Add Poly Haven importer editor tool
Tools/Poly Haven Importer: fetches asset metadata/files via the public Poly Haven API, browsable texture catalog with searchable thumbnail grid, per-map resolution selection, async non-blocking downloads, and automatic URP/Lit material build (roughness->smoothness repacking into _MetallicGlossMap, ARM unpacking as fallback for missing AO/Rough/Metal maps, linear import settings for data textures). Thumbnails survive play mode via HideAndDontSave + destroyed-cache recovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3441e3413e
commit
897ba57599
9 files changed
+1220
No files matched your search
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e7186f7ca5e56f14382071f77388acaa
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
/// <summary>One downloadable file variant (a specific map/resolution/format).</summary>
|
||||
public readonly struct PolyHavenFile
|
||||
{
|
||||
public readonly string Url;
|
||||
public readonly string Format; // "jpg" / "png" / "exr"
|
||||
public readonly long Size;
|
||||
|
||||
public PolyHavenFile(string url, string format, long size)
|
||||
{
|
||||
Url = url;
|
||||
Format = format;
|
||||
Size = size;
|
||||
}
|
||||
|
||||
public bool IsValid => !string.IsNullOrEmpty(Url);
|
||||
}
|
||||
|
||||
/// <summary>Asset metadata from /info/{slug}.</summary>
|
||||
public sealed class PolyHavenInfo
|
||||
{
|
||||
public string Slug;
|
||||
public string Name;
|
||||
public int Type; // 0=HDRI, 1=Texture, 2=Model
|
||||
public string ThumbnailUrl;
|
||||
public string[] Categories = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>One catalog entry from /assets (lightweight, for the browser grid).</summary>
|
||||
public sealed class PolyHavenAsset
|
||||
{
|
||||
public string Slug;
|
||||
public string Name;
|
||||
public string ThumbnailUrl;
|
||||
public string[] Categories = Array.Empty<string>();
|
||||
public string[] Tags = Array.Empty<string>();
|
||||
public string Search; // pre-lowered "name slug categories tags" haystack
|
||||
}
|
||||
|
||||
/// <summary>Parsed /files/{slug}: MapType -> resolution("2k") -> best file.</summary>
|
||||
public sealed class PolyHavenFiles
|
||||
{
|
||||
public readonly Dictionary<PolyHavenMapType, Dictionary<string, PolyHavenFile>> Maps =
|
||||
new Dictionary<PolyHavenMapType, Dictionary<string, PolyHavenFile>>();
|
||||
|
||||
// Every resolution string seen across all maps (e.g. "1k","2k","4k"), sorted low->high.
|
||||
public List<string> Resolutions = new List<string>();
|
||||
|
||||
public bool Has(PolyHavenMapType type) => Maps.ContainsKey(type);
|
||||
|
||||
public bool TryGet(PolyHavenMapType type, string res, out PolyHavenFile file)
|
||||
{
|
||||
file = default;
|
||||
return Maps.TryGetValue(type, out var byRes) && byRes.TryGetValue(res, out file);
|
||||
}
|
||||
|
||||
/// <summary>Best available file at res, else next-lower resolution of the same map.</summary>
|
||||
public bool TryGetWithFallback(PolyHavenMapType type, string res, out PolyHavenFile file, out string usedRes)
|
||||
{
|
||||
file = default;
|
||||
usedRes = null;
|
||||
if (!Maps.TryGetValue(type, out var byRes))
|
||||
return false;
|
||||
if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
|
||||
|
||||
int target = ResolutionRank(res);
|
||||
int bestRank = -1;
|
||||
foreach (var kv in byRes)
|
||||
{
|
||||
int rank = ResolutionRank(kv.Key);
|
||||
if (rank <= target && rank > bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
file = kv.Value;
|
||||
usedRes = kv.Key;
|
||||
}
|
||||
}
|
||||
if (usedRes == null) // nothing at-or-below target: take the lowest available
|
||||
{
|
||||
foreach (var kv in byRes)
|
||||
{
|
||||
int rank = ResolutionRank(kv.Key);
|
||||
if (bestRank == -1 || rank < bestRank)
|
||||
{
|
||||
bestRank = rank;
|
||||
file = kv.Value;
|
||||
usedRes = kv.Key;
|
||||
}
|
||||
}
|
||||
}
|
||||
return usedRes != null;
|
||||
}
|
||||
|
||||
public static int ResolutionRank(string res)
|
||||
{
|
||||
if (string.IsNullOrEmpty(res)) return int.MaxValue;
|
||||
var digits = res.TrimEnd('k', 'K');
|
||||
return int.TryParse(digits, out var n) ? n : int.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Async client for the public Poly Haven API (same endpoints the Blender/UE5 plugins use).
|
||||
/// All requests poll on EditorApplication.update — never blocks the editor.
|
||||
/// </summary>
|
||||
public static class PolyHavenApi
|
||||
{
|
||||
private const string ApiBase = "https://api.polyhaven.com";
|
||||
private const string UserAgent = "Chameleon-Unity-PolyHavenImporter/1.0";
|
||||
|
||||
// Best format preference per map: lossless where it matters, jpg otherwise.
|
||||
private static readonly string[] LosslessPref = { "png", "exr", "jpg" };
|
||||
private static readonly string[] LossyPref = { "jpg", "png", "exr" };
|
||||
|
||||
public static string ExtractSlug(string urlOrSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(urlOrSlug)) return null;
|
||||
var s = urlOrSlug.Trim();
|
||||
|
||||
// Full URL like https://polyhaven.com/a/broken_brick_wall
|
||||
int a = s.IndexOf("/a/", StringComparison.OrdinalIgnoreCase);
|
||||
if (a >= 0)
|
||||
s = s.Substring(a + 3);
|
||||
|
||||
// Strip any trailing path/query/fragment.
|
||||
foreach (var sep in new[] { '?', '#', '/' })
|
||||
{
|
||||
int i = s.IndexOf(sep);
|
||||
if (i >= 0) s = s.Substring(0, i);
|
||||
}
|
||||
s = s.Trim();
|
||||
return s.Length == 0 ? null : s;
|
||||
}
|
||||
|
||||
public static string ThumbnailUrl(string slug) =>
|
||||
$"https://cdn.polyhaven.com/asset_img/thumbs/{slug}.png?width=256&height=256";
|
||||
|
||||
// ---- Requests ----------------------------------------------------------
|
||||
|
||||
public static void FetchInfo(string slug, Action<PolyHavenInfo> onDone, Action<string> onError)
|
||||
{
|
||||
var req = UnityWebRequest.Get($"{ApiBase}/info/{slug}");
|
||||
req.SetRequestHeader("User-Agent", UserAgent);
|
||||
Send(req, () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var o = JObject.Parse(req.downloadHandler.text);
|
||||
var info = new PolyHavenInfo
|
||||
{
|
||||
Slug = slug,
|
||||
Name = (string)o["name"] ?? slug,
|
||||
Type = o["type"] != null ? (int)o["type"] : -1,
|
||||
ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(slug),
|
||||
};
|
||||
if (o["categories"] is JArray cats)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var c in cats) list.Add((string)c);
|
||||
info.Categories = list.ToArray();
|
||||
}
|
||||
onDone?.Invoke(info);
|
||||
}
|
||||
catch (Exception e) { onError?.Invoke($"Info parse failed: {e.Message}"); }
|
||||
}, onError);
|
||||
}
|
||||
|
||||
/// <summary>Loads the full texture catalog (keyed by slug) for the browser grid.</summary>
|
||||
public static void FetchAssets(string type, Action<List<PolyHavenAsset>> onDone, Action<string> onError)
|
||||
{
|
||||
var req = UnityWebRequest.Get($"{ApiBase}/assets?t={type}");
|
||||
req.SetRequestHeader("User-Agent", UserAgent);
|
||||
Send(req, () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = JObject.Parse(req.downloadHandler.text);
|
||||
var list = new List<PolyHavenAsset>(root.Count);
|
||||
foreach (var prop in root)
|
||||
{
|
||||
if (prop.Value is not JObject o) continue;
|
||||
var a = new PolyHavenAsset
|
||||
{
|
||||
Slug = prop.Key,
|
||||
Name = (string)o["name"] ?? prop.Key,
|
||||
ThumbnailUrl = (string)o["thumbnail_url"] ?? ThumbnailUrl(prop.Key),
|
||||
Categories = ToStringArray(o["categories"]),
|
||||
Tags = ToStringArray(o["tags"]),
|
||||
};
|
||||
a.Search = (a.Name + " " + a.Slug + " " +
|
||||
string.Join(" ", a.Categories) + " " +
|
||||
string.Join(" ", a.Tags)).ToLowerInvariant();
|
||||
list.Add(a);
|
||||
}
|
||||
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase));
|
||||
onDone?.Invoke(list);
|
||||
}
|
||||
catch (Exception e) { onError?.Invoke($"Assets parse failed: {e.Message}"); }
|
||||
}, onError);
|
||||
}
|
||||
|
||||
public static void FetchFiles(string slug, Action<PolyHavenFiles> onDone, Action<string> onError)
|
||||
{
|
||||
var req = UnityWebRequest.Get($"{ApiBase}/files/{slug}");
|
||||
req.SetRequestHeader("User-Agent", UserAgent);
|
||||
Send(req, () =>
|
||||
{
|
||||
try { onDone?.Invoke(ParseFiles(req.downloadHandler.text)); }
|
||||
catch (Exception e) { onError?.Invoke($"Files parse failed: {e.Message}"); }
|
||||
}, onError);
|
||||
}
|
||||
|
||||
public static void FetchThumbnail(string url, Action<Texture2D> onDone, Action<string> onError)
|
||||
{
|
||||
var req = UnityWebRequestTexture.GetTexture(url);
|
||||
req.SetRequestHeader("User-Agent", UserAgent);
|
||||
Send(req, () =>
|
||||
{
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
// Survive play-mode enter/exit cleanup — otherwise Unity destroys these
|
||||
// loose runtime textures and the browser previews go blank.
|
||||
if (tex)
|
||||
tex.hideFlags = HideFlags.HideAndDontSave;
|
||||
onDone?.Invoke(tex);
|
||||
}, onError);
|
||||
}
|
||||
|
||||
/// <summary>Streams a file straight to disk (no RAM peak on big EXR/8K files).</summary>
|
||||
public static void DownloadFile(PolyHavenFile file, string destPath, Action onDone,
|
||||
Action<string> onError, Action<float> onProgress = null)
|
||||
{
|
||||
var req = new UnityWebRequest(file.Url, UnityWebRequest.kHttpVerbGET);
|
||||
req.downloadHandler = new DownloadHandlerFile(destPath) { removeFileOnAbort = true };
|
||||
req.SetRequestHeader("User-Agent", UserAgent);
|
||||
Send(req, () => onDone?.Invoke(), onError, onProgress);
|
||||
}
|
||||
|
||||
// ---- Parsing -----------------------------------------------------------
|
||||
|
||||
private static string[] ToStringArray(JToken token)
|
||||
{
|
||||
if (token is not JArray arr) return Array.Empty<string>();
|
||||
var list = new List<string>(arr.Count);
|
||||
foreach (var t in arr)
|
||||
{
|
||||
var s = (string)t;
|
||||
if (!string.IsNullOrEmpty(s)) list.Add(s);
|
||||
}
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
private static PolyHavenFiles ParseFiles(string json)
|
||||
{
|
||||
var root = JObject.Parse(json);
|
||||
var result = new PolyHavenFiles();
|
||||
var resSet = new HashSet<string>();
|
||||
|
||||
foreach (var meta in PolyHavenMapInfo.All)
|
||||
{
|
||||
if (root[meta.ApiKey] is not JObject byRes)
|
||||
continue;
|
||||
|
||||
var pref = (meta.IsNormal || meta.Type == PolyHavenMapType.Displacement)
|
||||
? LosslessPref
|
||||
: LossyPref;
|
||||
|
||||
var perRes = new Dictionary<string, PolyHavenFile>();
|
||||
foreach (var resProp in byRes)
|
||||
{
|
||||
string res = resProp.Key;
|
||||
if (resProp.Value is not JObject formats)
|
||||
continue;
|
||||
|
||||
PolyHavenFile picked = default;
|
||||
foreach (var fmt in pref)
|
||||
{
|
||||
if (formats[fmt] is JObject fo && fo["url"] != null)
|
||||
{
|
||||
picked = new PolyHavenFile(
|
||||
(string)fo["url"], fmt,
|
||||
fo["size"] != null ? (long)fo["size"] : 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (picked.IsValid)
|
||||
{
|
||||
perRes[res] = picked;
|
||||
resSet.Add(res);
|
||||
}
|
||||
}
|
||||
|
||||
if (perRes.Count > 0)
|
||||
result.Maps[meta.Type] = perRes;
|
||||
}
|
||||
|
||||
var resList = new List<string>(resSet);
|
||||
resList.Sort((x, y) => PolyHavenFiles.ResolutionRank(x).CompareTo(PolyHavenFiles.ResolutionRank(y)));
|
||||
result.Resolutions = resList;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Non-blocking transport -------------------------------------------
|
||||
|
||||
private static void Send(UnityWebRequest req, Action onSuccess, Action<string> onError,
|
||||
Action<float> onProgress = null)
|
||||
{
|
||||
var op = req.SendWebRequest();
|
||||
void Tick()
|
||||
{
|
||||
if (onProgress != null && !op.isDone)
|
||||
onProgress(req.downloadProgress);
|
||||
|
||||
if (!op.isDone)
|
||||
return;
|
||||
|
||||
EditorApplication.update -= Tick;
|
||||
try
|
||||
{
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"{req.error} ({req.url})");
|
||||
}
|
||||
else
|
||||
{
|
||||
onProgress?.Invoke(1f);
|
||||
onSuccess?.Invoke();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
req.Dispose();
|
||||
}
|
||||
}
|
||||
EditorApplication.update += Tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc6fa3c02522bc342877a7d50977f482
|
||||
@@ -0,0 +1,534 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor window: paste a Poly Haven asset URL, fetch its maps, pick resolution +
|
||||
/// maps, then download and auto-build a URP/Lit material under Assets/Poly Haven/{Name}/.
|
||||
/// No browser — asset selection stays on polyhaven.com.
|
||||
/// </summary>
|
||||
public sealed class PolyHavenImporterWindow : EditorWindow
|
||||
{
|
||||
private const string RootFolder = "Assets/Poly Haven";
|
||||
private const string DefaultRes = "2k";
|
||||
|
||||
private string _urlInput = "https://polyhaven.com/a/";
|
||||
private PolyHavenInfo _info;
|
||||
private PolyHavenFiles _files;
|
||||
private Texture2D _thumbnail;
|
||||
private string _selectedRes = DefaultRes;
|
||||
private readonly Dictionary<PolyHavenMapType, bool> _mapEnabled = new Dictionary<PolyHavenMapType, bool>();
|
||||
|
||||
private bool _busy;
|
||||
private string _status = "";
|
||||
private Vector2 _scroll;
|
||||
|
||||
// ---- Browser ----------------------------------------------------------
|
||||
private const int MaxResults = 150;
|
||||
private const int MaxThumbInFlight = 6;
|
||||
|
||||
// Catalog cached for the whole session (survives window close, cleared on domain reload).
|
||||
private static List<PolyHavenAsset> s_catalog;
|
||||
|
||||
private bool _browseFoldout = true;
|
||||
private bool _autoLoadTried;
|
||||
private bool _catalogLoading;
|
||||
private string _searchQuery = "";
|
||||
private readonly List<PolyHavenAsset> _filtered = new List<PolyHavenAsset>();
|
||||
private Vector2 _gridScroll;
|
||||
|
||||
private readonly Dictionary<string, Texture2D> _thumbs = new Dictionary<string, Texture2D>();
|
||||
private readonly HashSet<string> _thumbRequested = new HashSet<string>();
|
||||
private int _thumbsInFlight;
|
||||
|
||||
[MenuItem("Tools/Poly Haven Importer")]
|
||||
public static void Open()
|
||||
{
|
||||
var w = GetWindow<PolyHavenImporterWindow>("Poly Haven Importer");
|
||||
w.minSize = new Vector2(440, 480);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Catalog may already be cached from a previous window instance this session.
|
||||
if (s_catalog != null)
|
||||
{
|
||||
_autoLoadTried = true;
|
||||
ApplyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||
|
||||
EditorGUILayout.LabelField("Poly Haven Importer", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Pick a texture on polyhaven.com, paste its URL here (e.g. .../a/broken_brick_wall), " +
|
||||
"Fetch, choose resolution + maps, then Download. A URP/Lit material is built automatically.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space();
|
||||
|
||||
DrawBrowser();
|
||||
|
||||
EditorGUILayout.Space();
|
||||
|
||||
using (new EditorGUI.DisabledScope(_busy))
|
||||
{
|
||||
EditorGUILayout.LabelField("Asset URL or slug", EditorStyles.miniBoldLabel);
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
_urlInput = EditorGUILayout.TextField(_urlInput);
|
||||
if (GUILayout.Button("Fetch", GUILayout.Width(70)))
|
||||
Fetch();
|
||||
}
|
||||
}
|
||||
|
||||
if (_info != null)
|
||||
DrawAssetSection();
|
||||
|
||||
EditorGUILayout.Space();
|
||||
if (!string.IsNullOrEmpty(_status))
|
||||
EditorGUILayout.HelpBox(_status, _busy ? MessageType.None : MessageType.Info);
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawAssetSection()
|
||||
{
|
||||
EditorGUILayout.Space();
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (_thumbnail)
|
||||
{
|
||||
var r = GUILayoutUtility.GetRect(96, 96, GUILayout.Width(96), GUILayout.Height(96));
|
||||
GUI.DrawTexture(r, _thumbnail, ScaleMode.ScaleToFit);
|
||||
}
|
||||
using (new EditorGUILayout.VerticalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_info.Name, EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"Slug: {_info.Slug}", EditorStyles.miniLabel);
|
||||
if (_info.Categories != null && _info.Categories.Length > 0)
|
||||
EditorGUILayout.LabelField($"Categories: {string.Join(", ", _info.Categories)}", EditorStyles.miniLabel);
|
||||
if (_info.Type != 1)
|
||||
EditorGUILayout.HelpBox("This asset is not a texture — only textures are supported.", MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
if (_files == null || _files.Resolutions.Count == 0)
|
||||
return;
|
||||
|
||||
EditorGUILayout.Space();
|
||||
|
||||
// Resolution popup.
|
||||
var resArray = _files.Resolutions.ToArray();
|
||||
int resIdx = Mathf.Max(0, System.Array.IndexOf(resArray, _selectedRes));
|
||||
resIdx = EditorGUILayout.Popup("Resolution", resIdx, resArray.Select(r => r.ToUpperInvariant()).ToArray());
|
||||
_selectedRes = resArray[resIdx];
|
||||
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
|
||||
foreach (var meta in PolyHavenMapInfo.All)
|
||||
{
|
||||
if (!_files.Has(meta.Type))
|
||||
continue;
|
||||
bool cur = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
|
||||
_mapEnabled[meta.Type] = EditorGUILayout.ToggleLeft(meta.Label, cur);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space();
|
||||
bool canDownload = !_busy && _info.Type == 1 && AnyMapSelected();
|
||||
using (new EditorGUI.DisabledScope(!canDownload))
|
||||
{
|
||||
if (GUILayout.Button("Download / Import", GUILayout.Height(30)))
|
||||
Download();
|
||||
}
|
||||
}
|
||||
|
||||
private bool AnyMapSelected()
|
||||
{
|
||||
foreach (var meta in PolyHavenMapInfo.All)
|
||||
if (_files.Has(meta.Type) && _mapEnabled.TryGetValue(meta.Type, out var v) && v)
|
||||
return true;
|
||||
// Fall back to defaults if the user hasn't toggled anything yet.
|
||||
foreach (var meta in PolyHavenMapInfo.All)
|
||||
if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Browser -----------------------------------------------------------
|
||||
|
||||
private void DrawBrowser()
|
||||
{
|
||||
_browseFoldout = EditorGUILayout.Foldout(_browseFoldout, "Browse catalog", true, EditorStyles.foldoutHeader);
|
||||
if (!_browseFoldout)
|
||||
return;
|
||||
|
||||
using (new EditorGUI.DisabledScope(_busy))
|
||||
{
|
||||
if (s_catalog == null)
|
||||
{
|
||||
// Auto-load once when the browser is first opened; still offer a manual retry.
|
||||
if (!_catalogLoading && !_autoLoadTried)
|
||||
{
|
||||
_autoLoadTried = true;
|
||||
LoadCatalog();
|
||||
}
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUILayout.LabelField(_catalogLoading
|
||||
? "Loading texture catalog…"
|
||||
: "Catalog not loaded.");
|
||||
using (new EditorGUI.DisabledScope(_catalogLoading))
|
||||
if (GUILayout.Button("Load catalog", GUILayout.Width(110)))
|
||||
LoadCatalog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
EditorGUI.BeginChangeCheck();
|
||||
_searchQuery = EditorGUILayout.TextField("Search", _searchQuery);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
ApplyFilter();
|
||||
if (GUILayout.Button("↻", GUILayout.Width(26)))
|
||||
LoadCatalog();
|
||||
}
|
||||
|
||||
int shown = Mathf.Min(_filtered.Count, MaxResults);
|
||||
string cap = _filtered.Count > MaxResults ? $", showing first {MaxResults}" : "";
|
||||
EditorGUILayout.LabelField(
|
||||
$"{_filtered.Count} match(es){cap} of {s_catalog.Count} textures.",
|
||||
EditorStyles.miniLabel);
|
||||
|
||||
DrawGrid(shown);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawGrid(int count)
|
||||
{
|
||||
const float cell = 104f;
|
||||
const float pad = 6f;
|
||||
float viewW = EditorGUIUtility.currentViewWidth - 24f;
|
||||
int cols = Mathf.Max(1, Mathf.FloorToInt((viewW + pad) / (cell + pad)));
|
||||
|
||||
_gridScroll = EditorGUILayout.BeginScrollView(_gridScroll, GUILayout.Height(320));
|
||||
|
||||
int rows = Mathf.CeilToInt(count / (float)cols);
|
||||
for (int row = 0; row < rows; row++)
|
||||
{
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
for (int c = 0; c < cols; c++)
|
||||
{
|
||||
int i = row * cols + c;
|
||||
if (i >= count)
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
break;
|
||||
}
|
||||
DrawCell(_filtered[i], cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawCell(PolyHavenAsset a, float size)
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope(GUILayout.Width(size)))
|
||||
{
|
||||
var thumbRect = GUILayoutUtility.GetRect(size, size - 22f,
|
||||
GUILayout.Width(size), GUILayout.Height(size - 22f));
|
||||
|
||||
var tex = GetThumb(a);
|
||||
if (tex)
|
||||
GUI.DrawTexture(thumbRect, tex, ScaleMode.ScaleToFit);
|
||||
else
|
||||
EditorGUI.DrawRect(thumbRect, new Color(0f, 0f, 0f, 0.15f));
|
||||
|
||||
// Transparent hit target over the thumbnail.
|
||||
if (GUI.Button(thumbRect, new GUIContent("", a.Name), GUIStyle.none))
|
||||
SelectAsset(a);
|
||||
|
||||
EditorGUILayout.LabelField(new GUIContent(a.Name, a.Name),
|
||||
EditorStyles.miniLabel, GUILayout.Width(size));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lazy, throttled thumbnail loader — only visible cells request, capped in-flight.</summary>
|
||||
private Texture2D GetThumb(PolyHavenAsset a)
|
||||
{
|
||||
if (_thumbs.TryGetValue(a.Slug, out var tex))
|
||||
{
|
||||
if (tex)
|
||||
return tex;
|
||||
// Cached texture was destroyed (e.g. play-mode cleanup) — purge and re-request.
|
||||
_thumbs.Remove(a.Slug);
|
||||
_thumbRequested.Remove(a.Slug);
|
||||
}
|
||||
|
||||
// Don't add to _thumbRequested until a slot is free, so it retries next repaint.
|
||||
if (!_thumbRequested.Contains(a.Slug) && _thumbsInFlight < MaxThumbInFlight)
|
||||
{
|
||||
_thumbRequested.Add(a.Slug);
|
||||
_thumbsInFlight++;
|
||||
PolyHavenApi.FetchThumbnail(a.ThumbnailUrl,
|
||||
t => { _thumbs[a.Slug] = t; _thumbsInFlight--; Repaint(); },
|
||||
_ => { _thumbsInFlight--; Repaint(); }); // keep as requested: no retry-spam on error
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void SelectAsset(PolyHavenAsset a)
|
||||
{
|
||||
_urlInput = "https://polyhaven.com/a/" + a.Slug;
|
||||
GUI.FocusControl(null);
|
||||
Fetch(); // url filled -> fetch, then everything as before
|
||||
}
|
||||
|
||||
private void LoadCatalog()
|
||||
{
|
||||
_catalogLoading = true;
|
||||
_status = "Loading Poly Haven texture catalog…";
|
||||
Repaint();
|
||||
|
||||
PolyHavenApi.FetchAssets("textures",
|
||||
list =>
|
||||
{
|
||||
s_catalog = list;
|
||||
_catalogLoading = false;
|
||||
ApplyFilter();
|
||||
_status = $"Catalog loaded: {list.Count} textures.";
|
||||
Repaint();
|
||||
},
|
||||
err =>
|
||||
{
|
||||
_catalogLoading = false;
|
||||
_status = "Catalog error: " + err;
|
||||
Debug.LogError("[PolyHaven] " + err);
|
||||
Repaint();
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
_filtered.Clear();
|
||||
if (s_catalog == null)
|
||||
return;
|
||||
|
||||
var q = _searchQuery?.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(q))
|
||||
{
|
||||
_filtered.AddRange(s_catalog);
|
||||
return;
|
||||
}
|
||||
|
||||
var terms = q.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var a in s_catalog)
|
||||
{
|
||||
bool all = true;
|
||||
foreach (var t in terms)
|
||||
if (a.Search.IndexOf(t, StringComparison.Ordinal) < 0) { all = false; break; }
|
||||
if (all)
|
||||
_filtered.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Fetch -------------------------------------------------------------
|
||||
|
||||
private void Fetch()
|
||||
{
|
||||
string slug = PolyHavenApi.ExtractSlug(_urlInput);
|
||||
if (string.IsNullOrEmpty(slug))
|
||||
{
|
||||
EditorUtility.DisplayDialog("Poly Haven", "Could not read a slug from the URL.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
_status = $"Fetching '{slug}'…";
|
||||
_info = null;
|
||||
_files = null;
|
||||
_thumbnail = null;
|
||||
_mapEnabled.Clear();
|
||||
Repaint();
|
||||
|
||||
PolyHavenApi.FetchInfo(slug, info =>
|
||||
{
|
||||
_info = info;
|
||||
Repaint();
|
||||
PolyHavenApi.FetchThumbnail(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
|
||||
|
||||
PolyHavenApi.FetchFiles(slug, files =>
|
||||
{
|
||||
_files = files;
|
||||
if (files.Resolutions.Contains(DefaultRes))
|
||||
_selectedRes = DefaultRes;
|
||||
else if (files.Resolutions.Count > 0)
|
||||
_selectedRes = files.Resolutions[Mathf.Min(1, files.Resolutions.Count - 1)];
|
||||
|
||||
_busy = false;
|
||||
_status = info.Type == 1
|
||||
? $"Ready: {files.Maps.Count} map type(s), {files.Resolutions.Count} resolution(s)."
|
||||
: "Fetched, but this asset is not a texture.";
|
||||
Repaint();
|
||||
}, err => Fail(err));
|
||||
}, err => Fail(err));
|
||||
}
|
||||
|
||||
// ---- Download ----------------------------------------------------------
|
||||
|
||||
private void Download()
|
||||
{
|
||||
// Resolve which maps to fetch.
|
||||
var wanted = new List<PolyHavenMapType>();
|
||||
foreach (var meta in PolyHavenMapInfo.All)
|
||||
{
|
||||
if (!_files.Has(meta.Type)) continue;
|
||||
bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
|
||||
if (on) wanted.Add(meta.Type);
|
||||
}
|
||||
|
||||
// ARM fallback: if the asset packs AO/Rough/Metal only into ARM (no dedicated
|
||||
// map for a channel), pull ARM so the builder can unpack the missing channels.
|
||||
if (_files.Has(PolyHavenMapType.Arm) && !wanted.Contains(PolyHavenMapType.Arm))
|
||||
{
|
||||
bool channelMissing = !_files.Has(PolyHavenMapType.Metal)
|
||||
|| !_files.Has(PolyHavenMapType.Rough)
|
||||
|| !_files.Has(PolyHavenMapType.AO);
|
||||
if (channelMissing)
|
||||
wanted.Add(PolyHavenMapType.Arm);
|
||||
}
|
||||
|
||||
if (wanted.Count == 0)
|
||||
return;
|
||||
|
||||
string displayName = _info.Name;
|
||||
string folder = $"{RootFolder}/{PolyHavenMaterialBuilder.SanitizeFileName(displayName)}";
|
||||
|
||||
// Folder-collision handling (non-destructive default).
|
||||
if (AssetDatabase.IsValidFolder(folder))
|
||||
{
|
||||
int choice = EditorUtility.DisplayDialogComplex("Poly Haven",
|
||||
$"Folder already exists:\n{folder}", "New folder", "Cancel", "Overwrite");
|
||||
if (choice == 1) return; // Cancel
|
||||
if (choice == 0) // New folder
|
||||
folder = AssetDatabase.GenerateUniqueAssetPath(folder);
|
||||
// choice == 2 => Overwrite: reuse folder as-is.
|
||||
}
|
||||
EnsureFolder(folder);
|
||||
|
||||
int maxSize = ResolutionToMaxSize(_selectedRes);
|
||||
_busy = true;
|
||||
_status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…";
|
||||
Repaint();
|
||||
|
||||
var results = new List<PolyHavenMaterialBuilder.Downloaded>();
|
||||
var failures = new List<string>();
|
||||
int remaining = wanted.Count;
|
||||
|
||||
AssetDatabase.StartAssetEditing();
|
||||
|
||||
void FinishOne()
|
||||
{
|
||||
remaining--;
|
||||
if (remaining > 0)
|
||||
return;
|
||||
|
||||
AssetDatabase.StopAssetEditing();
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
if (results.Count == 0)
|
||||
{
|
||||
_busy = false;
|
||||
_status = "Download failed — no maps saved.";
|
||||
if (failures.Count > 0)
|
||||
Debug.LogError("[PolyHaven] " + string.Join("; ", failures));
|
||||
Repaint();
|
||||
return;
|
||||
}
|
||||
|
||||
PolyHavenMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
|
||||
_busy = false;
|
||||
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
|
||||
if (failures.Count > 0)
|
||||
_status += $" ({failures.Count} map(s) failed — see console.)";
|
||||
Repaint();
|
||||
}
|
||||
|
||||
foreach (var type in wanted)
|
||||
{
|
||||
if (!_files.TryGetWithFallback(type, _selectedRes, out var file, out var usedRes))
|
||||
{
|
||||
failures.Add($"{type}: no file");
|
||||
FinishOne();
|
||||
continue;
|
||||
}
|
||||
if (usedRes != _selectedRes)
|
||||
Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}.");
|
||||
|
||||
var meta = PolyHavenMapInfo.Get(type);
|
||||
string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}";
|
||||
string destPath = $"{folder}/{fileName}";
|
||||
|
||||
PolyHavenApi.DownloadFile(file, Path.GetFullPath(destPath),
|
||||
onDone: () =>
|
||||
{
|
||||
results.Add(new PolyHavenMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
|
||||
FinishOne();
|
||||
},
|
||||
onError: err =>
|
||||
{
|
||||
failures.Add($"{type}: {err}");
|
||||
FinishOne();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers -----------------------------------------------------------
|
||||
|
||||
private void Fail(string message)
|
||||
{
|
||||
_busy = false;
|
||||
_status = "Error: " + message;
|
||||
Debug.LogError("[PolyHaven] " + message);
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private static int ResolutionToMaxSize(string res)
|
||||
{
|
||||
int k = PolyHavenFiles.ResolutionRank(res);
|
||||
int px = k * 1024;
|
||||
// Clamp to a valid Unity max texture size (power of two up to 16384).
|
||||
int[] valid = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 };
|
||||
foreach (var v in valid)
|
||||
if (px <= v) return v;
|
||||
return 16384;
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string folder)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(folder))
|
||||
return;
|
||||
var parts = folder.Split('/');
|
||||
string cur = parts[0]; // "Assets"
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
string next = $"{cur}/{parts[i]}";
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
AssetDatabase.CreateFolder(cur, parts[i]);
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 401846ce5f8a2eb4fbd4db733689b7b8
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
/// <summary>
|
||||
/// The PBR map types the importer understands. Values map onto Poly Haven's
|
||||
/// dynamic file-tree keys (e.g. "nor_gl", "Rough"). "Arm" is the packed
|
||||
/// AO/Rough/Metal fallback some assets ship instead of separate maps.
|
||||
/// </summary>
|
||||
public enum PolyHavenMapType
|
||||
{
|
||||
Diffuse,
|
||||
NormalGL,
|
||||
Rough,
|
||||
AO,
|
||||
Metal,
|
||||
Displacement,
|
||||
Arm,
|
||||
}
|
||||
|
||||
/// <summary>Static per-map metadata: API key, file suffix, colour space, defaults.</summary>
|
||||
public static class PolyHavenMapInfo
|
||||
{
|
||||
public readonly struct Meta
|
||||
{
|
||||
public readonly PolyHavenMapType Type;
|
||||
public readonly string ApiKey; // exact key in the /files JSON
|
||||
public readonly string Suffix; // file name suffix on disk
|
||||
public readonly bool SRGB; // true => colour, false => linear data
|
||||
public readonly bool IsNormal; // import as TextureImporterType.NormalMap
|
||||
public readonly bool DefaultEnabled; // pre-checked in the UI when available
|
||||
public readonly string Label; // UI label
|
||||
|
||||
public Meta(PolyHavenMapType type, string apiKey, string suffix, bool srgb,
|
||||
bool isNormal, bool defaultEnabled, string label)
|
||||
{
|
||||
Type = type;
|
||||
ApiKey = apiKey;
|
||||
Suffix = suffix;
|
||||
SRGB = srgb;
|
||||
IsNormal = isNormal;
|
||||
DefaultEnabled = defaultEnabled;
|
||||
Label = label;
|
||||
}
|
||||
}
|
||||
|
||||
// Order here defines UI order.
|
||||
public static readonly Meta[] All =
|
||||
{
|
||||
new Meta(PolyHavenMapType.Diffuse, "Diffuse", "diffuse", true, false, true, "Diffuse (Albedo)"),
|
||||
new Meta(PolyHavenMapType.NormalGL, "nor_gl", "normal", false, true, true, "Normal (OpenGL)"),
|
||||
new Meta(PolyHavenMapType.Rough, "Rough", "roughness", false, false, true, "Roughness"),
|
||||
new Meta(PolyHavenMapType.AO, "AO", "ao", false, false, true, "Ambient Occlusion"),
|
||||
new Meta(PolyHavenMapType.Metal, "Metal", "metallic", false, false, true, "Metallic"),
|
||||
new Meta(PolyHavenMapType.Displacement, "Displacement", "height", false, false, false, "Displacement (Height)"),
|
||||
new Meta(PolyHavenMapType.Arm, "arm", "arm", false, false, false, "ARM (packed AO/Rough/Metal)"),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<PolyHavenMapType, Meta> ByType = BuildLookup();
|
||||
|
||||
private static Dictionary<PolyHavenMapType, Meta> BuildLookup()
|
||||
{
|
||||
var d = new Dictionary<PolyHavenMapType, Meta>(All.Length);
|
||||
foreach (var m in All)
|
||||
d[m.Type] = m;
|
||||
return d;
|
||||
}
|
||||
|
||||
public static Meta Get(PolyHavenMapType type) => ByType[type];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d47448fcb2873654d8d7b4369c6c5da4
|
||||
@@ -0,0 +1,254 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Chameleon.Editor.PolyHaven
|
||||
{
|
||||
/// <summary>
|
||||
/// Turns downloaded Poly Haven maps into a ready URP/Lit material:
|
||||
/// correct import settings per map, Roughness→Smoothness repacking into a
|
||||
/// MetallicGloss texture, and wired-up keywords.
|
||||
/// </summary>
|
||||
public static class PolyHavenMaterialBuilder
|
||||
{
|
||||
/// <summary>A map that was successfully downloaded, ready to import.</summary>
|
||||
public struct Downloaded
|
||||
{
|
||||
public PolyHavenMapType Type;
|
||||
public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg"
|
||||
}
|
||||
|
||||
public static void Build(string folder, string displayName, string slug, int maxSize,
|
||||
List<Downloaded> maps)
|
||||
{
|
||||
var byType = new Dictionary<PolyHavenMapType, string>();
|
||||
foreach (var m in maps)
|
||||
byType[m.Type] = m.AssetPath;
|
||||
|
||||
// 1) Import settings for every raw map.
|
||||
foreach (var m in maps)
|
||||
ApplyImportSettings(m.AssetPath, m.Type, maxSize);
|
||||
|
||||
// 2) Build a MetallicSmoothness texture. Rough/Metal come from the dedicated
|
||||
// maps when present, otherwise from the packed ARM map (G=rough, B=metal).
|
||||
byType.TryGetValue(PolyHavenMapType.Arm, out var armPath);
|
||||
byType.TryGetValue(PolyHavenMapType.Rough, out var roughPath);
|
||||
byType.TryGetValue(PolyHavenMapType.Metal, out var metalPath);
|
||||
|
||||
string metallicGlossPath = null;
|
||||
if (!string.IsNullOrEmpty(roughPath) || !string.IsNullOrEmpty(armPath))
|
||||
metallicGlossPath = BuildMetallicSmoothness(folder, slug, roughPath, metalPath, armPath, maxSize);
|
||||
|
||||
// AO comes from the dedicated map, or the ARM red channel as a fallback.
|
||||
string occlusionPath = null;
|
||||
if (byType.TryGetValue(PolyHavenMapType.AO, out var aoSeparate))
|
||||
occlusionPath = aoSeparate;
|
||||
else if (!string.IsNullOrEmpty(armPath))
|
||||
occlusionPath = BuildAoFromArm(folder, slug, armPath, maxSize);
|
||||
|
||||
// 3) Create the URP/Lit material.
|
||||
var shader = Shader.Find("Universal Render Pipeline/Lit");
|
||||
if (!shader)
|
||||
{
|
||||
Debug.LogError("[PolyHaven] URP/Lit shader not found — is URP installed?");
|
||||
return;
|
||||
}
|
||||
|
||||
var mat = new Material(shader) { name = displayName };
|
||||
string matPath = Path.Combine(folder, SanitizeFileName(displayName) + ".mat").Replace('\\', '/');
|
||||
matPath = AssetDatabase.GenerateUniqueAssetPath(matPath);
|
||||
AssetDatabase.CreateAsset(mat, matPath);
|
||||
|
||||
if (byType.TryGetValue(PolyHavenMapType.Diffuse, out var diffPath))
|
||||
mat.SetTexture("_BaseMap", Load(diffPath));
|
||||
|
||||
if (byType.TryGetValue(PolyHavenMapType.NormalGL, out var norPath))
|
||||
{
|
||||
mat.SetTexture("_BumpMap", Load(norPath));
|
||||
mat.SetFloat("_BumpScale", 1f);
|
||||
mat.EnableKeyword("_NORMALMAP");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(metallicGlossPath))
|
||||
{
|
||||
mat.SetTexture("_MetallicGlossMap", Load(metallicGlossPath));
|
||||
mat.SetFloat("_Smoothness", 1f); // acts as multiplier on the map's alpha
|
||||
mat.EnableKeyword("_METALLICSPECGLOSSMAP");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(occlusionPath))
|
||||
{
|
||||
mat.SetTexture("_OcclusionMap", Load(occlusionPath));
|
||||
mat.SetFloat("_OcclusionStrength", 1f);
|
||||
mat.EnableKeyword("_OCCLUSIONMAP");
|
||||
}
|
||||
|
||||
if (byType.TryGetValue(PolyHavenMapType.Displacement, out var heightPath))
|
||||
{
|
||||
mat.SetTexture("_ParallaxMap", Load(heightPath));
|
||||
mat.SetFloat("_Parallax", 0.03f);
|
||||
mat.EnableKeyword("_PARALLAXMAP");
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(mat);
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorGUIUtility.PingObject(mat);
|
||||
Selection.activeObject = mat;
|
||||
Debug.Log($"[PolyHaven] Material created: {matPath}");
|
||||
}
|
||||
|
||||
private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path);
|
||||
|
||||
public static void ApplyImportSettings(string assetPath, PolyHavenMapType type, int maxSize)
|
||||
{
|
||||
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||||
if (!importer)
|
||||
return;
|
||||
|
||||
var meta = PolyHavenMapInfo.Get(type);
|
||||
importer.textureType = meta.IsNormal ? TextureImporterType.NormalMap : TextureImporterType.Default;
|
||||
if (!meta.IsNormal)
|
||||
importer.sRGBTexture = meta.SRGB;
|
||||
importer.maxTextureSize = maxSize;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs metallic into RGB and smoothness (1-roughness) into A, as URP/Lit expects
|
||||
/// in _MetallicGlossMap. Roughness/Metal are read from the dedicated maps when given,
|
||||
/// otherwise unpacked from the ARM map (G=rough, B=metal). Reads raw bytes off disk
|
||||
/// into linear textures to avoid sRGB sampling on data maps.
|
||||
/// </summary>
|
||||
private static string BuildMetallicSmoothness(string folder, string slug, string roughPath,
|
||||
string metalPath, string armPath, int maxSize)
|
||||
{
|
||||
Texture2D rough = string.IsNullOrEmpty(roughPath) ? null : LoadLinear(roughPath);
|
||||
Texture2D metal = string.IsNullOrEmpty(metalPath) ? null : LoadLinear(metalPath);
|
||||
Texture2D arm = string.IsNullOrEmpty(armPath) ? null : LoadLinear(armPath);
|
||||
|
||||
// Sizing grid: prefer the dedicated roughness map, else the ARM map.
|
||||
Texture2D grid = rough ? rough : arm;
|
||||
if (!grid)
|
||||
{
|
||||
if (rough) Object.DestroyImmediate(rough);
|
||||
if (metal) Object.DestroyImmediate(metal);
|
||||
if (arm) Object.DestroyImmediate(arm);
|
||||
return null;
|
||||
}
|
||||
|
||||
int w = grid.width, h = grid.height;
|
||||
var outPixels = new Color[w * h];
|
||||
|
||||
for (int y = 0; y < h; y++)
|
||||
{
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
int i = y * w + x;
|
||||
float u = (x + 0.5f) / w, v = (y + 0.5f) / h;
|
||||
|
||||
// Roughness: dedicated map .r, else ARM .g. Smoothness = 1 - roughness.
|
||||
float roughness = rough ? rough.GetPixelBilinear(u, v).r
|
||||
: arm ? arm.GetPixelBilinear(u, v).g
|
||||
: 1f;
|
||||
float smoothness = 1f - roughness;
|
||||
|
||||
// Metallic: dedicated map .r, else ARM .b.
|
||||
float m = metal ? metal.GetPixelBilinear(u, v).r
|
||||
: arm ? arm.GetPixelBilinear(u, v).b
|
||||
: 0f;
|
||||
|
||||
outPixels[i] = new Color(m, m, m, smoothness);
|
||||
}
|
||||
}
|
||||
|
||||
var packed = new Texture2D(w, h, TextureFormat.RGBA32, true, true);
|
||||
packed.SetPixels(outPixels);
|
||||
packed.Apply();
|
||||
|
||||
string outPath = Path.Combine(folder, slug + "_metallic_smoothness.png").Replace('\\', '/');
|
||||
File.WriteAllBytes(outPath, packed.EncodeToPNG());
|
||||
|
||||
Object.DestroyImmediate(packed);
|
||||
if (rough) Object.DestroyImmediate(rough);
|
||||
if (metal) Object.DestroyImmediate(metal);
|
||||
if (arm) Object.DestroyImmediate(arm);
|
||||
|
||||
ReimportLinear(outPath, maxSize, withAlpha: true);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the AO (red) channel from a packed ARM map into a standalone grayscale
|
||||
/// occlusion texture, used when the asset ships no dedicated AO map.
|
||||
/// </summary>
|
||||
private static string BuildAoFromArm(string folder, string slug, string armPath, int maxSize)
|
||||
{
|
||||
var arm = LoadLinear(armPath);
|
||||
if (!arm)
|
||||
return null;
|
||||
|
||||
int w = arm.width, h = arm.height;
|
||||
var src = arm.GetPixels();
|
||||
var outPixels = new Color[src.Length];
|
||||
for (int i = 0; i < src.Length; i++)
|
||||
{
|
||||
float ao = src[i].r; // ARM red channel = ambient occlusion
|
||||
outPixels[i] = new Color(ao, ao, ao, 1f);
|
||||
}
|
||||
|
||||
var tex = new Texture2D(w, h, TextureFormat.RGBA32, true, true);
|
||||
tex.SetPixels(outPixels);
|
||||
tex.Apply();
|
||||
|
||||
string outPath = Path.Combine(folder, slug + "_ao.png").Replace('\\', '/');
|
||||
File.WriteAllBytes(outPath, tex.EncodeToPNG());
|
||||
|
||||
Object.DestroyImmediate(tex);
|
||||
Object.DestroyImmediate(arm);
|
||||
|
||||
ReimportLinear(outPath, maxSize, withAlpha: false);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
/// <summary>Reimports a generated data texture as linear, at the target size.</summary>
|
||||
private static void ReimportLinear(string outPath, int maxSize, bool withAlpha)
|
||||
{
|
||||
AssetDatabase.ImportAsset(outPath, ImportAssetOptions.ForceSynchronousImport);
|
||||
var imp = AssetImporter.GetAtPath(outPath) as TextureImporter;
|
||||
if (!imp)
|
||||
return;
|
||||
imp.sRGBTexture = false;
|
||||
imp.maxTextureSize = maxSize;
|
||||
if (withAlpha)
|
||||
{
|
||||
imp.alphaSource = TextureImporterAlphaSource.FromInput;
|
||||
imp.alphaIsTransparency = false;
|
||||
}
|
||||
imp.SaveAndReimport();
|
||||
}
|
||||
|
||||
/// <summary>Loads an image file straight off disk into a linear Texture2D (no import needed).</summary>
|
||||
private static Texture2D LoadLinear(string assetPath)
|
||||
{
|
||||
string full = Path.GetFullPath(assetPath);
|
||||
if (!File.Exists(full))
|
||||
return null;
|
||||
var bytes = File.ReadAllBytes(full);
|
||||
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, true, true);
|
||||
if (!tex.LoadImage(bytes))
|
||||
{
|
||||
Object.DestroyImmediate(tex);
|
||||
return null;
|
||||
}
|
||||
return tex;
|
||||
}
|
||||
|
||||
public static string SanitizeFileName(string name)
|
||||
{
|
||||
foreach (var c in Path.GetInvalidFileNameChars())
|
||||
name = name.Replace(c, ' ');
|
||||
return name.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c242963f05cc5545b0034908b5807f1
|
||||
Reference in new issue
Block a user