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

@@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Chameleon.Editor.WebAssets
{
/// <summary>
/// Reusable catalog browser for importer windows: search field + responsive
/// thumbnail grid with lazy, throttled thumbnail loading. The host window owns
/// the catalog data and reacts to cell clicks via the onSelect callback.
/// </summary>
public sealed class AssetBrowserGrid
{
public sealed class Entry
{
public string Key; // slug / asset id
public string Name; // display name (cell label)
public string ThumbnailUrl;
public string Search; // pre-lowered haystack for filtering
}
private const int MaxResults = 150;
private const int MaxThumbInFlight = 6;
private readonly Action _repaint;
private List<Entry> _catalog;
private readonly List<Entry> _filtered = new List<Entry>();
private string _searchQuery = "";
private Vector2 _gridScroll;
private readonly Dictionary<string, Texture2D> _thumbs = new Dictionary<string, Texture2D>();
private readonly HashSet<string> _thumbRequested = new HashSet<string>();
private int _thumbsInFlight;
public AssetBrowserGrid(Action repaint)
{
_repaint = repaint;
}
public bool HasCatalog => _catalog != null;
public void SetCatalog(List<Entry> catalog)
{
_catalog = catalog;
ApplyFilter();
}
/// <summary>Draws search field, match count and the grid. Call only when HasCatalog.</summary>
public void Draw(Action<Entry> onSelect, Action reloadCatalog, string itemNoun, float gridHeight = 320f)
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUI.BeginChangeCheck();
_searchQuery = EditorGUILayout.TextField("Search", _searchQuery);
if (EditorGUI.EndChangeCheck())
ApplyFilter();
if (reloadCatalog != null && GUILayout.Button("↻", GUILayout.Width(26)))
reloadCatalog();
}
int shown = Mathf.Min(_filtered.Count, MaxResults);
string cap = _filtered.Count > MaxResults ? $", showing first {MaxResults}" : "";
EditorGUILayout.LabelField(
$"{_filtered.Count} match(es){cap} of {_catalog.Count} {itemNoun}.",
EditorStyles.miniLabel);
DrawGrid(shown, onSelect, gridHeight);
}
private void DrawGrid(int count, Action<Entry> onSelect, float gridHeight)
{
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(gridHeight));
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, onSelect);
}
}
}
EditorGUILayout.EndScrollView();
}
private void DrawCell(Entry a, float size, Action<Entry> onSelect)
{
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))
onSelect?.Invoke(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(Entry a)
{
if (_thumbs.TryGetValue(a.Key, out var tex))
{
if (tex)
return tex;
// Cached texture was destroyed (e.g. play-mode cleanup) — purge and re-request.
_thumbs.Remove(a.Key);
_thumbRequested.Remove(a.Key);
}
// Don't add to _thumbRequested until a slot is free, so it retries next repaint.
if (!_thumbRequested.Contains(a.Key) && _thumbsInFlight < MaxThumbInFlight)
{
_thumbRequested.Add(a.Key);
_thumbsInFlight++;
EditorWebRequest.GetTexture(a.ThumbnailUrl,
t => { _thumbs[a.Key] = t; _thumbsInFlight--; _repaint?.Invoke(); },
_ => { _thumbsInFlight--; _repaint?.Invoke(); }); // no retry-spam on error
}
return null;
}
private void ApplyFilter()
{
_filtered.Clear();
if (_catalog == null)
return;
var q = _searchQuery?.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(q))
{
_filtered.AddRange(_catalog);
return;
}
var terms = q.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
foreach (var a in _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);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 464e6d1b4d5744241ac0dcdfc3d7cf78
@@ -0,0 +1,84 @@
using System;
using UnityEditor;
using UnityEngine;
using UnityEngine.Networking;
namespace Chameleon.Editor.WebAssets
{
/// <summary>
/// Non-blocking UnityWebRequest transport for editor tools. All requests poll on
/// EditorApplication.update — never blocks the editor. Shared by the Poly Haven
/// and ambientCG importers.
/// </summary>
public static class EditorWebRequest
{
public const string UserAgent = "Chameleon-Unity-AssetImporter/1.0";
/// <summary>GET a URL and return the response body as text.</summary>
public static void GetText(string url, Action<string> onDone, Action<string> onError)
{
var req = UnityWebRequest.Get(url);
req.SetRequestHeader("User-Agent", UserAgent);
Send(req, () => onDone?.Invoke(req.downloadHandler.text), onError);
}
/// <summary>Fetches an image as a Texture2D (browser thumbnails, previews).</summary>
public static void GetTexture(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 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 downloads).</summary>
public static void DownloadFile(string url, string destPath, Action onDone,
Action<string> onError, Action<float> onProgress = null)
{
var req = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET);
req.downloadHandler = new DownloadHandlerFile(destPath) { removeFileOnAbort = true };
req.SetRequestHeader("User-Agent", UserAgent);
Send(req, () => onDone?.Invoke(), onError, onProgress);
}
public 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: edbbb3505f143824590b8d77228d2407
@@ -0,0 +1,66 @@
using System.IO;
using UnityEditor;
namespace Chameleon.Editor.WebAssets
{
/// <summary>Small shared helpers for the web-asset importer windows.</summary>
public static class ImportUtil
{
public static string SanitizeFileName(string name)
{
foreach (var c in Path.GetInvalidFileNameChars())
name = name.Replace(c, ' ');
return name.Trim();
}
/// <summary>"2k"/"2K" -> 2. Unknown strings sort last.</summary>
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>Maps a "2k"-style resolution to a valid Unity max texture size.</summary>
public static int ResolutionToMaxSize(string res)
{
int k = ResolutionRank(res);
int px = k * 1024;
int[] valid = { 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 };
foreach (var v in valid)
if (px <= v) return v;
return 16384;
}
public 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;
}
}
/// <summary>
/// Non-destructive folder-collision handling: if the folder exists, asks the user
/// (New folder / Cancel / Overwrite). Returns the folder to use, or null on cancel.
/// </summary>
public static string ResolveFolderCollision(string folder, string dialogTitle)
{
if (!AssetDatabase.IsValidFolder(folder))
return folder;
int choice = EditorUtility.DisplayDialogComplex(dialogTitle,
$"Folder already exists:\n{folder}", "New folder", "Cancel", "Overwrite");
if (choice == 1) return null; // Cancel
if (choice == 0) return AssetDatabase.GenerateUniqueAssetPath(folder); // New folder
return folder; // Overwrite
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 22c722d093b45124fb66addc8fa83211
@@ -0,0 +1,68 @@
using System.Collections.Generic;
namespace Chameleon.Editor.WebAssets
{
/// <summary>
/// The PBR map types the importers understand, provider-agnostic. "Arm" is the
/// packed AO/Rough/Metal map some Poly Haven assets ship instead of separate maps.
/// </summary>
public enum PbrMapType
{
Diffuse,
NormalGL,
Rough,
AO,
Metal,
Displacement,
Arm,
}
/// <summary>Static per-map metadata: file suffix, colour space, UI defaults.</summary>
public static class PbrMapInfo
{
public readonly struct Meta
{
public readonly PbrMapType Type;
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(PbrMapType type, string suffix, bool srgb, bool isNormal,
bool defaultEnabled, string label)
{
Type = type;
Suffix = suffix;
SRGB = srgb;
IsNormal = isNormal;
DefaultEnabled = defaultEnabled;
Label = label;
}
}
// Order here defines UI order.
public static readonly Meta[] All =
{
new Meta(PbrMapType.Diffuse, "diffuse", true, false, true, "Diffuse (Albedo)"),
new Meta(PbrMapType.NormalGL, "normal", false, true, true, "Normal (OpenGL)"),
new Meta(PbrMapType.Rough, "roughness", false, false, true, "Roughness"),
new Meta(PbrMapType.AO, "ao", false, false, true, "Ambient Occlusion"),
new Meta(PbrMapType.Metal, "metallic", false, false, true, "Metallic"),
new Meta(PbrMapType.Displacement, "height", false, false, false, "Displacement (Height)"),
new Meta(PbrMapType.Arm, "arm", false, false, false, "ARM (packed AO/Rough/Metal)"),
};
private static readonly Dictionary<PbrMapType, Meta> ByType = BuildLookup();
private static Dictionary<PbrMapType, Meta> BuildLookup()
{
var d = new Dictionary<PbrMapType, Meta>(All.Length);
foreach (var m in All)
d[m.Type] = m;
return d;
}
public static Meta Get(PbrMapType type) => ByType[type];
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6e3acec0a249ed549a95141ea60978bc
@@ -0,0 +1,256 @@
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Chameleon.Editor.WebAssets
{
/// <summary>
/// Turns downloaded PBR maps (Poly Haven, ambientCG, ...) 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 PbrMaterialBuilder
{
/// <summary>A map that was successfully downloaded, ready to import.</summary>
public struct Downloaded
{
public PbrMapType 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<PbrMapType, 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(PbrMapType.Arm, out var armPath);
byType.TryGetValue(PbrMapType.Rough, out var roughPath);
byType.TryGetValue(PbrMapType.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(PbrMapType.Rough);
Debug.Log($"[PBR Import] 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(PbrMapType.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("[PBR Import] URP/Lit shader not found — is URP installed?");
return;
}
var mat = new Material(shader) { name = displayName };
string matPath = Path.Combine(folder, ImportUtil.SanitizeFileName(displayName) + ".mat").Replace('\\', '/');
matPath = AssetDatabase.GenerateUniqueAssetPath(matPath);
AssetDatabase.CreateAsset(mat, matPath);
if (byType.TryGetValue(PbrMapType.Diffuse, out var diffPath))
mat.SetTexture("_BaseMap", Load(diffPath));
if (byType.TryGetValue(PbrMapType.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(PbrMapType.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($"[PBR Import] Material created: {matPath}");
}
private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path);
public static void ApplyImportSettings(string assetPath, PbrMapType type, int maxSize)
{
var importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
if (!importer)
return;
var meta = PbrMapInfo.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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c4d448a85521344783f6d1b0dc64c53