From 662f85809024ad6016af8812b0d3c094f09568bf Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 19 Oct 2026 00:21:12 -0700 Subject: [PATCH] urp: editor material importer for .vpe packages Implements IVpeMaterialEditorImporter for URP (port of the HDRP importer) so an editor/batch .vpe import rebuilds URP materials and saves them as assets. Masks UrpMaskRemap bakes into RenderTextures are read back and saved as linear PNGs: AssetDatabase.CreateAsset drops references to non-asset textures, which left remapped steel materials without their mask (matte white). Co-Authored-By: Claude Opus 5.5 --- Editor/UrpMaterialEditorImporter.cs | 271 +++++++++++++++++++++++ Editor/UrpMaterialEditorImporter.cs.meta | 2 + 2 files changed, 273 insertions(+) create mode 100644 Editor/UrpMaterialEditorImporter.cs create mode 100644 Editor/UrpMaterialEditorImporter.cs.meta diff --git a/Editor/UrpMaterialEditorImporter.cs b/Editor/UrpMaterialEditorImporter.cs new file mode 100644 index 0000000..3b4b9e9 --- /dev/null +++ b/Editor/UrpMaterialEditorImporter.cs @@ -0,0 +1,271 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; +using VisualPinball.Unity.Editor; + +namespace VisualPinball.Unity.Urp +{ + // Editor-side .vpe material reconstruction. Drives the same UrpMaterialResolver the player + // uses — so property mapping has a single home — but feeds it the freshly imported texture + // assets and persists the resulting materials as .mat assets. + internal sealed class UrpMaterialEditorImporter : IVpeMaterialEditorImporter + { + public VpeMaterialImportResult Apply( + Transform tableRoot, + VpeMaterialsPayload payload, + IReadOnlyDictionary texturesById, + string materialAssetFolder, + Func resolveNode) + { + if (!tableRoot || payload?.Profiles == null) { + return new VpeMaterialImportResult(); + } + + var resolver = new UrpMaterialResolver(); + + var provider = new AssetTextureProvider(texturesById); + var profilesByName = new Dictionary(StringComparer.Ordinal); + var profilesById = new Dictionary(StringComparer.Ordinal); + var boundIds = new HashSet(StringComparer.Ordinal); + foreach (var binding in payload.Bindings ?? Array.Empty()) { + if (!string.IsNullOrEmpty(binding?.ProfileId)) { boundIds.Add(binding.ProfileId); } + } + foreach (var profile in payload.Profiles) { + if (profile != null && !string.IsNullOrWhiteSpace(profile.Name) + && (boundIds.Count == 0 || boundIds.Contains(profile.Id))) { + profilesByName[VpeMaterialNameUtil.NormalizeMaterialName(profile.Name)] = profile; + } + if (profile != null && !string.IsNullOrEmpty(profile.Id)) { profilesById[profile.Id] = profile; } + } + var captured = new Dictionary(); + var saved = new Dictionary(); + var profilesBySlot = new Dictionary<(Renderer, int), VpeMaterialProfile>(); + if (payload.Bindings != null) { + foreach (var binding in payload.Bindings) { + if (binding == null || string.IsNullOrEmpty(binding.ProfileId) + || !profilesById.TryGetValue(binding.ProfileId, out var profile)) { continue; } + var node = resolveNode?.Invoke(binding.NodeId); + if (node && node.TryGetComponent(out var renderer)) { + profilesBySlot[(renderer, binding.Slot)] = profile; + } + } + } + + var materialsByProfile = new Dictionary(); + var materialsById = new Dictionary(StringComparer.Ordinal); + var appliedSlots = 0; + foreach (var renderer in tableRoot.GetComponentsInChildren(true)) { + if (!renderer) { + continue; + } + var materials = renderer.sharedMaterials; + var modified = false; + for (var i = 0; i < materials.Length; i++) { + var imported = materials[i]; + if (!imported) { + continue; + } + var key = VpeMaterialNameUtil.NormalizeMaterialName(imported.name); + if (!profilesBySlot.TryGetValue((renderer, i), out var profile) + && !profilesByName.TryGetValue(key, out profile)) { + continue; + } + + if (!materialsByProfile.TryGetValue(profile, out var material)) { + material = resolver.CreateMaterial(profile, provider, imported); + var transient = CaptureRenderTextures(material, captured); + if (material) { + var assetPath = $"{materialAssetFolder}/{SanitizeFileName(profile.Name ?? "Material")}-{SanitizeFileName(profile.Id ?? "material")}.mat"; + AssetDatabase.CreateAsset(material, assetPath); + AttachTransientTextures(material, assetPath, transient, saved); + if (!string.IsNullOrEmpty(profile.Id)) { materialsById[profile.Id] = material; } + } + materialsByProfile[profile] = material; + } + if (!material) { + continue; + } + + materials[i] = material; + modified = true; + appliedSlots++; + } + if (modified) { + renderer.sharedMaterials = materials; + } + } + // The editor sidecar can reference a pre-bake or bake-preview material that is + // no longer assigned to a renderer. Persist those profiles as assets too. + foreach (var profile in payload.Profiles) { + if (profile == null || materialsByProfile.ContainsKey(profile)) { continue; } + var material = resolver.CreateMaterial(profile, provider, null); + var transient = CaptureRenderTextures(material, captured); + if (!material) { continue; } + var assetPath = $"{materialAssetFolder}/{SanitizeFileName(profile.Name ?? "Material")}-{SanitizeFileName(profile.Id ?? "material")}.mat"; + AssetDatabase.CreateAsset(material, assetPath); + AttachTransientTextures(material, assetPath, transient, saved); + if (!string.IsNullOrEmpty(profile.Id)) { materialsById[profile.Id] = material; } + } + + ApplyRendererStates(tableRoot, payload, resolveNode); + AssetDatabase.SaveAssets(); + return new VpeMaterialImportResult { AppliedSlots = appliedSlots, MaterialsById = materialsById }; + } + + // The resolver bakes some textures into RenderTextures (UrpMaskRemap: HDRP mask remaps URP + // cannot express as scalars). A .mat asset cannot reference a RenderTexture, so the slot would + // be empty after a reload. Read each one back into a Texture2D right after the material is + // built, before anything else can release it; the resolver's cache shares targets between + // materials, so each one is read once. + private static List<(int id, Texture2D tex)> CaptureRenderTextures(Material material, Dictionary captured) + { + var result = new List<(int id, Texture2D tex)>(); + if (!material) { + return result; + } + foreach (var id in material.GetTexturePropertyNameIDs()) { + if (!(material.GetTexture(id) is RenderTexture rt) || !rt) { + continue; + } + if (!captured.TryGetValue(rt, out var tex)) { + tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, rt.useMipMap, !rt.sRGB) { + name = rt.name, + wrapModeU = rt.wrapModeU, + wrapModeV = rt.wrapModeV, + filterMode = rt.filterMode, + anisoLevel = rt.anisoLevel, + }; + var previous = RenderTexture.active; + RenderTexture.active = rt; + tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + tex.Apply(true); + RenderTexture.active = previous; + captured[rt] = tex; + } + material.SetTexture(id, tex); + result.Add((id, tex)); + } + return result; + } + + // Stores every texture the material references that is not an asset yet (captured masks, or + // any Texture2D a resolver builds in memory) inside the material asset, so it survives a reload. + private static void AttachTransientTextures(Material material, string assetPath, List<(int id, Texture2D tex)> transient, Dictionary saved) + { + // CreateAsset drops references to objects that are not assets, so each captured texture is + // saved as a linear PNG next to the materials (compressed, shared by every material that + // uses it) and assigned again afterwards. + foreach (var (id, tex) in transient) { + if (!tex) { + continue; + } + if (!saved.TryGetValue(tex, out var asset)) { + // one file per distinct bake in this import (two remaps of one mask share a name) + var stem = $"{Path.GetDirectoryName(assetPath)}/{SanitizeFileName(tex.name)}"; + var pngPath = $"{stem}.png"; + for (var n = 2; saved.Values.Any(t => t && AssetDatabase.GetAssetPath(t) == pngPath); n++) { + pngPath = $"{stem} {n}.png"; + } + File.WriteAllBytes(pngPath, tex.EncodeToPNG()); + AssetDatabase.ImportAsset(pngPath, ImportAssetOptions.ForceUpdate); + if (AssetImporter.GetAtPath(pngPath) is TextureImporter importer) { + importer.sRGBTexture = false; + importer.mipmapEnabled = tex.mipmapCount > 1; + importer.wrapModeU = tex.wrapModeU; + importer.wrapModeV = tex.wrapModeV; + importer.filterMode = tex.filterMode; + importer.anisoLevel = tex.anisoLevel; + importer.SaveAndReimport(); + } + asset = AssetDatabase.LoadAssetAtPath(pngPath); + saved[tex] = asset; + } + if (asset) { + material.SetTexture(id, asset); + } + } + if (transient.Count > 0) { + EditorUtility.SetDirty(material); + } + } + + private static void ApplyRendererStates(Transform tableRoot, VpeMaterialsPayload payload, Func resolveNode) + { + if (payload.RendererStates == null) { + return; + } + foreach (var state in payload.RendererStates) { + if (state == null) { + continue; + } + Transform target = null; + if (!string.IsNullOrEmpty(state.NodeId) && resolveNode != null) { + target = resolveNode(state.NodeId); + } + if (!target || !target.TryGetComponent(out var renderer)) { + continue; + } + renderer.shadowCastingMode = VpeMaterialEnums.ParseShadowCastingMode(state.CastShadows); + renderer.receiveShadows = state.ReceiveShadows; + renderer.renderingLayerMask = state.RenderingLayerMask; + } + } + + private static string SanitizeFileName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + var chars = name.ToCharArray(); + for (var i = 0; i < chars.Length; i++) { + if (Array.IndexOf(invalid, chars[i]) >= 0) { + chars[i] = '_'; + } + } + return new string(chars); + } + + private sealed class AssetTextureProvider : IVpeTextureProvider + { + private readonly IReadOnlyDictionary _texturesById; + + public AssetTextureProvider(IReadOnlyDictionary texturesById) + { + _texturesById = texturesById ?? new Dictionary(); + } + + public Texture2D Get(string textureId) + { + return !string.IsNullOrEmpty(textureId) && _texturesById.TryGetValue(textureId, out var texture) + ? texture + : null; + } + } + } + + [InitializeOnLoad] + internal static class UrpMaterialEditorImporterRegistration + { + static UrpMaterialEditorImporterRegistration() + { + VpeMaterialEditorImport.Register(new UrpMaterialEditorImporter()); + } + } +} diff --git a/Editor/UrpMaterialEditorImporter.cs.meta b/Editor/UrpMaterialEditorImporter.cs.meta new file mode 100644 index 0000000..3372bc9 --- /dev/null +++ b/Editor/UrpMaterialEditorImporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 02f1fdd09ee44b7ba721f83dd1a55509