using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Chameleon.Editor.PolyHaven
{
///
/// 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.
///
public static class PolyHavenMaterialBuilder
{
/// A map that was successfully downloaded, ready to import.
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 maps)
{
var byType = new Dictionary();
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(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();
}
///
/// 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.
///
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;
}
///
/// 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.
///
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;
}
/// Reimports a generated data texture as linear, at the target size.
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();
}
/// Loads an image file straight off disk into a linear Texture2D (no import needed).
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();
}
}
}