using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; namespace Chameleon.Editor.WebAssets { /// /// 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. /// public static class PbrMaterialBuilder { /// A map that was successfully downloaded, ready to import. public struct Downloaded { public PbrMapType Type; public string AssetPath; // "Assets/Poly Haven/.../foo_diffuse.jpg" } /// Builds the material and returns it (null if it could not be created). public static Material 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(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 null; } 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}"); return mat; } private static Texture2D Load(string path) => AssetDatabase.LoadAssetAtPath(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(); } /// /// 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; } } }