Add ambientCG importer and extract shared web-asset import framework

New Tools/ambientCG Importer window (catalog browser, ZIP download with
selective map extraction, URP/Lit material build). Generic pieces moved
from the PolyHaven tool into Editor/WebAssets (EditorWebRequest,
PbrMapType, PbrMaterialBuilder, ImportUtil, AssetBrowserGrid); PolyHaven
API/window now delegate to the shared code, behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-16 15:27:02 +02:00
1 parent 9b35ee2a97
commit fb4098b9f8
21 files changed
+1205 -417

No files matched your search

+49 -123
View File
@@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using Chameleon.Editor.WebAssets;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;
namespace Chameleon.Editor.PolyHaven
{
@@ -34,36 +33,25 @@ namespace Chameleon.Editor.PolyHaven
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>>();
public readonly Dictionary<PbrMapType, Dictionary<string, PolyHavenFile>> Maps =
new Dictionary<PbrMapType, 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 Has(PbrMapType type) => Maps.ContainsKey(type);
public bool TryGet(PolyHavenMapType type, string res, out PolyHavenFile file)
public bool TryGet(PbrMapType 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)
public bool TryGetWithFallback(PbrMapType type, string res, out PolyHavenFile file, out string usedRes)
{
file = default;
usedRes = null;
@@ -71,11 +59,11 @@ namespace Chameleon.Editor.PolyHaven
return false;
if (byRes.TryGetValue(res, out file)) { usedRes = res; return true; }
int target = ResolutionRank(res);
int target = ImportUtil.ResolutionRank(res);
int bestRank = -1;
foreach (var kv in byRes)
{
int rank = ResolutionRank(kv.Key);
int rank = ImportUtil.ResolutionRank(kv.Key);
if (rank <= target && rank > bestRank)
{
bestRank = rank;
@@ -87,7 +75,7 @@ namespace Chameleon.Editor.PolyHaven
{
foreach (var kv in byRes)
{
int rank = ResolutionRank(kv.Key);
int rank = ImportUtil.ResolutionRank(kv.Key);
if (bestRank == -1 || rank < bestRank)
{
bestRank = rank;
@@ -98,23 +86,27 @@ namespace Chameleon.Editor.PolyHaven
}
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.
/// Async client for the public Poly Haven API (same endpoints the Blender/UE5
/// plugins use). Transport is the shared non-blocking EditorWebRequest.
/// </summary>
public static class PolyHavenApi
{
private const string ApiBase = "https://api.polyhaven.com";
private const string UserAgent = "Chameleon-Unity-PolyHavenImporter/1.0";
// Poly Haven's dynamic file-tree keys per map type.
private static readonly (PbrMapType type, string apiKey)[] ApiKeys =
{
(PbrMapType.Diffuse, "Diffuse"),
(PbrMapType.NormalGL, "nor_gl"),
(PbrMapType.Rough, "Rough"),
(PbrMapType.AO, "AO"),
(PbrMapType.Metal, "Metal"),
(PbrMapType.Displacement, "Displacement"),
(PbrMapType.Arm, "arm"),
};
// Best format preference per map: lossless where it matters, jpg otherwise.
private static readonly string[] LosslessPref = { "png", "exr", "jpg" };
@@ -147,13 +139,11 @@ namespace Chameleon.Editor.PolyHaven
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, () =>
EditorWebRequest.GetText($"{ApiBase}/info/{slug}", text =>
{
try
{
var o = JObject.Parse(req.downloadHandler.text);
var o = JObject.Parse(text);
var info = new PolyHavenInfo
{
Slug = slug,
@@ -173,32 +163,29 @@ namespace Chameleon.Editor.PolyHaven
}, 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)
/// <summary>Loads the full texture catalog as browser-grid entries.</summary>
public static void FetchAssets(string type, Action<List<AssetBrowserGrid.Entry>> onDone, Action<string> onError)
{
var req = UnityWebRequest.Get($"{ApiBase}/assets?t={type}");
req.SetRequestHeader("User-Agent", UserAgent);
Send(req, () =>
EditorWebRequest.GetText($"{ApiBase}/assets?t={type}", text =>
{
try
{
var root = JObject.Parse(req.downloadHandler.text);
var list = new List<PolyHavenAsset>(root.Count);
var root = JObject.Parse(text);
var list = new List<AssetBrowserGrid.Entry>(root.Count);
foreach (var prop in root)
{
if (prop.Value is not JObject o) continue;
var a = new PolyHavenAsset
string name = (string)o["name"] ?? prop.Key;
var e = new AssetBrowserGrid.Entry
{
Slug = prop.Key,
Name = (string)o["name"] ?? prop.Key,
Key = prop.Key,
Name = name,
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);
e.Search = (name + " " + prop.Key + " " +
JoinStrings(o["categories"]) + " " +
JoinStrings(o["tags"])).ToLowerInvariant();
list.Add(e);
}
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase));
onDone?.Invoke(list);
@@ -209,52 +196,25 @@ namespace Chameleon.Editor.PolyHaven
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, () =>
EditorWebRequest.GetText($"{ApiBase}/files/{slug}", text =>
{
try { onDone?.Invoke(ParseFiles(req.downloadHandler.text)); }
try { onDone?.Invoke(ParseFiles(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)
private static string JoinStrings(JToken token)
{
if (token is not JArray arr) return Array.Empty<string>();
if (token is not JArray arr) return "";
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();
return string.Join(" ", list);
}
private static PolyHavenFiles ParseFiles(string json)
@@ -263,12 +223,13 @@ namespace Chameleon.Editor.PolyHaven
var result = new PolyHavenFiles();
var resSet = new HashSet<string>();
foreach (var meta in PolyHavenMapInfo.All)
foreach (var (type, apiKey) in ApiKeys)
{
if (root[meta.ApiKey] is not JObject byRes)
if (root[apiKey] is not JObject byRes)
continue;
var pref = (meta.IsNormal || meta.Type == PolyHavenMapType.Displacement)
var meta = PbrMapInfo.Get(type);
var pref = (meta.IsNormal || type == PbrMapType.Displacement)
? LosslessPref
: LossyPref;
@@ -298,48 +259,13 @@ namespace Chameleon.Editor.PolyHaven
}
if (perRes.Count > 0)
result.Maps[meta.Type] = perRes;
result.Maps[type] = perRes;
}
var resList = new List<string>(resSet);
resList.Sort((x, y) => PolyHavenFiles.ResolutionRank(x).CompareTo(PolyHavenFiles.ResolutionRank(y)));
resList.Sort((x, y) => ImportUtil.ResolutionRank(x).CompareTo(ImportUtil.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;
}
}
}
@@ -1,16 +1,15 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Chameleon.Editor.WebAssets;
using UnityEditor;
using UnityEngine;
namespace Chameleon.Editor.PolyHaven
{
/// <summary>
/// Editor window: paste a Poly Haven asset URL, fetch its maps, pick resolution +
/// Editor window: browse/paste a Poly Haven asset, 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
{
@@ -22,29 +21,21 @@ namespace Chameleon.Editor.PolyHaven
private PolyHavenFiles _files;
private Texture2D _thumbnail;
private string _selectedRes = DefaultRes;
private readonly Dictionary<PolyHavenMapType, bool> _mapEnabled = new Dictionary<PolyHavenMapType, bool>();
private readonly Dictionary<PbrMapType, bool> _mapEnabled = new Dictionary<PbrMapType, 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 static List<AssetBrowserGrid.Entry> s_catalog;
private AssetBrowserGrid _grid;
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()
@@ -55,11 +46,13 @@ namespace Chameleon.Editor.PolyHaven
private void OnEnable()
{
_grid = new AssetBrowserGrid(Repaint);
// Catalog may already be cached from a previous window instance this session.
if (s_catalog != null)
{
_autoLoadTried = true;
ApplyFilter();
_grid.SetCatalog(s_catalog);
}
}
@@ -134,7 +127,7 @@ namespace Chameleon.Editor.PolyHaven
EditorGUILayout.Space();
EditorGUILayout.LabelField("Maps", EditorStyles.miniBoldLabel);
foreach (var meta in PolyHavenMapInfo.All)
foreach (var meta in PbrMapInfo.All)
{
if (!_files.Has(meta.Type))
continue;
@@ -153,11 +146,11 @@ namespace Chameleon.Editor.PolyHaven
private bool AnyMapSelected()
{
foreach (var meta in PolyHavenMapInfo.All)
foreach (var meta in PbrMapInfo.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)
foreach (var meta in PbrMapInfo.All)
if (_files.Has(meta.Type) && meta.DefaultEnabled && !_mapEnabled.ContainsKey(meta.Type))
return true;
return false;
@@ -173,7 +166,7 @@ namespace Chameleon.Editor.PolyHaven
using (new EditorGUI.DisabledScope(_busy))
{
if (s_catalog == null)
if (!_grid.HasCatalog)
{
// Auto-load once when the browser is first opened; still offer a manual retry.
if (!_catalogLoading && !_autoLoadTried)
@@ -193,105 +186,13 @@ namespace Chameleon.Editor.PolyHaven
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);
_grid.Draw(SelectAsset, LoadCatalog, "textures");
}
}
private void DrawGrid(int count)
private void SelectAsset(AssetBrowserGrid.Entry a)
{
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;
_urlInput = "https://polyhaven.com/a/" + a.Key;
GUI.FocusControl(null);
Fetch(); // url filled -> fetch, then everything as before
}
@@ -307,7 +208,7 @@ namespace Chameleon.Editor.PolyHaven
{
s_catalog = list;
_catalogLoading = false;
ApplyFilter();
_grid.SetCatalog(list);
_status = $"Catalog loaded: {list.Count} textures.";
Repaint();
},
@@ -320,30 +221,6 @@ namespace Chameleon.Editor.PolyHaven
});
}
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()
@@ -367,7 +244,7 @@ namespace Chameleon.Editor.PolyHaven
{
_info = info;
Repaint();
PolyHavenApi.FetchThumbnail(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
EditorWebRequest.GetTexture(info.ThumbnailUrl, tex => { _thumbnail = tex; Repaint(); }, _ => { });
PolyHavenApi.FetchFiles(slug, files =>
{
@@ -391,8 +268,8 @@ namespace Chameleon.Editor.PolyHaven
private void Download()
{
// Resolve which maps to fetch.
var wanted = new List<PolyHavenMapType>();
foreach (var meta in PolyHavenMapInfo.All)
var wanted = new List<PbrMapType>();
foreach (var meta in PbrMapInfo.All)
{
if (!_files.Has(meta.Type)) continue;
bool on = _mapEnabled.TryGetValue(meta.Type, out var v) ? v : meta.DefaultEnabled;
@@ -401,39 +278,32 @@ namespace Chameleon.Editor.PolyHaven
// 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))
if (_files.Has(PbrMapType.Arm) && !wanted.Contains(PbrMapType.Arm))
{
bool channelMissing = !_files.Has(PolyHavenMapType.Metal)
|| !_files.Has(PolyHavenMapType.Rough)
|| !_files.Has(PolyHavenMapType.AO);
bool channelMissing = !_files.Has(PbrMapType.Metal)
|| !_files.Has(PbrMapType.Rough)
|| !_files.Has(PbrMapType.AO);
if (channelMissing)
wanted.Add(PolyHavenMapType.Arm);
wanted.Add(PbrMapType.Arm);
}
if (wanted.Count == 0)
return;
string displayName = _info.Name;
string folder = $"{RootFolder}/{PolyHavenMaterialBuilder.SanitizeFileName(displayName)}";
string folder = $"{RootFolder}/{ImportUtil.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);
folder = ImportUtil.ResolveFolderCollision(folder, "Poly Haven");
if (folder == null)
return;
ImportUtil.EnsureFolder(folder);
int maxSize = ResolutionToMaxSize(_selectedRes);
int maxSize = ImportUtil.ResolutionToMaxSize(_selectedRes);
_busy = true;
_status = $"Downloading {wanted.Count} map(s) at {_selectedRes.ToUpperInvariant()}…";
Repaint();
var results = new List<PolyHavenMaterialBuilder.Downloaded>();
var results = new List<PbrMaterialBuilder.Downloaded>();
var failures = new List<string>();
int remaining = wanted.Count;
@@ -458,7 +328,7 @@ namespace Chameleon.Editor.PolyHaven
return;
}
PolyHavenMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
PbrMaterialBuilder.Build(folder, displayName, _info.Slug, maxSize, results);
_busy = false;
_status = $"Done: {results.Count} map(s) imported, material built in {folder}.";
if (failures.Count > 0)
@@ -477,14 +347,14 @@ namespace Chameleon.Editor.PolyHaven
if (usedRes != _selectedRes)
Debug.LogWarning($"[PolyHaven] {type}: {_selectedRes} unavailable, using {usedRes}.");
var meta = PolyHavenMapInfo.Get(type);
var meta = PbrMapInfo.Get(type);
string fileName = $"{_info.Slug}_{meta.Suffix}.{file.Format}";
string destPath = $"{folder}/{fileName}";
PolyHavenApi.DownloadFile(file, Path.GetFullPath(destPath),
EditorWebRequest.DownloadFile(file.Url, Path.GetFullPath(destPath),
onDone: () =>
{
results.Add(new PolyHavenMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
results.Add(new PbrMaterialBuilder.Downloaded { Type = type, AssetPath = destPath });
FinishOne();
},
onError: err =>
@@ -504,31 +374,5 @@ namespace Chameleon.Editor.PolyHaven
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;
}
}
}
}
@@ -1,71 +0,0 @@
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];
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d47448fcb2873654d8d7b4369c6c5da4
@@ -1,263 +0,0 @@
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);
// The roughness source is fully consumed into the packed map and never
// referenced by the material — drop it once the repack succeeded.
if (!string.IsNullOrEmpty(metallicGlossPath) && !string.IsNullOrEmpty(roughPath))
{
AssetDatabase.DeleteAsset(roughPath);
byType.Remove(PolyHavenMapType.Rough);
Debug.Log($"[PolyHaven] Deleted consumed roughness source: {roughPath}");
}
// 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();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3c242963f05cc5545b0034908b5807f1