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;
}
}
}