Browser gets Textures/Models tabs (per-type catalog cache). Model file JSON is parsed into per-set texture maps (generic set-prefix matching), FBX is downloaded with resolution fallback, one URP/Lit material is built per set via the shared PbrMaterialBuilder (which now returns the created Material), and new ModelImportUtil remaps FBX material slots to the built materials by set-name matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
259 lines
11 KiB
C#
259 lines
11 KiB
C#
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"
|
|
}
|
|
|
|
/// <summary>Builds the material and returns it (null if it could not be created).</summary>
|
|
public static Material 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 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<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;
|
|
}
|
|
}
|
|
}
|