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:
adminandClaude Fable 5 committed 2026-07-16 14:29:38 +02:00
1 parent 3441e3413e
commit 897ba57599
9 files changed
+1220

No files matched your search

+8
View File
@@ -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