From 467d908a32126e62fc697cf0bba6699ca6c346ba Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 19 Apr 2026 15:53:08 +0200 Subject: [PATCH 01/55] export: Fix .vpe export. --- .../Packaging/PackageWriter.cs | 154 ++++++++++++++++-- 1 file changed, 140 insertions(+), 14 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 9eca9bc30..a15e056ac 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -27,6 +27,7 @@ using NLog; using UnityEditor; using UnityEngine; +using UnityEngine.Rendering; using Debug = UnityEngine.Debug; using Logger = NLog.Logger; using Object = UnityEngine.Object; @@ -73,15 +74,15 @@ private async Task WritePackage(string path) _metaFolder = _tableFolder.AddFolder(PackageApi.MetaFolder); _files = new PackagedFiles(_tableFolder, _refs); - // write scene data + // prepare scene data var sw1 = Stopwatch.StartNew(); - await WriteScene(); - Logger.Info($"Scene written in {sw1.ElapsedMilliseconds}ms."); + var saveScene = PrepareScene(); + Logger.Info($"Scene prepared in {sw1.ElapsedMilliseconds}ms."); - // write non-scene meshes + // prepare non-scene meshes sw1 = Stopwatch.StartNew(); - await WriteColliderMeshes(); - Logger.Info($"Collider meshes written in {sw1.ElapsedMilliseconds}ms."); + var saveColliderMeshes = PrepareColliderMeshes(); + Logger.Info($"Collider meshes prepared in {sw1.ElapsedMilliseconds}ms."); // write component data sw1 = Stopwatch.StartNew(); @@ -108,19 +109,36 @@ private async Task WritePackage(string path) _files.PackSoundMetas(); Logger.Info($"Assets and files written in {sw1.ElapsedMilliseconds}ms."); + // glTFast still reads Unity mesh data at the start of SaveToStreamAndDispose. Start both saves while + // we're still on the main thread, but write into memory first because the package zip stream only + // supports one active file entry at a time. + sw1 = Stopwatch.StartNew(); + var saveSceneTask = saveScene(); + var saveColliderMeshesTask = saveColliderMeshes?.Invoke(); + + var sceneData = await saveSceneTask; + WritePackageFile(_tableFolder, PackageApi.SceneFile, sceneData); + Logger.Info($"Scene written in {sw1.ElapsedMilliseconds}ms ({sceneData.Length} bytes)."); + + if (saveColliderMeshesTask != null) { + sw1 = Stopwatch.StartNew(); + var colliderMeshesData = await saveColliderMeshesTask; + WritePackageFile(_tableFolder, PackageApi.ColliderMeshesFile, colliderMeshesData); + Logger.Info($"Collider meshes written in {sw1.ElapsedMilliseconds}ms ({colliderMeshesData.Length} bytes)."); + } + storage.Close(); sw.Stop(); Debug.Log($"Done! File saved to {path} in {sw.ElapsedMilliseconds}ms."); } - private async Task WriteScene() + private Func> PrepareScene() { // make table meshes readable var meshFilters = _table.GetComponentsInChildren(!ExportActivesOnly); var skinnedMeshRenderers = _table.GetComponentsInChildren(!ExportActivesOnly); SetMeshesReadable(meshFilters, skinnedMeshRenderers); - var glbFile = _tableFolder.AddFile(PackageApi.SceneFile); var logger = new ConsoleLogger(); #region glTF Settings @@ -170,15 +188,22 @@ private async Task WriteScene() #endregion var export = new GameObjectExport(exportSettings, gameObjectExportSettings, logger: logger); - export.AddScene(new [] { _table }, _table.transform.worldToLocalMatrix, "VPE Table"); + var disabledRenderers = DisableInvalidMeshRenderers(meshFilters, skinnedMeshRenderers, _table.transform); + try { + export.AddScene(new [] { _table }, _table.transform.worldToLocalMatrix, "VPE Table"); + + } finally { + RestoreDisabledRenderers(disabledRenderers); + } - await export.SaveToStreamAndDispose(glbFile.AsStream()); + return () => SaveGltfToBytes(export); } - private async Task WriteColliderMeshes() + private Func> PrepareColliderMeshes() { var meshGos = new List(); var colliderMeshesMeta = new Dictionary(); + GameObjectExport export = null; try { foreach (var colMesh in _table.GetComponentsInChildren(!ExportActivesOnly)) { for (var index = 0; index < colMesh.NumColliderMeshes; index++) { @@ -186,6 +211,12 @@ private async Task WriteColliderMeshes() if (!mesh) { continue; } + if (IsInvalidMeshForGltfExport(mesh, out var reason)) { + var path = ((Component)colMesh).transform.GetPath(_table.transform); + Logger.Warn($"Skipping collider mesh '{mesh.name}' for '{path}' during package export because {reason}."); + Debug.LogWarning($"Skipping collider mesh '{mesh.name}' for '{path}' during package export because {reason}.", (Object)colMesh); + continue; + } var guid = Guid.NewGuid().ToString(); var meshGo = new GameObject($"{guid}-{index}"); var meshFilter = meshGo.AddComponent(); @@ -199,14 +230,12 @@ private async Task WriteColliderMeshes() if (meshGos.Count > 0) { Logger.Info($"Found {meshGos.Count} collider meshes."); - var glbFile = _tableFolder.AddFile(PackageApi.ColliderMeshesFile); var logger = new ConsoleLogger(); var exportSettings = new ExportSettings { Format = GltfFormat.Binary, }; - var export = new GameObjectExport(exportSettings, logger: logger); + export = new GameObjectExport(exportSettings, logger: logger); export.AddScene(meshGos.ToArray(), _table.transform.worldToLocalMatrix, "Colliders"); - await export.SaveToStreamAndDispose(glbFile.AsStream()); var glbMeta = _metaFolder.AddFile(PackageApi.ColliderMeshesMeta, PackageApi.Packer.FileExtension); glbMeta.SetData(PackageApi.Packer.Pack(colliderMeshesMeta)); @@ -219,6 +248,8 @@ private async Task WriteColliderMeshes() Object.DestroyImmediate(meshGo); } } + + return export == null ? null : () => SaveGltfToBytes(export); } /// @@ -252,6 +283,10 @@ private void WritePackables(string folderName, Func getPackab foreach (var component in t.gameObject.GetComponents()) { switch (component) { + case null: + Debug.LogWarning($"Skipping missing component on {key} during package export.", t.gameObject); + break; + case IPackable packageable: { var packName = _refs.GetName(packageable.GetType()); @@ -293,6 +328,10 @@ private void WritePackables(string folderName, Func getPackab private byte[] PackNativeComponent(Component comp) { + if (!comp) { + return null; + } + if (_refs.HasType(comp.GetType())) { try { @@ -337,6 +376,22 @@ private void WriteGlobals() _globalFolder.AddFile(PackageApi.LampsFile, PackageApi.Packer.FileExtension).SetData(PackageApi.Packer.Pack(tableComponent.MappingConfig.Lamps)); } + private static async Task SaveGltfToBytes(GameObjectExport export) + { + using var stream = new MemoryStream(); + await export.SaveToStreamAndDispose(stream); + return stream.ToArray(); + } + + private static void WritePackageFile(IPackageFolder folder, string fileName, byte[] data) + { + if (data == null || data.Length == 0) { + throw new InvalidOperationException($"Cannot write empty package file '{fileName}'."); + } + + folder.AddFile(fileName).SetData(data); + } + private static void SetMeshesReadable(MeshFilter[] meshFilters, SkinnedMeshRenderer[] skinnedMeshRenderers) { // Keep track of which assets we've changed to avoid re-importing multiple times @@ -377,5 +432,76 @@ private static void MakeModelReadable(Mesh mesh, HashSet changedAssets) changedAssets.Add(assetPath); } } + + private static List DisableInvalidMeshRenderers(MeshFilter[] meshFilters, SkinnedMeshRenderer[] skinnedMeshRenderers, Transform root) + { + var disabledRenderers = new List(); + + foreach (var mf in meshFilters) { + var renderer = mf.GetComponent(); + if (renderer && renderer.enabled && IsInvalidMeshForGltfExport(mf.sharedMesh, out var reason)) { + DisableRendererForExport(renderer, mf.sharedMesh, root, reason, disabledRenderers); + } + } + + foreach (var smr in skinnedMeshRenderers) { + if (smr.enabled && IsInvalidMeshForGltfExport(smr.sharedMesh, out var reason)) { + DisableRendererForExport(smr, smr.sharedMesh, root, reason, disabledRenderers); + } + } + + return disabledRenderers; + } + + private static void DisableRendererForExport(Renderer renderer, Mesh mesh, Transform root, string reason, List disabledRenderers) + { + if (disabledRenderers.Contains(renderer)) { + return; + } + + renderer.enabled = false; + disabledRenderers.Add(renderer); + + var path = renderer.transform.GetPath(root); + var meshName = mesh ? mesh.name : ""; + Logger.Warn($"Skipping mesh '{meshName}' on '{path}' during package export because {reason}."); + Debug.LogWarning($"Skipping mesh '{meshName}' on '{path}' during package export because {reason}.", renderer); + } + + private static void RestoreDisabledRenderers(List disabledRenderers) + { + foreach (var renderer in disabledRenderers) { + if (renderer) { + renderer.enabled = true; + } + } + } + + private static bool IsInvalidMeshForGltfExport(Mesh mesh, out string reason) + { + if (!mesh) { + reason = "no mesh is assigned"; + return true; + } + if (mesh.vertexCount == 0) { + reason = "it has no vertices"; + return true; + } + if (mesh.subMeshCount == 0) { + reason = "it has no submeshes"; + return true; + } + if (mesh.GetVertexAttributes().Length == 0) { + reason = "it has no vertex attributes"; + return true; + } + if (!mesh.HasVertexAttribute(VertexAttribute.Position)) { + reason = "it has no position vertex attribute"; + return true; + } + + reason = null; + return false; + } } } From 723e07197251e7bd8cb8a8d09968634622ece052 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 19 Apr 2026 16:08:02 +0200 Subject: [PATCH 02/55] packaging: Add table metadata. --- .../Packaging/PackageReader.cs | 33 ++++++++---- .../Packaging/PackageWriter.cs | 12 +++++ .../VPT/Table/TableInspector.cs | 39 +++++++++----- .../Packaging/PackageApi.cs | 5 +- .../VPT/Table/TableComponent.cs | 7 +-- .../VPT/Table/TableMetadata.cs | 54 +++++++++++++++++++ .../VPT/Table/TableMetadata.cs.meta | 11 ++++ 7 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs index 7f3badf7b..81380d3e0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageReader.cs @@ -82,10 +82,11 @@ public async Task ImportIntoScene(string tableName) var comp = item.gameObject.GetComponent(type) as IPackable; comp?.UnpackReferences(stream.GetData(), _table.transform, _refs, _files); }); - - ReadGlobals(); - - } finally { + + ReadGlobals(); + ReadTableMetadata(); + + } finally { storage.Close(); Logger.Info($"Scene import took {sw.ElapsedMilliseconds}ms."); } @@ -201,9 +202,21 @@ private void ReadGlobals() foreach (var lamp in tableComponent.MappingConfig.Lamps) { lamp.RestoreReference(_table.transform); } - foreach (var wire in tableComponent.MappingConfig.Wires) { - wire.RestoreReferences(_table.transform); - } - } - } -} + foreach (var wire in tableComponent.MappingConfig.Wires) { + wire.RestoreReferences(_table.transform); + } + } + + private void ReadTableMetadata() + { + var tableComponent = _table.GetComponent(); + if (!tableComponent) { + throw new Exception("Cannot find table component on table object."); + } + + if (_tableFolder.TryGetFile(PackageApi.TableMetadataFile, out var tableMetadataFile, PackageApi.Packer.FileExtension)) { + tableComponent.Metadata = PackageApi.Packer.Unpack(tableMetadataFile.GetData()) ?? new TableMetadata(); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index a15e056ac..8f7a8aa1c 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -100,6 +100,7 @@ private async Task WritePackage(string path) // write globals sw1 = Stopwatch.StartNew(); + WriteTableMetadata(); WriteGlobals(); Logger.Info($"Globals written in {sw1.ElapsedMilliseconds}ms."); @@ -376,6 +377,17 @@ private void WriteGlobals() _globalFolder.AddFile(PackageApi.LampsFile, PackageApi.Packer.FileExtension).SetData(PackageApi.Packer.Pack(tableComponent.MappingConfig.Lamps)); } + private void WriteTableMetadata() + { + var tableComponent = _table.GetComponent(); + if (!tableComponent) { + throw new Exception("Cannot find table component on table object."); + } + + tableComponent.Metadata ??= new TableMetadata(); + _tableFolder.AddFile(PackageApi.TableMetadataFile, PackageApi.Packer.FileExtension).SetData(PackageApi.Packer.Pack(tableComponent.Metadata)); + } + private static async Task SaveGltfToBytes(GameObjectExport export) { using var stream = new MemoryStream(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs index dd300d9cc..d041fda66 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs @@ -27,30 +27,41 @@ namespace VisualPinball.Unity.Editor [CanEditMultipleObjects] public class TableInspector : ItemInspector { - protected override MonoBehaviour UndoTarget => target as MonoBehaviour; - - private SerializedProperty _globalDifficultyProperty; + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + private SerializedProperty _globalDifficultyProperty; + private SerializedProperty _metadataProperty; + private bool _packageFoldout; protected override void OnEnable() { base.OnEnable(); - - _globalDifficultyProperty = serializedObject.FindProperty(nameof(TableComponent.GlobalDifficulty)); - } + + _globalDifficultyProperty = serializedObject.FindProperty(nameof(TableComponent.GlobalDifficulty)); + _metadataProperty = serializedObject.FindProperty(nameof(TableComponent.Metadata)); + } public override void OnInspectorGUI() { var tableComponent = (TableComponent) target; BeginEditing(); - - PropertyField(_globalDifficultyProperty); - - EndEditing(); - - if (!EditorApplication.isPlaying) { - //DrawDefaultInspector(); - const string ext = "vpe"; + + PropertyField(_globalDifficultyProperty); + + EditorGUILayout.Space(); + _packageFoldout = EditorGUILayout.Foldout(_packageFoldout, "Package", true); + if (_packageFoldout) { + EditorGUI.indentLevel++; + EditorGUILayout.PropertyField(_metadataProperty, true); + EditorGUI.indentLevel--; + } + + EndEditing(); + + if (_packageFoldout && !EditorApplication.isPlaying) { + //DrawDefaultInspector(); + const string ext = "vpe"; if (GUILayout.Button($"Save as .{ext}")) { var tableContainer = tableComponent.TableContainer; var path = EditorUtility.SaveFilePanel( diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index 52d128ffb..b1edfe318 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -25,8 +25,9 @@ namespace VisualPinball.Unity /// public static class PackageApi { - public const string TableFolder = "table"; - public const string ItemFolder = "items"; + public const string TableFolder = "table"; + public const string TableMetadataFile = "table"; + public const string ItemFolder = "items"; public const string ItemFile = "item"; public const string ItemReferencesFolder = "refs"; public const string SceneFile = "table.glb"; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs index 783140120..752c457ee 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableComponent.cs @@ -37,9 +37,10 @@ public class TableComponent : MainRenderableComponent, IPackable [SerializeReference] public LegacyContainer LegacyContainer; [SerializeReference] public MappingConfig MappingConfig = new MappingConfig(); - [SerializeField] public SerializableDictionary TableInfo = new SerializableDictionary(); - [SerializeField] [Obsolete("Use MappingConfig")] public CustomInfoTags CustomInfoTags = new CustomInfoTags(); - [SerializeField] public List Collections = new List(); + [SerializeField] public SerializableDictionary TableInfo = new SerializableDictionary(); + [SerializeField] [Obsolete("Use MappingConfig")] public CustomInfoTags CustomInfoTags = new CustomInfoTags(); + [SerializeField] public List Collections = new List(); + [SerializeField] public TableMetadata Metadata = new TableMetadata(); #region Data diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs new file mode 100644 index 000000000..cf626872d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs @@ -0,0 +1,54 @@ +// Visual Pinball Engine +// Copyright (C) 2023 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 Newtonsoft.Json; + +namespace VisualPinball.Unity +{ + [Serializable] + public class TableMetadata + { + [JsonProperty("tableName")] + public string TableName; + + [JsonProperty("primaryAuthors")] + public List PrimaryAuthors = new(); + + [JsonProperty("secondaryAuthors")] + public List SecondaryAuthors = new(); + + [JsonProperty("releaseDate")] + public string ReleaseDate; + + [JsonProperty("originalReleaseYear")] + public int OriginalReleaseYear; + + [JsonProperty("manufacturer")] + public string Manufacturer; + } + + [Serializable] + public class TableAuthor + { + [JsonProperty("name")] + public string Name; + + [JsonProperty("vpuHandle")] + public string VpuHandle; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs.meta new file mode 100644 index 000000000..ed1382136 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Table/TableMetadata.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9bc823af04a774c41bbf7952f3c43d14 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {fileID: 2800000, guid: 9d233d228acb6fa46b6a3cc394debd67, type: 3} + userData: + assetBundleName: + assetBundleVariant: From 8178549b205d93ae1fde624359ea9899b206182d Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 19 Apr 2026 22:26:33 +0200 Subject: [PATCH 03/55] packaging: Fix runtime package loading. --- .../Packaging/PackageWriter.cs | 47 +- .../Extensions/TransformExtensions.cs | 201 +++++++-- .../Mappings/CoilMapping.cs | 8 +- .../Mappings/LampMapping.cs | 8 +- .../Mappings/SwitchMapping.cs | 8 +- .../Mappings/WireMapping.cs | 10 +- .../Packaging/ItemPackable.cs | 20 +- .../Packaging/PackagedFiles.cs | 251 +++++++++-- .../Packaging/PackagedRefs.cs | 8 +- .../Packaging/RuntimePackageReader.cs | 423 ++++++++++++++++++ .../Packaging/RuntimePackageReader.cs.meta | 2 + .../Packaging/VpeTableMetadataReader.cs | 191 ++++++++ .../Packaging/VpeTableMetadataReader.cs.meta | 2 + .../VPT/DropTargetBank/DropTargetBankApi.cs | 35 +- .../VisualPinball.Unity/VPT/Mech/MechMark.cs | 8 +- 15 files changed, 1119 insertions(+), 103 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 8f7a8aa1c..34f5f973e 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -269,7 +269,7 @@ private void WritePackables(string folderName, Func getPackab foreach (var t in _table.transform.GetComponentsInChildren(!ExportActivesOnly)) { // for each game object, loop through all components - var key = t.GetPath(_table.transform); + var key = GetPackagePath(t, _table.transform, ExportActivesOnly); var counters = new Dictionary(); var itemData = getItemData?.Invoke(t.gameObject); @@ -350,6 +350,51 @@ private byte[] PackNativeComponent(Component comp) return null; } + private static string GetPackagePath(Transform transform, Transform root, bool activeOnly) + { + if (!transform) { + return "0"; + } + + if (transform == root) { + return "0"; + } + + var path = string.Empty; + var current = transform; + while (current != null && current != root) { + var siblingIndex = GetPackageSiblingIndex(current, activeOnly); + path = string.IsNullOrEmpty(path) ? $"{siblingIndex}" : $"{siblingIndex}.{path}"; + current = current.parent; + } + + return string.IsNullOrEmpty(path) ? "0" : $"0.{path}"; + } + + private static int GetPackageSiblingIndex(Transform transform, bool activeOnly) + { + if (!activeOnly || transform.parent == null) { + return transform.GetSiblingIndex(); + } + + var parent = transform.parent; + var activeSiblingIndex = 0; + for (var childIndex = 0; childIndex < parent.childCount; childIndex++) { + var sibling = parent.GetChild(childIndex); + if (!sibling.gameObject.activeInHierarchy) { + continue; + } + + if (sibling == transform) { + return activeSiblingIndex; + } + + activeSiblingIndex++; + } + + return transform.GetSiblingIndex(); + } + private void WriteGlobals() { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Extensions/TransformExtensions.cs b/VisualPinball.Unity/VisualPinball.Unity/Extensions/TransformExtensions.cs index 0a26d8182..66a939718 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Extensions/TransformExtensions.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Extensions/TransformExtensions.cs @@ -14,15 +14,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -using System; -using Unity.Mathematics; -using UnityEngine; - -namespace VisualPinball.Unity -{ - public static class TransformExtensions - { - private const string NodeSeparator = "."; +using System; +using System.Collections.Generic; +using Unity.Mathematics; +using UnityEngine; + +namespace VisualPinball.Unity +{ + public static class TransformExtensions + { + private const string NodeSeparator = "."; + public static IReadOnlyDictionary SparsePathIndexMap { get; set; } public static void SetFromMatrix(this Transform tf, Matrix4x4 trs) { @@ -82,31 +84,162 @@ public static void SetLocalXRotation(this Transform transform, float angleRad) transform.rotation = Quaternion.LookRotation(newForward, newUp); } - public static string GetPath(this Transform transform, Transform root = null, string path = "") - { - var name = $"{transform.GetSiblingIndex()}"; - if (transform == root || transform.parent == null) { - var suffix = string.IsNullOrEmpty(path) ? "" : NodeSeparator; - return $"0{suffix}{path}"; - } - return $"{transform.parent.GetPath(root, path)}{NodeSeparator}{name}"; - } - - public static Transform FindByPath(this Transform transform, string path) - { - return path == "0" ? transform : transform.FindChildrenByPath(path[2..]); - } - - private static Transform FindChildrenByPath(this Transform transform, string path) - { - var indexOfSeparator = path.IndexOf(NodeSeparator[0]); - var firstIndex = indexOfSeparator == -1 ? path : path[..indexOfSeparator]; - if (int.TryParse(firstIndex, out var index)) { + public static string GetPath(this Transform transform, Transform root = null, string path = "", bool activeOnly = false) + { + var name = $"{GetPathSiblingIndex(transform, activeOnly)}"; + if (transform == root || transform.parent == null) { + var suffix = string.IsNullOrEmpty(path) ? "" : NodeSeparator; + return $"0{suffix}{path}"; + } + return $"{transform.parent.GetPath(root, path, activeOnly)}{NodeSeparator}{name}"; + } + + public static Transform FindByPath(this Transform transform, string path) + { + if (!transform || string.IsNullOrWhiteSpace(path)) { + return null; + } + + if (path == "0") { + return transform; + } + + if (path.Length <= 2 || path[0] != '0' || path[1] != NodeSeparator[0]) { + return null; + } + + if (transform.TryFindChildrenByPath(path[2..], out var found)) { + return found; + } + + return SparsePathIndexMap != null && transform.TryFindByPathMapped(path, SparsePathIndexMap, out found) + ? found + : null; + } + + public static bool TryFindByPath(this Transform transform, string path, out Transform found) + { + found = null; + if (!transform || string.IsNullOrWhiteSpace(path)) { + return false; + } + + if (path == "0") { + found = transform; + return true; + } + + if (path.Length <= 2 || path[0] != '0' || path[1] != NodeSeparator[0]) { + return false; + } + + return transform.TryFindChildrenByPath(path[2..], out found); + } + + public static bool TryFindByPathMapped(this Transform transform, string path, IReadOnlyDictionary sparseIndexMap, out Transform found) + { + found = null; + if (!transform || string.IsNullOrWhiteSpace(path)) { + return false; + } + + if (path == "0") { + found = transform; + return true; + } + + if (path.Length <= 2 || path[0] != '0' || path[1] != NodeSeparator[0]) { + return false; + } + + var segments = path.Split(NodeSeparator[0]); + if (segments.Length < 2 || segments[0] != "0") { + return false; + } + + var current = transform; + var parentPath = "0"; + for (var segmentIndex = 1; segmentIndex < segments.Length; segmentIndex++) { + if (!int.TryParse(segments[segmentIndex], out var requestedIndex)) { + return false; + } + + var denseIndex = requestedIndex; + if (sparseIndexMap != null && sparseIndexMap.TryGetValue(parentPath, out var sparseChildren) && sparseChildren != null && sparseChildren.Length > 0) { + var sparsePosition = Array.BinarySearch(sparseChildren, requestedIndex); + if (sparsePosition >= 0) { + denseIndex = sparsePosition; + } + } + + if (denseIndex < 0 || denseIndex >= current.childCount) { + return false; + } + + current = current.GetChild(denseIndex); + parentPath = $"{parentPath}.{requestedIndex}"; + } + + found = current; + return true; + } + + private static Transform FindChildrenByPath(this Transform transform, string path) + { + var indexOfSeparator = path.IndexOf(NodeSeparator[0]); + var firstIndex = indexOfSeparator == -1 ? path : path[..indexOfSeparator]; + if (int.TryParse(firstIndex, out var index)) { return indexOfSeparator == -1 ? transform.GetChild(index) : transform.GetChild(index).FindChildrenByPath(path[(indexOfSeparator + 1)..]); - } - throw new InvalidOperationException($"Cannot parse index {firstIndex}."); - } - } -} + } + throw new InvalidOperationException($"Cannot parse index {firstIndex}."); + } + + private static bool TryFindChildrenByPath(this Transform transform, string path, out Transform found) + { + found = null; + if (!transform) { + return false; + } + + var indexOfSeparator = path.IndexOf(NodeSeparator[0]); + var firstIndex = indexOfSeparator == -1 ? path : path[..indexOfSeparator]; + if (!int.TryParse(firstIndex, out var index) || index < 0 || index >= transform.childCount) { + return false; + } + + var child = transform.GetChild(index); + if (indexOfSeparator == -1) { + found = child; + return true; + } + + return child.TryFindChildrenByPath(path[(indexOfSeparator + 1)..], out found); + } + + private static int GetPathSiblingIndex(Transform transform, bool activeOnly) + { + if (!activeOnly || transform.parent == null) { + return transform.GetSiblingIndex(); + } + + var parent = transform.parent; + var activeSiblingIndex = 0; + for (var childIndex = 0; childIndex < parent.childCount; childIndex++) { + var sibling = parent.GetChild(childIndex); + if (!sibling.gameObject.activeInHierarchy) { + continue; + } + + if (sibling == transform) { + return activeSiblingIndex; + } + + activeSiblingIndex++; + } + + return transform.GetSiblingIndex(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Mappings/CoilMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Mappings/CoilMapping.cs index 575d109ad..36a5452ee 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Mappings/CoilMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Mappings/CoilMapping.cs @@ -43,10 +43,10 @@ public class CoilMapping public string DeviceItem = string.Empty; - public void SaveReference(Transform tableRoot) - { - _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot) : null; - } + public void SaveReference(Transform tableRoot) + { + _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot, activeOnly: true) : null; + } public void RestoreReference(Transform tableRoot) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Mappings/LampMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Mappings/LampMapping.cs index f95875a3c..1dc07e336 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Mappings/LampMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Mappings/LampMapping.cs @@ -53,10 +53,10 @@ public class LampMapping public ColorChannel Channel = ColorChannel.Alpha; - public void SaveReference(Transform tableRoot) - { - _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot) : null; - } + public void SaveReference(Transform tableRoot) + { + _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot, activeOnly: true) : null; + } public void RestoreReference(Transform tableRoot) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Mappings/SwitchMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Mappings/SwitchMapping.cs index 97e9d4f87..75d1842c7 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Mappings/SwitchMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Mappings/SwitchMapping.cs @@ -53,10 +53,10 @@ public class SwitchMapping public int PulseDelay = 250; - public void SaveReference(Transform tableRoot) - { - _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot) : null; - } + public void SaveReference(Transform tableRoot) + { + _devicePath = _device ? _device.gameObject.transform.GetPath(tableRoot, activeOnly: true) : null; + } public void RestoreReference(Transform tableRoot) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Mappings/WireMapping.cs b/VisualPinball.Unity/VisualPinball.Unity/Mappings/WireMapping.cs index f07767027..d61654fe4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Mappings/WireMapping.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Mappings/WireMapping.cs @@ -72,11 +72,11 @@ public WireMapping() { } - public void SaveReferences(Transform tableRoot) - { - _sourceDevicePath = _sourceDevice ? _sourceDevice.gameObject.transform.GetPath(tableRoot) : null; - _destinationDevicePath = _destinationDevice ? _destinationDevice.gameObject.transform.GetPath(tableRoot) : null; - } + public void SaveReferences(Transform tableRoot) + { + _sourceDevicePath = _sourceDevice ? _sourceDevice.gameObject.transform.GetPath(tableRoot, activeOnly: true) : null; + _destinationDevicePath = _destinationDevice ? _destinationDevice.gameObject.transform.GetPath(tableRoot, activeOnly: true) : null; + } public void RestoreReferences(Transform tableRoot) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/ItemPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/ItemPackable.cs index 28b672520..dd4f4c916 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/ItemPackable.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/ItemPackable.cs @@ -31,6 +31,25 @@ public struct ItemPackable private bool IsEmpty => string.IsNullOrEmpty(PrefabGuid) && IsActive && !IsStatic; + public void ApplyRuntime(GameObject go) + { + go.SetActive(IsActive); + go.isStatic = IsStatic; + } + + public static ItemPackable Unpack(byte[] data) + { + if (data == null || data.Length == 0) { + return new ItemPackable { + IsActive = true, + IsStatic = false, + PrefabGuid = null + }; + } + + return PackageApi.Packer.Unpack(data); + } + #if UNITY_EDITOR public static ItemPackable Instantiate(GameObject go) { @@ -69,7 +88,6 @@ public void Apply(GameObject go) UnityEditor.GameObjectUtility.SetStaticEditorFlags(go, IsStatic ? (UnityEditor.StaticEditorFlags)127 : 0); } - public static ItemPackable Unpack(byte[] data) => PackageApi.Packer.Unpack(data); public byte[] Pack() => IsEmpty ? Array.Empty() : PackageApi.Packer.Pack(this); #endif } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs index e0618ef0b..2816daffe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs @@ -14,13 +14,17 @@ // 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.Threading.Tasks; -using NLog; -using UnityEngine; -using Logger = NLog.Logger; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GLTFast; +using GLTFast.Logging; +using NLog; +using UnityEngine; +using UnityEngine.Networking; +using Logger = NLog.Logger; #if UNITY_EDITOR using UnityEditor; @@ -72,9 +76,9 @@ public void AddColliderMeshGuid(IColliderMesh cm, string guid, int index) _colliderMeshInstanceIdToGuid.Add(key, guid); } -#if UNITY_EDITOR - public async Task UnpackMeshes(string assetPath) - { +#if UNITY_EDITOR + public async Task UnpackMeshes(string assetPath) + { if (!_tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder)) { return; } @@ -143,13 +147,58 @@ public async Task UnpackMeshes(string assetPath) } _colliderMeshes.Add(key, collider.GetComponent().sharedMesh); } - } -#endif - - public Mesh GetColliderMesh(string guid, int index) - { - return _colliderMeshes[$"{guid}-{index}"]; - } + } +#endif + + public async Task UnpackMeshesRuntime(CancellationToken cancellationToken = default) + { + if (!_tableFolder.TryGetFile(PackageApi.ColliderMeshesFile, out var colliderMeshes)) { + return; + } + + var colliderMeshData = colliderMeshes.GetData(); + if (colliderMeshData == null || colliderMeshData.Length == 0) { + return; + } + + var importRoot = new GameObject("__vpe_runtime_colliders"); + importRoot.hideFlags = HideFlags.HideAndDontSave; + try { + var gltf = new GltfImport(logger: new ConsoleLogger()); + var loaded = await gltf.Load(colliderMeshData, cancellationToken: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!loaded) { + throw new Exception("Unable to load colliders.glb from .vpe package."); + } + + var instantiated = await gltf.InstantiateMainSceneAsync(importRoot.transform, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!instantiated) { + throw new Exception("Unable to instantiate colliders.glb scene."); + } + + _colliderMeshes.Clear(); + foreach (var meshFilter in importRoot.GetComponentsInChildren(true)) { + if (!meshFilter || !meshFilter.sharedMesh || meshFilter.transform == importRoot.transform) { + continue; + } + + _colliderMeshes[meshFilter.gameObject.name] = meshFilter.sharedMesh; + } + + } finally { + if (Application.isPlaying) { + UnityEngine.Object.Destroy(importRoot); + } else { + UnityEngine.Object.DestroyImmediate(importRoot); + } + } + } + + public Mesh GetColliderMesh(string guid, int index) + { + return _colliderMeshes[$"{guid}-{index}"]; + } #endregion @@ -213,9 +262,9 @@ public void PackAssets() } } -#if UNITY_EDITOR - public void UnpackAssets(string assetPath) - { +#if UNITY_EDITOR + public void UnpackAssets(string assetPath) + { if (!_tableFolder.TryGetFolder(PackageApi.AssetFolder, out var assetFolder)) { return; } @@ -255,10 +304,47 @@ public void UnpackAssets(string assetPath) _deserializedAssets.Add(meta.InstanceId, asset); }); }); - } -#endif - - #endregion + } +#endif + + public void UnpackAssetsRuntime() + { + if (!_tableFolder.TryGetFolder(PackageApi.AssetFolder, out var assetFolder)) { + return; + } + + assetFolder.VisitFolders(assetTypeFolder => { + assetTypeFolder.VisitFiles(assetFile => { + if (assetFile.Name.Contains(".meta")) { + return; + } + + var metaFilename = $"{Path.GetFileNameWithoutExtension(assetFile.Name)}.meta{Path.GetExtension(assetFile.Name)}"; + if (!assetTypeFolder.TryGetFile(metaFilename, out var metaFile)) { + throw new Exception($"Cannot find meta file {metaFilename} for {assetFile.Name}"); + } + + var type = _typeLookup.GetType(assetTypeFolder.Name); + if (type == null) { + throw new Exception($"Unknown asset type {assetTypeFolder.Name}"); + } + + var asset = PackageApi.Packer.Unpack(type, assetFile.GetData()) as ScriptableObject; + if (asset == null) { + throw new Exception($"Failed to unpack asset {assetFile.Name}"); + } + + var packer = PackerFactory.GetPacker(type); + var meta = packer == null + ? MetaPackable.UnpackMeta(metaFile.GetData()) + : packer.Unpack(metaFile.GetData(), asset, this); + + _deserializedAssets[meta.InstanceId] = asset; + }); + }); + } + + #endregion #region Sounds @@ -315,10 +401,10 @@ public void PackSoundMetas() soundMeta.SetData(PackageApi.Packer.Pack(_soundMeta)); } -#if UNITY_EDITOR - - public void UnpackSounds(string assetPath) - { +#if UNITY_EDITOR + + public void UnpackSounds(string assetPath) + { if (!_tableFolder.TryGetFolder(PackageApi.SoundFolder, out var soundFolder)) { return; } @@ -369,10 +455,111 @@ public void UnpackSounds(string assetPath) foreach (var (guid, path) in dumpedSounds) { _audioClips.Add(guid, AssetDatabase.LoadAssetAtPath(path)); } - } -#endif - - #endregion + } +#endif + + public async Task UnpackSoundsRuntime(CancellationToken cancellationToken = default) + { + if (!_tableFolder.TryGetFolder(PackageApi.SoundFolder, out var soundFolder)) { + return; + } + if (!_tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder)) { + return; + } + if (!metaFolder.TryGetFile(PackageApi.SoundFolder, out var soundMeta, PackageApi.Packer.FileExtension)) { + return; + } + + var soundMetas = PackageApi.Packer.Unpack>(soundMeta.GetData()); + if (soundMetas == null || soundMetas.Count == 0) { + return; + } + + _audioClips.Clear(); + var pendingSounds = new List<(string Guid, string Name, byte[] Data)>(); + soundFolder.VisitFiles(soundFile => { + if (!soundMetas.TryGetValue(soundFile.Name, out var meta)) { + Logger.Warn($"Missing meta data for sound file {soundFile.Name}."); + return; + } + + pendingSounds.Add((meta.Guid, soundFile.Name, soundFile.GetData())); + }); + + foreach (var sound in pendingSounds) { + cancellationToken.ThrowIfCancellationRequested(); + var clip = await LoadAudioClipRuntime(sound.Name, sound.Data, cancellationToken); + if (clip) { + _audioClips[sound.Guid] = clip; + } + } + } + + private static AudioType GetAudioType(string fileName) + { + switch (Path.GetExtension(fileName).ToLowerInvariant()) { + case ".wav": + return AudioType.WAV; + case ".ogg": + return AudioType.OGGVORBIS; + case ".mp3": + return AudioType.MPEG; + case ".aif": + case ".aiff": + return AudioType.AIFF; + default: + return AudioType.UNKNOWN; + } + } + + private static async Task LoadAudioClipRuntime(string fileName, byte[] bytes, CancellationToken cancellationToken) + { + if (bytes == null || bytes.Length == 0) { + return null; + } + + var audioType = GetAudioType(fileName); + if (audioType == AudioType.UNKNOWN) { + Logger.Warn($"Unsupported sound format for runtime loading: {fileName}"); + return null; + } + + Directory.CreateDirectory(Application.temporaryCachePath); + var extension = Path.GetExtension(fileName); + var tempPath = Path.Combine(Application.temporaryCachePath, $"vpe-audio-{Guid.NewGuid():N}{extension}"); + try { + File.WriteAllBytes(tempPath, bytes); + var uri = new Uri(tempPath).AbsoluteUri; + using var request = UnityWebRequestMultimedia.GetAudioClip(uri, audioType); + var op = request.SendWebRequest(); + while (!op.isDone) { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + } + + if (request.result != UnityWebRequest.Result.Success) { + Logger.Warn($"Failed to load audio file {fileName}: {request.error}"); + return null; + } + + var clip = DownloadHandlerAudioClip.GetContent(request); + if (clip) { + clip.name = Path.GetFileNameWithoutExtension(fileName); + } + return clip; + + } finally { + try { + if (File.Exists(tempPath)) { + File.Delete(tempPath); + } + } catch (Exception) { + // best effort cleanup + } + } + } + + #endregion } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs index bdf9fec35..690ec46f3 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedRefs.cs @@ -88,10 +88,10 @@ public string GetName(Type type) public IEnumerable PackReferences(IEnumerable comps) where T : Component => comps.Select(PackReference); - public ReferencePackable PackReference(T comp) where T : Component - => comp != null - ? new ReferencePackable(comp.transform.GetPath(_tableRoot), GetName(comp.GetType())) - : new ReferencePackable(null, null); + public ReferencePackable PackReference(T comp) where T : Component + => comp != null + ? new ReferencePackable(comp.transform.GetPath(_tableRoot, activeOnly: true), GetName(comp.GetType())) + : new ReferencePackable(null, null); public T Resolve(ReferencePackable packedRef) where T: class { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs new file mode 100644 index 000000000..0ff7c4d3c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -0,0 +1,423 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using GLTFast; +using GLTFast.Logging; +using NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + public class RuntimePackageReader + { + private readonly string _vpePath; + private GameObject _table; + private IPackageFolder _tableFolder; + private PackagedRefs _refs; + private PackagedFiles _files; + private Dictionary _sparsePathIndexMap; + + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public RuntimePackageReader(string vpePath) + { + _vpePath = vpePath; + } + + public async Task ImportIntoScene(Transform parent = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_vpePath)) { + throw new ArgumentException("No .vpe path was provided."); + } + if (!File.Exists(_vpePath)) { + throw new FileNotFoundException($"Cannot find .vpe package at {_vpePath}"); + } + + using var storage = PackageApi.StorageManager.OpenStorage(_vpePath); + _tableFolder = storage.GetFolder(PackageApi.TableFolder); + _sparsePathIndexMap = BuildSparsePathIndexMap(PackageApi.ItemFolder, PackageApi.ItemReferencesFolder); + + var previousSparsePathIndexMap = TransformExtensions.SparsePathIndexMap; + TransformExtensions.SparsePathIndexMap = _sparsePathIndexMap; + + try { + try { + _table = await ImportModels(parent, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var restoreActive = _table.activeSelf; + var loadSucceeded = false; + _table.SetActive(false); + + try { + _refs = new PackagedRefs(_table.transform); + _files = new PackagedFiles(_tableFolder, _refs); + + await _files.UnpackSoundsRuntime(cancellationToken); + _files.UnpackAssetsRuntime(); + await _files.UnpackMeshesRuntime(cancellationToken); + + ReadPackables(PackageApi.ItemFolder, ApplyItemData, (item, type, file, index) => { + var comps = item.gameObject.GetComponents(type); + var comp = comps.Length > index + ? comps[index] + : item.gameObject.AddComponent(type); + if (comp is IPackable packable) { + packable.Unpack(file.GetData()); + } else { + PackageApi.Packer.Unpack(file.GetData(), comp); + } + }); + + ReadPackables(PackageApi.ItemReferencesFolder, null, (item, type, file, _) => { + var comp = item.gameObject.GetComponent(type) as IPackable; + if (comp == null) { + Logger.Warn($"Cannot unpack references for type {type.FullName} on {item.name}."); + return; + } + comp.UnpackReferences(file.GetData(), _table.transform, _refs, _files); + }); + + ReadGlobals(); + ReadTableMetadata(); + loadSucceeded = true; + + } finally { + if (loadSucceeded && _table) { + _table.SetActive(restoreActive); + } + } + + return _table; + + } catch { + DestroyLoadedTable(); + throw; + } + + } finally { + TransformExtensions.SparsePathIndexMap = previousSparsePathIndexMap; + } + } + + private async Task ImportModels(Transform parent, CancellationToken cancellationToken) + { + var sceneFile = _tableFolder.GetFile(PackageApi.SceneFile); + var sceneData = sceneFile.GetData(); + if (sceneData == null || sceneData.Length == 0) { + throw new Exception($"Scene data file '{PackageApi.SceneFile}' is missing or empty."); + } + + var importRoot = new GameObject("__vpe_runtime_import"); + importRoot.hideFlags = HideFlags.HideAndDontSave; + if (parent != null) { + importRoot.transform.SetParent(parent, false); + } + + try { + var gltf = new GltfImport(logger: new ConsoleLogger()); + var uri = new Uri(Path.GetFullPath(_vpePath)); + var loaded = await gltf.Load(sceneData, uri, cancellationToken: cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!loaded) { + throw new Exception("Failed loading table.glb from package."); + } + + var instantiated = await gltf.InstantiateMainSceneAsync(importRoot.transform, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!instantiated) { + throw new Exception("Failed instantiating table.glb scene."); + } + + if (importRoot.transform.childCount == 0) { + throw new Exception("The .vpe scene did not instantiate any root object."); + } + + var tableRoot = SelectTableRoot(importRoot.transform); + if (!tableRoot) { + throw new Exception("Could not determine table root from imported .vpe scene."); + } + + var table = tableRoot.gameObject; + table.transform.SetParent(parent, false); + + if (Application.isPlaying) { + UnityEngine.Object.Destroy(importRoot); + } else { + UnityEngine.Object.DestroyImmediate(importRoot); + } + return table; + + } catch { + if (Application.isPlaying) { + UnityEngine.Object.Destroy(importRoot); + } else { + UnityEngine.Object.DestroyImmediate(importRoot); + } + throw; + } + } + + private Transform SelectTableRoot(Transform importRoot) + { + var candidates = CollectRootCandidates(importRoot); + if (candidates.Count == 0) { + return null; + } + + if (!_tableFolder.TryGetFolder(PackageApi.ItemFolder, out var itemsFolder)) { + return candidates[0]; + } + + var paths = new List(); + itemsFolder.VisitFolders(itemFolder => paths.Add(itemFolder.Name)); + if (paths.Count == 0) { + return candidates[0]; + } + + Transform bestCandidate = candidates[0]; + var bestScore = ScoreCandidate(candidates[0], paths); + foreach (var candidate in candidates.Skip(1)) { + var score = ScoreCandidate(candidate, paths); + if (score <= bestScore) { + continue; + } + + bestCandidate = candidate; + bestScore = score; + if (bestScore == paths.Count) { + break; + } + } + + if (bestScore == 0) { + Logger.Warn("Runtime loader could not match any packaged paths on imported scene roots. Falling back to first root candidate."); + return candidates[0]; + } + + return bestCandidate; + } + + private static List CollectRootCandidates(Transform importRoot) + { + var candidates = new List(); + var visited = new HashSet(); + + void Add(Transform transform) + { + if (!transform) { + return; + } + + var instanceId = transform.GetInstanceID(); + if (visited.Add(instanceId)) { + candidates.Add(transform); + } + } + + for (var i = 0; i < importRoot.childCount; i++) { + var child = importRoot.GetChild(i); + Add(child); + for (var j = 0; j < child.childCount; j++) { + var grandChild = child.GetChild(j); + Add(grandChild); + } + } + + return candidates; + } + + private static int ScoreCandidate(Transform candidate, IReadOnlyList paths) + { + var score = 0; + foreach (var path in paths) { + if (candidate.FindByPath(path) != null) { + score++; + } + } + + return score; + } + + private void ApplyItemData(GameObject gameObject, IPackageFile itemFile) + { + if (itemFile == null) { + return; + } + var itemData = ItemPackable.Unpack(itemFile.GetData()); + itemData.ApplyRuntime(gameObject); + } + + /// + /// Loops through all items and components in the package, and applies the given action. + /// The item action is executed before the component action. + /// + private void ReadPackables(string rootFolder, Action itemAction, Action componentAction) + { + if (!_tableFolder.TryGetFolder(rootFolder, out var itemsFolder)) { + return; + } + + // -> rootFolder <- / 0.0.0 / CompType / 0 + itemsFolder.VisitFolders(itemFolder => { + // rootFolder / -> 0.0.0 <- / CompType / 0 + var item = _table.transform.FindByPath(itemFolder.Name); + if (item == null) { + throw new Exception($"Cannot find item at path {itemFolder.Name} on node {_table.name}. Imported hierarchy does not match packaged item paths."); + } + + if (itemAction != null && itemFolder.TryGetFile(PackageApi.ItemFile, out var itemFile, PackageApi.Packer.FileExtension)) { + itemAction(item.gameObject, itemFile); + } + + itemFolder.VisitFolders(typeFolder => { + // rootFolder / 0.0.0 / -> CompType <- / 0 + var type = _refs.GetType(typeFolder.Name); + if (type == null) { + throw new Exception($"Unknown component type '{typeFolder.Name}' while reading package."); + } + + var index = 0; + typeFolder.VisitFiles(compFile => { + // rootFolder / 0.0.0 / CompType / -> 0 <- + componentAction(item, type, compFile, index++); + }); + }); + }); + } + + private Dictionary BuildSparsePathIndexMap(params string[] rootFolders) + { + var sparseChildrenByParent = new Dictionary>(StringComparer.Ordinal); + foreach (var rootFolder in rootFolders) { + if (!_tableFolder.TryGetFolder(rootFolder, out var pathFolder)) { + continue; + } + + pathFolder.VisitFolders(itemFolder => RegisterPath(itemFolder.Name, sparseChildrenByParent)); + } + + var sparsePathIndexMap = new Dictionary(sparseChildrenByParent.Count, StringComparer.Ordinal); + foreach (var entry in sparseChildrenByParent) { + var parentPath = entry.Key; + var sparseChildren = entry.Value; + var indices = new int[sparseChildren.Count]; + sparseChildren.CopyTo(indices); + sparsePathIndexMap[parentPath] = indices; + } + + return sparsePathIndexMap; + } + + private static void RegisterPath(string path, IDictionary> sparseChildrenByParent) + { + if (string.IsNullOrWhiteSpace(path)) { + return; + } + + var segments = path.Split('.'); + if (segments.Length == 0 || segments[0] != "0") { + return; + } + + var parentPath = "0"; + for (var segmentIndex = 1; segmentIndex < segments.Length; segmentIndex++) { + if (!int.TryParse(segments[segmentIndex], out var sparseChildIndex)) { + return; + } + + if (!sparseChildrenByParent.TryGetValue(parentPath, out var sparseChildren)) { + sparseChildren = new SortedSet(); + sparseChildrenByParent[parentPath] = sparseChildren; + } + sparseChildren.Add(sparseChildIndex); + parentPath = $"{parentPath}.{segments[segmentIndex]}"; + } + } + + private void ReadGlobals() + { + var tableComponent = _table.GetComponent(); + if (!tableComponent) { + throw new Exception("Cannot find table component on table object."); + } + if (!_tableFolder.TryGetFolder(PackageApi.GlobalFolder, out var globalStorage)) { + return; + } + + tableComponent.MappingConfig = new MappingConfig { + Switches = ReadGlobalList(globalStorage, PackageApi.SwitchesFile), + Coils = ReadGlobalList(globalStorage, PackageApi.CoilsFile), + Lamps = ReadGlobalList(globalStorage, PackageApi.LampsFile), + Wires = ReadGlobalList(globalStorage, PackageApi.WiresFile), + }; + + foreach (var sw in tableComponent.MappingConfig.Switches) { + sw.RestoreReference(_table.transform); + } + foreach (var coil in tableComponent.MappingConfig.Coils) { + coil.RestoreReference(_table.transform); + } + foreach (var lamp in tableComponent.MappingConfig.Lamps) { + lamp.RestoreReference(_table.transform); + } + foreach (var wire in tableComponent.MappingConfig.Wires) { + wire.RestoreReferences(_table.transform); + } + } + + private static List ReadGlobalList(IPackageFolder folder, string fileName) + { + if (!folder.TryGetFile(fileName, out var file, PackageApi.Packer.FileExtension)) { + return new List(); + } + + return PackageApi.Packer.Unpack>(file.GetData()) ?? new List(); + } + + private void ReadTableMetadata() + { + var tableComponent = _table.GetComponent(); + if (!tableComponent) { + throw new Exception("Cannot find table component on table object."); + } + + if (_tableFolder.TryGetFile(PackageApi.TableMetadataFile, out var tableMetadataFile, PackageApi.Packer.FileExtension)) { + tableComponent.Metadata = PackageApi.Packer.Unpack(tableMetadataFile.GetData()) ?? new TableMetadata(); + } + } + + private void DestroyLoadedTable() + { + if (!_table) { + return; + } + + if (Application.isPlaying) { + UnityEngine.Object.Destroy(_table); + } else { + UnityEngine.Object.DestroyImmediate(_table); + } + _table = null; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs.meta new file mode 100644 index 000000000..a71044908 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f5e8340ea0e518240909965adf6579fa \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs new file mode 100644 index 000000000..91916c444 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs @@ -0,0 +1,191 @@ +// Visual Pinball Engine +// Copyright (C) 2023 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.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ICSharpCode.SharpZipLib.Zip; +using Newtonsoft.Json; + +namespace VisualPinball.Unity +{ + /// + /// Lightweight metadata extracted from a packaged .vpe table. + /// + public sealed class VpeTableMetadataSummary + { + public string Title { get; set; } + public string Manufacturer { get; set; } + public int? OriginalReleaseYear { get; set; } + public IReadOnlyList PrimaryAuthors { get; set; } = Array.Empty(); + } + + /// + /// Reads table metadata from table/table.json inside a .vpe package. + /// + public static class VpeTableMetadataReader + { + private const string TableMetadataEntryPath = "table/table.json"; + private const int DefaultMaxDegreeOfParallelism = 6; + private static readonly ConcurrentDictionary Cache = new(StringComparer.OrdinalIgnoreCase); + + private sealed class CacheEntry + { + public long FileSizeBytes { get; set; } + public long LastWriteTimeUtcTicks { get; set; } + public bool HasMetadata { get; set; } + public VpeTableMetadataSummary Metadata { get; set; } + } + + public static bool TryRead(string vpePath, out VpeTableMetadataSummary metadata) + { + metadata = null; + + if (string.IsNullOrWhiteSpace(vpePath)) { + return false; + } + + var fileInfo = new FileInfo(vpePath); + if (!fileInfo.Exists) { + return false; + } + + if (TryReadFromCache(fileInfo, out metadata, out var hasMetadata)) { + return hasMetadata; + } + + try { + using var zip = new ZipFile(File.OpenRead(fileInfo.FullName)); + var entry = FindMetadataEntry(zip); + if (entry == null) { + UpdateCache(fileInfo, false, null); + return false; + } + + using var stream = zip.GetInputStream(entry); + using var reader = new StreamReader(stream); + using var jsonReader = new JsonTextReader(reader); + var tableMetadata = JsonSerializer.CreateDefault().Deserialize(jsonReader); + if (tableMetadata == null) { + return false; + } + + var primaryAuthors = tableMetadata.PrimaryAuthors? + .Select(author => author?.Name ?? author?.VpuHandle) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray() ?? Array.Empty(); + + metadata = new VpeTableMetadataSummary { + Title = tableMetadata.TableName?.Trim(), + Manufacturer = tableMetadata.Manufacturer?.Trim(), + OriginalReleaseYear = tableMetadata.OriginalReleaseYear > 0 ? tableMetadata.OriginalReleaseYear : null, + PrimaryAuthors = primaryAuthors + }; + UpdateCache(fileInfo, true, metadata); + + return true; + + } catch { + UpdateCache(fileInfo, false, null); + return false; + } + } + + public static IReadOnlyDictionary ReadMany(IEnumerable vpePaths, int maxDegreeOfParallelism = 0) + { + if (vpePaths == null) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var files = vpePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => new FileInfo(path)) + .Where(file => file.Exists) + .ToArray(); + + return ReadMany(files, maxDegreeOfParallelism); + } + + public static IReadOnlyDictionary ReadMany(IEnumerable vpeFiles, int maxDegreeOfParallelism = 0) + { + if (vpeFiles == null) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var result = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var options = new ParallelOptions { + MaxDegreeOfParallelism = maxDegreeOfParallelism > 0 + ? maxDegreeOfParallelism + : System.Math.Min(DefaultMaxDegreeOfParallelism, System.Math.Max(1, Environment.ProcessorCount - 1)) + }; + + Parallel.ForEach(vpeFiles, options, fileInfo => { + if (TryRead(fileInfo.FullName, out var metadata)) { + result[fileInfo.FullName] = metadata; + } + }); + + return result; + } + + private static bool TryReadFromCache(FileInfo fileInfo, out VpeTableMetadataSummary metadata, out bool hasMetadata) + { + metadata = null; + hasMetadata = false; + + if (!Cache.TryGetValue(fileInfo.FullName, out var cacheEntry)) { + return false; + } + + if (cacheEntry.FileSizeBytes != fileInfo.Length || cacheEntry.LastWriteTimeUtcTicks != fileInfo.LastWriteTimeUtc.Ticks) { + return false; + } + + hasMetadata = cacheEntry.HasMetadata; + metadata = cacheEntry.Metadata; + return true; + } + + private static void UpdateCache(FileInfo fileInfo, bool hasMetadata, VpeTableMetadataSummary metadata) + { + Cache[fileInfo.FullName] = new CacheEntry { + FileSizeBytes = fileInfo.Length, + LastWriteTimeUtcTicks = fileInfo.LastWriteTimeUtc.Ticks, + HasMetadata = hasMetadata, + Metadata = metadata + }; + } + + private static ZipEntry FindMetadataEntry(ZipFile zip) + { + var exact = zip.GetEntry(TableMetadataEntryPath); + if (exact != null) { + return exact; + } + + foreach (ZipEntry entry in zip) { + if (!entry.IsDirectory && string.Equals(entry.Name, TableMetadataEntryPath, StringComparison.OrdinalIgnoreCase)) { + return entry; + } + } + + return null; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs.meta new file mode 100644 index 000000000..387666a09 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bd7317d1ac8a2ca47afc8fe53c115dbc \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/DropTargetBank/DropTargetBankApi.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/DropTargetBank/DropTargetBankApi.cs index 7db9d68d0..674a5bb2a 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/DropTargetBank/DropTargetBankApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/DropTargetBank/DropTargetBankApi.cs @@ -66,17 +66,30 @@ internal DropTargetBankApi(GameObject go, Player player, PhysicsEngine physicsEn ResetCoil = new DeviceCoil(_player, OnResetCoilEnabled); } - void IApi.OnInit(BallManager ballManager) - { - for (var index = 0; index < _dropTargetBankComponent.BankSize; index++) { - var dropTargetApi = _player.TableApi.DropTarget(_dropTargetBankComponent.DropTargets[index]); - if (dropTargetApi == null) { - Debug.LogWarning($"Cannot init reference to non-existing drop target \"{_dropTargetBankComponent.DropTargets[index]}\" in bank \"{_dropTargetBankComponent.name}\"."); - return; - } - dropTargetApi.Switch += OnSwitch; - _dropTargetApis.Add(dropTargetApi); - } + void IApi.OnInit(BallManager ballManager) + { + var configuredBankSize = _dropTargetBankComponent.BankSize; + var dropTargets = _dropTargetBankComponent.DropTargets ?? Array.Empty(); + var initCount = configuredBankSize < dropTargets.Length ? configuredBankSize : dropTargets.Length; + if (dropTargets.Length < configuredBankSize) { + Logger.Warn($"Bank \"{_dropTargetBankComponent.name}\" has BankSize={configuredBankSize} but only {dropTargets.Length} drop target reference(s)."); + } + + for (var index = 0; index < initCount; index++) { + var dropTarget = dropTargets[index]; + if (!dropTarget) { + Logger.Warn($"Cannot init empty drop target reference at index {index} in bank \"{_dropTargetBankComponent.name}\"."); + continue; + } + + var dropTargetApi = _player.TableApi.DropTarget(dropTarget); + if (dropTargetApi == null) { + Debug.LogWarning($"Cannot init reference to non-existing drop target \"{dropTarget.name}\" in bank \"{_dropTargetBankComponent.name}\"."); + continue; + } + dropTargetApi.Switch += OnSwitch; + _dropTargetApis.Add(dropTargetApi); + } Init?.Invoke(this, EventArgs.Empty); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/VPT/Mech/MechMark.cs b/VisualPinball.Unity/VisualPinball.Unity/VPT/Mech/MechMark.cs index a482d406a..048870723 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VPT/Mech/MechMark.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/VPT/Mech/MechMark.cs @@ -16,8 +16,9 @@ // ReSharper disable InconsistentNaming -using System; -using VisualPinball.Engine.Game.Engines; +using System; +using Newtonsoft.Json; +using VisualPinball.Engine.Game.Engines; namespace VisualPinball.Unity { @@ -34,7 +35,8 @@ public class MechMark [Unit("ms")] public int PulseDuration = 50; - public GamelogicEngineSwitch Switch => new(SwitchId) { Description = Name }; + [JsonIgnore] + public GamelogicEngineSwitch Switch => new(SwitchId) { Description = Name }; public MechMark(MechMarkSwitchType type, string name, string switchId, int stepBeginning, int stepEnd) { From 08e6b12c131c364128cbb450d1b9659273e18bd8 Mon Sep 17 00:00:00 2001 From: freezy Date: Tue, 21 Apr 2026 01:13:48 +0200 Subject: [PATCH 04/55] packaging: Add abstraction layer to material export. --- .../Packaging/PackageWriter.cs | 73 ++- .../Packaging/VpeMaterialV1TextureEncoder.cs | 70 +++ .../VpeMaterialV1TextureEncoder.cs.meta | 2 + .../Packaging/VpeMaterialV1Translator.cs | 432 ++++++++++++++++++ .../Packaging/VpeMaterialV1Translator.cs.meta | 2 + .../Packaging/IVpeMaterialResolver.cs | 64 +++ .../Packaging/IVpeMaterialResolver.cs.meta | 2 + .../Packaging/PackageApi.cs | 12 +- .../Packaging/RuntimePackageReader.cs | 36 +- .../Packaging/VpeMaterialNameUtil.cs | 64 +++ .../Packaging/VpeMaterialNameUtil.cs.meta | 2 + .../Packaging/VpeMaterialV1.cs | 258 +++++++++++ .../Packaging/VpeMaterialV1.cs.meta | 2 + .../Packaging/VpeMaterialV1Reader.cs | 245 ++++++++++ .../Packaging/VpeMaterialV1Reader.cs.meta | 2 + 15 files changed, 1256 insertions(+), 10 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs.meta create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 34f5f973e..57705b202 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -102,6 +102,7 @@ private async Task WritePackage(string path) sw1 = Stopwatch.StartNew(); WriteTableMetadata(); WriteGlobals(); + WriteMaterialProfiles(); Logger.Info($"Globals written in {sw1.ElapsedMilliseconds}ms."); // write assets & co @@ -179,7 +180,11 @@ private Func> PrepareScene() // Include inactive GameObjects in export OnlyActiveInHierarchy = ExportActivesOnly, - // Also export disabled components + // Keep disabled components out of the export. Insert/lamp Light components are + // typically disabled at author time (LightComponent.Awake flips them on when a + // lamp fires); we temporarily re-enable them below so they flow into the glb, + // then restore. Flipping DisabledComponents=true wholesale would also drag in + // disabled MeshRenderers with degenerate meshes that crash gltFast. DisabledComponents = false // Only export GameObjects on certain layers @@ -190,16 +195,43 @@ private Func> PrepareScene() var export = new GameObjectExport(exportSettings, gameObjectExportSettings, logger: logger); var disabledRenderers = DisableInvalidMeshRenderers(meshFilters, skinnedMeshRenderers, _table.transform); + var reenabledLights = EnableDisabledLights(); try { export.AddScene(new [] { _table }, _table.transform.worldToLocalMatrix, "VPE Table"); } finally { + RestoreDisabledLights(reenabledLights); RestoreDisabledRenderers(disabledRenderers); } return () => SaveGltfToBytes(export); } + // Temporarily re-enables Light components that are disabled at author time so gltFast + // emits them into the KHR_lights_punctual extension. Inserts and flasher bulbs are the + // main targets: VPE's LightComponent disables the Unity Light by default and only + // enables it while a lamp is actively firing at runtime. + private List EnableDisabledLights() + { + var toRestore = new List(); + foreach (var light in _table.GetComponentsInChildren(!ExportActivesOnly)) { + if (!light.enabled) { + light.enabled = true; + toRestore.Add(light); + } + } + return toRestore; + } + + private static void RestoreDisabledLights(List reenabledLights) + { + foreach (var light in reenabledLights) { + if (light) { + light.enabled = false; + } + } + } + private Func> PrepareColliderMeshes() { var meshGos = new List(); @@ -294,7 +326,8 @@ private void WritePackables(string folderName, Func getPackab counters.TryAdd(packName, 0); var packableData = getPackableData(packageable); - if (packableData?.Length > 0) { + var shouldWriteEmptyMarker = folderName == PackageApi.ItemFolder && (packableData == null || packableData.Length == 0); + if (packableData?.Length > 0 || shouldWriteEmptyMarker) { // rootName / -> 0.0.0 <- / CompType / 0 itemPathFolder ??= folder.AddFolder(key); @@ -306,7 +339,7 @@ private void WritePackables(string folderName, Func getPackab // rootName / 0.0.0 / CompType / -> 0 <- var itemComponentFile = itemComponentFolder.AddFile($"{counters[packName]++}", PackageApi.Packer.FileExtension); - itemComponentFile.SetData(packableData); + itemComponentFile.SetData(packableData?.Length > 0 ? packableData : PackageApi.Packer.Empty); } break; } @@ -433,6 +466,40 @@ private void WriteTableMetadata() _tableFolder.AddFile(PackageApi.TableMetadataFile, PackageApi.Packer.FileExtension).SetData(PackageApi.Packer.Pack(tableComponent.Metadata)); } + private void WriteMaterialProfiles() + { + var renderers = _table.GetComponentsInChildren(!ExportActivesOnly); + + var capture = VpeMaterialV1Translator.Capture(renderers); + var payload = capture.Payload; + if (payload.Profiles == null || payload.Profiles.Length == 0) { + return; + } + + var textureCount = 0; + var textureBytes = 0L; + if (capture.TextureBlobs.Count > 0) { + var texturesFolder = _metaFolder.AddFolder(PackageApi.TexturesV1Folder); + foreach (var entry in capture.TextureBlobs) { + if (entry.Value == null || entry.Value.Length == 0) { + continue; + } + texturesFolder.AddFile(entry.Key).SetData(entry.Value); + textureCount++; + textureBytes += entry.Value.Length; + } + } + + _metaFolder + .AddFile(PackageApi.MaterialsV1File, PackageApi.Packer.FileExtension) + .SetData(PackageApi.Packer.Pack(payload)); + + Logger.Info( + $"Wrote vpe.material v1 payload: {payload.Profiles.Length} profile(s), " + + $"{textureCount} texture(s) ({textureBytes / 1024f / 1024f:F2} MB) at " + + $"{PackageApi.MetaFolder}/{PackageApi.TexturesV1Folder}."); + } + private static async Task SaveGltfToBytes(GameObjectExport export) { using var stream = new MemoryStream(); diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs new file mode 100644 index 000000000..a3bda8711 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs @@ -0,0 +1,70 @@ +// 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 NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Editor +{ + // Utility for round-tripping arbitrary Unity textures (compressed, GPU-only, etc.) through a + // PNG blob. Goes via a temporary RenderTexture + ReadPixels so it works on assets without the + // Read/Write flag. + internal static class VpeMaterialV1TextureEncoder + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public static bool TryEncode(Texture2D source, bool linear, out byte[] pngData) + { + pngData = null; + if (!source) { + return false; + } + + var readWrite = linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB; + var renderTexture = RenderTexture.GetTemporary( + source.width, source.height, 0, RenderTextureFormat.ARGB32, readWrite); + + var previousRenderTexture = RenderTexture.active; + Texture2D readableTexture = null; + try { + Graphics.Blit(source, renderTexture); + RenderTexture.active = renderTexture; + readableTexture = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false, linear); + readableTexture.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); + readableTexture.Apply(updateMipmaps: false, makeNoLongerReadable: false); + pngData = readableTexture.EncodeToPNG(); + return pngData is { Length: > 0 }; + + } catch (Exception e) { + Logger.Warn(e, $"Unable to encode texture '{source.name}' for v1 material export."); + return false; + + } finally { + if (readableTexture) { + if (Application.isPlaying) { + UnityEngine.Object.Destroy(readableTexture); + } else { + UnityEngine.Object.DestroyImmediate(readableTexture); + } + } + RenderTexture.active = previousRenderTexture; + RenderTexture.ReleaseTemporary(renderTexture); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta new file mode 100644 index 000000000..ca39b5207 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7a1b2c3d4e5f681923a4b5c6d7e8f910 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs new file mode 100644 index 000000000..168b73aa6 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs @@ -0,0 +1,432 @@ +// 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.Linq; +using NLog; +using UnityEditor; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity.Editor +{ + // Editor-only. Translates Unity Materials on a scene's renderers into a portable + // VpeMaterialsPayloadV1 plus a set of PNG texture blobs keyed by stable ids. + // + // Only HDRP-aware mappings are implemented here; if VPE adopts additional pipelines the + // translator fans out on shader name. + public static class VpeMaterialV1Translator + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private const string HdrpLitShaderName = "HDRP/Lit"; + private const string HdrpDecalShaderName = "HDRP/Decal"; + private const string HdrpUnlitShaderName = "HDRP/Unlit"; + + public readonly struct CaptureResult + { + public CaptureResult(VpeMaterialsPayloadV1 payload, IReadOnlyDictionary textureBlobs) + { + Payload = payload; + TextureBlobs = textureBlobs; + } + + public VpeMaterialsPayloadV1 Payload { get; } + // Maps texture file name (matches VpeTextureAssetV1.FileName) to its PNG bytes. + public IReadOnlyDictionary TextureBlobs { get; } + } + + public static CaptureResult Capture(IEnumerable renderers) + { + var profiles = new Dictionary(StringComparer.Ordinal); + var ctx = new CaptureContext(); + + if (renderers != null) { + foreach (var renderer in renderers) { + if (!renderer) { + continue; + } + + foreach (var material in renderer.sharedMaterials) { + if (!material) { + continue; + } + var key = NormalizeMaterialName(material.name); + if (string.IsNullOrWhiteSpace(key) || profiles.ContainsKey(key)) { + continue; + } + + var profile = TranslateMaterial(material, ctx); + if (profile != null) { + profile.Name = key; + profiles[key] = profile; + } + } + } + } + + var payload = new VpeMaterialsPayloadV1 { + FormatVersion = 1, + WrittenBy = "VpeMaterialV1Translator", + Profiles = profiles.Values.ToArray(), + Textures = ctx.BuildTextureAssets(), + }; + return new CaptureResult(payload, ctx.TextureBlobs); + } + + private static VpeMaterialProfileV1 TranslateMaterial(Material material, CaptureContext ctx) + { + if (!material || !material.shader) { + return null; + } + + var shaderName = material.shader.name; + switch (shaderName) { + case HdrpLitShaderName: + return TranslateHdrpLit(material, ctx); + case HdrpDecalShaderName: + return TranslateHdrpDecal(material, ctx); + case HdrpUnlitShaderName: + return TranslateHdrpUnlit(material, ctx); + default: + Logger.Warn( + $"Material '{material.name}' uses shader '{shaderName}' which has no v1 translation. " + + "It will fall back to the glTF-imported material at runtime."); + return null; + } + } + + private static VpeMaterialProfileV1 TranslateHdrpLit(Material material, CaptureContext ctx) + { + // For alpha-tested and transparent surfaces, the base color texture's alpha channel is + // load-bearing (alpha-test discards pixels below cutoff; transparent blends by alpha). + // gltFast's glTF round-trip does not preserve the alpha channel reliably for HDRP + // alphaMode=MASK materials, so we side-channel the full RGBA PNG for those. Plain opaque + // materials keep the leaner glb-only path where we only record tiling. + var baseColorNeedsAlpha = + SafeGetFloat(material, "_SurfaceType", 0f) > 0.5f /* Transparent */ + || SafeGetFloat(material, "_AlphaCutoffEnable", 0f) > 0.5f /* AlphaTest */; + var baseColorTexture = baseColorNeedsAlpha + ? ctx.CaptureSideChannelTextureRef(material, "_BaseColorMap", VpeColorSpaces.SRgb) + : ctx.CaptureImportedTextureRef(material, "_BaseColorMap"); + + var lit = new VpeLitProfileV1 { + BaseColor = { + Color = SafeGetColor(material, "_BaseColor", Color.white), + Texture = baseColorTexture, + }, + Metallic = SafeGetFloat(material, "_Metallic", 0f), + Smoothness = SafeGetFloat(material, "_Smoothness", 0.5f), + OcclusionStrength = 1f, + // MaskMap packs HDRP-specific channels (R=metal, G=AO, B=detail, A=smooth). glTF + // has no lossless equivalent, so this is the one texture that gets side-channeled. + MaskMap = ctx.CaptureSideChannelTextureRef(material, "_MaskMap", VpeColorSpaces.Linear), + MaskPacking = VpeMaskPackings.HdrpMaskMap, + MetallicRemap = new Vector2( + SafeGetFloat(material, "_MetallicRemapMin", 0f), + SafeGetFloat(material, "_MetallicRemapMax", 1f)), + SmoothnessRemap = new Vector2( + SafeGetFloat(material, "_SmoothnessRemapMin", 0f), + SafeGetFloat(material, "_SmoothnessRemapMax", 1f)), + AoRemap = new Vector2( + SafeGetFloat(material, "_AORemapMin", 0f), + SafeGetFloat(material, "_AORemapMax", 1f)), + AlphaRemap = new Vector2( + SafeGetFloat(material, "_AlphaRemapMin", 0f), + SafeGetFloat(material, "_AlphaRemapMax", 1f)), + NormalMap = ctx.CaptureImportedNormalMapRef(material, "_NormalMap", + strength: SafeGetFloat(material, "_NormalScale", 1f)), + Emissive = new VpeEmissiveV1 { + Color = SafeGetColor(material, "_EmissiveColor", Color.black), + Texture = ctx.CaptureImportedTextureRef(material, "_EmissiveColorMap"), + Intensity = SafeGetFloat(material, "_EmissiveIntensity", 0f), + IntensityUnit = HdrpEmissiveIntensityUnitToString( + SafeGetFloat(material, "_EmissiveIntensityUnit", 0f)), + ExposureWeight = SafeGetFloat(material, "_EmissiveExposureWeight", 1f), + }, + SurfaceType = HdrpSurfaceTypeToString( + SafeGetFloat(material, "_SurfaceType", 0f), + SafeGetFloat(material, "_AlphaCutoffEnable", 0f)), + AlphaCutoff = SafeGetFloat(material, "_AlphaCutoff", 0.5f), + DoubleSided = SafeGetFloat(material, "_DoubleSidedEnable", 0f) > 0.5f, + DoubleSidedGi = material.doubleSidedGI, + TransparentBlendMode = Mathf.RoundToInt(SafeGetFloat(material, "_BlendMode", 0f)), + EnableFogOnTransparent = SafeGetFloat(material, "_EnableFogOnTransparent", 1f) > 0.5f + || material.IsKeywordEnabled("_ENABLE_FOG_ON_TRANSPARENT"), + TransparentDepthPrepass = SafeGetFloat(material, "_TransparentDepthPrepassEnable", 0f) > 0.5f, + TransparentDepthPostpass = SafeGetFloat(material, "_TransparentDepthPostpassEnable", 0f) > 0.5f, + TransparentWritesMotionVectors = SafeGetFloat(material, "_TransparentWritingMotionVec", 0f) > 0.5f + || material.IsKeywordEnabled("_TRANSPARENT_WRITES_MOTION_VEC"), + DisableSsrTransparent = material.IsKeywordEnabled("_DISABLE_SSR_TRANSPARENT") + || SafeGetFloat(material, "_ReceivesSSRTransparent", 0f) < 0.5f, + RenderQueueOverride = -1, + + RefractionModel = HdrpRefractionModelToString( + SafeGetFloat(material, "_RefractionModel", 0f), + material), + Ior = SafeGetFloat(material, "_Ior", 1f), + // Authoring intent is encoded by the explicit HDRP translucent signals: + // MaterialID==5 or the transmission keyword. Do not infer from _TransmissionEnable; + // HDRP keeps that float at 1 on many non-translucent materials. + HasTransmission = material.IsKeywordEnabled("_MATERIAL_FEATURE_TRANSMISSION") + || Mathf.Approximately(SafeGetFloat(material, "_MaterialID", 1f), 5f), + Thickness = SafeGetFloat(material, "_Thickness", 1f), + ThicknessMap = ctx.CaptureSideChannelTextureRef(material, "_ThicknessMap", VpeColorSpaces.Linear), + }; + + return new VpeMaterialProfileV1 { + Type = VpeMaterialTypes.Lit, + Lit = lit, + }; + } + + private static VpeMaterialProfileV1 TranslateHdrpDecal(Material material, CaptureContext ctx) + { + var decal = new VpeDecalProfileV1 { + BaseColor = { + Color = SafeGetColor(material, "_BaseColor", Color.white), + // Decal albedo alpha is load-bearing (where the decal applies). Exporting through + // glTF can convert this map to JPEG and drop alpha, so always side-channel it. + Texture = ctx.CaptureSideChannelTextureRef(material, "_BaseColorMap", VpeColorSpaces.SRgb), + }, + NormalMap = ctx.CaptureImportedNormalMapRef(material, "_NormalMap", + strength: SafeGetFloat(material, "_NormalScale", 1f)), + MaskMap = ctx.CaptureSideChannelTextureRef(material, "_MaskMap", VpeColorSpaces.Linear), + MaskPacking = VpeMaskPackings.HdrpMaskMap, + AffectAlbedo = material.IsKeywordEnabled("_MATERIAL_AFFECTS_ALBEDO") + || SafeGetFloat(material, "_AffectAlbedo", 1f) > 0.5f, + AffectNormal = material.IsKeywordEnabled("_MATERIAL_AFFECTS_NORMAL") + || SafeGetFloat(material, "_AffectNormal", 1f) > 0.5f, + AffectMask = material.IsKeywordEnabled("_MATERIAL_AFFECTS_MASKMAP") + || SafeGetFloat(material, "_AffectMaskmap", 0f) > 0.5f, + DecalBlend = SafeGetFloat(material, "_DecalBlend", 1f), + NormalBlendSrc = SafeGetFloat(material, "_NormalBlendSrc", 1f), + MaskBlendSrc = SafeGetFloat(material, "_MaskBlendSrc", 1f), + Smoothness = SafeGetFloat(material, "_DecalSmoothness", 0.5f), + Metallic = SafeGetFloat(material, "_DecalMetallic", 0f), + AmbientOcclusion = SafeGetFloat(material, "_DecalAO", 1f), + }; + + return new VpeMaterialProfileV1 { + Type = VpeMaterialTypes.Decal, + Decal = decal, + }; + } + + private static VpeMaterialProfileV1 TranslateHdrpUnlit(Material material, CaptureContext ctx) + { + var unlit = new VpeUnlitProfileV1 { + BaseColor = { + Color = SafeGetColor(material, "_UnlitColor", SafeGetColor(material, "_BaseColor", Color.white)), + Texture = ctx.CaptureImportedTextureRef(material, "_UnlitColorMap") + ?? ctx.CaptureImportedTextureRef(material, "_BaseColorMap"), + }, + SurfaceType = HdrpSurfaceTypeToString( + SafeGetFloat(material, "_SurfaceType", 0f), + SafeGetFloat(material, "_AlphaCutoffEnable", 0f)), + AlphaCutoff = SafeGetFloat(material, "_AlphaCutoff", 0.5f), + DoubleSided = SafeGetFloat(material, "_DoubleSidedEnable", 0f) > 0.5f, + }; + return new VpeMaterialProfileV1 { + Type = VpeMaterialTypes.Unlit, + Unlit = unlit, + }; + } + + private static string HdrpSurfaceTypeToString(float surfaceType, float alphaCutoffEnable) + { + if (surfaceType > 0.5f) { + return VpeSurfaceTypes.Transparent; + } + return alphaCutoffEnable > 0.5f ? VpeSurfaceTypes.AlphaTest : VpeSurfaceTypes.Opaque; + } + + private static string HdrpEmissiveIntensityUnitToString(float value) + { + // HDRP: 0 = Nits, 1 = EV100. + return value > 0.5f ? VpeEmissiveIntensityUnits.Ev100 : VpeEmissiveIntensityUnits.Nits; + } + + // HDRP _RefractionModel float: 0=None, 1=Plane, 2=Sphere, 3=Thin. We also check keywords + // since the float is sometimes left at a stale value while the keyword tells the real story. + private static string HdrpRefractionModelToString(float value, Material material) + { + if (material.IsKeywordEnabled("_REFRACTION_PLANE")) { + return VpeRefractionModels.Planar; + } + if (material.IsKeywordEnabled("_REFRACTION_SPHERE")) { + return VpeRefractionModels.Sphere; + } + if (material.IsKeywordEnabled("_REFRACTION_THIN")) { + return VpeRefractionModels.Thin; + } + var mode = Mathf.RoundToInt(value); + return mode switch { + 1 => VpeRefractionModels.Planar, + 2 => VpeRefractionModels.Sphere, + 3 => VpeRefractionModels.Thin, + _ => VpeRefractionModels.None, + }; + } + + private static float SafeGetFloat(Material material, string property, float fallback) + { + return material.HasProperty(property) ? material.GetFloat(property) : fallback; + } + + private static Color SafeGetColor(Material material, string property, Color fallback) + { + return material.HasProperty(property) ? material.GetColor(property) : fallback; + } + + public static string NormalizeMaterialName(string materialName) + => VpeMaterialNameUtil.NormalizeMaterialName(materialName); + + private sealed class CaptureContext + { + private readonly Dictionary _assetsByTexture = new(); + private readonly Dictionary _textureBlobs = new(StringComparer.Ordinal); + private int _nextIndex; + + public IReadOnlyDictionary TextureBlobs => _textureBlobs; + + public VpeTextureAssetV1[] BuildTextureAssets() + { + var assets = new VpeTextureAssetV1[_assetsByTexture.Count]; + var i = 0; + foreach (var asset in _assetsByTexture.Values) { + assets[i++] = asset; + } + return assets; + } + + // Captures a texture reference whose pixel data must be shipped in the side-channel + // (i.e. is not losslessly reproduced by the glb). Use for HDRP-specific packings like + // MaskMap where channel semantics differ from glTF's PBR textures. + public VpeTextureRefV1 CaptureSideChannelTextureRef(Material material, string property, string colorSpace, VpeTextureRefV1 fallback = null) + { + if (!material.HasProperty(property)) { + return fallback; + } + var texture = material.GetTexture(property) as Texture2D; + if (!texture) { + return fallback; + } + + var asset = GetOrCaptureAsset(texture, colorSpace); + if (asset == null) { + return fallback; + } + + return new VpeTextureRefV1 { + TextureId = asset.Id, + Offset = material.GetTextureOffset(property), + Scale = material.GetTextureScale(property), + }; + } + + // Captures tiling only — no TextureId, no side-channel bytes. Pixel data is read at + // runtime from the gltFast-imported material by matching property-name aliases. + public VpeTextureRefV1 CaptureImportedTextureRef(Material material, string property) + { + if (!material.HasProperty(property)) { + return null; + } + var texture = material.GetTexture(property); + if (!texture) { + return null; + } + return new VpeTextureRefV1 { + TextureId = null, + Offset = material.GetTextureOffset(property), + Scale = material.GetTextureScale(property), + }; + } + + public VpeNormalMapRefV1 CaptureImportedNormalMapRef(Material material, string property, float strength) + { + if (!material.HasProperty(property)) { + return null; + } + var texture = material.GetTexture(property); + if (!texture) { + return null; + } + return new VpeNormalMapRefV1 { + TextureId = null, + Offset = material.GetTextureOffset(property), + Scale = material.GetTextureScale(property), + Strength = strength, + // Runtime imports may arrive as plain RGB (glTFast doesn't carry Unity's normal + // map import flag). The resolver re-packs as needed. + Packing = VpeNormalPackings.Rgb, + }; + } + + private VpeTextureAssetV1 GetOrCaptureAsset(Texture2D texture, string colorSpace) + { + if (_assetsByTexture.TryGetValue(texture, out var existing)) { + return existing; + } + + var linear = colorSpace == VpeColorSpaces.Linear; + if (!VpeMaterialV1TextureEncoder.TryEncode(texture, linear, out var pngData)) { + return null; + } + + var id = BuildId(texture); + var fileName = $"tex_{_nextIndex++:D4}.png"; + var asset = new VpeTextureAssetV1 { + Id = id, + FileName = fileName, + ColorSpace = linear ? VpeColorSpaces.Linear : VpeColorSpaces.SRgb, + WrapMode = (int)texture.wrapMode, + FilterMode = (int)texture.filterMode, + AnisoLevel = Mathf.Max(1, texture.anisoLevel), + GenerateMipMaps = true, + SourceName = texture.name, + Width = texture.width, + Height = texture.height, + }; + + // Ask the Editor TextureImporter for canonical settings when available. This lets us + // preserve the author's intent (sRGB, wrap mode, aniso) instead of reading from a + // Texture instance that may have been mutated at runtime. + var assetPath = AssetDatabase.GetAssetPath(texture); + if (!string.IsNullOrEmpty(assetPath) && AssetImporter.GetAtPath(assetPath) is TextureImporter importer) { + asset.ColorSpace = importer.sRGBTexture ? VpeColorSpaces.SRgb : VpeColorSpaces.Linear; + asset.GenerateMipMaps = importer.mipmapEnabled; + asset.AnisoLevel = Mathf.Max(asset.AnisoLevel, importer.anisoLevel); + asset.WrapMode = (int)importer.wrapMode; + asset.FilterMode = (int)importer.filterMode; + } + + _assetsByTexture[texture] = asset; + _textureBlobs[fileName] = pngData; + return asset; + } + + private string BuildId(Texture2D texture) + { + var raw = string.IsNullOrWhiteSpace(texture.name) ? $"tex{_nextIndex}" : texture.name; + // Normalize so the id is stable across exports regardless of editor instance suffixes. + return VpeMaterialNameUtil.NormalizeTextureName(raw); + } + } + } + +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs.meta new file mode 100644 index 000000000..5009aa5b1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5c7d9e1b2a3f487ea1b2c3d4e5f60718 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs new file mode 100644 index 000000000..10828f9cc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs @@ -0,0 +1,64 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity +{ + // The contract between a .vpe reader (portable) and the Player app (pipeline-specific). + // + // A resolver turns a VpeMaterialProfileV1 + its textures into a live Unity Material rendered by + // whatever shader the Player owns at its own build time. This is the only place HDRP/URP/custom + // SRP specifics are allowed to appear on the reader side. + // + // Registration is static because RuntimePackageReader is a plain class instantiated by gameplay + // code; the Player registers its resolver once at bootstrap (a MonoBehaviour on the player scene + // works well). + public interface IVpeMaterialResolver + { + // Returns true if this resolver knows how to build a material for the given profile type. + // See VpeMaterialTypes. + bool Supports(string materialType); + + // Build a Unity Material from the portable profile. The importedMaterial is the material + // that the glTF import produced on the renderer slot; resolvers may sample it for fallback + // values but should not return it directly (the whole point is to replace it). + // Return null if the resolver cannot produce a material for this profile. + Material CreateMaterial(VpeMaterialProfileV1 profile, IVpeTextureProvider textures, Material importedMaterial); + } + + public interface IVpeTextureProvider + { + // Returns the runtime Texture2D for the given id, or null if the id is unknown / empty. + // Textures are materialized lazily on first request and cached for the lifetime of the + // provider (which lives as long as the import). + Texture2D Get(string textureId); + } + + public static class VpeMaterialResolver + { + private static IVpeMaterialResolver _active; + + // Register a resolver for the current process. A null argument clears the registration. + // The last registration wins — tests or the Player bootstrap should call this once. + public static void Register(IVpeMaterialResolver resolver) + { + _active = resolver; + } + + public static IVpeMaterialResolver Active => _active; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs.meta new file mode 100644 index 000000000..dfdef6800 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b8a9d2c1e4f76a5b8c9d1e2f4a6b8c3 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index b1edfe318..046e5e242 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -25,9 +25,9 @@ namespace VisualPinball.Unity /// public static class PackageApi { - public const string TableFolder = "table"; - public const string TableMetadataFile = "table"; - public const string ItemFolder = "items"; + public const string TableFolder = "table"; + public const string TableMetadataFile = "table"; + public const string ItemFolder = "items"; public const string ItemFile = "item"; public const string ItemReferencesFolder = "refs"; public const string SceneFile = "table.glb"; @@ -41,6 +41,12 @@ public static class PackageApi public const string LampsFile = "lamps"; public const string AssetFolder = "assets"; public const string SoundFolder = "sounds"; + // vpe.material v1 — portable, SRP-agnostic material interchange. + // See VpeMaterialV1.cs for the schema and the separation of concerns between the exporter + // (translates authoring shaders into intent) and the Player-side IVpeMaterialResolver + // (renders intent with shaders it owns at its own build time). + public const string MaterialsV1File = "materials.v1"; + public const string TexturesV1Folder = "textures"; public static readonly IStorageManager StorageManager = new SharpZipStorageManager(); // public static IStorageManager StorageManager => new OpenMcdfStorageManager(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index 0ff7c4d3c..dfd499e53 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -88,17 +88,36 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat } }); - ReadPackables(PackageApi.ItemReferencesFolder, null, (item, type, file, _) => { - var comp = item.gameObject.GetComponent(type) as IPackable; + ReadPackables(PackageApi.ItemReferencesFolder, null, (item, type, file, index) => { + var comps = item.gameObject.GetComponents(type); + var comp = comps.Length > index + ? comps[index] + : item.gameObject.AddComponent(type); if (comp == null) { - Logger.Warn($"Cannot unpack references for type {type.FullName} on {item.name}."); + Logger.Warn($"Cannot create component of type {type.FullName} on {item.name}."); return; } - comp.UnpackReferences(file.GetData(), _table.transform, _refs, _files); + if (comp is not IPackable packable) { + Logger.Warn($"Cannot unpack references for type {type.FullName} on {item.name} because the component does not implement {nameof(IPackable)}."); + return; + } + + // Some components are intentionally refs-only and return null from Pack(). + // Ensure they still get created so UnpackReferences can restore wiring. + if (comps.Length <= index) { + Logger.Info($"Created refs-only component {type.FullName} on {item.name} (index {index})."); + } + + try { + packable.UnpackReferences(file.GetData(), _table.transform, _refs, _files); + } catch (Exception ex) { + Logger.Warn(ex, $"Failed unpacking references for type {type.FullName} on {item.name} (index {index})."); + } }); ReadGlobals(); ReadTableMetadata(); + RestoreMaterialProfiles(); loadSucceeded = true; } finally { @@ -406,6 +425,15 @@ private void ReadTableMetadata() } } + private void RestoreMaterialProfiles() + { + if (!_tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder)) { + return; + } + + VpeMaterialV1Reader.TryApply(metaFolder, _table.transform); + } + private void DestroyLoadedTable() { if (!_table) { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs new file mode 100644 index 000000000..43c1778b3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs @@ -0,0 +1,64 @@ +// 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; + +namespace VisualPinball.Unity +{ + // Normalization helpers for matching materials between the exporter (Editor-side Materials) + // and the importer (glTF-imported Materials that may carry a " (Instance)" suffix). + public static class VpeMaterialNameUtil + { + public static string NormalizeMaterialName(string materialName) + { + if (string.IsNullOrWhiteSpace(materialName)) { + return string.Empty; + } + + const string instanceSuffix = " (Instance)"; + return materialName.EndsWith(instanceSuffix, StringComparison.Ordinal) + ? materialName[..^instanceSuffix.Length] + : materialName; + } + + public static string NormalizeTextureName(string textureName) + { + if (string.IsNullOrWhiteSpace(textureName)) { + return string.Empty; + } + + var name = textureName.Trim().ToLowerInvariant(); + if (name.EndsWith(".png", StringComparison.Ordinal)) { + name = name[..^4]; + } else if (name.EndsWith(".jpg", StringComparison.Ordinal)) { + name = name[..^4]; + } else if (name.EndsWith(".jpeg", StringComparison.Ordinal)) { + name = name[..^5]; + } else if (name.EndsWith(".tga", StringComparison.Ordinal)) { + name = name[..^4]; + } else if (name.EndsWith(".exr", StringComparison.Ordinal)) { + name = name[..^4]; + } + + const string instanceSuffix = " (instance)"; + if (name.EndsWith(instanceSuffix, StringComparison.Ordinal)) { + name = name[..^instanceSuffix.Length]; + } + + return name.Replace(" ", string.Empty); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs.meta new file mode 100644 index 000000000..d2f334688 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialNameUtil.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f6a8b2c1d3e495867a1b2c3d4e5f681 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs new file mode 100644 index 000000000..224387a15 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs @@ -0,0 +1,258 @@ +// 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 UnityEngine; + +namespace VisualPinball.Unity +{ + // vpe.material v1 is the portable material interchange schema carried inside a .vpe package. + // + // Design goals: + // - Describes rendering *intent* (base color, normal, mask packing, emissive, alpha mode, ...), + // not a shader-specific property bag. Keyword strings and HDRP-specific property names do not + // appear anywhere in this schema. + // - Readable by a Player app built years later against a different Unity / SRP version. The Player + // registers an IVpeMaterialResolver that maps these intents onto shaders it owns at its own + // build time. + // - Extensible via a `Type` discriminator. Unknown types are skipped with a warning. + // + // When adding a new material type, add a new sibling property on VpeMaterialProfileV1 and a new + // Type constant. Never repurpose an existing field. + + public static class VpeMaterialTypes + { + public const string Lit = "vpe.lit"; + public const string Decal = "vpe.decal"; + public const string Unlit = "vpe.unlit"; + } + + public static class VpeColorSpaces + { + public const string SRgb = "sRGB"; + public const string Linear = "Linear"; + } + + public static class VpeNormalPackings + { + // R,G,B store X,Y,Z as an RGB PNG. Shader reconstructs as normalize(rgb * 2 - 1). + public const string Rgb = "rgb"; + // R,G store X,Y; Z is reconstructed. Matches Unity's tangent-space normal sampling. + public const string Rg = "rg"; + // Dxt5nm swizzle (A,G store X,Y). Emitted when source was a compressed Unity normal map. + public const string Dxt5nm = "dxt5nm"; + } + + public static class VpeMaskPackings + { + // HDRP MaskMap: R=metallic, G=AO, B=detailMask, A=smoothness. + public const string HdrpMaskMap = "hdrpMaskMap"; + // glTF metallicRoughness + occlusion (R=occlusion, G=roughness, B=metallic). + public const string GltfMetallicRoughness = "gltfMetallicRoughness"; + } + + public static class VpeSurfaceTypes + { + public const string Opaque = "opaque"; + public const string AlphaTest = "alphaTest"; + public const string Transparent = "transparent"; + } + + public static class VpeRefractionModels + { + public const string None = "none"; + // Flat card, refracts through a planar slab. HDRP equivalent: _REFRACTION_PLANE. + public const string Planar = "planar"; + // Sphere-like (bumper caps, thick hard plastics). HDRP: _REFRACTION_SPHERE. + public const string Sphere = "sphere"; + // Thin sheet (ramp plastics, lenses). HDRP: _REFRACTION_THIN. + public const string Thin = "thin"; + } + + public static class VpeEmissiveIntensityUnits + { + public const string Nits = "nits"; + public const string Ev100 = "ev100"; + public const string Luminance = "luminance"; + } + + [Serializable] + public class VpeMaterialsPayloadV1 + { + // Schema version. Readers MUST check this before interpreting the payload. + public int FormatVersion = 1; + // Optional free-form identifier for the writing tool. For diagnostics only. + public string WrittenBy; + public VpeMaterialProfileV1[] Profiles = Array.Empty(); + public VpeTextureAssetV1[] Textures = Array.Empty(); + } + + [Serializable] + public class VpeMaterialProfileV1 + { + // Name of the source material. Used to match against renderer materials at import. + public string Name; + // Discriminator for the payload shape. See VpeMaterialTypes. + public string Type; + + // At most one of these is populated, matching Type. Others stay null so JSON stays compact. + public VpeLitProfileV1 Lit; + public VpeDecalProfileV1 Decal; + public VpeUnlitProfileV1 Unlit; + } + + [Serializable] + public class VpeLitProfileV1 + { + public VpeColorAndTextureV1 BaseColor = new(); + + public float Metallic; + public float Smoothness = 0.5f; + public float OcclusionStrength = 1f; + + // Optional packed mask. When provided, Metallic/Smoothness/OcclusionStrength are still used + // as remap anchors against the mask channels (see *Remap fields). + public VpeTextureRefV1 MaskMap; + public string MaskPacking = VpeMaskPackings.HdrpMaskMap; + + public Vector2 MetallicRemap = new(0f, 1f); + public Vector2 SmoothnessRemap = new(0f, 1f); + public Vector2 AoRemap = new(0f, 1f); + public Vector2 AlphaRemap = new(0f, 1f); + + public VpeNormalMapRefV1 NormalMap; + + public VpeEmissiveV1 Emissive = new(); + + public string SurfaceType = VpeSurfaceTypes.Opaque; + public float AlphaCutoff = 0.5f; + public bool DoubleSided; + public bool DoubleSidedGi; + + // Transparent-surface hints. These map to per-pipeline blend/depth behavior and are + // harmless for readers that ignore them. + public int TransparentBlendMode; + public bool EnableFogOnTransparent = true; + public bool TransparentDepthPrepass; + public bool TransparentDepthPostpass; + public bool TransparentWritesMotionVectors; + + // Hints for SRPs that support them. Safe to ignore. + public bool DisableSsrTransparent; + + // Explicit render queue override (-1 = inherit from shader). Avoid using unless the author + // really meant to deviate from the surface-type default. + public int RenderQueueOverride = -1; + + // Translucency features. These only have effect when SurfaceType == "transparent". + // See VpeRefractionModels. "none" disables refraction entirely. + public string RefractionModel = VpeRefractionModels.None; + public float Ior = 1f; + + // Lets light energy pass through the material surface (HDRP's Translucent material ID). + // Needed for pinball inserts and plastics to pick up light from the playfield behind them. + public bool HasTransmission; + public float Thickness = 1f; + public VpeTextureRefV1 ThicknessMap; + } + + [Serializable] + public class VpeDecalProfileV1 + { + public VpeColorAndTextureV1 BaseColor = new(); + public VpeNormalMapRefV1 NormalMap; + public VpeTextureRefV1 MaskMap; + public string MaskPacking = VpeMaskPackings.HdrpMaskMap; + + public bool AffectAlbedo = true; + public bool AffectNormal = true; + public bool AffectMask; + + public float DecalBlend = 1f; + public float NormalBlendSrc = 1f; + public float MaskBlendSrc = 1f; + public float Smoothness = 0.5f; + public float Metallic; + public float AmbientOcclusion = 1f; + } + + [Serializable] + public class VpeUnlitProfileV1 + { + public VpeColorAndTextureV1 BaseColor = new(); + public string SurfaceType = VpeSurfaceTypes.Opaque; + public float AlphaCutoff = 0.5f; + public bool DoubleSided; + } + + [Serializable] + public class VpeColorAndTextureV1 + { + public PackableColor Color = new(1f, 1f, 1f, 1f); + public VpeTextureRefV1 Texture; + } + + [Serializable] + public class VpeEmissiveV1 + { + public PackableColor Color = new(0f, 0f, 0f, 1f); + public VpeTextureRefV1 Texture; + public float Intensity; + // See VpeEmissiveIntensityUnits. + public string IntensityUnit = VpeEmissiveIntensityUnits.Nits; + // HDRP ships with per-material exposure weighting. Harmless for SRPs that don't model it. + public float ExposureWeight = 1f; + } + + [Serializable] + public class VpeTextureRefV1 + { + // Id into VpeMaterialsPayloadV1.Textures. Null/empty means "no texture". + public string TextureId; + public Vector2 Offset = Vector2.zero; + public Vector2 Scale = Vector2.one; + } + + [Serializable] + public class VpeNormalMapRefV1 + { + public string TextureId; + public Vector2 Offset = Vector2.zero; + public Vector2 Scale = Vector2.one; + public float Strength = 1f; + // See VpeNormalPackings. Defaults to rgb since that's what PNG round-tripping produces. + public string Packing = VpeNormalPackings.Rgb; + } + + [Serializable] + public class VpeTextureAssetV1 + { + // Stable id referenced by VpeTextureRefV1.TextureId. + public string Id; + // File under table/meta/textures/ inside the package. + public string FileName; + // "sRGB" or "Linear". See VpeColorSpaces. + public string ColorSpace = VpeColorSpaces.SRgb; + public int WrapMode; // UnityEngine.TextureWrapMode + public int FilterMode = 2; // Trilinear + public int AnisoLevel = 1; + public bool GenerateMipMaps = true; + // Optional source hint for debugging. + public string SourceName; + public int Width; + public int Height; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs.meta new file mode 100644 index 000000000..58a1ae458 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f7e5d3c4b2a18e4a8c2d1b3e6a5f7c9 diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs new file mode 100644 index 000000000..5059b12f9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -0,0 +1,245 @@ +// 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 NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + // Runtime reader for the v1 material interchange. Owns the texture provider cache for one + // import. Has no knowledge of HDRP, URP, or any SRP — the concrete material creation goes + // through IVpeMaterialResolver.Active, which the Player registers at bootstrap. + public static class VpeMaterialV1Reader + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + public static bool TryApply(IPackageFolder metaFolder, Transform tableRoot) + { + if (metaFolder == null || !tableRoot) { + return false; + } + + if (!metaFolder.TryGetFile(PackageApi.MaterialsV1File, out var payloadFile, PackageApi.Packer.FileExtension)) { + return false; + } + + VpeMaterialsPayloadV1 payload; + try { + payload = PackageApi.Packer.Unpack(payloadFile.GetData()); + } catch (Exception e) { + Logger.Warn(e, "Failed to parse materials.v1 payload. Falling back to legacy path."); + return false; + } + + if (payload == null || payload.Profiles == null || payload.Profiles.Length == 0) { + return false; + } + if (payload.FormatVersion != 1) { + Logger.Warn( + $"materials.v1 declares FormatVersion={payload.FormatVersion} which this reader does not " + + "understand. Falling back to legacy path."); + return false; + } + + var resolver = VpeMaterialResolver.Active; + if (resolver == null) { + Logger.Warn( + "v1 material payload present but no IVpeMaterialResolver is registered. The Player app " + + "must register a resolver at startup. Falling back to glTF-imported materials (visuals " + + "will not match authoring)."); + return false; + } + + var profilesByName = BuildProfileLookup(payload.Profiles); + metaFolder.TryGetFolder(PackageApi.TexturesV1Folder, out var texturesFolder); + using var textures = new TextureProvider(payload.Textures, texturesFolder); + + var stats = new Stats(); + 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; + } + stats.TotalSlots++; + + var key = NormalizeMaterialName(imported.name); + if (!profilesByName.TryGetValue(key, out var profile)) { + stats.UnmatchedNames.Add(key); + continue; + } + stats.MatchedSlots++; + + if (!resolver.Supports(profile.Type)) { + stats.UnsupportedTypes.Add(profile.Type); + continue; + } + + var replacement = resolver.CreateMaterial(profile, textures, imported); + if (!replacement) { + stats.ResolverReturnedNull++; + continue; + } + materials[i] = replacement; + modified = true; + stats.AppliedSlots++; + } + + if (modified) { + renderer.sharedMaterials = materials; + } + } + + Logger.Info( + $"vpe.material v1 applied: profiles={payload.Profiles.Length}, textures={payload.Textures?.Length ?? 0}, " + + $"slots={stats.TotalSlots}, matched={stats.MatchedSlots}, applied={stats.AppliedSlots}, " + + $"resolverNull={stats.ResolverReturnedNull}, unsupportedTypes={stats.UnsupportedTypes.Count}, " + + $"unmatched={stats.UnmatchedNames.Count}."); + if (stats.UnmatchedNames.Count > 0) { + var sample = string.Join(", ", TakeFirst(stats.UnmatchedNames, 12)); + Logger.Warn($"vpe.material v1 unmatched material-name sample: {sample}"); + } + + return true; + } + + private static IEnumerable TakeFirst(IEnumerable source, int count) + { + var i = 0; + foreach (var item in source) { + yield return item; + i++; + if (i >= count) { + yield break; + } + } + } + + private static Dictionary BuildProfileLookup(VpeMaterialProfileV1[] profiles) + { + var lookup = new Dictionary(profiles.Length, StringComparer.Ordinal); + foreach (var profile in profiles) { + if (profile == null || string.IsNullOrWhiteSpace(profile.Name)) { + continue; + } + lookup[NormalizeMaterialName(profile.Name)] = profile; + } + return lookup; + } + + private static string NormalizeMaterialName(string name) => VpeMaterialNameUtil.NormalizeMaterialName(name); + + private sealed class Stats + { + public int TotalSlots; + public int MatchedSlots; + public int AppliedSlots; + public int ResolverReturnedNull; + public readonly HashSet UnmatchedNames = new(StringComparer.Ordinal); + public readonly HashSet UnsupportedTypes = new(StringComparer.Ordinal); + } + + private sealed class TextureProvider : IVpeTextureProvider, IDisposable + { + private readonly Dictionary _assetsById; + private readonly IPackageFolder _folder; + private readonly Dictionary _loaded = new(StringComparer.Ordinal); + private readonly HashSet _missingTextureIdsLogged = new(StringComparer.Ordinal); + + public TextureProvider(VpeTextureAssetV1[] assets, IPackageFolder folder) + { + _folder = folder; + _assetsById = new Dictionary(StringComparer.Ordinal); + if (assets == null) { + return; + } + foreach (var asset in assets) { + if (asset == null || string.IsNullOrWhiteSpace(asset.Id)) { + continue; + } + _assetsById[asset.Id] = asset; + } + } + + public Texture2D Get(string textureId) + { + if (string.IsNullOrWhiteSpace(textureId)) { + return null; + } + if (_loaded.TryGetValue(textureId, out var cached)) { + return cached; + } + if (!_assetsById.TryGetValue(textureId, out var asset) || _folder == null) { + LogMissingTexture(textureId, reason: "id-not-in-payload-or-texture-folder-missing"); + _loaded[textureId] = null; + return null; + } + if (!_folder.TryGetFile(asset.FileName, out var file)) { + LogMissingTexture(textureId, reason: $"file-not-found:{asset.FileName}"); + _loaded[textureId] = null; + return null; + } + + var bytes = file.GetData(); + if (bytes == null || bytes.Length == 0) { + LogMissingTexture(textureId, reason: $"file-empty:{asset.FileName}"); + _loaded[textureId] = null; + return null; + } + + var linear = string.Equals(asset.ColorSpace, VpeColorSpaces.Linear, StringComparison.OrdinalIgnoreCase); + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, asset.GenerateMipMaps, linear) { + name = string.IsNullOrWhiteSpace(asset.SourceName) ? asset.Id : asset.SourceName, + }; + if (!ImageConversion.LoadImage(texture, bytes, markNonReadable: true)) { + UnityEngine.Object.Destroy(texture); + LogMissingTexture(textureId, reason: $"load-image-failed:{asset.FileName}"); + _loaded[textureId] = null; + return null; + } + texture.wrapMode = (TextureWrapMode)asset.WrapMode; + texture.filterMode = (FilterMode)asset.FilterMode; + texture.anisoLevel = Mathf.Max(1, asset.AnisoLevel); + _loaded[textureId] = texture; + return texture; + } + + public void Dispose() + { + // Textures are handed to materials on the instantiated table. The table owns them from + // here on; disposing would destroy still-referenced textures. Intentionally no-op. + _loaded.Clear(); + } + + private void LogMissingTexture(string textureId, string reason) + { + if (!_missingTextureIdsLogged.Add(textureId)) { + return; + } + Logger.Warn($"vpe.material v1 texture lookup failed for TextureId='{textureId}' ({reason})."); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs.meta new file mode 100644 index 000000000..0fb2b2964 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8d2e3f4a1b5c687923a4b5c6d7e8f921 From c663c4511de4c5aac4913e9a9425a4563ae91ffd Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Apr 2026 16:31:09 +0200 Subject: [PATCH 05/55] fix: Normal map encoding when loading during runtime. --- .../Packaging/PackageWriter.cs | 2 +- .../Packaging/VpeMaterialV1Translator.cs | 25 ++++++++-- .../Packaging/VpeMaterialV1.cs | 19 ++++++++ .../Packaging/VpeMaterialV1Reader.cs | 46 +++++++++++++++++-- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 57705b202..e2b1667d5 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -470,7 +470,7 @@ private void WriteMaterialProfiles() { var renderers = _table.GetComponentsInChildren(!ExportActivesOnly); - var capture = VpeMaterialV1Translator.Capture(renderers); + var capture = VpeMaterialV1Translator.Capture(_table.transform, renderers); var payload = capture.Payload; if (payload.Profiles == null || payload.Profiles.Length == 0) { return; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs index 168b73aa6..2beb1b533 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs @@ -50,9 +50,10 @@ public CaptureResult(VpeMaterialsPayloadV1 payload, IReadOnlyDictionary TextureBlobs { get; } } - public static CaptureResult Capture(IEnumerable renderers) + public static CaptureResult Capture(Transform tableRoot, IEnumerable renderers) { var profiles = new Dictionary(StringComparer.Ordinal); + var rendererStates = new List(); var ctx = new CaptureContext(); if (renderers != null) { @@ -61,6 +62,10 @@ public static CaptureResult Capture(IEnumerable renderers) continue; } + if (tableRoot) { + rendererStates.Add(CaptureRendererState(renderer, tableRoot)); + } + foreach (var material in renderer.sharedMaterials) { if (!material) { continue; @@ -84,10 +89,21 @@ public static CaptureResult Capture(IEnumerable renderers) WrittenBy = "VpeMaterialV1Translator", Profiles = profiles.Values.ToArray(), Textures = ctx.BuildTextureAssets(), + RendererStates = rendererStates.ToArray(), }; return new CaptureResult(payload, ctx.TextureBlobs); } + private static VpeRendererStateV1 CaptureRendererState(Renderer renderer, Transform tableRoot) + { + return new VpeRendererStateV1 { + Path = renderer.transform.GetPath(tableRoot), + ShadowCastingMode = (int)renderer.shadowCastingMode, + ReceiveShadows = renderer.receiveShadows, + RenderingLayerMask = renderer.renderingLayerMask, + }; + } + private static VpeMaterialProfileV1 TranslateMaterial(Material material, CaptureContext ctx) { if (!material || !material.shader) { @@ -169,10 +185,13 @@ private static VpeMaterialProfileV1 TranslateHdrpLit(Material material, CaptureC || material.IsKeywordEnabled("_ENABLE_FOG_ON_TRANSPARENT"), TransparentDepthPrepass = SafeGetFloat(material, "_TransparentDepthPrepassEnable", 0f) > 0.5f, TransparentDepthPostpass = SafeGetFloat(material, "_TransparentDepthPostpassEnable", 0f) > 0.5f, - TransparentWritesMotionVectors = SafeGetFloat(material, "_TransparentWritingMotionVec", 0f) > 0.5f - || material.IsKeywordEnabled("_TRANSPARENT_WRITES_MOTION_VEC"), + TransparentWritesMotionVectors = (SafeGetFloat(material, "_TransparentWritingMotionVec", 0f) > 0.5f + || material.IsKeywordEnabled("_TRANSPARENT_WRITES_MOTION_VEC")) + && (material.GetShaderPassEnabled("MOTIONVECTORS") || material.GetShaderPassEnabled("MotionVectors")), DisableSsrTransparent = material.IsKeywordEnabled("_DISABLE_SSR_TRANSPARENT") || SafeGetFloat(material, "_ReceivesSSRTransparent", 0f) < 0.5f, + DisableSsr = material.IsKeywordEnabled("_DISABLE_SSR") + || SafeGetFloat(material, "_ReceivesSSR", 1f) < 0.5f, RenderQueueOverride = -1, RefractionModel = HdrpRefractionModelToString( diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs index 224387a15..8b2192060 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs @@ -98,6 +98,24 @@ public class VpeMaterialsPayloadV1 public string WrittenBy; public VpeMaterialProfileV1[] Profiles = Array.Empty(); public VpeTextureAssetV1[] Textures = Array.Empty(); + // Per-renderer state that Unity authors but glTF does not carry. Restored after glTF import + // so the Player sees the same shadow/lighting topology the author set up. + public VpeRendererStateV1[] RendererStates = Array.Empty(); + } + + [Serializable] + public class VpeRendererStateV1 + { + // Path to the renderer's transform, relative to the table root, encoded via + // TransformExtensions.GetPath so it round-trips through the same sibling-index scheme + // the rest of the package uses. + public string Path; + // UnityEngine.Rendering.ShadowCastingMode: 0=Off, 1=On, 2=TwoSided, 3=ShadowsOnly. + public int ShadowCastingMode = 1; + public bool ReceiveShadows = true; + // Unity renderingLayerMask. Bit 0 is the Default layer. Authoring tables commonly set + // bit 8 (light-layer 8) on table geometry in addition to Default. + public uint RenderingLayerMask = 1; } [Serializable] @@ -152,6 +170,7 @@ public class VpeLitProfileV1 // Hints for SRPs that support them. Safe to ignore. public bool DisableSsrTransparent; + public bool DisableSsr; // Explicit render queue override (-1 = inherit from shader). Avoid using unless the author // really meant to deviate from the surface-type default. diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index 5059b12f9..525e72f84 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using NLog; using UnityEngine; +using UnityEngine.Rendering; using Logger = NLog.Logger; namespace VisualPinball.Unity @@ -112,15 +113,41 @@ public static bool TryApply(IPackageFolder metaFolder, Transform tableRoot) } } - Logger.Info( - $"vpe.material v1 applied: profiles={payload.Profiles.Length}, textures={payload.Textures?.Length ?? 0}, " + - $"slots={stats.TotalSlots}, matched={stats.MatchedSlots}, applied={stats.AppliedSlots}, " + - $"resolverNull={stats.ResolverReturnedNull}, unsupportedTypes={stats.UnsupportedTypes.Count}, " + - $"unmatched={stats.UnmatchedNames.Count}."); + // Apply per-renderer state (shadowCastingMode, receiveShadows, renderingLayerMask) that + // glTF doesn't carry. Paths are resolved through FindByPath so the existing + // SparsePathIndexMap handles any sibling-index shift introduced by the glTF round-trip. + if (payload.RendererStates != null) { + foreach (var state in payload.RendererStates) { + if (state == null || string.IsNullOrEmpty(state.Path)) { + continue; + } + var target = tableRoot.FindByPath(state.Path); + if (!target) { + stats.RendererStatesMissing++; + continue; + } + if (!target.TryGetComponent(out var renderer)) { + stats.RendererStatesMissing++; + continue; + } + ApplyRendererState(renderer, state); + stats.RendererStatesApplied++; + } + } + if (stats.UnmatchedNames.Count > 0) { var sample = string.Join(", ", TakeFirst(stats.UnmatchedNames, 12)); Logger.Warn($"vpe.material v1 unmatched material-name sample: {sample}"); } + // Logged at Warn level during development so the summary survives Unity's console ring + // buffer alongside the resolver's per-material warnings. Drop to Info once the v1 + // interchange is stable. + Logger.Warn( + $"vpe.material v1 applied: profiles={payload.Profiles.Length}, textures={payload.Textures?.Length ?? 0}, " + + $"slots={stats.TotalSlots}, matched={stats.MatchedSlots}, applied={stats.AppliedSlots}, " + + $"rendererStates={stats.RendererStatesApplied}/{payload.RendererStates?.Length ?? 0} (missing at import={stats.RendererStatesMissing}), " + + $"resolverNull={stats.ResolverReturnedNull}, unsupportedTypes={stats.UnsupportedTypes.Count}, " + + $"unmatched={stats.UnmatchedNames.Count}."); return true; } @@ -149,6 +176,13 @@ private static Dictionary BuildProfileLookup(VpeMa return lookup; } + private static void ApplyRendererState(Renderer renderer, VpeRendererStateV1 state) + { + renderer.shadowCastingMode = (ShadowCastingMode)state.ShadowCastingMode; + renderer.receiveShadows = state.ReceiveShadows; + renderer.renderingLayerMask = state.RenderingLayerMask; + } + private static string NormalizeMaterialName(string name) => VpeMaterialNameUtil.NormalizeMaterialName(name); private sealed class Stats @@ -157,6 +191,8 @@ private sealed class Stats public int MatchedSlots; public int AppliedSlots; public int ResolverReturnedNull; + public int RendererStatesApplied; + public int RendererStatesMissing; public readonly HashSet UnmatchedNames = new(StringComparer.Ordinal); public readonly HashSet UnsupportedTypes = new(StringComparer.Ordinal); } From af4ee96923f8bb09a18421ded994bbe04a609ab5 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Apr 2026 16:54:28 +0200 Subject: [PATCH 06/55] Move runtime HDRP conversion code to HDRP repo. --- .../Packaging/PackageWriter.cs | 7 +- .../Packaging/VpeMaterialV1TextureEncoder.cs | 70 --- .../VpeMaterialV1TextureEncoder.cs.meta | 2 - .../Packaging/VpeMaterialV1Translator.cs | 438 +----------------- 4 files changed, 29 insertions(+), 488 deletions(-) delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs delete mode 100644 VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index e2b1667d5..220780994 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -472,13 +472,16 @@ private void WriteMaterialProfiles() var capture = VpeMaterialV1Translator.Capture(_table.transform, renderers); var payload = capture.Payload; - if (payload.Profiles == null || payload.Profiles.Length == 0) { + if (payload?.Profiles == null || payload.Profiles.Length == 0) { + if (VpeMaterialV1Translator.Active == null) { + Logger.Info("Skipping materials.v1 export: no IVpeMaterialV1Translator is registered."); + } return; } var textureCount = 0; var textureBytes = 0L; - if (capture.TextureBlobs.Count > 0) { + if (capture.TextureBlobs != null && capture.TextureBlobs.Count > 0) { var texturesFolder = _metaFolder.AddFolder(PackageApi.TexturesV1Folder); foreach (var entry in capture.TextureBlobs) { if (entry.Value == null || entry.Value.Length == 0) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs deleted file mode 100644 index a3bda8711..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs +++ /dev/null @@ -1,70 +0,0 @@ -// 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 NLog; -using UnityEngine; -using Logger = NLog.Logger; - -namespace VisualPinball.Unity.Editor -{ - // Utility for round-tripping arbitrary Unity textures (compressed, GPU-only, etc.) through a - // PNG blob. Goes via a temporary RenderTexture + ReadPixels so it works on assets without the - // Read/Write flag. - internal static class VpeMaterialV1TextureEncoder - { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - - public static bool TryEncode(Texture2D source, bool linear, out byte[] pngData) - { - pngData = null; - if (!source) { - return false; - } - - var readWrite = linear ? RenderTextureReadWrite.Linear : RenderTextureReadWrite.sRGB; - var renderTexture = RenderTexture.GetTemporary( - source.width, source.height, 0, RenderTextureFormat.ARGB32, readWrite); - - var previousRenderTexture = RenderTexture.active; - Texture2D readableTexture = null; - try { - Graphics.Blit(source, renderTexture); - RenderTexture.active = renderTexture; - readableTexture = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false, linear); - readableTexture.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); - readableTexture.Apply(updateMipmaps: false, makeNoLongerReadable: false); - pngData = readableTexture.EncodeToPNG(); - return pngData is { Length: > 0 }; - - } catch (Exception e) { - Logger.Warn(e, $"Unable to encode texture '{source.name}' for v1 material export."); - return false; - - } finally { - if (readableTexture) { - if (Application.isPlaying) { - UnityEngine.Object.Destroy(readableTexture); - } else { - UnityEngine.Object.DestroyImmediate(readableTexture); - } - } - RenderTexture.active = previousRenderTexture; - RenderTexture.ReleaseTemporary(renderTexture); - } - } - } -} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta deleted file mode 100644 index ca39b5207..000000000 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1TextureEncoder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 7a1b2c3d4e5f681923a4b5c6d7e8f910 diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs index 2beb1b533..28b639379 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs @@ -14,438 +14,48 @@ // 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.Linq; -using NLog; -using UnityEditor; using UnityEngine; -using Logger = NLog.Logger; namespace VisualPinball.Unity.Editor { - // Editor-only. Translates Unity Materials on a scene's renderers into a portable - // VpeMaterialsPayloadV1 plus a set of PNG texture blobs keyed by stable ids. - // - // Only HDRP-aware mappings are implemented here; if VPE adopts additional pipelines the - // translator fans out on shader name. - public static class VpeMaterialV1Translator + // Editor-side extension point for translating authoring materials into vpe.material v1 payload. + // Pipeline packages (HDRP/URP/custom) register an implementation at editor load time. + public interface IVpeMaterialV1Translator { - private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); - - private const string HdrpLitShaderName = "HDRP/Lit"; - private const string HdrpDecalShaderName = "HDRP/Decal"; - private const string HdrpUnlitShaderName = "HDRP/Unlit"; - - public readonly struct CaptureResult - { - public CaptureResult(VpeMaterialsPayloadV1 payload, IReadOnlyDictionary textureBlobs) - { - Payload = payload; - TextureBlobs = textureBlobs; - } - - public VpeMaterialsPayloadV1 Payload { get; } - // Maps texture file name (matches VpeTextureAssetV1.FileName) to its PNG bytes. - public IReadOnlyDictionary TextureBlobs { get; } - } - - public static CaptureResult Capture(Transform tableRoot, IEnumerable renderers) - { - var profiles = new Dictionary(StringComparer.Ordinal); - var rendererStates = new List(); - var ctx = new CaptureContext(); - - if (renderers != null) { - foreach (var renderer in renderers) { - if (!renderer) { - continue; - } - - if (tableRoot) { - rendererStates.Add(CaptureRendererState(renderer, tableRoot)); - } - - foreach (var material in renderer.sharedMaterials) { - if (!material) { - continue; - } - var key = NormalizeMaterialName(material.name); - if (string.IsNullOrWhiteSpace(key) || profiles.ContainsKey(key)) { - continue; - } - - var profile = TranslateMaterial(material, ctx); - if (profile != null) { - profile.Name = key; - profiles[key] = profile; - } - } - } - } - - var payload = new VpeMaterialsPayloadV1 { - FormatVersion = 1, - WrittenBy = "VpeMaterialV1Translator", - Profiles = profiles.Values.ToArray(), - Textures = ctx.BuildTextureAssets(), - RendererStates = rendererStates.ToArray(), - }; - return new CaptureResult(payload, ctx.TextureBlobs); - } - - private static VpeRendererStateV1 CaptureRendererState(Renderer renderer, Transform tableRoot) - { - return new VpeRendererStateV1 { - Path = renderer.transform.GetPath(tableRoot), - ShadowCastingMode = (int)renderer.shadowCastingMode, - ReceiveShadows = renderer.receiveShadows, - RenderingLayerMask = renderer.renderingLayerMask, - }; - } - - private static VpeMaterialProfileV1 TranslateMaterial(Material material, CaptureContext ctx) - { - if (!material || !material.shader) { - return null; - } - - var shaderName = material.shader.name; - switch (shaderName) { - case HdrpLitShaderName: - return TranslateHdrpLit(material, ctx); - case HdrpDecalShaderName: - return TranslateHdrpDecal(material, ctx); - case HdrpUnlitShaderName: - return TranslateHdrpUnlit(material, ctx); - default: - Logger.Warn( - $"Material '{material.name}' uses shader '{shaderName}' which has no v1 translation. " + - "It will fall back to the glTF-imported material at runtime."); - return null; - } - } - - private static VpeMaterialProfileV1 TranslateHdrpLit(Material material, CaptureContext ctx) - { - // For alpha-tested and transparent surfaces, the base color texture's alpha channel is - // load-bearing (alpha-test discards pixels below cutoff; transparent blends by alpha). - // gltFast's glTF round-trip does not preserve the alpha channel reliably for HDRP - // alphaMode=MASK materials, so we side-channel the full RGBA PNG for those. Plain opaque - // materials keep the leaner glb-only path where we only record tiling. - var baseColorNeedsAlpha = - SafeGetFloat(material, "_SurfaceType", 0f) > 0.5f /* Transparent */ - || SafeGetFloat(material, "_AlphaCutoffEnable", 0f) > 0.5f /* AlphaTest */; - var baseColorTexture = baseColorNeedsAlpha - ? ctx.CaptureSideChannelTextureRef(material, "_BaseColorMap", VpeColorSpaces.SRgb) - : ctx.CaptureImportedTextureRef(material, "_BaseColorMap"); - - var lit = new VpeLitProfileV1 { - BaseColor = { - Color = SafeGetColor(material, "_BaseColor", Color.white), - Texture = baseColorTexture, - }, - Metallic = SafeGetFloat(material, "_Metallic", 0f), - Smoothness = SafeGetFloat(material, "_Smoothness", 0.5f), - OcclusionStrength = 1f, - // MaskMap packs HDRP-specific channels (R=metal, G=AO, B=detail, A=smooth). glTF - // has no lossless equivalent, so this is the one texture that gets side-channeled. - MaskMap = ctx.CaptureSideChannelTextureRef(material, "_MaskMap", VpeColorSpaces.Linear), - MaskPacking = VpeMaskPackings.HdrpMaskMap, - MetallicRemap = new Vector2( - SafeGetFloat(material, "_MetallicRemapMin", 0f), - SafeGetFloat(material, "_MetallicRemapMax", 1f)), - SmoothnessRemap = new Vector2( - SafeGetFloat(material, "_SmoothnessRemapMin", 0f), - SafeGetFloat(material, "_SmoothnessRemapMax", 1f)), - AoRemap = new Vector2( - SafeGetFloat(material, "_AORemapMin", 0f), - SafeGetFloat(material, "_AORemapMax", 1f)), - AlphaRemap = new Vector2( - SafeGetFloat(material, "_AlphaRemapMin", 0f), - SafeGetFloat(material, "_AlphaRemapMax", 1f)), - NormalMap = ctx.CaptureImportedNormalMapRef(material, "_NormalMap", - strength: SafeGetFloat(material, "_NormalScale", 1f)), - Emissive = new VpeEmissiveV1 { - Color = SafeGetColor(material, "_EmissiveColor", Color.black), - Texture = ctx.CaptureImportedTextureRef(material, "_EmissiveColorMap"), - Intensity = SafeGetFloat(material, "_EmissiveIntensity", 0f), - IntensityUnit = HdrpEmissiveIntensityUnitToString( - SafeGetFloat(material, "_EmissiveIntensityUnit", 0f)), - ExposureWeight = SafeGetFloat(material, "_EmissiveExposureWeight", 1f), - }, - SurfaceType = HdrpSurfaceTypeToString( - SafeGetFloat(material, "_SurfaceType", 0f), - SafeGetFloat(material, "_AlphaCutoffEnable", 0f)), - AlphaCutoff = SafeGetFloat(material, "_AlphaCutoff", 0.5f), - DoubleSided = SafeGetFloat(material, "_DoubleSidedEnable", 0f) > 0.5f, - DoubleSidedGi = material.doubleSidedGI, - TransparentBlendMode = Mathf.RoundToInt(SafeGetFloat(material, "_BlendMode", 0f)), - EnableFogOnTransparent = SafeGetFloat(material, "_EnableFogOnTransparent", 1f) > 0.5f - || material.IsKeywordEnabled("_ENABLE_FOG_ON_TRANSPARENT"), - TransparentDepthPrepass = SafeGetFloat(material, "_TransparentDepthPrepassEnable", 0f) > 0.5f, - TransparentDepthPostpass = SafeGetFloat(material, "_TransparentDepthPostpassEnable", 0f) > 0.5f, - TransparentWritesMotionVectors = (SafeGetFloat(material, "_TransparentWritingMotionVec", 0f) > 0.5f - || material.IsKeywordEnabled("_TRANSPARENT_WRITES_MOTION_VEC")) - && (material.GetShaderPassEnabled("MOTIONVECTORS") || material.GetShaderPassEnabled("MotionVectors")), - DisableSsrTransparent = material.IsKeywordEnabled("_DISABLE_SSR_TRANSPARENT") - || SafeGetFloat(material, "_ReceivesSSRTransparent", 0f) < 0.5f, - DisableSsr = material.IsKeywordEnabled("_DISABLE_SSR") - || SafeGetFloat(material, "_ReceivesSSR", 1f) < 0.5f, - RenderQueueOverride = -1, - - RefractionModel = HdrpRefractionModelToString( - SafeGetFloat(material, "_RefractionModel", 0f), - material), - Ior = SafeGetFloat(material, "_Ior", 1f), - // Authoring intent is encoded by the explicit HDRP translucent signals: - // MaterialID==5 or the transmission keyword. Do not infer from _TransmissionEnable; - // HDRP keeps that float at 1 on many non-translucent materials. - HasTransmission = material.IsKeywordEnabled("_MATERIAL_FEATURE_TRANSMISSION") - || Mathf.Approximately(SafeGetFloat(material, "_MaterialID", 1f), 5f), - Thickness = SafeGetFloat(material, "_Thickness", 1f), - ThicknessMap = ctx.CaptureSideChannelTextureRef(material, "_ThicknessMap", VpeColorSpaces.Linear), - }; - - return new VpeMaterialProfileV1 { - Type = VpeMaterialTypes.Lit, - Lit = lit, - }; - } - - private static VpeMaterialProfileV1 TranslateHdrpDecal(Material material, CaptureContext ctx) - { - var decal = new VpeDecalProfileV1 { - BaseColor = { - Color = SafeGetColor(material, "_BaseColor", Color.white), - // Decal albedo alpha is load-bearing (where the decal applies). Exporting through - // glTF can convert this map to JPEG and drop alpha, so always side-channel it. - Texture = ctx.CaptureSideChannelTextureRef(material, "_BaseColorMap", VpeColorSpaces.SRgb), - }, - NormalMap = ctx.CaptureImportedNormalMapRef(material, "_NormalMap", - strength: SafeGetFloat(material, "_NormalScale", 1f)), - MaskMap = ctx.CaptureSideChannelTextureRef(material, "_MaskMap", VpeColorSpaces.Linear), - MaskPacking = VpeMaskPackings.HdrpMaskMap, - AffectAlbedo = material.IsKeywordEnabled("_MATERIAL_AFFECTS_ALBEDO") - || SafeGetFloat(material, "_AffectAlbedo", 1f) > 0.5f, - AffectNormal = material.IsKeywordEnabled("_MATERIAL_AFFECTS_NORMAL") - || SafeGetFloat(material, "_AffectNormal", 1f) > 0.5f, - AffectMask = material.IsKeywordEnabled("_MATERIAL_AFFECTS_MASKMAP") - || SafeGetFloat(material, "_AffectMaskmap", 0f) > 0.5f, - DecalBlend = SafeGetFloat(material, "_DecalBlend", 1f), - NormalBlendSrc = SafeGetFloat(material, "_NormalBlendSrc", 1f), - MaskBlendSrc = SafeGetFloat(material, "_MaskBlendSrc", 1f), - Smoothness = SafeGetFloat(material, "_DecalSmoothness", 0.5f), - Metallic = SafeGetFloat(material, "_DecalMetallic", 0f), - AmbientOcclusion = SafeGetFloat(material, "_DecalAO", 1f), - }; - - return new VpeMaterialProfileV1 { - Type = VpeMaterialTypes.Decal, - Decal = decal, - }; - } - - private static VpeMaterialProfileV1 TranslateHdrpUnlit(Material material, CaptureContext ctx) - { - var unlit = new VpeUnlitProfileV1 { - BaseColor = { - Color = SafeGetColor(material, "_UnlitColor", SafeGetColor(material, "_BaseColor", Color.white)), - Texture = ctx.CaptureImportedTextureRef(material, "_UnlitColorMap") - ?? ctx.CaptureImportedTextureRef(material, "_BaseColorMap"), - }, - SurfaceType = HdrpSurfaceTypeToString( - SafeGetFloat(material, "_SurfaceType", 0f), - SafeGetFloat(material, "_AlphaCutoffEnable", 0f)), - AlphaCutoff = SafeGetFloat(material, "_AlphaCutoff", 0.5f), - DoubleSided = SafeGetFloat(material, "_DoubleSidedEnable", 0f) > 0.5f, - }; - return new VpeMaterialProfileV1 { - Type = VpeMaterialTypes.Unlit, - Unlit = unlit, - }; - } + VpeMaterialCaptureResult Capture(Transform tableRoot, IEnumerable renderers); + } - private static string HdrpSurfaceTypeToString(float surfaceType, float alphaCutoffEnable) + public readonly struct VpeMaterialCaptureResult + { + public VpeMaterialCaptureResult(VpeMaterialsPayloadV1 payload, IReadOnlyDictionary textureBlobs) { - if (surfaceType > 0.5f) { - return VpeSurfaceTypes.Transparent; - } - return alphaCutoffEnable > 0.5f ? VpeSurfaceTypes.AlphaTest : VpeSurfaceTypes.Opaque; + Payload = payload; + TextureBlobs = textureBlobs; } - private static string HdrpEmissiveIntensityUnitToString(float value) - { - // HDRP: 0 = Nits, 1 = EV100. - return value > 0.5f ? VpeEmissiveIntensityUnits.Ev100 : VpeEmissiveIntensityUnits.Nits; - } + public VpeMaterialsPayloadV1 Payload { get; } - // HDRP _RefractionModel float: 0=None, 1=Plane, 2=Sphere, 3=Thin. We also check keywords - // since the float is sometimes left at a stale value while the keyword tells the real story. - private static string HdrpRefractionModelToString(float value, Material material) - { - if (material.IsKeywordEnabled("_REFRACTION_PLANE")) { - return VpeRefractionModels.Planar; - } - if (material.IsKeywordEnabled("_REFRACTION_SPHERE")) { - return VpeRefractionModels.Sphere; - } - if (material.IsKeywordEnabled("_REFRACTION_THIN")) { - return VpeRefractionModels.Thin; - } - var mode = Mathf.RoundToInt(value); - return mode switch { - 1 => VpeRefractionModels.Planar, - 2 => VpeRefractionModels.Sphere, - 3 => VpeRefractionModels.Thin, - _ => VpeRefractionModels.None, - }; - } + // Maps texture file name (matches VpeTextureAssetV1.FileName) to its PNG bytes. + public IReadOnlyDictionary TextureBlobs { get; } + } - private static float SafeGetFloat(Material material, string property, float fallback) - { - return material.HasProperty(property) ? material.GetFloat(property) : fallback; - } + public static class VpeMaterialV1Translator + { + private static IVpeMaterialV1Translator _active; - private static Color SafeGetColor(Material material, string property, Color fallback) + public static void Register(IVpeMaterialV1Translator translator) { - return material.HasProperty(property) ? material.GetColor(property) : fallback; + _active = translator; } - public static string NormalizeMaterialName(string materialName) - => VpeMaterialNameUtil.NormalizeMaterialName(materialName); + public static IVpeMaterialV1Translator Active => _active; - private sealed class CaptureContext + public static VpeMaterialCaptureResult Capture(Transform tableRoot, IEnumerable renderers) { - private readonly Dictionary _assetsByTexture = new(); - private readonly Dictionary _textureBlobs = new(StringComparer.Ordinal); - private int _nextIndex; - - public IReadOnlyDictionary TextureBlobs => _textureBlobs; - - public VpeTextureAssetV1[] BuildTextureAssets() - { - var assets = new VpeTextureAssetV1[_assetsByTexture.Count]; - var i = 0; - foreach (var asset in _assetsByTexture.Values) { - assets[i++] = asset; - } - return assets; - } - - // Captures a texture reference whose pixel data must be shipped in the side-channel - // (i.e. is not losslessly reproduced by the glb). Use for HDRP-specific packings like - // MaskMap where channel semantics differ from glTF's PBR textures. - public VpeTextureRefV1 CaptureSideChannelTextureRef(Material material, string property, string colorSpace, VpeTextureRefV1 fallback = null) - { - if (!material.HasProperty(property)) { - return fallback; - } - var texture = material.GetTexture(property) as Texture2D; - if (!texture) { - return fallback; - } - - var asset = GetOrCaptureAsset(texture, colorSpace); - if (asset == null) { - return fallback; - } - - return new VpeTextureRefV1 { - TextureId = asset.Id, - Offset = material.GetTextureOffset(property), - Scale = material.GetTextureScale(property), - }; - } - - // Captures tiling only — no TextureId, no side-channel bytes. Pixel data is read at - // runtime from the gltFast-imported material by matching property-name aliases. - public VpeTextureRefV1 CaptureImportedTextureRef(Material material, string property) - { - if (!material.HasProperty(property)) { - return null; - } - var texture = material.GetTexture(property); - if (!texture) { - return null; - } - return new VpeTextureRefV1 { - TextureId = null, - Offset = material.GetTextureOffset(property), - Scale = material.GetTextureScale(property), - }; - } - - public VpeNormalMapRefV1 CaptureImportedNormalMapRef(Material material, string property, float strength) - { - if (!material.HasProperty(property)) { - return null; - } - var texture = material.GetTexture(property); - if (!texture) { - return null; - } - return new VpeNormalMapRefV1 { - TextureId = null, - Offset = material.GetTextureOffset(property), - Scale = material.GetTextureScale(property), - Strength = strength, - // Runtime imports may arrive as plain RGB (glTFast doesn't carry Unity's normal - // map import flag). The resolver re-packs as needed. - Packing = VpeNormalPackings.Rgb, - }; - } - - private VpeTextureAssetV1 GetOrCaptureAsset(Texture2D texture, string colorSpace) - { - if (_assetsByTexture.TryGetValue(texture, out var existing)) { - return existing; - } - - var linear = colorSpace == VpeColorSpaces.Linear; - if (!VpeMaterialV1TextureEncoder.TryEncode(texture, linear, out var pngData)) { - return null; - } - - var id = BuildId(texture); - var fileName = $"tex_{_nextIndex++:D4}.png"; - var asset = new VpeTextureAssetV1 { - Id = id, - FileName = fileName, - ColorSpace = linear ? VpeColorSpaces.Linear : VpeColorSpaces.SRgb, - WrapMode = (int)texture.wrapMode, - FilterMode = (int)texture.filterMode, - AnisoLevel = Mathf.Max(1, texture.anisoLevel), - GenerateMipMaps = true, - SourceName = texture.name, - Width = texture.width, - Height = texture.height, - }; - - // Ask the Editor TextureImporter for canonical settings when available. This lets us - // preserve the author's intent (sRGB, wrap mode, aniso) instead of reading from a - // Texture instance that may have been mutated at runtime. - var assetPath = AssetDatabase.GetAssetPath(texture); - if (!string.IsNullOrEmpty(assetPath) && AssetImporter.GetAtPath(assetPath) is TextureImporter importer) { - asset.ColorSpace = importer.sRGBTexture ? VpeColorSpaces.SRgb : VpeColorSpaces.Linear; - asset.GenerateMipMaps = importer.mipmapEnabled; - asset.AnisoLevel = Mathf.Max(asset.AnisoLevel, importer.anisoLevel); - asset.WrapMode = (int)importer.wrapMode; - asset.FilterMode = (int)importer.filterMode; - } - - _assetsByTexture[texture] = asset; - _textureBlobs[fileName] = pngData; - return asset; - } - - private string BuildId(Texture2D texture) - { - var raw = string.IsNullOrWhiteSpace(texture.name) ? $"tex{_nextIndex}" : texture.name; - // Normalize so the id is stable across exports regardless of editor instance suffixes. - return VpeMaterialNameUtil.NormalizeTextureName(raw); - } + return _active == null + ? default + : _active.Capture(tableRoot, renderers); } } - } From 44f7d8d1c76019bef89d823606f158219d084f75 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Apr 2026 20:50:00 +0200 Subject: [PATCH 07/55] export: First migration toward a coherent GLB-based format. --- .../Packaging/PackageWriter.cs | 23 +- .../Packaging/RuntimePackageReader.cs | 15 +- .../Packaging/VpeMaterialV1Reader.cs | 19 +- .../Packaging/VpeMaterialsGltfExtension.cs | 220 ++++++++++++++++++ .../VpeMaterialsGltfExtension.cs.meta | 3 + 5 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs create mode 100644 VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs.meta diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 220780994..8c3740ffe 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -42,6 +42,7 @@ public class PackageWriter private PackagedFiles _files; private IPackageFolder _globalFolder; private IPackageFolder _metaFolder; + private VpeMaterialsPayloadV1 _capturedMaterialPayload; private const bool ExportActivesOnly = true; @@ -204,7 +205,7 @@ private Func> PrepareScene() RestoreDisabledRenderers(disabledRenderers); } - return () => SaveGltfToBytes(export); + return () => SaveGltfToBytes(export, embedMaterialPayload: true); } // Temporarily re-enables Light components that are disabled at author time so gltFast @@ -472,12 +473,14 @@ private void WriteMaterialProfiles() var capture = VpeMaterialV1Translator.Capture(_table.transform, renderers); var payload = capture.Payload; + _capturedMaterialPayload = null; if (payload?.Profiles == null || payload.Profiles.Length == 0) { if (VpeMaterialV1Translator.Active == null) { Logger.Info("Skipping materials.v1 export: no IVpeMaterialV1Translator is registered."); } return; } + _capturedMaterialPayload = payload; var textureCount = 0; var textureBytes = 0L; @@ -503,11 +506,25 @@ private void WriteMaterialProfiles() $"{PackageApi.MetaFolder}/{PackageApi.TexturesV1Folder}."); } - private static async Task SaveGltfToBytes(GameObjectExport export) + private async Task SaveGltfToBytes(GameObjectExport export, bool embedMaterialPayload = false) { using var stream = new MemoryStream(); await export.SaveToStreamAndDispose(stream); - return stream.ToArray(); + var glbData = stream.ToArray(); + if (!embedMaterialPayload || _capturedMaterialPayload == null) { + return glbData; + } + + var payload = _capturedMaterialPayload; + try { + glbData = VpeMaterialsGltfExtension.WritePayload(glbData, payload); + Logger.Info( + $"Embedded {VpeMaterialsGltfExtension.ExtensionName} in {PackageApi.SceneFile}: " + + $"{payload.Profiles?.Length ?? 0} profile(s), {payload.Textures?.Length ?? 0} texture reference(s)."); + } catch (Exception ex) { + Logger.Warn(ex, $"Failed embedding {VpeMaterialsGltfExtension.ExtensionName} in {PackageApi.SceneFile}. Writing plain GLB and keeping sidecar material payload."); + } + return glbData; } private static void WritePackageFile(IPackageFolder folder, string fileName, byte[] data) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index dfd499e53..ce43d9552 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -36,6 +36,7 @@ public class RuntimePackageReader private PackagedRefs _refs; private PackagedFiles _files; private Dictionary _sparsePathIndexMap; + private VpeMaterialsPayloadV1 _embeddedMaterialPayload; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -146,6 +147,11 @@ private async Task ImportModels(Transform parent, CancellationToken throw new Exception($"Scene data file '{PackageApi.SceneFile}' is missing or empty."); } + _embeddedMaterialPayload = null; + if (VpeMaterialsGltfExtension.TryReadPayload(sceneData, out var embeddedMaterialPayload)) { + _embeddedMaterialPayload = embeddedMaterialPayload; + } + var importRoot = new GameObject("__vpe_runtime_import"); importRoot.hideFlags = HideFlags.HideAndDontSave; if (parent != null) { @@ -427,10 +433,17 @@ private void ReadTableMetadata() private void RestoreMaterialProfiles() { - if (!_tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder)) { + _tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder); + + if (_embeddedMaterialPayload != null && + VpeMaterialV1Reader.TryApply(_embeddedMaterialPayload, metaFolder, _table.transform, + $"{PackageApi.SceneFile}/{VpeMaterialsGltfExtension.ExtensionName}")) { return; } + if (metaFolder == null) { + return; + } VpeMaterialV1Reader.TryApply(metaFolder, _table.transform); } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index 525e72f84..5c31df4bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -48,27 +48,33 @@ public static bool TryApply(IPackageFolder metaFolder, Transform tableRoot) return false; } - if (payload == null || payload.Profiles == null || payload.Profiles.Length == 0) { + return TryApply(payload, metaFolder, tableRoot, PackageApi.MaterialsV1File); + } + + public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFolder, Transform tableRoot, string sourceLabel) + { + if (payload == null || !tableRoot || payload.Profiles == null || payload.Profiles.Length == 0) { return false; } if (payload.FormatVersion != 1) { Logger.Warn( - $"materials.v1 declares FormatVersion={payload.FormatVersion} which this reader does not " + - "understand. Falling back to legacy path."); + $"{sourceLabel} declares FormatVersion={payload.FormatVersion} which this reader does not " + + "understand. Falling back to glTF-imported materials."); return false; } var resolver = VpeMaterialResolver.Active; if (resolver == null) { Logger.Warn( - "v1 material payload present but no IVpeMaterialResolver is registered. The Player app " + + $"v1 material payload present in {sourceLabel} but no IVpeMaterialResolver is registered. The Player app " + "must register a resolver at startup. Falling back to glTF-imported materials (visuals " + "will not match authoring)."); return false; } var profilesByName = BuildProfileLookup(payload.Profiles); - metaFolder.TryGetFolder(PackageApi.TexturesV1Folder, out var texturesFolder); + IPackageFolder texturesFolder = null; + metaFolder?.TryGetFolder(PackageApi.TexturesV1Folder, out texturesFolder); using var textures = new TextureProvider(payload.Textures, texturesFolder); var stats = new Stats(); @@ -143,7 +149,8 @@ public static bool TryApply(IPackageFolder metaFolder, Transform tableRoot) // buffer alongside the resolver's per-material warnings. Drop to Info once the v1 // interchange is stable. Logger.Warn( - $"vpe.material v1 applied: profiles={payload.Profiles.Length}, textures={payload.Textures?.Length ?? 0}, " + + $"vpe.material v1 applied from {sourceLabel}: profiles={payload.Profiles.Length}, " + + $"textures={payload.Textures?.Length ?? 0}, " + $"slots={stats.TotalSlots}, matched={stats.MatchedSlots}, applied={stats.AppliedSlots}, " + $"rendererStates={stats.RendererStatesApplied}/{payload.RendererStates?.Length ?? 0} (missing at import={stats.RendererStatesMissing}), " + $"resolverNull={stats.ResolverReturnedNull}, unsupportedTypes={stats.UnsupportedTypes.Count}, " + diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs new file mode 100644 index 000000000..927b3b0d3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs @@ -0,0 +1,220 @@ +// 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.Buffers.Binary; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Unity +{ + // Minimal GLB post-process helper for carrying VPE material metadata inside the exported + // table.glb. Keeps the existing payload shape intact so runtime can migrate without a second + // schema translation step. + public static class VpeMaterialsGltfExtension + { + public const string ExtensionName = "VPE_materials"; + + private const uint GlbMagic = 0x46546C67; + private const uint GlbVersion = 2; + private const uint JsonChunkType = 0x4E4F534A; + + public static bool TryReadPayload(byte[] glbData, out VpeMaterialsPayloadV1 payload) + { + payload = null; + if (!TryReadRoot(glbData, out var root)) { + return false; + } + + var payloadToken = root["extensions"]?[ExtensionName]; + if (payloadToken == null) { + return false; + } + + try { + var payloadJson = payloadToken.ToString(); + payload = PackageApi.Packer.Unpack(Encoding.UTF8.GetBytes(payloadJson)); + return payload != null; + } catch (Exception) { + return false; + } + } + + public static byte[] WritePayload(byte[] glbData, VpeMaterialsPayloadV1 payload) + { + if (glbData == null || glbData.Length == 0) { + throw new ArgumentException("GLB data is missing.", nameof(glbData)); + } + if (payload == null) { + return glbData; + } + + var chunks = ReadChunks(glbData); + var jsonChunkIndex = chunks.FindIndex(chunk => chunk.Type == JsonChunkType); + if (jsonChunkIndex < 0) { + throw new InvalidOperationException("GLB does not contain a JSON chunk."); + } + + var root = ParseRoot(chunks[jsonChunkIndex].Data); + var extensions = root["extensions"] as JObject ?? new JObject(); + root["extensions"] = extensions; + var payloadJson = Encoding.UTF8.GetString(PackageApi.Packer.Pack(payload)); + extensions[ExtensionName] = JToken.Parse(payloadJson); + EnsureStringArrayContains(root, "extensionsUsed", ExtensionName); + + chunks[jsonChunkIndex] = new GlbChunk(JsonChunkType, SerializeJsonChunk(root)); + return WriteChunks(chunks); + } + + private static bool TryReadRoot(byte[] glbData, out JObject root) + { + root = null; + try { + var chunks = ReadChunks(glbData); + var jsonChunk = chunks.Find(chunk => chunk.Type == JsonChunkType); + if (jsonChunk.Data == null) { + return false; + } + + root = ParseRoot(jsonChunk.Data); + return true; + } catch (Exception) { + return false; + } + } + + private static List ReadChunks(byte[] glbData) + { + if (glbData.Length < 12) { + throw new InvalidOperationException("GLB is shorter than the 12-byte header."); + } + + var magic = BinaryPrimitives.ReadUInt32LittleEndian(glbData.AsSpan(0, 4)); + var version = BinaryPrimitives.ReadUInt32LittleEndian(glbData.AsSpan(4, 4)); + var declaredLength = BinaryPrimitives.ReadUInt32LittleEndian(glbData.AsSpan(8, 4)); + if (magic != GlbMagic) { + throw new InvalidOperationException("Data is not a GLB file."); + } + if (version != GlbVersion) { + throw new InvalidOperationException($"Unsupported GLB version {version}."); + } + if (declaredLength != glbData.Length) { + throw new InvalidOperationException($"GLB header length {declaredLength} does not match data length {glbData.Length}."); + } + + var chunks = new List(); + var offset = 12; + while (offset < glbData.Length) { + if (offset + 8 > glbData.Length) { + throw new InvalidOperationException("GLB chunk header is truncated."); + } + + var length = BinaryPrimitives.ReadInt32LittleEndian(glbData.AsSpan(offset, 4)); + var type = BinaryPrimitives.ReadUInt32LittleEndian(glbData.AsSpan(offset + 4, 4)); + offset += 8; + + if (length < 0 || offset + length > glbData.Length) { + throw new InvalidOperationException("GLB chunk length is invalid."); + } + + var data = new byte[length]; + Buffer.BlockCopy(glbData, offset, data, 0, length); + chunks.Add(new GlbChunk(type, data)); + offset += length; + } + + return chunks; + } + + private static byte[] WriteChunks(IReadOnlyList chunks) + { + var totalLength = 12; + foreach (var chunk in chunks) { + totalLength += 8 + chunk.Data.Length; + } + + var glbData = new byte[totalLength]; + BinaryPrimitives.WriteUInt32LittleEndian(glbData.AsSpan(0, 4), GlbMagic); + BinaryPrimitives.WriteUInt32LittleEndian(glbData.AsSpan(4, 4), GlbVersion); + BinaryPrimitives.WriteUInt32LittleEndian(glbData.AsSpan(8, 4), (uint)totalLength); + + var offset = 12; + foreach (var chunk in chunks) { + BinaryPrimitives.WriteInt32LittleEndian(glbData.AsSpan(offset, 4), chunk.Data.Length); + BinaryPrimitives.WriteUInt32LittleEndian(glbData.AsSpan(offset + 4, 4), chunk.Type); + offset += 8; + Buffer.BlockCopy(chunk.Data, 0, glbData, offset, chunk.Data.Length); + offset += chunk.Data.Length; + } + + return glbData; + } + + private static JObject ParseRoot(byte[] jsonChunk) + { + var json = Encoding.UTF8.GetString(jsonChunk).TrimEnd('\0', ' ', '\t', '\r', '\n'); + return JObject.Parse(json); + } + + private static byte[] SerializeJsonChunk(JObject root) + { + var json = root.ToString(Formatting.None); + var bytes = Encoding.UTF8.GetBytes(json); + var paddedLength = AlignTo4(bytes.Length); + if (paddedLength == bytes.Length) { + return bytes; + } + + var padded = new byte[paddedLength]; + Buffer.BlockCopy(bytes, 0, padded, 0, bytes.Length); + for (var i = bytes.Length; i < padded.Length; i++) { + padded[i] = 0x20; + } + return padded; + } + + private static void EnsureStringArrayContains(JObject root, string propertyName, string value) + { + var array = root[propertyName] as JArray ?? new JArray(); + root[propertyName] = array; + foreach (var token in array) { + if (string.Equals(token.Value(), value, StringComparison.Ordinal)) { + return; + } + } + array.Add(value); + } + + private static int AlignTo4(int value) + { + return (value + 3) & ~3; + } + + private readonly struct GlbChunk + { + public readonly uint Type; + public readonly byte[] Data; + + public GlbChunk(uint type, byte[] data) + { + Type = type; + Data = data ?? Array.Empty(); + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs.meta new file mode 100644 index 000000000..f7d0332ca --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6f0796fd3db8447e8dc41873b8872ed3 +timeCreated: 1777082400 From ceba2723af4c1702469ed6277958762a303926b8 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Apr 2026 22:15:56 +0200 Subject: [PATCH 08/55] packaging: Pack sidecar textures into single BLOB for faster loading. --- .../Packaging/PackageWriter.cs | 124 ++++++++-- .../Packaging/VpeMaterialV1Translator.cs | 16 ++ .../Packaging/PackageApi.cs | 9 +- .../Packaging/RuntimePackageReader.cs | 47 +++- .../Packaging/VpeMaterialV1.cs | 11 +- .../Packaging/VpeMaterialV1Reader.cs | 230 +++++++++++++++++- .../Packaging/VpeMaterialsGltfExtension.cs | 208 +++++++++++++++- 7 files changed, 611 insertions(+), 34 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs index 8c3740ffe..f88b7f9c9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageWriter.cs @@ -18,6 +18,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using GLTFast; @@ -43,8 +44,11 @@ public class PackageWriter private IPackageFolder _globalFolder; private IPackageFolder _metaFolder; private VpeMaterialsPayloadV1 _capturedMaterialPayload; + private IReadOnlyDictionary _capturedMaterialTextureBlobs; + private bool _embeddedMaterialPayloadSuccessfully; private const bool ExportActivesOnly = true; + private const bool EmbedVpeMaterialsIntoGlb = false; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -122,6 +126,7 @@ private async Task WritePackage(string path) var sceneData = await saveSceneTask; WritePackageFile(_tableFolder, PackageApi.SceneFile, sceneData); Logger.Info($"Scene written in {sw1.ElapsedMilliseconds}ms ({sceneData.Length} bytes)."); + WriteMaterialPayloadFallbackIfNeeded(); if (saveColliderMeshesTask != null) { sw1 = Stopwatch.StartNew(); @@ -197,15 +202,18 @@ private Func> PrepareScene() var export = new GameObjectExport(exportSettings, gameObjectExportSettings, logger: logger); var disabledRenderers = DisableInvalidMeshRenderers(meshFilters, skinnedMeshRenderers, _table.transform); var reenabledLights = EnableDisabledLights(); + var renderers = _table.GetComponentsInChildren(!ExportActivesOnly); + var gltfExportScope = VpeMaterialV1Translator.PrepareGltfExport(renderers); try { export.AddScene(new [] { _table }, _table.transform.worldToLocalMatrix, "VPE Table"); } finally { + gltfExportScope?.Dispose(); RestoreDisabledLights(reenabledLights); RestoreDisabledRenderers(disabledRenderers); } - return () => SaveGltfToBytes(export, embedMaterialPayload: true); + return () => SaveGltfToBytes(export, embedMaterialPayload: EmbedVpeMaterialsIntoGlb); } // Temporarily re-enables Light components that are disabled at author time so gltFast @@ -474,6 +482,8 @@ private void WriteMaterialProfiles() var capture = VpeMaterialV1Translator.Capture(_table.transform, renderers); var payload = capture.Payload; _capturedMaterialPayload = null; + _capturedMaterialTextureBlobs = null; + _embeddedMaterialPayloadSuccessfully = false; if (payload?.Profiles == null || payload.Profiles.Length == 0) { if (VpeMaterialV1Translator.Active == null) { Logger.Info("Skipping materials.v1 export: no IVpeMaterialV1Translator is registered."); @@ -481,52 +491,138 @@ private void WriteMaterialProfiles() return; } _capturedMaterialPayload = payload; + _capturedMaterialTextureBlobs = capture.TextureBlobs; var textureCount = 0; var textureBytes = 0L; if (capture.TextureBlobs != null && capture.TextureBlobs.Count > 0) { - var texturesFolder = _metaFolder.AddFolder(PackageApi.TexturesV1Folder); foreach (var entry in capture.TextureBlobs) { if (entry.Value == null || entry.Value.Length == 0) { continue; } - texturesFolder.AddFile(entry.Key).SetData(entry.Value); textureCount++; textureBytes += entry.Value.Length; } } - - _metaFolder - .AddFile(PackageApi.MaterialsV1File, PackageApi.Packer.FileExtension) - .SetData(PackageApi.Packer.Pack(payload)); - Logger.Info( - $"Wrote vpe.material v1 payload: {payload.Profiles.Length} profile(s), " + - $"{textureCount} texture(s) ({textureBytes / 1024f / 1024f:F2} MB) at " + - $"{PackageApi.MetaFolder}/{PackageApi.TexturesV1Folder}."); + $"Captured vpe.material v1 payload: {payload.Profiles.Length} profile(s), " + + $"{textureCount} VPE-only texture(s) ({textureBytes / 1024f / 1024f:F2} MB)."); } private async Task SaveGltfToBytes(GameObjectExport export, bool embedMaterialPayload = false) { using var stream = new MemoryStream(); await export.SaveToStreamAndDispose(stream); - var glbData = stream.ToArray(); + var originalGlbData = stream.ToArray(); + var glbData = originalGlbData; if (!embedMaterialPayload || _capturedMaterialPayload == null) { return glbData; } var payload = _capturedMaterialPayload; try { - glbData = VpeMaterialsGltfExtension.WritePayload(glbData, payload); + var candidateGlbData = VpeMaterialsGltfExtension.WritePayload(glbData, payload, _capturedMaterialTextureBlobs); + if (!VpeMaterialsGltfExtension.TryReadPayload(candidateGlbData, out var roundTrippedPayload) + || roundTrippedPayload?.Profiles == null + || roundTrippedPayload.Profiles.Length != (payload.Profiles?.Length ?? 0)) { + throw new InvalidOperationException( + $"Failed validating embedded {VpeMaterialsGltfExtension.ExtensionName} payload after GLB rewrite."); + } + + var expectedEmbeddedTextureCount = payload.Textures?.Count(asset => asset != null && asset.GlbBufferView >= 0) ?? 0; + if (expectedEmbeddedTextureCount > 0 + && !VpeMaterialsGltfExtension.TryReadEmbeddedTextureBlobs( + candidateGlbData, + roundTrippedPayload, + out var roundTrippedTextureBlobsById)) { + throw new InvalidOperationException( + $"Failed validating embedded {VpeMaterialsGltfExtension.ExtensionName} texture blobs after GLB rewrite."); + } + + glbData = candidateGlbData; + _embeddedMaterialPayloadSuccessfully = true; Logger.Info( $"Embedded {VpeMaterialsGltfExtension.ExtensionName} in {PackageApi.SceneFile}: " + $"{payload.Profiles?.Length ?? 0} profile(s), {payload.Textures?.Length ?? 0} texture reference(s)."); } catch (Exception ex) { - Logger.Warn(ex, $"Failed embedding {VpeMaterialsGltfExtension.ExtensionName} in {PackageApi.SceneFile}. Writing plain GLB and keeping sidecar material payload."); + _embeddedMaterialPayloadSuccessfully = false; + glbData = originalGlbData; + Logger.Warn( + $"Failed embedding {VpeMaterialsGltfExtension.ExtensionName} in {PackageApi.SceneFile} " + + $"({ex.Message}). Writing plain GLB and keeping sidecar material payload."); } return glbData; } + private void WriteMaterialPayloadFallbackIfNeeded() + { + if (_capturedMaterialPayload == null || _embeddedMaterialPayloadSuccessfully) { + return; + } + + var packedTextureData = BuildPackedMaterialTextureData( + _capturedMaterialPayload, + _capturedMaterialTextureBlobs, + out var textureCount, + out var textureBytes); + if (packedTextureData != null && packedTextureData.Length > 0) { + _metaFolder.AddFile(PackageApi.TexturesV1PackFile).SetData(packedTextureData); + } + + _metaFolder + .AddFile(PackageApi.MaterialsV1File, PackageApi.Packer.FileExtension) + .SetData(PackageApi.Packer.Pack(_capturedMaterialPayload)); + + Logger.Warn( + $"Fell back to sidecar vpe.material export: profiles={_capturedMaterialPayload.Profiles?.Length ?? 0}, " + + $"textures={textureCount} ({textureBytes / 1024f / 1024f:F2} MB) at " + + $"{PackageApi.MetaFolder}/{PackageApi.TexturesV1PackFile}."); + } + + private static byte[] BuildPackedMaterialTextureData( + VpeMaterialsPayloadV1 payload, + IReadOnlyDictionary textureBlobsByFileName, + out int textureCount, + out long textureBytes) + { + textureCount = 0; + textureBytes = 0L; + if (payload?.Textures == null || payload.Textures.Length == 0) { + return null; + } + + foreach (var asset in payload.Textures) { + if (asset == null) { + continue; + } + asset.ByteOffset = -1; + asset.ByteLength = 0; + } + + if (textureBlobsByFileName == null || textureBlobsByFileName.Count == 0) { + return null; + } + + using var stream = new MemoryStream(); + foreach (var asset in payload.Textures) { + if (asset == null + || string.IsNullOrWhiteSpace(asset.FileName) + || !textureBlobsByFileName.TryGetValue(asset.FileName, out var blobData) + || blobData == null + || blobData.Length == 0) { + continue; + } + + asset.ByteOffset = checked((int)stream.Position); + asset.ByteLength = blobData.Length; + stream.Write(blobData, 0, blobData.Length); + textureCount++; + textureBytes += blobData.LongLength; + } + + return stream.Length > 0 ? stream.ToArray() : null; + } + private static void WritePackageFile(IPackageFolder folder, string fileName, byte[] data) { if (data == null || data.Length == 0) { diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs index 28b639379..d205512d9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeMaterialV1Translator.cs @@ -14,6 +14,7 @@ // 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 UnityEngine; @@ -26,6 +27,14 @@ public interface IVpeMaterialV1Translator VpeMaterialCaptureResult Capture(Transform tableRoot, IEnumerable renderers); } + // Optional editor-side hook that lets a pipeline package strip VPE-managed textures from the + // temporary glTF export materials. This removes duplicate bytes from table.glb while keeping the + // authored scene materials untouched. + public interface IVpeMaterialGltfExportPreprocessor + { + IDisposable PrepareGltfExport(IEnumerable renderers); + } + public readonly struct VpeMaterialCaptureResult { public VpeMaterialCaptureResult(VpeMaterialsPayloadV1 payload, IReadOnlyDictionary textureBlobs) @@ -57,5 +66,12 @@ public static VpeMaterialCaptureResult Capture(Transform tableRoot, IEnumerable< ? default : _active.Capture(tableRoot, renderers); } + + public static IDisposable PrepareGltfExport(IEnumerable renderers) + { + return _active is IVpeMaterialGltfExportPreprocessor preprocessor + ? preprocessor.PrepareGltfExport(renderers) + : null; + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index 046e5e242..260cddaf9 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -43,10 +43,11 @@ public static class PackageApi public const string SoundFolder = "sounds"; // vpe.material v1 — portable, SRP-agnostic material interchange. // See VpeMaterialV1.cs for the schema and the separation of concerns between the exporter - // (translates authoring shaders into intent) and the Player-side IVpeMaterialResolver - // (renders intent with shaders it owns at its own build time). - public const string MaterialsV1File = "materials.v1"; - public const string TexturesV1Folder = "textures"; + // (translates authoring shaders into intent) and the Player-side IVpeMaterialResolver + // (renders intent with shaders it owns at its own build time). + public const string MaterialsV1File = "materials.v1"; + public const string TexturesV1Folder = "textures"; + public const string TexturesV1PackFile = "textures.bin"; public static readonly IStorageManager StorageManager = new SharpZipStorageManager(); // public static IStorageManager StorageManager => new OpenMcdfStorageManager(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index ce43d9552..be4db0ff0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -37,6 +38,7 @@ public class RuntimePackageReader private PackagedFiles _files; private Dictionary _sparsePathIndexMap; private VpeMaterialsPayloadV1 _embeddedMaterialPayload; + private IReadOnlyDictionary _embeddedMaterialTextureBlobs; private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); @@ -54,6 +56,7 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat throw new FileNotFoundException($"Cannot find .vpe package at {_vpePath}"); } + var importStopwatch = Stopwatch.StartNew(); using var storage = PackageApi.StorageManager.OpenStorage(_vpePath); _tableFolder = storage.GetFolder(PackageApi.TableFolder); _sparsePathIndexMap = BuildSparsePathIndexMap(PackageApi.ItemFolder, PackageApi.ItemReferencesFolder); @@ -63,7 +66,12 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat try { try { + var importModelsStopwatch = Stopwatch.StartNew(); _table = await ImportModels(parent, cancellationToken); + importModelsStopwatch.Stop(); + Logger.Info( + $"RuntimePackageReader: Imported {PackageApi.SceneFile} in {importModelsStopwatch.ElapsedMilliseconds}ms " + + $"from '{Path.GetFileName(_vpePath)}'."); cancellationToken.ThrowIfCancellationRequested(); var restoreActive = _table.activeSelf; var loadSucceeded = false; @@ -73,10 +81,22 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat _refs = new PackagedRefs(_table.transform); _files = new PackagedFiles(_tableFolder, _refs); + var unpackSoundsStopwatch = Stopwatch.StartNew(); await _files.UnpackSoundsRuntime(cancellationToken); + unpackSoundsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Unpacked sounds in {unpackSoundsStopwatch.ElapsedMilliseconds}ms."); + + var unpackAssetsStopwatch = Stopwatch.StartNew(); _files.UnpackAssetsRuntime(); + unpackAssetsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Unpacked assets in {unpackAssetsStopwatch.ElapsedMilliseconds}ms."); + + var unpackMeshesStopwatch = Stopwatch.StartNew(); await _files.UnpackMeshesRuntime(cancellationToken); + unpackMeshesStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Unpacked meshes in {unpackMeshesStopwatch.ElapsedMilliseconds}ms."); + var readItemsStopwatch = Stopwatch.StartNew(); ReadPackables(PackageApi.ItemFolder, ApplyItemData, (item, type, file, index) => { var comps = item.gameObject.GetComponents(type); var comp = comps.Length > index @@ -88,7 +108,10 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat PackageApi.Packer.Unpack(file.GetData(), comp); } }); + readItemsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Restored packables in {readItemsStopwatch.ElapsedMilliseconds}ms."); + var readRefsStopwatch = Stopwatch.StartNew(); ReadPackables(PackageApi.ItemReferencesFolder, null, (item, type, file, index) => { var comps = item.gameObject.GetComponents(type); var comp = comps.Length > index @@ -115,10 +138,23 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat Logger.Warn(ex, $"Failed unpacking references for type {type.FullName} on {item.name} (index {index})."); } }); + readRefsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Restored references in {readRefsStopwatch.ElapsedMilliseconds}ms."); + var globalsStopwatch = Stopwatch.StartNew(); ReadGlobals(); + globalsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Read globals in {globalsStopwatch.ElapsedMilliseconds}ms."); + + var tableMetadataStopwatch = Stopwatch.StartNew(); ReadTableMetadata(); + tableMetadataStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Read table metadata in {tableMetadataStopwatch.ElapsedMilliseconds}ms."); + + var materialsStopwatch = Stopwatch.StartNew(); RestoreMaterialProfiles(); + materialsStopwatch.Stop(); + Logger.Info($"RuntimePackageReader: Restored material profiles in {materialsStopwatch.ElapsedMilliseconds}ms."); loadSucceeded = true; } finally { @@ -127,9 +163,13 @@ public async Task ImportIntoScene(Transform parent = null, Cancellat } } + importStopwatch.Stop(); + Logger.Info( + $"RuntimePackageReader: Imported '{Path.GetFileName(_vpePath)}' in {importStopwatch.ElapsedMilliseconds}ms total."); return _table; } catch { + importStopwatch.Stop(); DestroyLoadedTable(); throw; } @@ -148,8 +188,13 @@ private async Task ImportModels(Transform parent, CancellationToken } _embeddedMaterialPayload = null; + _embeddedMaterialTextureBlobs = null; if (VpeMaterialsGltfExtension.TryReadPayload(sceneData, out var embeddedMaterialPayload)) { _embeddedMaterialPayload = embeddedMaterialPayload; + VpeMaterialsGltfExtension.TryReadEmbeddedTextureBlobs( + sceneData, + embeddedMaterialPayload, + out _embeddedMaterialTextureBlobs); } var importRoot = new GameObject("__vpe_runtime_import"); @@ -436,7 +481,7 @@ private void RestoreMaterialProfiles() _tableFolder.TryGetFolder(PackageApi.MetaFolder, out var metaFolder); if (_embeddedMaterialPayload != null && - VpeMaterialV1Reader.TryApply(_embeddedMaterialPayload, metaFolder, _table.transform, + VpeMaterialV1Reader.TryApply(_embeddedMaterialPayload, _embeddedMaterialTextureBlobs, metaFolder, _table.transform, $"{PackageApi.SceneFile}/{VpeMaterialsGltfExtension.ExtensionName}")) { return; } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs index 8b2192060..21edfe920 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs @@ -261,8 +261,17 @@ public class VpeTextureAssetV1 { // Stable id referenced by VpeTextureRefV1.TextureId. public string Id; - // File under table/meta/textures/ inside the package. + // Legacy loose file under table/meta/textures/ inside the package. public string FileName; + // Byte range inside table/meta/textures.bin for non-GLB packages. When set, runtime should + // prefer this over FileName to avoid per-texture package lookups. + public int ByteOffset = -1; + public int ByteLength; + // Optional GLB bufferView index for packages that embed VPE-only texture bytes directly in + // table.glb. When set, runtime should prefer this over FileName. + public int GlbBufferView = -1; + // MIME type for the embedded bytes. Current writer emits PNG side-channel textures. + public string MimeType = "image/png"; // "sRGB" or "Linear". See VpeColorSpaces. public string ColorSpace = VpeColorSpaces.SRgb; public int WrapMode; // UnityEngine.TextureWrapMode diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index 5c31df4bb..a79cdc6d6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -16,6 +16,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Text; using NLog; using UnityEngine; using UnityEngine.Rendering; @@ -48,10 +50,15 @@ public static bool TryApply(IPackageFolder metaFolder, Transform tableRoot) return false; } - return TryApply(payload, metaFolder, tableRoot, PackageApi.MaterialsV1File); + return TryApply(payload, embeddedTextureBlobsById: null, metaFolder, tableRoot, PackageApi.MaterialsV1File); } - public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFolder, Transform tableRoot, string sourceLabel) + public static bool TryApply( + VpeMaterialsPayloadV1 payload, + IReadOnlyDictionary embeddedTextureBlobsById, + IPackageFolder metaFolder, + Transform tableRoot, + string sourceLabel) { if (payload == null || !tableRoot || payload.Profiles == null || payload.Profiles.Length == 0) { return false; @@ -73,11 +80,18 @@ public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFo } var profilesByName = BuildProfileLookup(payload.Profiles); + var resolvedMaterialsByImportedId = new Dictionary(); + var resolvedMaterialsBySignature = new Dictionary(StringComparer.Ordinal); IPackageFolder texturesFolder = null; metaFolder?.TryGetFolder(PackageApi.TexturesV1Folder, out texturesFolder); - using var textures = new TextureProvider(payload.Textures, texturesFolder); + byte[] packedTextureData = null; + if (metaFolder != null && metaFolder.TryGetFile(PackageApi.TexturesV1PackFile, out var packedTexturesFile)) { + packedTextureData = packedTexturesFile.GetData(); + } + using var textures = new TextureProvider(payload.Textures, texturesFolder, packedTextureData, embeddedTextureBlobsById); var stats = new Stats(); + var materialTraversalStopwatch = Stopwatch.StartNew(); foreach (var renderer in tableRoot.GetComponentsInChildren(true)) { if (!renderer) { continue; @@ -104,24 +118,56 @@ public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFo continue; } + var importedMaterialId = imported.GetInstanceID(); + if (resolvedMaterialsByImportedId.TryGetValue(importedMaterialId, out var cachedReplacement)) { + materials[i] = cachedReplacement; + modified = true; + stats.AppliedSlots++; + stats.ReusedResolvedMaterials++; + continue; + } + + var semanticCacheKey = BuildResolvedMaterialCacheKey(profile, imported); + if (semanticCacheKey != null + && resolvedMaterialsBySignature.TryGetValue(semanticCacheKey, out var signatureCachedReplacement)) { + resolvedMaterialsByImportedId[importedMaterialId] = signatureCachedReplacement; + materials[i] = signatureCachedReplacement; + modified = true; + stats.AppliedSlots++; + stats.ReusedResolvedMaterials++; + stats.ReusedResolvedMaterialSignatures++; + continue; + } + + var resolverStopwatch = Stopwatch.StartNew(); var replacement = resolver.CreateMaterial(profile, textures, imported); + resolverStopwatch.Stop(); + stats.ResolverCreateMaterialMilliseconds += resolverStopwatch.ElapsedMilliseconds; if (!replacement) { stats.ResolverReturnedNull++; continue; } + resolvedMaterialsByImportedId[importedMaterialId] = replacement; + if (semanticCacheKey != null) { + resolvedMaterialsBySignature[semanticCacheKey] = replacement; + } materials[i] = replacement; modified = true; stats.AppliedSlots++; + stats.CreatedResolvedMaterials++; } if (modified) { renderer.sharedMaterials = materials; } } + materialTraversalStopwatch.Stop(); + stats.MaterialTraversalMilliseconds = materialTraversalStopwatch.ElapsedMilliseconds; // Apply per-renderer state (shadowCastingMode, receiveShadows, renderingLayerMask) that // glTF doesn't carry. Paths are resolved through FindByPath so the existing // SparsePathIndexMap handles any sibling-index shift introduced by the glTF round-trip. + var rendererStateStopwatch = Stopwatch.StartNew(); if (payload.RendererStates != null) { foreach (var state in payload.RendererStates) { if (state == null || string.IsNullOrEmpty(state.Path)) { @@ -140,6 +186,8 @@ public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFo stats.RendererStatesApplied++; } } + rendererStateStopwatch.Stop(); + stats.RendererStateMilliseconds = rendererStateStopwatch.ElapsedMilliseconds; if (stats.UnmatchedNames.Count > 0) { var sample = string.Join(", ", TakeFirst(stats.UnmatchedNames, 12)); @@ -153,8 +201,16 @@ public static bool TryApply(VpeMaterialsPayloadV1 payload, IPackageFolder metaFo $"textures={payload.Textures?.Length ?? 0}, " + $"slots={stats.TotalSlots}, matched={stats.MatchedSlots}, applied={stats.AppliedSlots}, " + $"rendererStates={stats.RendererStatesApplied}/{payload.RendererStates?.Length ?? 0} (missing at import={stats.RendererStatesMissing}), " + + $"materialTraversalMs={stats.MaterialTraversalMilliseconds}, resolverCreateMaterialMs={stats.ResolverCreateMaterialMilliseconds}, " + + $"rendererStateMs={stats.RendererStateMilliseconds}, " + $"resolverNull={stats.ResolverReturnedNull}, unsupportedTypes={stats.UnsupportedTypes.Count}, " + - $"unmatched={stats.UnmatchedNames.Count}."); + $"unmatched={stats.UnmatchedNames.Count}, " + + $"resolvedMaterialsCreated={stats.CreatedResolvedMaterials}, resolvedMaterialsReused={stats.ReusedResolvedMaterials}, " + + $"resolvedMaterialsSignatureReused={stats.ReusedResolvedMaterialSignatures}, " + + $"textureCacheHits={textures.CacheHits}, textureLoads={textures.LoadCount}, " + + $"textureLoadMs={textures.LoadMilliseconds}, textureBytes={textures.LoadedBytes}, " + + $"embeddedTextureLoads={textures.EmbeddedLoadCount}, packedTextureLoads={textures.PackedLoadCount}, " + + $"looseTextureLoads={textures.LooseFileLoadCount}."); return true; } @@ -183,6 +239,69 @@ private static Dictionary BuildProfileLookup(VpeMa return lookup; } + private static string BuildResolvedMaterialCacheKey(VpeMaterialProfileV1 profile, Material imported) + { + if (profile == null || !imported) { + return null; + } + + var texturePropertyNames = imported.GetTexturePropertyNames(); + Array.Sort(texturePropertyNames, StringComparer.Ordinal); + + var builder = new StringBuilder(256); + builder.Append(profile.Type ?? string.Empty) + .Append('|') + .Append(imported.shader ? imported.shader.name : string.Empty); + AppendProfileSemanticKey(builder, profile); + + foreach (var propertyName in texturePropertyNames) { + if (string.IsNullOrWhiteSpace(propertyName)) { + continue; + } + + var texture = imported.GetTexture(propertyName); + builder.Append('|') + .Append(propertyName) + .Append('=') + .Append(texture ? texture.GetInstanceID() : 0); + } + + return builder.ToString(); + } + + private static void AppendProfileSemanticKey(StringBuilder builder, VpeMaterialProfileV1 profile) + { + if (builder == null || profile == null) { + return; + } + + byte[] payload = null; + switch (profile.Type) { + case VpeMaterialTypes.Lit: + if (profile.Lit != null) { + payload = PackageApi.Packer.Pack(profile.Lit); + } + break; + case VpeMaterialTypes.Decal: + if (profile.Decal != null) { + payload = PackageApi.Packer.Pack(profile.Decal); + } + break; + case VpeMaterialTypes.Unlit: + if (profile.Unlit != null) { + payload = PackageApi.Packer.Pack(profile.Unlit); + } + break; + } + + if (payload == null || payload.Length == 0) { + return; + } + + builder.Append("|profile=") + .Append(Encoding.UTF8.GetString(payload)); + } + private static void ApplyRendererState(Renderer renderer, VpeRendererStateV1 state) { renderer.shadowCastingMode = (ShadowCastingMode)state.ShadowCastingMode; @@ -200,6 +319,12 @@ private sealed class Stats public int ResolverReturnedNull; public int RendererStatesApplied; public int RendererStatesMissing; + public int CreatedResolvedMaterials; + public int ReusedResolvedMaterials; + public int ReusedResolvedMaterialSignatures; + public long MaterialTraversalMilliseconds; + public long ResolverCreateMaterialMilliseconds; + public long RendererStateMilliseconds; public readonly HashSet UnmatchedNames = new(StringComparer.Ordinal); public readonly HashSet UnsupportedTypes = new(StringComparer.Ordinal); } @@ -207,13 +332,28 @@ private sealed class Stats private sealed class TextureProvider : IVpeTextureProvider, IDisposable { private readonly Dictionary _assetsById; + private readonly IReadOnlyDictionary _embeddedTextureBlobsById; + private readonly byte[] _packedTextureData; private readonly IPackageFolder _folder; private readonly Dictionary _loaded = new(StringComparer.Ordinal); private readonly HashSet _missingTextureIdsLogged = new(StringComparer.Ordinal); + private long _loadedBytes; + private long _loadMilliseconds; + private int _loadCount; + private int _cacheHits; + private int _embeddedLoadCount; + private int _packedLoadCount; + private int _looseFileLoadCount; - public TextureProvider(VpeTextureAssetV1[] assets, IPackageFolder folder) + public TextureProvider( + VpeTextureAssetV1[] assets, + IPackageFolder folder, + byte[] packedTextureData, + IReadOnlyDictionary embeddedTextureBlobsById) { _folder = folder; + _packedTextureData = packedTextureData; + _embeddedTextureBlobsById = embeddedTextureBlobsById; _assetsById = new Dictionary(StringComparer.Ordinal); if (assets == null) { return; @@ -232,43 +372,87 @@ public Texture2D Get(string textureId) return null; } if (_loaded.TryGetValue(textureId, out var cached)) { + _cacheHits++; return cached; } - if (!_assetsById.TryGetValue(textureId, out var asset) || _folder == null) { - LogMissingTexture(textureId, reason: "id-not-in-payload-or-texture-folder-missing"); + if (!_assetsById.TryGetValue(textureId, out var asset)) { + LogMissingTexture(textureId, reason: "id-not-in-payload"); _loaded[textureId] = null; return null; } - if (!_folder.TryGetFile(asset.FileName, out var file)) { - LogMissingTexture(textureId, reason: $"file-not-found:{asset.FileName}"); - _loaded[textureId] = null; - return null; + + byte[] bytes = null; + var loadedFromEmbedded = false; + var loadedFromPacked = false; + if (_embeddedTextureBlobsById != null) { + _embeddedTextureBlobsById.TryGetValue(textureId, out bytes); + loadedFromEmbedded = bytes != null && bytes.Length > 0; + } + if ((bytes == null || bytes.Length == 0) && TryReadPackedTextureBytes(asset, out var packedBytes)) { + bytes = packedBytes; + loadedFromPacked = true; + } + if ((bytes == null || bytes.Length == 0) + && _folder != null + && !string.IsNullOrWhiteSpace(asset.FileName) + && _folder.TryGetFile(asset.FileName, out var file)) { + bytes = file.GetData(); + loadedFromEmbedded = false; + loadedFromPacked = false; } - var bytes = file.GetData(); if (bytes == null || bytes.Length == 0) { - LogMissingTexture(textureId, reason: $"file-empty:{asset.FileName}"); + string reason; + if (asset.GlbBufferView >= 0) { + reason = $"glb-bufferView-missing:{asset.GlbBufferView}"; + } else if (asset.ByteOffset >= 0 && asset.ByteLength > 0) { + reason = $"packed-texture-range-missing:{asset.ByteOffset}+{asset.ByteLength}"; + } else { + reason = $"file-not-found-or-empty:{asset.FileName}"; + } + LogMissingTexture(textureId, reason: reason); _loaded[textureId] = null; return null; } var linear = string.Equals(asset.ColorSpace, VpeColorSpaces.Linear, StringComparison.OrdinalIgnoreCase); + var loadStopwatch = Stopwatch.StartNew(); var texture = new Texture2D(2, 2, TextureFormat.RGBA32, asset.GenerateMipMaps, linear) { name = string.IsNullOrWhiteSpace(asset.SourceName) ? asset.Id : asset.SourceName, }; if (!ImageConversion.LoadImage(texture, bytes, markNonReadable: true)) { + loadStopwatch.Stop(); UnityEngine.Object.Destroy(texture); LogMissingTexture(textureId, reason: $"load-image-failed:{asset.FileName}"); _loaded[textureId] = null; return null; } + loadStopwatch.Stop(); texture.wrapMode = (TextureWrapMode)asset.WrapMode; texture.filterMode = (FilterMode)asset.FilterMode; texture.anisoLevel = Mathf.Max(1, asset.AnisoLevel); + _loadCount++; + _loadMilliseconds += loadStopwatch.ElapsedMilliseconds; + _loadedBytes += bytes.LongLength; + if (loadedFromEmbedded) { + _embeddedLoadCount++; + } else if (loadedFromPacked) { + _packedLoadCount++; + } else { + _looseFileLoadCount++; + } _loaded[textureId] = texture; return texture; } + public int LoadCount => _loadCount; + public int CacheHits => _cacheHits; + public long LoadMilliseconds => _loadMilliseconds; + public long LoadedBytes => _loadedBytes; + public int EmbeddedLoadCount => _embeddedLoadCount; + public int PackedLoadCount => _packedLoadCount; + public int LooseFileLoadCount => _looseFileLoadCount; + public void Dispose() { // Textures are handed to materials on the instantiated table. The table owns them from @@ -283,6 +467,26 @@ private void LogMissingTexture(string textureId, string reason) } Logger.Warn($"vpe.material v1 texture lookup failed for TextureId='{textureId}' ({reason})."); } + + private bool TryReadPackedTextureBytes(VpeTextureAssetV1 asset, out byte[] bytes) + { + bytes = null; + if (asset == null + || _packedTextureData == null + || asset.ByteOffset < 0 + || asset.ByteLength <= 0) { + return false; + } + + var endOffset = asset.ByteOffset + asset.ByteLength; + if (endOffset > _packedTextureData.Length || endOffset < asset.ByteOffset) { + return false; + } + + bytes = new byte[asset.ByteLength]; + Buffer.BlockCopy(_packedTextureData, asset.ByteOffset, bytes, 0, asset.ByteLength); + return true; + } } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs index 927b3b0d3..6e52701a4 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialsGltfExtension.cs @@ -33,6 +33,7 @@ public static class VpeMaterialsGltfExtension private const uint GlbMagic = 0x46546C67; private const uint GlbVersion = 2; private const uint JsonChunkType = 0x4E4F534A; + private const uint BinChunkType = 0x004E4942; public static bool TryReadPayload(byte[] glbData, out VpeMaterialsPayloadV1 payload) { @@ -55,7 +56,60 @@ public static bool TryReadPayload(byte[] glbData, out VpeMaterialsPayloadV1 payl } } - public static byte[] WritePayload(byte[] glbData, VpeMaterialsPayloadV1 payload) + public static bool TryReadEmbeddedTextureBlobs( + byte[] glbData, + VpeMaterialsPayloadV1 payload, + out IReadOnlyDictionary textureBlobsById) + { + textureBlobsById = null; + if (payload?.Textures == null || payload.Textures.Length == 0) { + return false; + } + + try { + var chunks = ReadChunks(glbData); + var binChunk = chunks.Find(chunk => chunk.Type == BinChunkType); + if (binChunk.Data == null || binChunk.Data.Length == 0) { + return false; + } + + var root = ParseRoot(GetChunkData(chunks, JsonChunkType)); + if (root["bufferViews"] is not JArray bufferViews) { + return false; + } + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var asset in payload.Textures) { + if (asset == null + || string.IsNullOrWhiteSpace(asset.Id) + || asset.GlbBufferView < 0 + || asset.GlbBufferView >= bufferViews.Count + || bufferViews[asset.GlbBufferView] is not JObject bufferView) { + continue; + } + + if (!TryReadBufferViewData(bufferView, binChunk.Data, out var blobData)) { + continue; + } + + map[asset.Id] = blobData; + } + + if (map.Count == 0) { + return false; + } + + textureBlobsById = map; + return true; + } catch (Exception) { + return false; + } + } + + public static byte[] WritePayload( + byte[] glbData, + VpeMaterialsPayloadV1 payload, + IReadOnlyDictionary textureBlobsByFileName = null) { if (glbData == null || glbData.Length == 0) { throw new ArgumentException("GLB data is missing.", nameof(glbData)); @@ -71,6 +125,10 @@ public static byte[] WritePayload(byte[] glbData, VpeMaterialsPayloadV1 payload) } var root = ParseRoot(chunks[jsonChunkIndex].Data); + var binChunkIndex = chunks.FindIndex(chunk => chunk.Type == BinChunkType); + var binData = binChunkIndex >= 0 ? chunks[binChunkIndex].Data : Array.Empty(); + var embeddedTextureCount = EmbedTextureBlobs(root, payload, textureBlobsByFileName, ref binData); + var extensions = root["extensions"] as JObject ?? new JObject(); root["extensions"] = extensions; var payloadJson = Encoding.UTF8.GetString(PackageApi.Packer.Pack(payload)); @@ -78,6 +136,14 @@ public static byte[] WritePayload(byte[] glbData, VpeMaterialsPayloadV1 payload) EnsureStringArrayContains(root, "extensionsUsed", ExtensionName); chunks[jsonChunkIndex] = new GlbChunk(JsonChunkType, SerializeJsonChunk(root)); + if (embeddedTextureCount > 0 || binChunkIndex >= 0) { + var normalizedBinData = AlignBinaryChunk(binData); + if (binChunkIndex >= 0) { + chunks[binChunkIndex] = new GlbChunk(BinChunkType, normalizedBinData); + } else { + chunks.Add(new GlbChunk(BinChunkType, normalizedBinData)); + } + } return WriteChunks(chunks); } @@ -98,6 +164,146 @@ private static bool TryReadRoot(byte[] glbData, out JObject root) } } + private static int EmbedTextureBlobs( + JObject root, + VpeMaterialsPayloadV1 payload, + IReadOnlyDictionary textureBlobsByFileName, + ref byte[] binData) + { + if (payload?.Textures == null + || payload.Textures.Length == 0 + || textureBlobsByFileName == null + || textureBlobsByFileName.Count == 0) { + return 0; + } + + var bufferViews = root["bufferViews"] as JArray ?? new JArray(); + root["bufferViews"] = bufferViews; + EnsureRootBuffer(root); + var bufferByteLength = GetRootBufferLength(root, binData.Length); + + var embeddedCount = 0; + foreach (var asset in payload.Textures) { + if (asset == null + || string.IsNullOrWhiteSpace(asset.FileName) + || !textureBlobsByFileName.TryGetValue(asset.FileName, out var blobData) + || blobData == null + || blobData.Length == 0) { + continue; + } + + var byteOffset = AlignTo4(bufferByteLength); + binData = AppendBinaryData(binData, byteOffset, blobData); + bufferByteLength = byteOffset + blobData.Length; + var bufferViewIndex = bufferViews.Count; + bufferViews.Add(new JObject { + ["buffer"] = 0, + ["byteOffset"] = byteOffset, + ["byteLength"] = blobData.Length, + ["name"] = $"VPE:{asset.Id ?? asset.FileName}" + }); + + asset.GlbBufferView = bufferViewIndex; + asset.MimeType = string.IsNullOrWhiteSpace(asset.MimeType) ? "image/png" : asset.MimeType; + embeddedCount++; + } + + UpdateRootBufferLength(root, bufferByteLength); + return embeddedCount; + } + + private static byte[] AppendBinaryData(byte[] existingData, int byteOffset, byte[] blobData) + { + var newLength = byteOffset + blobData.Length; + var combined = new byte[newLength]; + if (existingData.Length > 0) { + Buffer.BlockCopy(existingData, 0, combined, 0, existingData.Length); + } + Buffer.BlockCopy(blobData, 0, combined, byteOffset, blobData.Length); + return combined; + } + + private static byte[] AlignBinaryChunk(byte[] binData) + { + if (binData == null || binData.Length == 0) { + return Array.Empty(); + } + + var paddedLength = AlignTo4(binData.Length); + if (paddedLength == binData.Length) { + return binData; + } + + var padded = new byte[paddedLength]; + Buffer.BlockCopy(binData, 0, padded, 0, binData.Length); + return padded; + } + + private static void EnsureRootBuffer(JObject root) + { + if (root["buffers"] is JArray buffers && buffers.Count > 0) { + return; + } + + root["buffers"] = new JArray { + new JObject { + ["byteLength"] = 0 + } + }; + } + + private static void UpdateRootBufferLength(JObject root, int byteLength) + { + EnsureRootBuffer(root); + var buffers = (JArray)root["buffers"]; + if (buffers[0] is not JObject buffer) { + buffer = new JObject(); + buffers[0] = buffer; + } + buffer["byteLength"] = byteLength; + } + + private static int GetRootBufferLength(JObject root, int fallback) + { + if (root["buffers"] is JArray buffers + && buffers.Count > 0 + && buffers[0] is JObject buffer + && buffer.Value("byteLength") is int byteLength + && byteLength >= 0) { + return byteLength; + } + + return fallback; + } + + private static byte[] GetChunkData(List chunks, uint chunkType) + { + var chunk = chunks.Find(candidate => candidate.Type == chunkType); + if (chunk.Data == null) { + throw new InvalidOperationException($"GLB does not contain chunk type 0x{chunkType:X8}."); + } + return chunk.Data; + } + + private static bool TryReadBufferViewData(JObject bufferView, byte[] binData, out byte[] data) + { + data = null; + var bufferIndex = bufferView.Value("buffer") ?? 0; + if (bufferIndex != 0) { + return false; + } + + var byteOffset = bufferView.Value("byteOffset") ?? 0; + var byteLength = bufferView.Value("byteLength") ?? 0; + if (byteOffset < 0 || byteLength <= 0 || byteOffset + byteLength > binData.Length) { + return false; + } + + data = new byte[byteLength]; + Buffer.BlockCopy(binData, byteOffset, data, 0, byteLength); + return true; + } + private static List ReadChunks(byte[] glbData) { if (glbData.Length < 12) { From 62a59bed18ea87afd89519c507eba434c2af5816 Mon Sep 17 00:00:00 2001 From: freezy Date: Sat, 25 Apr 2026 23:09:46 +0200 Subject: [PATCH 09/55] packaging: Add some diagnostics. --- .../VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs | 6 ++++++ .../VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs index 10828f9cc..9047cf933 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IVpeMaterialResolver.cs @@ -48,6 +48,12 @@ public interface IVpeTextureProvider Texture2D Get(string textureId); } + public interface IVpeMaterialResolverDiagnostics + { + void ResetDiagnostics(); + string GetDiagnosticsSummary(); + } + public static class VpeMaterialResolver { private static IVpeMaterialResolver _active; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index a79cdc6d6..9cddd88b0 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -78,6 +78,8 @@ public static bool TryApply( "will not match authoring)."); return false; } + var resolverDiagnostics = resolver as IVpeMaterialResolverDiagnostics; + resolverDiagnostics?.ResetDiagnostics(); var profilesByName = BuildProfileLookup(payload.Profiles); var resolvedMaterialsByImportedId = new Dictionary(); @@ -210,7 +212,8 @@ public static bool TryApply( $"textureCacheHits={textures.CacheHits}, textureLoads={textures.LoadCount}, " + $"textureLoadMs={textures.LoadMilliseconds}, textureBytes={textures.LoadedBytes}, " + $"embeddedTextureLoads={textures.EmbeddedLoadCount}, packedTextureLoads={textures.PackedLoadCount}, " + - $"looseTextureLoads={textures.LooseFileLoadCount}."); + $"looseTextureLoads={textures.LooseFileLoadCount}, " + + $"resolverStats=[{resolverDiagnostics?.GetDiagnosticsSummary() ?? "n/a"}]."); return true; } From 736237269d973721e204e00a7a39f3f3a923bf64 Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Apr 2026 00:22:50 +0200 Subject: [PATCH 10/55] packaging: Disables MIPS for linear textures. --- .../VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index 9cddd88b0..7a5cdc694 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -347,7 +347,6 @@ private sealed class TextureProvider : IVpeTextureProvider, IDisposable private int _embeddedLoadCount; private int _packedLoadCount; private int _looseFileLoadCount; - public TextureProvider( VpeTextureAssetV1[] assets, IPackageFolder folder, @@ -419,8 +418,9 @@ public Texture2D Get(string textureId) } var linear = string.Equals(asset.ColorSpace, VpeColorSpaces.Linear, StringComparison.OrdinalIgnoreCase); + var generateMipMaps = asset.GenerateMipMaps && !linear; var loadStopwatch = Stopwatch.StartNew(); - var texture = new Texture2D(2, 2, TextureFormat.RGBA32, asset.GenerateMipMaps, linear) { + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, generateMipMaps, linear) { name = string.IsNullOrWhiteSpace(asset.SourceName) ? asset.Id : asset.SourceName, }; if (!ImageConversion.LoadImage(texture, bytes, markNonReadable: true)) { From 8aeea8a3fa6c542c5491ad16a902cd76ba3bd61c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Apr 2026 22:26:28 +0200 Subject: [PATCH 11/55] packaging: More cleanup. --- .../Packaging/PackageApi.cs | 1 - .../VisualPinball.Unity/Packaging/README.md | 811 +++++++++++++++--- .../Packaging/README.md.meta | 8 +- .../Packaging/VpeMaterialV1.cs | 9 +- .../Packaging/VpeMaterialV1Reader.cs | 23 +- 5 files changed, 691 insertions(+), 161 deletions(-) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs index 260cddaf9..4e6dbe9fc 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs @@ -46,7 +46,6 @@ public static class PackageApi // (translates authoring shaders into intent) and the Player-side IVpeMaterialResolver // (renders intent with shaders it owns at its own build time). public const string MaterialsV1File = "materials.v1"; - public const string TexturesV1Folder = "textures"; public const string TexturesV1PackFile = "textures.bin"; public static readonly IStorageManager StorageManager = new SharpZipStorageManager(); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md b/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md index 32b3dac9c..4672bec93 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md @@ -1,133 +1,678 @@ -# Packaging - -By packaging we mean serializing a table file. Table files in VPE come with the `.vpe` file extension and are based on three common technologies: - -- The container format is a ZIP file. -- The mesh and texture data is stored as a [glTF binary](https://www.khronos.org/gltf/). -- The non-binary metadata is stored in JSON files. - -> [!NOTE] -> Originally, there were thoughts about using the same container format as VPX (the [Compound Binary File](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-cfb/53989ce4-7b05-4f8d-829b-d08d6148375b)), but ultimately, given the inner structure would be quite different anyway, there was no real benefit. -> -> We've also tested [a more efficient packing structure](https://github.com/Cysharp/MemoryPack) than JSON, but since the metadata to which it would apply is minuscule compared to the rest, the performance advantage was quickly outweighed by its unreadability and the hassle to set up. - -## File Structure - -If you extract a `.vpe` file, you'll see the following structure: - -```plain -📁 table - ├─ 📁 assets - │ └─ 📁 PhysicsMaterial - │ ├─ 📄 WallMaterial.json - │ └─ 📄 WallMaterial.meta.json - ├─ 📁 global - │ ├─ 📄 coils.json - │ ├─ 📄 lamps.json - │ ├─ 📄 switches.json - │ └─ 📄 wires.json - ├─ 📁 items - │ ├─ 📁 0 - │ ├─ 📁 0.0 - │ │ ... - │ └─ 📁 0.0.5.0 - │ ├─ 📁 Bumper - │ │ └─ 📄 0.json - │ └─ 📁 BumperCollider - │ └─ 📄 0.json - ├─ 📁 meta - │ ├─ 📄 colliders.json - │ └─ 📄 sounds.json - ├─ 📁 refs - │ ├─ 📁 0 - │ ├─ 📁 0.1 - │ │ ... - │ └─ 📁 0.1.2.3 - │ ├─ 📁 BumperCollider - │ └─ 📁 BumperSound - │ └─ 📄 0.json - ├─ 📁 sounds - │ └─ 📄 Flipper 1.wav - ├─ 📄 table.glb - └─ 📄 colliders.glb -``` - -## Export - -Let's go through those items by looking at how they are written. Afterwards, we'll go through the reading process as well, to see the differences between editor and runtime loading. - -You can open up `PackageWriter` to see the implementation. - -### glTF Export - -We start by exporting the entire GameObject hierarchy starting at the table node as glTF. We're using [Unity's fork](https://docs.unity3d.com/Packages/com.unity.cloud.gltfast@6.10/manual/index.html) of [`atteneder/glTFast`](https://github.com/atteneder/glTFast) for this. The binary data is [streamed](https://docs.unity3d.com/Packages/com.unity.cloud.gltfast@6.10/api/GLTFast.Export.GameObjectExport.html#GLTFast_Export_GameObjectExport_SaveToStreamAndDispose_System_IO_Stream_System_Threading_CancellationToken_) directly into the ZIP archive's input stream to keep the memory footprint low. - -The glTF export includes the hierarchy, meshes, and materials, but does not include any component data, external assets, or other metadata needed for the table to run. - -> [!NOTE] -> We are not 100% sure yet how materials work. They seem to be restored correctly in HDRP, but they might use special shaders after import. We might need to side-load them as well. -> -> The resulting binary ends up at the root of the archive as `📄 table.glb`. - -### Collider Meshes - -Some of our components use a mesh for physics collision. In Unity, these are references to regular meshes, but they aren't part of the hierarchy recognized by the glTF exporter, so we export them separately. - -We do this by fetching all `IMeshCollider` components, generating a GUID for each, and exporting them (named by the GUID) in another `.glb` file, `📄 colliders.glb`. We keep a reference between the meshes’ instance IDs and these GUIDs for when we export the components. - -Additionally, we save whether the component using the collider mesh is part of a prefab. This is important so we can identify the prefab when the package is re-imported into the editor, and re-link it, if so. This data is saved as `📄 colliders.json` in the `📁 meta` folder and looks like this: - -```json -{ - "9c42922e-a8b8-416a-b859-b22f24fa205e": { - "IsPrefabMeshOverriden": false, - "PrefabGuid": "1d547d87da8b11c44a083695469ff8b8", - "PathWithinPrefab": "" - } -} -``` - -Here, we store a map keyed by the GUID for quick lookup, along with the prefab’s GUID if there is one, and whether the mesh was actually overridden. The `PathWithinPrefab` property points to the object within the prefab, because there might be multiple objects. - -### Components - -Next, we serialize the GameObject and component data into `📁 items` and `📁 refs`. The structure inside these folders is the same. On the first level, folders are named after each GameObject’s indices in the hierarchy. The second level defines the type of the component (for example, `📁 Bumper` maps to `BumperComponent` through the class's `[PackAs]` attribute). Finally, at the third level, the actual data is stored as JSON. - -Each component determines for itself which data is written to `📁 items` and which to `📁 refs`. The purpose of these two folders is that data is read in two passes during import: the first pass creates the components, and the second pass updates cross-references between them. - -Components might add include other files. For example, sound components add the actual sound files, which are written to `📁 sounds`. - -### Globals - -Global data is then written to `📁 global`. Currently, this folder contains mappings for switches, coils, lamps, and wires. - -### Assets - -In this context, assets are instances of `ScriptableObject`, usually serialized in the editor as `.asset` files. We save them to the `📁 assets` folder in our package. - -Assets are grouped into folders based on their type (again determined by the `[PackAs]` attribute). Because they are deserialized as-is, we need an easy way to reference them, which is the purpose of their `*.meta.json` counterparts. The goal of these meta files is to link each asset to an identifier, which is then used by the component data. - -This step also writes other metadata such as `📄 sounds.json` in the `📁 meta` folder, which maps the GUID of the sound assets to their name. - -### More to come - -Future additions will include shaders, external dependencies such as PinMAME, MPF, and Visual Scripting, and more. - - -## Import - -We'll quickly go through the editor import process to explain a few details that were only implied in the export section above. - -One important point is that loading a `.vpe` file during runtime is fundamentally different from loading it into the editor. While the runtime goal is simply to play the table, the editor goal is to import it so it can be easily modified. It's also supposed to save all the data in a folder structure that is easily accessible to the user. - -- The glTF import uses different APIs in the editor versus runtime. In the editor, we write the .glb binary to the asset folder of the Unity project, load it as a prefab, and instantiate a GameObject from it. At runtime, we instantiate a GameObject directly from the binary in memory. -- The order in which data is imported is important for both runtime and edit time, because some steps depend on others: - 1. Load `📄 table.glb`, which gives us the scene hierarchy. - 2. Unpack `📁 assets` and `📄 colliders.glb` - 3. Unpack sounds to `📁 sounds`. Since the GUIDs used for referencing the sound files are the original asset GUIDs, we only write sound files that aren't already in the asset database. - 4. Loop through `📁 items` and do, in this order: - 1. Instantiate and apply components - 2. Link them to their prefab (if the prefab exists in the editor). - 3. Apply component data. - 5. Loop through `📁 refs` and restore cross-references between components. - 6. Import data from `📁 global`. +# Packaging + +By packaging we mean serializing a table into a `.vpe` file. A `.vpe` is a ZIP container with: + +- one glTF binary scene (`table.glb`) +- one optional glTF binary for physics-only meshes (`colliders.glb`) +- JSON metadata for items, refs, globals, assets, and material interchange +- optional VPE-only texture payloads that do not round-trip cleanly through glTF + +This document describes: + +- the on-disk format +- the export/import pipeline +- the material/texture split between `table.glb` and VPE sidecar data +- measured texture-compression experiments and their outcomes +- glTFast limitations that shape the implementation + +> [!NOTE] +> Originally, there were thoughts about using the same container format as VPX (the Compound Binary File), but ultimately, given the inner structure would be quite different anyway, there was no real benefit. +> +> We've also tested a more efficient packing structure than JSON, but since the metadata to which it would apply is minuscule compared to the rest, the performance advantage was quickly outweighed by its unreadability and the hassle to set up. + +## Design Summary + +The package format is optimized around two competing goals: + +- keep `table.glb` as the canonical scene graph, mesh, and standard-material payload +- keep VPE/HDRP-specific data out of glTF unless it survives import/export without visual regressions + +In practice this means: + +- geometry, lights, most standard textures, and the instantiated hierarchy live in `table.glb` +- VPE-only material semantics live in `meta/materials.v1.json` +- VPE-only texture bytes live in `meta/textures.bin` +- runtime imports the GLB first, then replaces or augments imported materials using the `materials.v1` payload + +## File Structure + +If you extract a `.vpe`, the relevant structure looks like this: + +```plain +table/ +|-- assets/ +| `-- ... +|-- global/ +| |-- coils.json +| |-- lamps.json +| |-- switches.json +| `-- wires.json +|-- items/ +| `-- ... +|-- meta/ +| |-- colliders.json +| |-- materials.v1.json +| |-- sounds.json +| `-- textures.bin +|-- refs/ +| `-- ... +|-- sounds/ +| `-- ... +|-- colliders.glb +`-- table.glb +``` + +Important details: + +- `meta/materials.v1.json` is optional. It is only written when a `IVpeMaterialV1Translator` is registered and captures at least one profile. +- `meta/textures.bin` is the sidecar texture payload. It is a raw concatenation of VPE-only texture blobs. Offsets and lengths are stored per texture in `VpeTextureAssetV1`. +- there is no loose `meta/textures/` runtime path anymore. New development should assume side-channel textures come from `textures.bin`, or optionally from embedded GLB buffer views. +- `table.glb` may also carry a `VPE_materials` custom extension via `VpeMaterialsGltfExtension`, but export keeps that path disabled (`EmbedVpeMaterialsIntoGlb = false`) because the sidecar path is the supported one. + +## Export + +The main export entry point is `PackageWriter`. + +### 1. Scene Preparation + +For glTF export, the writer prepares the scene: + +- ensures meshes are readable so glTFast can access vertex data +- temporarily enables author-time disabled `Light` components so `KHR_lights_punctual` contains the same light topology the runtime needs +- disables invalid renderers/meshes that would crash glTF export +- creates a temporary material-sanitizing scope via `VpeMaterialV1Translator.PrepareGltfExport(...)` + +The sanitizing scope is important. It prevents exporting the same texture data twice when the VPE material system already owns a texture. Today it mainly strips textures that are intentionally side-channeled, while preserving the imported GLB fallback for everything else. + +### 2. `table.glb` + +The scene hierarchy rooted at the table is exported through Unity's fork of glTFast: + +- format: binary glTF (`.glb`) +- images: standard glTFast export path, which means PNG/JPG only +- output: written to memory first, then stored as `table/table.glb` + +The GLB contains: + +- hierarchy +- transforms +- meshes +- lights +- imported fallback materials +- textures that stay on the glTF path + +The GLB does not contain: + +- component packables +- refs wiring +- globals +- assets +- VPE material profiles +- packed sidecar texture bytes + +### 3. `colliders.glb` and `meta/colliders.json` + +Physics-only meshes are not always part of the visible hierarchy that glTF export sees, so collider meshes are exported separately: + +- mesh bytes go to `table/colliders.glb` +- authoring metadata goes to `table/meta/colliders.json` + +`colliders.json` stores prefab linkage and override data so editor re-import can reconnect collider meshes correctly. + +### 4. Items, Refs, Globals, Assets, Sounds + +The rest of the package is exported in this shape: + +- `items/` contains component/item data +- `refs/` contains cross-reference data restored in a second pass +- `global/` contains switches, coils, lamps, wires +- `assets/` contains serialized `ScriptableObject` assets plus their metadata +- `sounds/` contains wave data +- `meta/sounds.json` contains sound metadata + +### 5. `materials.v1` Capture + +HDRP material capture is performed by `HdrpMaterialV1Translator`. + +The translator converts supported HDRP materials into a portable `VpeMaterialsPayloadV1`: + +- supported authoring shaders: + - `HDRP/Lit` + - `HDRP/Decal` + - `HDRP/Unlit` +- unsupported shaders are not fatal + - they fall back to the imported GLB material at runtime + - the translator aggregates them into a summary instead of spamming one warning per material + +The payload contains: + +- `Profiles` + - one logical material profile per normalized material name +- `Textures` + - one `VpeTextureAssetV1` per side-channeled texture blob +- `RendererStates` + - shadow casting mode, receive shadows, rendering layer mask + +### 6. Texture Ownership Rules + +This is the most important part of the format. + +The translator intentionally splits texture ownership between glTF and the VPE sidecar. + +#### Textures that stay on the imported GLB path + +These are captured as "imported texture refs", meaning the profile stores tiling/semantic intent, but runtime reads pixels from the material that glTFast already imported: + +- opaque lit base color maps +- emissive maps +- most unlit color maps +- all supported normal maps + +Normals stay on the GLB path. Several benchmarked alternatives were faster or smaller, but caused visual regressions such as insert/plastic ghosting. + +#### Textures that are side-channeled into `textures.bin` + +These are captured as explicit `TextureId` references backed by `VpeTextureAssetV1` entries: + +- HDRP `MaskMap` +- HDRP `ThicknessMap` +- alpha-bearing lit base color maps + - transparent + - alpha-tested +- decal base color maps +- decal mask maps + +Why these are side-channeled: + +- glTF cannot represent HDRP `MaskMap` semantics losslessly +- albedo alpha is load-bearing for inserts, plastics, and decals +- glTF/JPG conversion can silently drop or alter alpha when relying on fallback material export + +### 7. `textures.bin` + +The exporter does not write one file per side texture. It writes one packed blob: + +- `meta/textures.bin` + +`BuildPackedMaterialTextureData(...)` simply concatenates each side texture byte array and records: + +- `ByteOffset` +- `ByteLength` +- `FileName` (export-side blob key only) +- `MimeType` +- `ColorSpace` +- filter/wrap/aniso/mipmap hints +- width/height + +Writer behavior: + +- side-channel textures are PNG-encoded +- `MimeType` defaults to `image/png` +- `ByteOffset` / `ByteLength` are filled in +- `GlbBufferView` remains unused in the shipping path + +### 8. Optional GLB Embedding Path + +`VpeMaterialsGltfExtension` exists as a post-process helper that can: + +- inject a `VPE_materials` custom extension into `table.glb` +- append side texture blobs into GLB buffer views +- read the payload back at runtime + +Export keeps that path disabled: + +- `PackageWriter.EmbedVpeMaterialsIntoGlb = false` + +Reason: + +- the sidecar path is stable +- the GLB-embedding path is useful as infrastructure and for future work +- but it is not the format used by exported packages + +## Runtime Import + +The runtime import entry point is `RuntimePackageReader`. + +### Import Order + +Runtime import is intentionally ordered: + +1. Load `table.glb` +2. Unpack sounds +3. Unpack assets +4. Unpack collider meshes +5. Restore packables from `items/` +6. Restore refs from `refs/` +7. Restore globals +8. Read table metadata +9. Restore material profiles from `materials.v1` + +`table.glb` must be first because every later step depends on the imported hierarchy already existing. + +### Runtime GLB Import + +At runtime, the GLB is imported directly from bytes in memory: + +- `sceneFile.GetData()` +- `new GltfImport(...)` +- `gltf.Load(sceneData, uri, cancellationToken: ...)` +- `InstantiateMainSceneAsync(...)` + +The reader also checks the optional `VPE_materials` extension before import: + +- `VpeMaterialsGltfExtension.TryReadPayload(...)` +- `VpeMaterialsGltfExtension.TryReadEmbeddedTextureBlobs(...)` + +That code path is dormant for normal exports, because the writer keeps extension embedding disabled. It exists so GLB-only experiments do not need a separate schema. + +### Runtime Material Restore + +`VpeMaterialV1Reader` owns the runtime restore pass. + +Important behavior: + +- it reads `meta/materials.v1.json` +- it prefers embedded GLB texture blobs if present +- otherwise it reads `textures.bin` + +`TextureProvider.Get(...)` currently: + +- slices bytes from `textures.bin` using `ByteOffset` / `ByteLength` +- creates `Texture2D` +- uses `ImageConversion.LoadImage(...)` +- applies wrap/filter/aniso +- caches the resulting `Texture2D` per import + +Mip behavior: + +- sRGB side textures respect `GenerateMipMaps` +- linear side textures force runtime mip generation off + +That last change was intentional and measurable. The heavy linear payload is dominated by mask/thickness data, where runtime mip generation was expensive and offered limited visual benefit. + +### Runtime Resolver + +`VpeMaterialV1Reader` itself is SRP-agnostic. It delegates actual material creation to `IVpeMaterialResolver`. + +For HDRP, `HdrpMaterialResolver`: + +- clones pre-authored template materials +- restores base color, mask map, emissive, transmission, and so on +- restores renderer states after the material pass +- caches resolved materials aggressively +- reuses one resolved material for repeated imported-material instances + +It also contains the most important merged runtime optimization so far: + +- RGB normal maps are repacked for HDRP on the GPU with `VpePackNormalForHdrp.shader` +- fallback remains CPU-based if the shader path fails + +This optimization reduced the normal repack cost from multiple seconds to effectively negligible on the benchmark table. + +## Editor Import + +Editor import differs from runtime import in one important way: + +- runtime loads GLB directly from memory +- editor writes GLB back into the Unity project and re-imports it through the asset pipeline + +The rest of the logical order is similar: + +1. import GLB +2. unpack assets and colliders +3. restore items +4. restore refs +5. restore globals +6. restore authoring metadata + +## Texture Compression + +This section documents the shipping texture path and the benchmarked alternatives. + +### Shipping Texture Path + +Mainline behavior is: + +- `table.glb` images are whatever glTFast exports + - PNG or JPG +- side-channel VPE-only textures are PNG +- side-channel texture bytes are stored in `meta/textures.bin` +- runtime loads side-channel textures via `ImageConversion.LoadImage(...)` + +This path is stable and visually correct, but not the fastest possible one. + +### Why Texture Compression Matters + +The benchmarks showed that once: + +- duplicate textures were removed +- material reuse caching was added +- GPU normal repack was added + +the next dominant cost was texture decode, not ZIP IO itself. + +The biggest speedups came from removing `LoadImage(...)` and moving side textures onto direct GPU-uploadable formats. + +### Formats and Outcomes + +The measurements below were taken on the Terminator 2 table during the optimization pass. Treat them as benchmark data, not guarantees for every table. + +#### 1. PNG Sidecar in `textures.bin` + +Status: + +- shipping baseline + +Behavior: + +- side textures are exported as PNG +- runtime decodes them with `LoadImage(...)` + +Representative result after other merged optimizations: + +- package around `186 MB` +- total import around `7.3s` to `7.7s` + +Notes: + +- stable +- visually correct +- pays a large PNG decode cost + +#### 2. Raw `RGBA32` Sidecar + +Status: + +- benchmark only +- rejected + +Intent: + +- eliminate PNG decode cost entirely +- store raw pixels and upload via `LoadRawTextureData(...)` + +Observed result: + +- package around `178 MB` +- export time ballooned to roughly `3 minutes` +- visual regressions were observed during the experiment + +Conclusion: + +- useful as a proof that decode avoidance matters +- not viable as-is +- export cost and regression risk were too high + +#### 3. `DXT5` for Linear Side Textures + +Status: + +- benchmark only +- successful for speed and size + +Applied to: + +- linear VPE side textures +- primarily mask/thickness-style data + +Observed result: + +- package around `158 MB` +- total import around `6.57s` +- side-texture load time dropped substantially +- visuals looked acceptable on the benchmark table + +Conclusion: + +- strong improvement +- lossy, but tolerable for those linear texture classes + +#### 4. `DXT5` for Linear + `BC7` for sRGB Side Textures + +Status: + +- benchmark only +- best sidecar result + +Applied to: + +- `DXT5` for linear side textures +- `BC7` for remaining sRGB side textures + +Observed result: + +- package around `142.5 MB` +- total import around `6.23s` +- side-texture load time dropped to roughly `80 ms` +- `compressedTextureLoads=97` +- `encodedTextureLoads=0` +- visuals looked fine on the benchmark table + +Conclusion: + +- this was the strongest sidecar compression result +- the main win was format/direct-upload, not just file size +- this is the most promising future reimplementation path for side textures + +#### 5. Move GLB Normals to Sidecar + +Status: + +- benchmark only +- rejected + +Observed result: + +- package could shrink dramatically, down near `119 MB` +- but visual regressions returned +- the main symptom was ghosting on inserts/plastics + +Conclusion: + +- the path was faster and smaller +- but semantically wrong +- do not reimplement blindly without first understanding what imported glTF normals preserve that the sidecar path currently does not + +#### 6. DDS / BC7 Embedded Directly in GLB + +Status: + +- benchmark only +- rejected + +Observed result: + +- package shrank to about `116.8 MB` +- but `table.glb` import time exploded +- representative timings: + - `table.glb`: about `7.9s` + - total import: about `11.3s` + +Why it failed: + +- DDS/BC blocks are poor ZIP citizens in this setup +- compressed-on-disk GLB got smaller, but the uncompressed GLB payload got much larger +- runtime paid that inflated payload cost during GLB import + +Conclusion: + +- good for disk footprint +- bad for uncached load time in this `.vpe` container + +#### 7. KTX2 / `KHR_texture_basisu` for GLB Normals + +Status: + +- benchmark only +- not kept + +Implementation notes: + +- required patching local glTFast +- normals were exported through `toktx` +- textures were serialized through `KHR_texture_basisu` + +Observed result: + +- package around `130.6 MB` +- GLB really contained `87` `image/ktx2` images +- but runtime was slower than the PNG/JPG GLB baseline +- representative timings: + - total import: about `6.17s` + - `table.glb`: about `2.81s` +- this was slower than the best PNG/JPG GLB + compressed-sidecar baseline + +Why it failed: + +- KTX2 reduced on-disk size +- but the runtime transcode path added enough overhead to lose on startup time + +Conclusion: + +- useful for footprint +- not a load-time win for this table in the tested setup + +### Re-Implementation Notes for the Best Unmerged Sidecar Path + +If a future agent wants to re-implement the successful sidecar compression branch, the important points are: + +- keep the logical split between imported GLB textures and side-channel textures +- only change the side-channel encoding and runtime upload path +- do not change normal ownership yet + +Recommended shape: + +1. Keep `meta/textures.bin` as the carrier. +2. Extend `VpeTextureAssetV1` with an explicit texture-data-format discriminator. +3. On export: + - make a readable copy of the texture + - compress linear side textures to `DXT5` + - compress sRGB side textures to `BC7` + - store raw compressed bytes in `textures.bin` +4. On runtime import: + - create `Texture2D(width, height, format, mipChain, linear)` + - call `LoadRawTextureData(...)` + - `Apply(...)` + +Critical constraint: + +- keep a fallback path for platforms that do not support the chosen GPU format +- keep a fallback path for editor re-import if those packages need to round-trip on unsupported hardware + +## glTFast Issues and Constraints + +These findings shape the implementation. + +### 1. Export Is PNG/JPG-Oriented + +glTFast export currently assumes standard encoded images: + +- PNG +- JPG + +There is no clean public export hook for "use this custom image payload/extension format instead". + +Practical consequence: + +- standard GLB texture experiments required patching or forking glTFast +- sidecar experiments were much cheaper to iterate on than GLB texture experiments + +### 2. KTX2 Import Exists, Export Does Not + +glTFast can import `KHR_texture_basisu`, but export is not wired for it. + +Practical consequence: + +- KTX2 GLB experiments required local fork work +- they were not one-line package upgrades + +### 3. Custom Extension Support Is Incomplete for This Use Case + +`VpeMaterialsGltfExtension` exists and works as a local post-process helper, but glTFast does not provide a clean end-to-end public API for exporting arbitrary VPE material extensions. + +Practical consequence: + +- a `VPE_materials`-inside-GLB format is technically possible +- but today it is more practical as a local GLB rewrite step than as a stock glTFast export feature + +Related import-side issue: + +- the fast runtime parser path is optimized for known schema +- arbitrary custom extension payloads are not something the stock runtime path exposes conveniently +- this is one reason `VpeMaterialsGltfExtension` reads and writes raw GLB JSON/chunks itself instead of relying on glTFast to surface custom payload data + +### 4. Runtime Performance Depends on Payload Shape, Not Just File Size + +The DDS and KTX2 experiments were the clearest proof: + +- smaller `.vpe` files do not automatically load faster +- transcoding cost, ZIP behavior, and uncompressed in-memory payload size matter just as much + +This is why the sidecar compression experiments outperformed the GLB compression experiments. + +## Performance Summary + +### Merged Improvements + +The following changes are in the format/runtime path: + +- duplicate texture storage between GLB and sidecar was reduced substantially +- VPE-only textures are packed into `meta/textures.bin` + - no more one-file-per-texture export on the normal path +- unsupported HDRP shaders do not flood export with warnings +- runtime material replacement caches resolved materials aggressively +- runtime normal repack uses a GPU path via `VpePackNormalForHdrp.shader` +- linear side textures skip runtime mip generation +- import timings and resolver diagnostics are logged in detail + +### Measured Effect of the Merged Work + +On the benchmark table, the merged work took import from roughly the original `~10-11s` range down to roughly `~7.3-7.7s`, depending on the exact branch state and rerun. + +The biggest merged win was the GPU normal repack: + +- it reduced the normal repack cost from multiple seconds to effectively negligible + +### Best Additional Gains Found During Experiments + +The best unmerged result came from compressing side-channel textures into GPU-native formats while leaving GLB textures alone: + +- `DXT5` for linear side textures +- `BC7` for sRGB side textures + +Representative result: + +- package around `142.5 MB` +- total import around `6.23s` + +### Future Gains Worth Pursuing + +Most promising: + +- re-implement compressed sidecar textures with a platform-aware fallback story +- add persistent caching only after the raw format is stable +- overlap IO/decode/setup work more aggressively once the payload format is fixed + +Possible but lower-confidence: + +- GLB-side compressed texture work + - DDS and KTX2 experiments did not beat the sidecar approach on load time +- deeper glTFast fork work + - likely only worth it if the project decides that GLB must become the canonical home for more textures again + +Not recommended without new evidence: + +- moving GLB normals into the sidecar again without first solving the ghosting issue +- raw `RGBA32` sidecar export as a shipping format + +## Recommended Next Steps + +If a future agent picks this up, the most practical order is: + +1. Keep the sidecar/material split. +2. Re-implement compressed `textures.bin` first. +3. Add a format discriminator plus platform fallback handling. +4. Re-measure cold-load import. +5. Only then decide whether GLB texture work is worth the complexity. + +That sequence gave the best empirical results during this optimization pass. diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md.meta index 66ffb6887..370750e85 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md.meta +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/README.md.meta @@ -1,3 +1,7 @@ -fileFormatVersion: 2 +fileFormatVersion: 2 guid: d546873760754068926dba0883ecf8f5 -timeCreated: 1738959291 \ No newline at end of file +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs index 21edfe920..6644e10f2 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1.cs @@ -261,14 +261,15 @@ public class VpeTextureAssetV1 { // Stable id referenced by VpeTextureRefV1.TextureId. public string Id; - // Legacy loose file under table/meta/textures/ inside the package. + // Export-side blob key. The current writer uses this to match texture metadata with the + // captured byte payload before packing everything into textures.bin. public string FileName; - // Byte range inside table/meta/textures.bin for non-GLB packages. When set, runtime should - // prefer this over FileName to avoid per-texture package lookups. + // Byte range inside table/meta/textures.bin. This is the normal runtime path for VPE-only + // textures when they are not embedded into the GLB. public int ByteOffset = -1; public int ByteLength; // Optional GLB bufferView index for packages that embed VPE-only texture bytes directly in - // table.glb. When set, runtime should prefer this over FileName. + // table.glb. When set, runtime should prefer this over textures.bin. public int GlbBufferView = -1; // MIME type for the embedded bytes. Current writer emits PNG side-channel textures. public string MimeType = "image/png"; diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs index 7a5cdc694..eef88b1c1 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeMaterialV1Reader.cs @@ -84,13 +84,11 @@ public static bool TryApply( var profilesByName = BuildProfileLookup(payload.Profiles); var resolvedMaterialsByImportedId = new Dictionary(); var resolvedMaterialsBySignature = new Dictionary(StringComparer.Ordinal); - IPackageFolder texturesFolder = null; - metaFolder?.TryGetFolder(PackageApi.TexturesV1Folder, out texturesFolder); byte[] packedTextureData = null; if (metaFolder != null && metaFolder.TryGetFile(PackageApi.TexturesV1PackFile, out var packedTexturesFile)) { packedTextureData = packedTexturesFile.GetData(); } - using var textures = new TextureProvider(payload.Textures, texturesFolder, packedTextureData, embeddedTextureBlobsById); + using var textures = new TextureProvider(payload.Textures, packedTextureData, embeddedTextureBlobsById); var stats = new Stats(); var materialTraversalStopwatch = Stopwatch.StartNew(); @@ -212,7 +210,6 @@ public static bool TryApply( $"textureCacheHits={textures.CacheHits}, textureLoads={textures.LoadCount}, " + $"textureLoadMs={textures.LoadMilliseconds}, textureBytes={textures.LoadedBytes}, " + $"embeddedTextureLoads={textures.EmbeddedLoadCount}, packedTextureLoads={textures.PackedLoadCount}, " + - $"looseTextureLoads={textures.LooseFileLoadCount}, " + $"resolverStats=[{resolverDiagnostics?.GetDiagnosticsSummary() ?? "n/a"}]."); return true; @@ -337,7 +334,6 @@ private sealed class TextureProvider : IVpeTextureProvider, IDisposable private readonly Dictionary _assetsById; private readonly IReadOnlyDictionary _embeddedTextureBlobsById; private readonly byte[] _packedTextureData; - private readonly IPackageFolder _folder; private readonly Dictionary _loaded = new(StringComparer.Ordinal); private readonly HashSet _missingTextureIdsLogged = new(StringComparer.Ordinal); private long _loadedBytes; @@ -346,14 +342,11 @@ private sealed class TextureProvider : IVpeTextureProvider, IDisposable private int _cacheHits; private int _embeddedLoadCount; private int _packedLoadCount; - private int _looseFileLoadCount; public TextureProvider( VpeTextureAssetV1[] assets, - IPackageFolder folder, byte[] packedTextureData, IReadOnlyDictionary embeddedTextureBlobsById) { - _folder = folder; _packedTextureData = packedTextureData; _embeddedTextureBlobsById = embeddedTextureBlobsById; _assetsById = new Dictionary(StringComparer.Ordinal); @@ -394,15 +387,6 @@ public Texture2D Get(string textureId) bytes = packedBytes; loadedFromPacked = true; } - if ((bytes == null || bytes.Length == 0) - && _folder != null - && !string.IsNullOrWhiteSpace(asset.FileName) - && _folder.TryGetFile(asset.FileName, out var file)) { - bytes = file.GetData(); - loadedFromEmbedded = false; - loadedFromPacked = false; - } - if (bytes == null || bytes.Length == 0) { string reason; if (asset.GlbBufferView >= 0) { @@ -410,7 +394,7 @@ public Texture2D Get(string textureId) } else if (asset.ByteOffset >= 0 && asset.ByteLength > 0) { reason = $"packed-texture-range-missing:{asset.ByteOffset}+{asset.ByteLength}"; } else { - reason = $"file-not-found-or-empty:{asset.FileName}"; + reason = $"no-embedded-or-packed-bytes:{asset.Id}"; } LogMissingTexture(textureId, reason: reason); _loaded[textureId] = null; @@ -441,8 +425,6 @@ public Texture2D Get(string textureId) _embeddedLoadCount++; } else if (loadedFromPacked) { _packedLoadCount++; - } else { - _looseFileLoadCount++; } _loaded[textureId] = texture; return texture; @@ -454,7 +436,6 @@ public Texture2D Get(string textureId) public long LoadedBytes => _loadedBytes; public int EmbeddedLoadCount => _embeddedLoadCount; public int PackedLoadCount => _packedLoadCount; - public int LooseFileLoadCount => _looseFileLoadCount; public void Dispose() { From fed7efce83eca0aa1449115d56137e837027dd9a Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 26 Apr 2026 22:29:39 +0200 Subject: [PATCH 12/55] doc: Add first part of packaging documentation. --- .../Documentation~/developer-guide/index.md | 11 +- .../developer-guide/packaging/benchmarks.md | 243 ++ .../developer-guide/packaging/export.md | 115 + .../developer-guide/packaging/import.md | 110 + .../developer-guide/packaging/index.md | 65 + .../developer-guide/packaging/materials.md | 220 ++ .../Documentation~/developer-guide/toc.yml | 14 +- .../template/vpe/layout/_master.tmpl | 9 +- .../template/vpe/public/main.css | 8 + .../vpe/public/mermaid-11.14.0.min.js | 3298 +++++++++++++++++ 10 files changed, 4086 insertions(+), 7 deletions(-) create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/packaging/benchmarks.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/packaging/export.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/packaging/import.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/packaging/index.md create mode 100644 VisualPinball.Unity/Documentation~/developer-guide/packaging/materials.md create mode 100644 VisualPinball.Unity/Documentation~/template/vpe/public/mermaid-11.14.0.min.js diff --git a/VisualPinball.Unity/Documentation~/developer-guide/index.md b/VisualPinball.Unity/Documentation~/developer-guide/index.md index 0998d8f01..a46e1e708 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/index.md +++ b/VisualPinball.Unity/Documentation~/developer-guide/index.md @@ -65,8 +65,9 @@ Use it for reusable art content rather than engine or gameplay code. ## Future integrations -The developer guide also tracks design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. - -- [Accelerometer Input Design](xref:developer-guide-accelerometer-input-design) covers analog nudge input, Open Pinball Device support, calibration, and how a future player app should participate in initial setup. -- [B2S Integration Design](xref:developer-guide-b2s-integration-design) proposes modernizing the upstream B2S runtime into a shared cross-platform core with a Windows COM shim, a native second-monitor host, and a Unity texture output for VR backglasses. -- [DOF Integration Design](xref:developer-guide-dof-integration-design) covers a Windows-first `DirectOutput` integration for the future player app and the later hybrid path toward a `libdof` backend. \ No newline at end of file +The developer guide also tracks design work for integrations that are not yet fully implemented in VPE. These pages are intended to capture architectural direction early, so implementation work across native input, runtime systems, and tooling can converge on the same design. + +- [Packaging](xref:developer-guide-packaging-overview) documents the `.vpe` format, the export/import split, the renderer-agnostic material vocabulary, and the benchmarked optimization work around package loading. +- [Accelerometer Input Design](xref:developer-guide-accelerometer-input-design) covers analog nudge input, Open Pinball Device support, calibration, and how a future player app should participate in initial setup. +- [B2S Integration Design](xref:developer-guide-b2s-integration-design) proposes modernizing the upstream B2S runtime into a shared cross-platform core with a Windows COM shim, a native second-monitor host, and a Unity texture output for VR backglasses. +- [DOF Integration Design](xref:developer-guide-dof-integration-design) covers a Windows-first `DirectOutput` integration for the future player app and the later hybrid path toward a `libdof` backend. diff --git a/VisualPinball.Unity/Documentation~/developer-guide/packaging/benchmarks.md b/VisualPinball.Unity/Documentation~/developer-guide/packaging/benchmarks.md new file mode 100644 index 000000000..67ce407f9 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/packaging/benchmarks.md @@ -0,0 +1,243 @@ +--- +uid: developer-guide-packaging-benchmarks +title: Packaging Benchmarks +description: Benchmarks, validated optimization results, and packaging experiments around .vpe loading. +--- + +# Packaging Benchmarks + +This page is a record of the optimization work around `.vpe` load time and package size. It is here for two reasons: to make the validated wins easy to keep, and to stop future work from re-running dead-end experiments without context. + +## Summary + +The table below is the short version. + +| Experiment | Status | Recommendation | Main result | +| --- | --- | --- | --- | +| Packed `textures.bin` sidecar | Validated and merged | Keep | Better package shape for runtime loading | +| GPU HDRP normal repack | Validated and merged | Keep | Removed a major normal-repack hotspot | +| Skip mip generation for heavy linear side textures | Validated and merged | Keep | Small but measurable runtime win | +| PNG side-channel textures | Validated | Baseline only | Correct but decode-heavy | +| `DXT5` linear + `BC7` sRGB side textures in `textures.bin` | Tested and validated as a benchmark | Recommended next optimization target | Best size/load tradeoff found | +| Raw `RGBA32` side textures | Tested and invalidated | Do not use as shipping format | Very slow export, regression risk | +| Move GLB normals to sidecar | Tested and invalidated | Do not use without deeper investigation | Smaller/faster, but caused ghosting | +| DDS/BC7 embedded directly in GLB | Tested and invalidated | Do not use | Smaller package, much slower load | +| KTX2 / `KHR_texture_basisu` for GLB normals | Tested and invalidated for startup speed | Do not prioritize for load time | Smaller package, slower load | +| Persistent raw runtime texture cache | Tested and invalidated | Do not revive in the same form | Speedups came with corruption/crash risk | + +## Baseline context + +The optimization work started from a package that had two different classes of problems: too much texture data was effectively being paid for twice, and the runtime was doing expensive work that was easy to miss until proper timing logs were added. + +In practical terms, the baseline package: + +- carried far too much texture data across GLB and sidecar paths +- spent large amounts of runtime in material reconstruction and normal handling +- loaded the benchmark table in roughly the `10-11s` range + +After the merged fixes, the stable baseline moved down substantially, which made it much easier to see where the remaining bottlenecks actually were. + +## Merged and validated improvements + +### 1. Remove duplicate texture storage + +The export path now keeps opaque GLB textures on the glTF path and only side-channels the texture classes that actually need it. + +Result: + +- package size dropped materially from the original `200+ MB` range +- the remaining size largely represented real payload, not double-stored content + +### 2. Pack side textures into `textures.bin` + +The old one-file-per-texture side path was replaced by one packed blob plus metadata offsets. + +Result: + +- better package structure +- lower per-entry ZIP overhead +- better foundation for later compression work + +This was not the biggest speed win by itself, but it changed the shape of the format in an important way. Once the side textures lived in one blob, experiments around compression and direct upload became much easier to reason about. + +### 3. Cache resolved materials during import + +`VpeMaterialV1Reader` now reuses already-resolved materials instead of rebuilding equivalent runtime materials repeatedly. + +Result: + +- measurable reduction in material-restore time +- especially helpful on tables with many repeated imported materials + +### 4. GPU normal repack for HDRP + +This was the most important merged optimization. + +Instead of repacking RGB normals for HDRP on the CPU, `HdrpMaterialResolver` uses `VpePackNormalForHdrp.shader` to do the channel conversion on the GPU. + +Representative outcome: + +- total import dropped from roughly `10s` toward the `7-8s` range +- the normal repack hotspot dropped from multiple seconds to effectively negligible + +### 5. Skip runtime mip generation for heavy linear side textures + +Linear side textures, especially mask/thickness payloads, no longer generate mips at runtime. + +Result: + +- small but real improvement +- low risk for the relevant texture classes + +## Best unmerged result + +The best benchmark result came from compressing side-channel textures into GPU-native formats while leaving GLB textures on the normal glTF path. + +Format split: + +- `DXT5` for linear side textures +- `BC7` for sRGB side textures + +Representative result on the benchmark table: + +- package around `142.5 MB` +- total import around `6.23s` +- side-texture load time around `80 ms` +- `compressedTextureLoads=97` +- `encodedTextureLoads=0` + +Why it worked: + +- it avoided PNG decode for VPE-owned textures +- it preserved the stable GLB semantics +- it aligned the side-channel path with direct GPU upload + +Why it is not merged: + +- desktop-oriented GPU format choice +- needs a fallback strategy for unsupported platforms +- needs a clear editor re-import story + +If someone wants one benchmark result to keep in their head, this is the one. It was the strongest combination of smaller package, faster load, and correct visuals. + +## Invalidated experiments + +### Raw `RGBA32` side textures + +Intent: + +- eliminate decode entirely by storing raw pixels + +Observed behavior: + +- package around `178 MB` +- export time ballooned to about `3 minutes` +- visual regressions were observed + +Conclusion: + +- useful as a proof that decode avoidance matters +- not suitable as a shipping format + +### Move GLB normals to sidecar + +Intent: + +- remove the heaviest remaining GLB image class +- reuse the side-channel path for normals + +Observed behavior: + +- package could shrink dramatically, down near `119 MB` +- visual regressions returned +- the main symptom was insert/plastic ghosting + +Conclusion: + +- do not revive this path without first understanding why imported GLB normals preserve behavior that the sidecar path did not + +### DDS / BC7 inside GLB + +Intent: + +- store precompressed GPU blocks directly inside GLB + +Observed behavior: + +- package shrank to roughly `116.8 MB` +- `table.glb` import became far slower +- total import climbed to roughly `11.3s` + +Conclusion: + +- smaller file on disk +- much worse uncached load time in this container format + +### KTX2 / `KHR_texture_basisu` for GLB normals + +Intent: + +- reduce GLB size while keeping payload compressed in a standard way + +Observed behavior: + +- package around `130.6 MB` +- GLB really contained KTX2 normals +- runtime was slower than the PNG/JPG GLB baseline + +Conclusion: + +- useful if package footprint is the goal +- not the right optimization if startup time is the goal + +### Persistent runtime texture cache + +Intent: + +- get close to editor-like warm-load behavior + +Observed behavior: + +- promising speedups +- but corrupted textures and second-run crashes appeared + +Conclusion: + +- caching has potential +- the tested implementation was not safe + +## glTFast-related findings + +Several experiments were constrained by glTFast: + +- export remains fundamentally PNG/JPG-oriented +- KTX2 import exists, but export is not a supported stock path +- custom GLB material extension export is easier as a local rewrite than as a glTFast-native feature +- smaller GLB image payloads do not automatically mean faster import + +These findings are why sidecar compression outperformed the GLB-focused experiments. + +## Recommended next targets + +### Highest-value next step + +Re-implement compressed `textures.bin` with platform-aware fallbacks: + +- `DXT5` for linear side textures +- `BC7` for sRGB side textures +- explicit format discriminator in `VpeTextureAssetV1` +- runtime direct upload path instead of `LoadImage(...)` + +This is the strongest measured path that did not break visuals. + +### Promising, but secondary + +- overlap IO/decode/setup work more aggressively once the sidecar format is stable +- revisit persistent caching with a safer cache contract + +### Low-priority or not recommended + +- GLB-normal relocation without deeper semantic investigation +- DDS-in-GLB for load time +- KTX2-in-GLB as a startup-speed optimization +- raw `RGBA32` side textures as a shipping format diff --git a/VisualPinball.Unity/Documentation~/developer-guide/packaging/export.md b/VisualPinball.Unity/Documentation~/developer-guide/packaging/export.md new file mode 100644 index 000000000..45a551c23 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/packaging/export.md @@ -0,0 +1,115 @@ +--- +uid: developer-guide-packaging-export +title: Packaging Export +description: How a table is written into the .vpe package structure. +--- + +# Packaging Export + +This page explains what gets written into a `.vpe` package and why each part exists. It is deliberately organized by package structure, not by the exact call order inside `PackageWriter`, because that is usually the more useful mental model when you are trying to understand or extend the format. + +The export entry point is `PackageWriter`. + +## Scene Payload + +The visible scene is exported to `table/table.glb` through [Unity's fork of glTFast](https://docs.unity3d.com/Packages/com.unity.cloud.gltfast@6.18/manual/index.html). + +The GLB contains: + +- hierarchy +- transforms +- meshes +- lights +- imported fallback materials +- textures that remain on the glTF path + +The GLB does not contain: + +- component packables +- cross-reference wiring +- globals +- editor assets +- table metadata +- VPE material vocabulary +- packed VPE-only texture bytes + +### Scene Preparation + +The exporter does a little housekeeping before it hands the table to glTFast. The point of this step is to make sure the GLB contains the scene the player actually needs, not the slightly awkward authoring-time version of it. + +- table meshes are made readable +- author-time disabled `Light` components are enabled so they flow into `KHR_lights_punctual` +- invalid mesh renderers are suppressed so glTF export does not fail +- a temporary material-sanitizing scope removes texture data that VPE already owns elsewhere + +## Collider Payload + +Physics-only meshes are exported to `table/colliders.glb`. Related metadata is written to `table/meta/colliders.json`, including: + +- prefab linkage +- whether the collider mesh was overridden +- path within the prefab + +This lets editor re-import reconnect those meshes correctly. + +## Packables and References + +Gameplay and authoring data is split into two trees: + +- `table/items/` - contains the data needed to instantiate and configure components. +- `table/refs/` - contains data that restores cross-references after the hierarchy and components already exist. + +The split exists because some data can be applied immediately, while other data only makes sense after the whole object/component graph has been rebuilt. + +## Table Metadata + +Table-level metadata is written to `table/table.json`. This contains things like table name, manufacturer and authors, and will be extended in the future. + +## Globals, Assets, and Sounds + +The remaining package content is the non-scene part of the table. These files are small compared to the GLB and texture payloads, but they are what make the table playable rather than just renderable. + +- `table/global/` - switches, coils, lamps and wires +- `table/assets/` - serialized `ScriptableObject` assets plus metadata +- `table/sounds/` - sound bytes +- `table/meta/sounds.json` - sound lookup metadata + +## Material Payload + +If a `IVpeMaterialV1Translator` is registered and captures material data, export writes: + +- `table/meta/materials.v1.json` - contains material profiles, texture metadata, per-renderer state not covered by glTF +- `table/meta/textures.bin` - contains raw concatenation of VPE-owned texture blobs. + +Each `VpeTextureAssetV1` records the byte range for its payload as `ByteOffset` and `ByteLength`. + +The exporter also records: + +- color space +- wrap/filter/aniso settings +- mip intent +- dimensions +- MIME type + +The important detail here is that texture metadata and texture bytes are separate. `materials.v1.json` tells runtime what a texture means and where it belongs; `textures.bin` carries the bytes. + +### Texture Ownership + +The exporter intentionally splits texture ownership. Some textures stay on the GLB path while some textures are side-channeled into `textures.bin` + +Textures that typically stay on the GLB path: + +- opaque lit base color +- emissive +- most unlit color maps +- supported normal maps + +Textures that are side-channeled: + +- HDRP `MaskMap` +- HDRP `ThicknessMap` +- alpha-bearing lit base color maps +- decal base color maps +- decal mask maps + +The reason is not convenience but correctness. Those maps either do not fit cleanly into glTF or rely on semantics that the fallback GLB path cannot preserve reliably. \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/developer-guide/packaging/import.md b/VisualPinball.Unity/Documentation~/developer-guide/packaging/import.md new file mode 100644 index 000000000..7c1b67609 --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/packaging/import.md @@ -0,0 +1,110 @@ +--- +uid: developer-guide-packaging-import +title: Packaging Import +description: How runtime loading reconstructs a playable table from a .vpe package. +--- + +# Packaging Import + +The runtime import entry point is `RuntimePackageReader`. + +Its job is to reconstruct a playable table from the package without relying on Unity editor asset import. That sounds obvious, but it is the reason the import path looks different from editor re-import: runtime cannot lean on the asset database to quietly sort things out later. + +## High-level Flow + +Runtime loading is built around one rule: + +1. import the GLB +2. restore everything else against the imported hierarchy + +That gives the importer a concrete transform tree and renderer set before packables, refs, globals, and material restoration run. + +## GLB import + +`RuntimePackageReader` reads `table.glb` from the ZIP storage and imports it directly from memory through `GltfImport`. + +This produces: + +- the scene hierarchy +- renderer components +- imported fallback materials +- imported textures that remain on the glTF path + +If present, the importer also probes the optional `VPE_materials` GLB extension before scene instantiation. That code exists for experiments and future work, but normal exported packages use the sidecar path instead. + +## Post-GLB Restoration + +After the hierarchy exists, runtime import restores: + +- sounds +- assets +- collider meshes +- packables from `items/` +- references from `refs/` +- globals +- table metadata from `table.json` +- material profiles from `materials.v1.json` + +## Material Restore + +`VpeMaterialV1Reader` owns the material-restore pass. + +It reads: + +- `meta/materials.v1.json` +- `meta/textures.bin` + +It then: + +- matches imported materials by normalized material name +- creates runtime materials through the active `IVpeMaterialResolver` +- reuses resolved materials aggressively to avoid rebuilding equivalent materials +- restores per-renderer state such as shadow casting and rendering layers + +After this step, the table is supposed to look like authored in the editor. + +## Texture Loading + +For side-channel textures, `VpeMaterialV1Reader.TextureProvider`: + +1. locates the `VpeTextureAssetV1` +2. slices the corresponding byte range from `textures.bin` +3. creates a `Texture2D` +4. decodes the bytes with `ImageConversion.LoadImage(...)` +5. applies wrap/filter/aniso settings +6. caches the texture for the remainder of the import + +If the package uses embedded GLB texture blobs instead, runtime prefers those over `textures.bin`. + +The reason this is called out explicitly is performance: texture decode and upload became one of the main bottlenecks once the more obvious duplication issues were removed. + +## Mipmapping Behavior + +Side-channel mip behavior is intentionally asymmetric: + +- sRGB side textures honor the `GenerateMipMaps` flag +- linear side textures skip runtime mip generation + +That tradeoff was made because the heaviest linear payloads are mostly mask and thickness data, where runtime mip generation was measurable overhead. + +## HDRP Resolver Details + +The HDRP implementation of `IVpeMaterialResolver` is `HdrpMaterialResolver`. It: + +- clones pre-authored HDRP template materials +- applies VPE material intent onto those templates +- restores transmission, mask packing, decals, and renderer-specific state +- repacks RGB normal maps into the layout HDRP expects + +The important performance optimization here is that normal repack uses a GPU path via `VpePackNormalForHdrp.shader`, with a CPU fallback only if that path fails. + +## Dependencies + +Runtime import assumes: + +- `table.glb` is valid and self-consistent +- `materials.v1.json` matches material names emitted into the GLB +- `textures.bin` byte ranges are valid +- a compatible `IVpeMaterialResolver` is registered by the player + +If a resolver is not registered, runtime falls back to the glTF-imported materials and the table will not match authoring visuals. \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/developer-guide/packaging/index.md b/VisualPinball.Unity/Documentation~/developer-guide/packaging/index.md new file mode 100644 index 000000000..ddebfab4a --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/packaging/index.md @@ -0,0 +1,65 @@ +--- +uid: developer-guide-packaging-overview +title: Packaging +description: Overview of the .vpe table format and how its parts fit together. +--- + +# Packaging + +*This section explains the `.vpe` table format.* + +A `.vpe` file is a ZIP container, split into a few layers with different jobs. We've tried using open and documented formats for most of it, but some data such as sidecar textures are stored in a way that allows more efficient loading. Basically, VPE is trying to solve two problems at once: + +- package a table in a way that is compact and fast to load +- keep the package independent from any specific render pipeline + +So, the scene itself lives in glTF. Gameplay and authoring metadata live in JSON. Material behavior that glTF cannot express cleanly is described in VPE's own vocabulary, with texture bytes stored separately when needed. + +## Container Structure + +The package looks like this at a high level: + +```mermaid +treeView-beta + "table/" + "table.glb" + "colliders.glb" + "table.json" + "assets/" + "global/" + "items/" + "meta/" + "colliders.json" + "sounds.json" + "materials.v1.json" + "textures.bin" + "refs/" + "sounds/" +``` + +| Part | Why it exists | +| --- | --- | +| `table.glb` | Carries the visible scene graph: hierarchy, transforms, meshes, lights, imported fallback materials, and textures that survive the glTF path cleanly. | +| `colliders.glb` | Carries meshes that exist for physics but are not naturally part of the visible glTF export. | +| `table.json` | Carries table-level metadata from `TableComponent.Metadata`. | +| `items/` | Carries component and item data needed to rebuild gameplay objects after the hierarchy exists. | +| `refs/` | Carries cross-references that can only be restored after all items and components are in place. | +| `global/` | Carries table-wide mapping data such as switches, coils, lamps, and wires. | +| `assets/` | Carries serialized `ScriptableObject` assets used by the table. | +| `sounds/` and `meta/sounds.json` | Carry sound bytes and the metadata needed to resolve them. | +| `meta/materials.v1.json` | Carries renderer-agnostic material intent for data that glTF does not express well enough for VPE. | +| `meta/textures.bin` | Carries packed texture bytes for VPE-owned textures referenced by `materials.v1.json`. | + +## Materials + +If glTF covered everything VPE needed, there would be no `materials.v1.json` and no `textures.bin`. In practice, glTF gets us a long way, but not all the way. Some authored material features are either renderer-specific, packed in ways glTF does not understand, or too fragile to trust to the fallback export/import path. + +VPE therefore uses a layered approach: + +- glTF carries what it already does well +- VPE captures the missing material intent in its own schema +- the active renderer in the player resolves that schema into real runtime materials + +That design keeps the package from depending on HDRP, while still letting an HDRP-based player reconstruct the authored look closely. + +For more details about how VPE's material abstraction, what glTF covers, what VPE adds, and what a renderer must implement, see [this page](materials.md). \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/developer-guide/packaging/materials.md b/VisualPinball.Unity/Documentation~/developer-guide/packaging/materials.md new file mode 100644 index 000000000..264b85abd --- /dev/null +++ b/VisualPinball.Unity/Documentation~/developer-guide/packaging/materials.md @@ -0,0 +1,220 @@ +--- +uid: developer-guide-packaging-materials +title: Packaging Materials +description: How VPE represents material intent beyond glTF and how a renderer consumes that contract. +--- + +# Packaging Materials + +This page explains the material side of the `.vpe` format. It is written for two audiences: + +- people trying to understand why materials are split between glTF and VPE metadata +- developers who want to implement a renderer for a different pipeline + +## glTF versus Custom Materials + +glTF already gives VPE a lot for free. It knows how to carry a scene graph, meshes, transforms, lights, images, and a useful subset of physically based material data. If we only cared about "show me a mesh with a base color and a normal map", plain glTF would be enough. + +Pinball tables are messier than that. They rely on alpha-bearing inserts and plastics, HDRP-specific mask packing, decals whose albedo alpha is part of the effect, and a handful of renderer-state details that glTF does not describe. At the same time, a `.vpe` file must remain table content, not a hard dependency on HDRP. The package should describe what the material is supposed to do, not which Unity shader property happened to be used by the authoring project. + +That is why VPE has its own material vocabulary: + +- export translates authoring materials into VPE's schema +- runtime reads that schema and asks the active renderer to realize it +- the renderer stays free to use HDRP, URP, or something else entirely + +In code, that vocabulary lives in: + +- `VpeMaterialsPayloadV1` +- `VpeMaterialProfileV1` +- `VpeTextureAssetV1` +- `VpeRendererStateV1` + +## glTF Data + +The GLB is not just a fallback, it's a real part of the material system. We deliberately keep any data on the glTF path that round-trips well enough to be worth it. + +| Concern | Where it lives | Why glTF is sufficient here | +| --- | --- | --- | +| Scene hierarchy | `table.glb` | Native glTF responsibility. | +| Meshes and transforms | `table.glb` | Native glTF responsibility. | +| Lights | `table.glb` | Exported through glTF/glTFast light support. | +| Opaque lit base color | Imported GLB material | Usually survives the standard glTF path without semantic loss. | +| Emissive textures | Imported GLB material | Standard enough to keep on the glTF path. | +| Most unlit color maps | Imported GLB material | Standard enough to keep on the glTF path. | +| Supported normal maps | Imported GLB material | Visually correct in the current shipping path, even though HDRP needs a runtime repack step. | + +When a texture stays on the GLB path, the VPE profile still owns the semantic meaning of that slot. What it does not own is the pixel payload. In those cases, the profile stores tiling or strength information, and runtime reads the actual texture from the imported material. + +## VPE Data + +VPE takes ownership where glTF is either lossy, underspecified for the feature, or too fragile in practice. + +### Texture and State + +| Authoring concern | VPE field(s) | Why it is not left to glTF | +| --- | --- | --- | +| HDRP `MaskMap` | `VpeLitProfileV1.MaskMap`, `MaskPacking` | HDRP mask packing is renderer-specific and not lossless in plain glTF. | +| HDRP `ThicknessMap` | `VpeLitProfileV1.ThicknessMap` | Thickness/transmission intent is outside plain glTF's core model. | +| Transparent / alpha-tested lit base color | `VpeLitProfileV1.BaseColor.Texture` with `TextureId` | The alpha channel is load-bearing for inserts and plastics and cannot be treated as an optional detail. | +| Decal base color | `VpeDecalProfileV1.BaseColor.Texture` with `TextureId` | The decal albedo alpha controls where the decal applies. | +| Decal mask map | `VpeDecalProfileV1.MaskMap` | Same problem as HDRP mask packing: the meaning is renderer-specific. | +| Per-renderer shadow and lighting state | `VpeRendererStateV1` | glTF does not carry Unity's shadow casting mode, receive-shadows flag, or rendering layer mask. | + +### Storage + +Those VPE-owned textures are serialized as `VpeTextureAssetV1` entries and their bytes are packed into `meta/textures.bin`. + +Each asset records: + +| Field | Meaning | +| --- | --- | +| `Id` | Stable logical identifier used by `TextureId`. | +| `ByteOffset` / `ByteLength` | Where the texture bytes live inside `textures.bin`. | +| `GlbBufferView` | Optional alternative storage location if the payload is embedded into GLB buffer views. | +| `MimeType` | Declared encoding of the stored bytes. | +| `ColorSpace` | `sRGB` or `Linear`. | +| `WrapMode`, `FilterMode`, `AnisoLevel` | Texture sampling intent. | +| `GenerateMipMaps` | Whether runtime should generate mips for the side texture. | +| `Width`, `Height` | Source dimensions. | + +## Ownership Model + +Each authored material becomes one logical VPE profile, keyed by normalized material name. The profile contains semantic intent; it does not try to preserve the authoring shader as data. + +There are two texture-reference modes in the schema: + +| Reference style | Shape | Runtime behavior | +| --- | --- | --- | +| Imported texture ref | `TextureId = null` | Read the texture from the imported GLB material. | +| Side-channel texture ref | `TextureId != null` | Resolve the texture through `VpeTextureAssetV1` and load bytes from `textures.bin` or embedded GLB data. | + +This is the heart of the design. It lets VPE say, for example, "this is the base color texture for a transparent insert" without also saying "therefore you must use this HDRP shader property bag forever". + +## Supported Material Types + +The schema currently defines three material types: + +| VPE type | Purpose | +| --- | --- | +| `vpe.lit` | Main physically based table materials, including transparency, transmission, and mask-based surface properties. | +| `vpe.decal` | Projected decal materials. | +| `vpe.unlit` | Unlit color-based materials. | + +The HDRP translator maps these authoring shaders into those types: + +| Authoring shader | VPE type | +| --- | --- | +| `HDRP/Lit` | `vpe.lit` | +| `HDRP/Decal` | `vpe.decal` | +| `HDRP/Unlit` | `vpe.unlit` | + +Unsupported shaders are not fatal. They simply do not produce a VPE profile, and runtime falls back to the imported GLB material for them. + +## Runtime Resolution + +The package never instantiates pipeline-specific materials directly. Instead, runtime performs a translation step: + +1. `VpeMaterialV1Reader` parses `materials.v1.json`. +2. It resolves side-channel textures from `textures.bin`. +3. It matches imported materials by normalized name. +4. It calls the active `IVpeMaterialResolver`. + +That last step is where the render pipeline comes back into the picture. For HDRP, `HdrpMaterialResolver` clones authored template materials, applies VPE intent onto them, and lets HDRP reconcile the resulting state. + +That separation keeps the package portable while letting the player own the final rendering behavior. + +## Specs + +This section is the renderer-facing contract. If you are implementing a new pipeline, this is the part to read carefully. Note that this is a subset of HDRP. We'll most likely add new attributes once we use them in a table build. In the tables below you'll see links to the HDRP documentation. + +### Inputs a renderer must consume + +A resolver implementation must be able to work with: + +| Input | Purpose | +| --- | --- | +| `VpeMaterialsPayloadV1` | Top-level material payload. | +| `VpeMaterialProfileV1` | Per-material semantic intent. | +| `VpeTextureAssetV1` | Metadata for side-channel texture bytes. | +| Imported fallback `Material` | Access point for textures that stay on the GLB path. | + +### Matching + +Profiles are matched to imported materials by normalized material name. A renderer should therefore treat the imported material name as the lookup key and degrade gracefully when there is no match. + +### Texture-source contract + +A renderer must support both texture-source modes: + +| Mode | How to detect it | What to do | +| --- | --- | --- | +| Imported | `TextureId == null` | Read the texture from the imported material using aliases appropriate for the pipeline. | +| Side-channel | `TextureId != null` | Resolve the `VpeTextureAssetV1`, load the bytes, create a texture, and apply the stored sampling/color-space settings. | + +### `vpe.lit` + +An implementation of `vpe.lit` should support the following fields. + +| Field | Meaning | What the renderer should do | +| --- | --- | --- | +| `BaseColor.Color` | Base albedo tint | Apply as the base color multiplier. | +| `BaseColor.Texture` | Base albedo texture | Use imported or side-channel source depending on `TextureId`. | +| `Metallic` | Scalar metallic fallback/remap anchor | Apply directly or use as part of mask remap behavior. | +| `Smoothness` | Scalar smoothness fallback/remap anchor | Apply directly or use as part of mask remap behavior. | +| `OcclusionStrength` | Occlusion contribution | Apply if the renderer supports AO strength. | +| `MaskMap` | Packed surface-property texture | Interpret according to `MaskPacking`. | +| `MaskPacking` | Declares channel meaning | Respect the declared packing; do not guess. | +| `MetallicRemap`, `SmoothnessRemap`, `AoRemap`, `AlphaRemap` | Channel remap ranges | Apply when unpacking the mask map. | +| `NormalMap.TextureId` / transform / `Strength` / `Packing` | Normal map intent | Support the declared normal packing and apply strength. | +| `Emissive.Color`, `Emissive.Texture`, `Intensity`, `IntensityUnit`, `ExposureWeight` | Emissive behavior | Map into the renderer's emissive model as closely as possible. | +| `SurfaceType` | Opaque, alpha test, or transparent | Choose the right material family and pass state. | +| `AlphaCutoff` | Alpha-test threshold | Apply when `SurfaceType` is alpha test. | +| `DoubleSided`, `DoubleSidedGi` | Double-sided behavior | Enable the renderer's double-sided handling. | +| `TransparentBlendMode` | Transparent blend intent | Map to the renderer's blend mode if supported. | +| `EnableFogOnTransparent`, `TransparentDepthPrepass`, `TransparentDepthPostpass`, `TransparentWritesMotionVectors` | Transparent rendering hints | Apply where the pipeline exposes equivalent behavior. | +| `DisableSsrTransparent`, `DisableSsr` | SSR hints | Respect if the pipeline exposes equivalent toggles. | +| `RenderQueueOverride` | Explicit queue override | Apply only when non-negative. | +| `RefractionModel`, `Ior` | Refraction behavior | Map to the pipeline's refraction model if available. | +| `HasTransmission`, `Thickness`, `ThicknessMap` | Transmission and thickness | Support if the pipeline can represent it; otherwise degrade predictably. | + +### `vpe.decal` + +| Field | Meaning | What the renderer should do | +| --- | --- | --- | +| `BaseColor.Color` and `BaseColor.Texture` | Decal color and alpha coverage | Apply as the projected decal albedo. | +| `NormalMap` | Decal normal contribution | Support if the renderer exposes decal normals. | +| `MaskMap` and `MaskPacking` | Packed decal surface properties | Interpret according to the declared packing. | +| `AffectAlbedo`, `AffectNormal`, `AffectMask` | Feature toggles | Respect which surface contributions are enabled. | +| `DecalBlend`, `NormalBlendSrc`, `MaskBlendSrc` | Blend controls | Map to the renderer's decal blending model. | +| `Smoothness`, `Metallic`, `AmbientOcclusion` | Scalar decal properties | Apply if the renderer supports them. | + +### `vpe.unlit` + +| Field | Meaning | What the renderer should do | +| --- | --- | --- | +| `BaseColor.Color` and `BaseColor.Texture` | Unlit color and texture | Use as the final color source. | +| `SurfaceType` | Opaque, alpha test, or transparent | Choose the right material family or pass state. | +| `AlphaCutoff` | Alpha-test threshold | Apply when relevant. | +| `DoubleSided` | Double-sided rendering | Enable if supported. | + +### Renderer state + +In addition to material properties, a renderer should restore `VpeRendererStateV1`: + +| Field | Meaning | +| --- | --- | +| `ShadowCastingMode` | Whether the renderer casts no shadows, normal shadows, two-sided shadows, or shadows only. | +| `ReceiveShadows` | Whether the renderer receives shadows. | +| `RenderingLayerMask` | Unity rendering-layer mask used for light-layer-style filtering. | + +### Failure behavior + +A package should remain loadable even if a renderer only implements part of the vocabulary. A good resolver therefore behaves conservatively: + +- unknown material types should be skipped with a warning +- missing texture IDs should be skipped with a warning +- unsupported semantics should degrade gracefully rather than abort package loading +- if no resolver is registered, runtime should leave the imported GLB materials in place + +That fallback behavior is not ideal visually, but it keeps the content usable while a renderer is still being brought up. \ No newline at end of file diff --git a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml index edd7d45ef..a3e014740 100644 --- a/VisualPinball.Unity/Documentation~/developer-guide/toc.yml +++ b/VisualPinball.Unity/Documentation~/developer-guide/toc.yml @@ -4,6 +4,18 @@ href: setup.md - name: Threading Model href: threading-model.md +- name: Packaging + items: + - name: Overview + href: packaging/index.md + - name: Export + href: packaging/export.md + - name: Import + href: packaging/import.md + - name: Materials + href: packaging/materials.md + - name: Benchmarks + href: packaging/benchmarks.md - name: Future Integrations items: - name: Accelerometer Input Design @@ -11,4 +23,4 @@ - name: B2S Integration Design href: b2s-integration-design.md - name: DOF Integration Design - href: dof-integration-design.md \ No newline at end of file + href: dof-integration-design.md diff --git a/VisualPinball.Unity/Documentation~/template/vpe/layout/_master.tmpl b/VisualPinball.Unity/Documentation~/template/vpe/layout/_master.tmpl index 67a770ab1..c631b9cfb 100644 --- a/VisualPinball.Unity/Documentation~/template/vpe/layout/_master.tmpl +++ b/VisualPinball.Unity/Documentation~/template/vpe/layout/_master.tmpl @@ -40,7 +40,7 @@ - +