From 802b7dd1a24ce719e88f992d9438d4873b368588 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Tue, 10 Feb 2026 16:33:02 -0800 Subject: [PATCH 01/12] export to OBJ working! able to export to OBJ from a static pose of a rigid_pose_v2 model from TW3 --- .../DependencyInjectionContainer.cs | 3 + .../Exporters/RmvToObj/RmvToObjExporter.cs | 137 ++++++++++++++++++ .../RmvToObj/RmvToObjExporterViewModel.cs | 39 +++++ 3 files changed, 179 insertions(+) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index eff9883f9..d235796a0 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -10,6 +10,7 @@ using Editors.ImportExport.Exporting.Presentation.DdsToNormalPng; using Editors.ImportExport.Exporting.Presentation.DdsToPng; using Editors.ImportExport.Exporting.Presentation.RmvToGltf; +using Editors.ImportExport.Exporting.Presentation.RmvToObj; using Editors.ImportExport.Importing; using Editors.ImportExport.Importing.Importers.GltfToRmv; using Editors.ImportExport.Importing.Importers.GltfToRmv.Helper; @@ -33,12 +34,14 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Exporters services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Importer ViewModels RegisterWindow(services); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs new file mode 100644 index 000000000..a6539a9b9 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.IO; +using System.Text; +using Editors.ImportExport.Misc; +using Shared.GameFormats.RigidModel; +using Serilog; +using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; + +namespace Editors.ImportExport.Exporting.Exporters +{ + public class RmvToObjExporter + { + private readonly ILogger _logger = Logging.Create(); + + public ExportSupportEnum CanExportFile(PackFile file) + { + var name = file.Name.ToLower(); + if (name.EndsWith(".rigidmodel") || name.EndsWith(".rmv2")) + return ExportSupportEnum.HighPriority; + return ExportSupportEnum.NotSupported; + } + + public void Export(RmvToObjExporterSettings settings) + { + try + { + _logger.Information($"Exporting RMV to OBJ: {settings.OutputPath}"); + + var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var lodLevel = rmv2.ModelList.First(); + + var sb = new StringBuilder(); + sb.AppendLine("# OBJ file exported from Total War RigidModel V2"); + sb.AppendLine($"# Model: {settings.InputModelFile.Name}"); + sb.AppendLine(); + + int globalVertexOffset = 1; // OBJ uses 1-based indexing + int meshIndex = 0; + + foreach (var rmvModel in lodLevel) + { + sb.AppendLine($"# Mesh {meshIndex}"); + sb.AppendLine($"o Mesh_{meshIndex}"); + sb.AppendLine($"usemtl {rmvModel.Material.ModelName}_Material"); + sb.AppendLine(); + + var mesh = rmvModel.Mesh; + var vertices = mesh.VertexList; + + // Write vertices + foreach (var vertex in vertices) + { + var pos = vertex.GetPosistionAsVec3(); + sb.AppendLine($"v {pos.X.ToString(CultureInfo.InvariantCulture)} {pos.Y.ToString(CultureInfo.InvariantCulture)} {pos.Z.ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write normals + foreach (var vertex in vertices) + { + var normal = vertex.Normal; + sb.AppendLine($"vn {normal.X.ToString(CultureInfo.InvariantCulture)} {normal.Y.ToString(CultureInfo.InvariantCulture)} {normal.Z.ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write UVs + foreach (var vertex in vertices) + { + var uv = vertex.Uv; + sb.AppendLine($"vt {uv.X.ToString(CultureInfo.InvariantCulture)} {(1.0f - uv.Y).ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write faces (indices) + var indices = mesh.IndexList; + for (int i = 0; i < indices.Length; i += 3) + { + int i0 = indices[i] + globalVertexOffset; + int i1 = indices[i + 1] + globalVertexOffset; + int i2 = indices[i + 2] + globalVertexOffset; + + // Format: f v/vt/vn v/vt/vn v/vt/vn + sb.AppendLine($"f {i0}/{i0}/{i0} {i1}/{i1}/{i1} {i2}/{i2}/{i2}"); + } + + sb.AppendLine(); + globalVertexOffset += vertices.Length; + meshIndex++; + } + + // Write basic MTL file + var mtlPath = Path.Combine(Path.GetDirectoryName(settings.OutputPath)!, + Path.GetFileNameWithoutExtension(settings.OutputPath) + ".mtl"); + WriteMaterialFile(lodLevel, mtlPath); + + File.WriteAllText(settings.OutputPath, sb.ToString(), Encoding.UTF8); + _logger.Information($"Successfully exported to {settings.OutputPath}"); + } + catch (Exception ex) + { + _logger.Error(ex, "Error exporting RMV to OBJ"); + throw; + } + } + + private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath) + { + var sb = new StringBuilder(); + sb.AppendLine("# MTL file for OBJ export"); + sb.AppendLine(); + + foreach (var rmvModel in lodLevel) + { + var materialName = rmvModel.Material.ModelName + "_Material"; + sb.AppendLine($"newmtl {materialName}"); + sb.AppendLine("Ka 1.0 1.0 1.0"); + sb.AppendLine("Kd 0.8 0.8 0.8"); + sb.AppendLine("Ks 0.5 0.5 0.5"); + sb.AppendLine("Ns 32.0"); + sb.AppendLine("illum 2"); + sb.AppendLine(); + } + + File.WriteAllText(mtlPath, sb.ToString(), Encoding.UTF8); + } + } + + public class RmvToObjExporterSettings + { + public PackFile InputModelFile { get; set; } + public string OutputPath { get; set; } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs new file mode 100644 index 000000000..0b6e15845 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Editors.ImportExport.Exporting.Exporters; +using Editors.ImportExport.Misc; +using Shared.Core.PackFiles.Models; +using Shared.Ui.Common.DataTemplates; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToObj +{ + internal partial class RmvToObjExporterViewModel : ObservableObject, IExporterViewModel + { + private readonly RmvToObjExporter _exporter; + + public string DisplayName => "Rmv_to_Obj"; + public string OutputExtension => ".obj"; + + public RmvToObjExporterViewModel(RmvToObjExporter exporter) + { + _exporter = exporter; + } + + public ExportSupportEnum CanExportFile(PackFile file) + { + var name = file.Name.ToLower(); + if (name.EndsWith(".rigidmodel") || name.EndsWith(".rmv2") || name.EndsWith(".rigid_model_v2")) + return ExportSupportEnum.HighPriority; + return ExportSupportEnum.NotSupported; + } + + public void Execute(PackFile exportSource, string outputPath, bool generateImporter) + { + var settings = new RmvToObjExporterSettings + { + InputModelFile = exportSource, + OutputPath = outputPath + }; + _exporter.Export(settings); + } + } +} From 19130544797409877097a4a4b0fc8e692bf0078c Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Wed, 11 Feb 2026 08:44:10 -0800 Subject: [PATCH 02/12] Mesh names now taken directly from model file name in obj export --- .../DependencyInjectionContainer.cs | 4 + .../Exporters/RmvToObj/RmvToObjExporter.cs | 285 ++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index eff9883f9..9f4dce86b 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -39,6 +39,10 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(sp => new RmvToObjExporter( + sp.GetRequiredService(), + sp.GetRequiredService() + )); // Importer ViewModels RegisterWindow(services); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs new file mode 100644 index 000000000..b1b5c96de --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using System.IO; +using System.Text; +using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; +using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using Editors.ImportExport.Misc; +using Shared.GameFormats.RigidModel; +using Shared.GameFormats.RigidModel.Types; +using Serilog; +using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; +using System.Drawing; +using System.Drawing.Imaging; + +namespace Editors.ImportExport.Exporting.Exporters +{ + public class RmvToObjExporter + { + private readonly ILogger _logger = Logging.Create(); + private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; + private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; + + public RmvToObjExporter(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter) + { + _ddsToNormalPngExporter = ddsToNormalPngExporter; + _ddsToMaterialPngExporter = ddsToMaterialPngExporter; + } + + public ExportSupportEnum CanExportFile(PackFile file) + { + var name = file.Name.ToLower(); + if (name.EndsWith(".rigidmodel") || name.EndsWith(".rmv2") || name.EndsWith(".rigid_model_v2")) + return ExportSupportEnum.HighPriority; + return ExportSupportEnum.NotSupported; + } + + public void Export(RmvToObjExporterSettings settings) + { + try + { + _logger.Information($"Exporting RMV to OBJ: {settings.OutputPath}"); + + var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var lodLevel = rmv2.ModelList.First(); + var outputDir = Path.GetDirectoryName(settings.OutputPath)!; + var baseName = Path.GetFileNameWithoutExtension(settings.OutputPath); + + var sb = new StringBuilder(); + sb.AppendLine("# OBJ file exported from Total War RigidModel V2"); + sb.AppendLine($"# Model: {settings.InputModelFile.Name}"); + sb.AppendLine(); + + int globalVertexOffset = 1; // OBJ uses 1-based indexing + int meshIndex = 0; + + foreach (var rmvModel in lodLevel) + { + var meshName = rmvModel.Material.ModelName; + sb.AppendLine($"# Mesh {meshName}"); + sb.AppendLine($"o {meshName}"); + sb.AppendLine($"usemtl {meshName}_Material"); + sb.AppendLine(); + + var mesh = rmvModel.Mesh; + var vertices = mesh.VertexList; + + // Write vertices + foreach (var vertex in vertices) + { + var pos = vertex.GetPosistionAsVec3(); + sb.AppendLine($"v {pos.X.ToString(CultureInfo.InvariantCulture)} {pos.Y.ToString(CultureInfo.InvariantCulture)} {pos.Z.ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write normals + foreach (var vertex in vertices) + { + var normal = vertex.Normal; + sb.AppendLine($"vn {normal.X.ToString(CultureInfo.InvariantCulture)} {normal.Y.ToString(CultureInfo.InvariantCulture)} {normal.Z.ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write UVs + foreach (var vertex in vertices) + { + var uv = vertex.Uv; + sb.AppendLine($"vt {uv.X.ToString(CultureInfo.InvariantCulture)} {(1.0f - uv.Y).ToString(CultureInfo.InvariantCulture)}"); + } + + sb.AppendLine(); + + // Write faces (indices) + var indices = mesh.IndexList; + for (int i = 0; i < indices.Length; i += 3) + { + int i0 = indices[i] + globalVertexOffset; + int i1 = indices[i + 1] + globalVertexOffset; + int i2 = indices[i + 2] + globalVertexOffset; + + // Format: f v/vt/vn v/vt/vn v/vt/vn + sb.AppendLine($"f {i0}/{i0}/{i0} {i1}/{i1}/{i1} {i2}/{i2}/{i2}"); + } + + sb.AppendLine(); + globalVertexOffset += vertices.Length; + meshIndex++; + } + + // Write OBJ file + File.WriteAllText(settings.OutputPath, sb.ToString(), Encoding.UTF8); + + // Write MTL file with texture references + var mtlPath = Path.Combine(outputDir, baseName + ".mtl"); + WriteMaterialFile(lodLevel, mtlPath, outputDir, baseName); + + _logger.Information($"Successfully exported to {settings.OutputPath}"); + } + catch (Exception ex) + { + _logger.Error(ex, "Error exporting RMV to OBJ"); + throw; + } + } + + private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outputDir, string baseName) + { + var sb = new StringBuilder(); + sb.AppendLine("# MTL file for OBJ export"); + sb.AppendLine(); + + int meshIndex = 0; + foreach (var rmvModel in lodLevel) + { + var materialName = rmvModel.Material.ModelName + "_Material"; + sb.AppendLine($"newmtl {materialName}"); + sb.AppendLine("Ka 1.0 1.0 1.0"); + sb.AppendLine("Kd 0.8 0.8 0.8"); + sb.AppendLine("Ks 0.5 0.5 0.5"); + sb.AppendLine("Ns 32.0"); + sb.AppendLine("illum 2"); + + // Export textures and add references to MTL + var normalTexture = rmvModel.Material.GetTexture(TextureType.Normal); + if (normalTexture != null) + { + try + { + var normalMapPath = ExportNormalMapAndCreateDisplacementMap(normalTexture, outputDir, meshIndex); + if (!string.IsNullOrEmpty(normalMapPath)) + { + var normalFileName = Path.GetFileName(normalMapPath); + var displacementFileName = Path.Combine(outputDir, + Path.GetFileNameWithoutExtension(normalMapPath) + "_displacement.png"); + var displacementFileNameOnly = Path.GetFileName(displacementFileName); + + sb.AppendLine($"map_bump {normalFileName}"); + sb.AppendLine($"disp {displacementFileNameOnly}"); + } + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to export normal map for mesh {MeshIndex}", meshIndex); + } + } + + var diffuseTexture = rmvModel.Material.GetTexture(TextureType.Diffuse) ?? + rmvModel.Material.GetTexture(TextureType.BaseColour); + if (diffuseTexture != null) + { + try + { + var diffuseMapPath = ExportDiffuseMap(diffuseTexture, outputDir, meshIndex); + if (!string.IsNullOrEmpty(diffuseMapPath)) + { + var diffuseFileName = Path.GetFileName(diffuseMapPath); + sb.AppendLine($"map_Kd {diffuseFileName}"); + } + } + catch (Exception ex) + { + _logger.Warning(ex, "Failed to export diffuse map for mesh {MeshIndex}", meshIndex); + } + } + + sb.AppendLine(); + meshIndex++; + } + + File.WriteAllText(mtlPath, sb.ToString(), Encoding.UTF8); + } + + private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, string outputDir, int meshIndex) + { + if (!texture.HasValue) + return null; + + try + { + // Create temporary path for PNG export + var normalMapFileName = $"mesh_{meshIndex}_normal.png"; + var normalMapPath = Path.Combine(outputDir, normalMapFileName); + + // Export DDS to PNG using existing exporter + var exportedPath = _ddsToNormalPngExporter.Export(texture.Value.Path, normalMapPath, convertToBlueNormalMap: true); + + if (File.Exists(exportedPath)) + { + // Load the normal map and create displacement map + using (var normalImage = new Bitmap(exportedPath)) + { + var displacementMap = ConvertNormalMapToHeightMap(normalImage); + var displacementPath = Path.Combine(outputDir, + Path.GetFileNameWithoutExtension(normalMapPath) + "_displacement.png"); + displacementMap.Save(displacementPath, ImageFormat.Png); + displacementMap.Dispose(); + } + + return exportedPath; + } + } + catch (Exception ex) + { + _logger.Error(ex, "Error exporting normal map for mesh {MeshIndex}", meshIndex); + } + + return null; + } + + private string ExportDiffuseMap(RmvTexture? texture, string outputDir, int meshIndex) + { + if (!texture.HasValue) + return null; + + try + { + var diffuseMapFileName = $"mesh_{meshIndex}_diffuse.png"; + var diffuseMapPath = Path.Combine(outputDir, diffuseMapFileName); + + // Export DDS to PNG using existing exporter + var exportedPath = _ddsToMaterialPngExporter.Export(texture.Value.Path, diffuseMapPath, convertToBlenderFormat: true); + return exportedPath; + } + catch (Exception ex) + { + _logger.Error(ex, "Error exporting diffuse map for mesh {MeshIndex}", meshIndex); + } + + return null; + } + + /// + /// Converts a normal map to a height/displacement map. + /// Extracts the Z component (blue channel) from the normal map to create height data. + /// + private Bitmap ConvertNormalMapToHeightMap(Bitmap normalMap) + { + var heightMap = new Bitmap(normalMap.Width, normalMap.Height); + + for (int y = 0; y < normalMap.Height; y++) + { + for (int x = 0; x < normalMap.Width; x++) + { + var normal = normalMap.GetPixel(x, y); + + // Extract Z component (blue channel) as height + byte heightValue = normal.B; + + // Create grayscale height map (same value for R, G, B) + var heightColor = Color.FromArgb(normal.A, heightValue, heightValue, heightValue); + heightMap.SetPixel(x, y, heightColor); + } + } + + return heightMap; + } + } + + public class RmvToObjExporterSettings + { + public PackFile InputModelFile { get; set; } + public string OutputPath { get; set; } + } +} From b0675fb37f30622b4eec5113c91327841208ed76 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Wed, 11 Feb 2026 12:20:34 -0800 Subject: [PATCH 03/12] not working end to end, running into compiler errors --- .../Core/MenuBarViews/MenuBarViewModel.cs | 3 +- .../Editors.KitbasherEditor.csproj | 1 + .../UiCommands/QuickExportPosedMeshCommand.cs | 141 ++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs diff --git a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs index b286ded86..1b247be96 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs @@ -55,6 +55,7 @@ void RegisterActions() { RegisterUiCommand(); RegisterUiCommand(); + RegisterUiCommand(); RegisterUiCommand(); RegisterUiCommand(); @@ -105,6 +106,7 @@ ObservableCollection CreateToolbarMenu() var fileToolbar = builder.CreateRootToolBar("File"); builder.CreateToolBarItem(fileToolbar, "Save"); builder.CreateToolBarItem(fileToolbar, "Save As"); + builder.CreateToolBarItem(fileToolbar, "Quick Export as OBJ"); builder.CreateToolBarSeparator(fileToolbar); builder.CreateToolBarItem(fileToolbar, "Import Reference model"); @@ -123,7 +125,6 @@ ObservableCollection CreateToolbarMenu() builder.CreateToolBarItem(renderingToolbar, "Reset camera"); builder.CreateToolBarItem(renderingToolbar, "Open render settings"); - return builder.Build(); } diff --git a/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj b/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj index b7ec24eec..6a24c8f28 100644 --- a/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj +++ b/Editors/Kitbashing/KitbasherEditor/Editors.KitbasherEditor.csproj @@ -23,6 +23,7 @@ + diff --git a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs new file mode 100644 index 000000000..73e6f4801 --- /dev/null +++ b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Editors.ImportExport.Exporting; +using Editors.KitbasherEditor.Core.MenuBarViews; +using GameWorld.Core.Commands; +using GameWorld.Core.Commands.Object; +using GameWorld.Core.Components; +using GameWorld.Core.Components.Selection; +using GameWorld.Core.SceneNodes; +using GameWorld.Core.Services.SceneSaving; +using Shared.Core.Misc; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Ui.Common.MenuSystem; +using MessageBox = System.Windows.MessageBox; + +namespace Editors.KitbasherEditor.UiCommands +{ + public class QuickExportPosedMeshCommand : ITransientKitbasherUiCommand + { + public string ToolTip { get; set; } = "Pose selected mesh at current frame and open export dialog"; + public ActionEnabledRule EnabledRule => ActionEnabledRule.AtleastOneObjectSelected; + public Hotkey? HotKey { get; } = null; + + private readonly AnimationsContainerComponent _animationsContainerComponent; + private readonly SelectionManager _selectionManager; + private readonly CommandFactory _commandFactory; + private readonly SceneManager _sceneManager; + private readonly SaveService _saveService; + private readonly GeometrySaveSettings _saveSettings; + private readonly IExportFileContextMenuHelper _exportFileContextMenuHelper; + + public QuickExportPosedMeshCommand( + AnimationsContainerComponent animationsContainerComponent, + SelectionManager selectionManager, + CommandFactory commandFactory, + SceneManager sceneManager, + SaveService saveService, + GeometrySaveSettings saveSettings, + IExportFileContextMenuHelper exportFileContextMenuHelper) + { + _animationsContainerComponent = animationsContainerComponent; + _selectionManager = selectionManager; + _commandFactory = commandFactory; + _sceneManager = sceneManager; + _saveService = saveService; + _saveSettings = saveSettings; + _exportFileContextMenuHelper = exportFileContextMenuHelper; + } + + public void Execute() + { + try + { + // Get the current animation frame + var animationPlayers = _animationsContainerComponent; + var mainPlayer = animationPlayers.Get("MainPlayer"); + + var frame = mainPlayer.GetCurrentAnimationFrame(); + if (frame is null) + { + MessageBox.Show("An animation must be playing for this tool to work"); + return; + } + + var state = _selectionManager.GetState(); + var selectedObjects = state.SelectedObjects(); + var selectedMeshNodes = selectedObjects.OfType().ToList(); + + if (!selectedMeshNodes.Any()) + { + MessageBox.Show("No mesh objects selected"); + return; + } + + // Step 1: Create a posed static mesh (same as CreateStaticMeshCommand) + var meshes = new List(); + var groupNodeContainer = new GroupNode("posedMesh_Export"); + var root = _sceneManager.GetNodeByName(SpecialNodes.EditableModel); + var lod0 = root.GetLodNodes()[0]; + lod0.AddObject(groupNodeContainer); + + foreach (var meshNode in selectedMeshNodes) + { + var cpy = SceneNodeHelper.CloneNode(meshNode); + groupNodeContainer.AddObject(cpy); + meshes.Add(cpy); + } + + // Step 2: Pose the meshes at the current animation frame + _commandFactory.Create() + .IsUndoable(false) + .Configure(x => x.Configure(meshes, frame, true)) + .BuildAndExecute(); + + // Step 3: Save the posed mesh to a temporary file + var tempPath = Path.Combine(Path.GetTempPath(), $"posed_{Guid.NewGuid():N}.rigid_model_v2"); + + // Update save settings with temp path + _saveSettings.OutputName = tempPath; + + var saveResult = _saveService.Save(root, _saveSettings); + if (saveResult is null || !File.Exists(tempPath)) + { + MessageBox.Show("Failed to save posed mesh"); + return; + } + + // Step 4: Create a PackFile from the temporary file and export + try + { + var posedPackFile = new PackFile(Path.GetFileName(tempPath), + new MemoryPackFileDataSource(File.ReadAllBytes(tempPath))); + + _exportFileContextMenuHelper.ShowDialog(posedPackFile); + + // Clean up temp file after export dialog closes + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch + { + // Ignore cleanup errors + } + } + catch (Exception ex) + { + MessageBox.Show($"Error exporting posed mesh: {ex.Message}"); + } + } + catch (Exception ex) + { + MessageBox.Show($"Error in quick export: {ex.Message}"); + } + } + } +} From 9333ddab61ec839cef83eb4b1974b2487c219cd2 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Wed, 11 Feb 2026 14:11:29 -0800 Subject: [PATCH 04/12] Add Rmv->Obj exporter and improve export Register and add an Rmv->OBJ exporter and its ViewModel, and update exporter logic and quick-export flow. Changes include: registering RmvToObjExporterViewModel in DI and adding RmvToObjExporterViewModel file; update RmvToObjExporter to use model names for exported normal/diffuse filenames, append a Kd line, and change export helper signatures to accept mesh/model name; enhance normal->height conversion with strength/contrast/blur parameters and a simple separable box blur implementation. Also harden QuickExportPosedMeshCommand: manage original mesh visibility, clone typed mesh nodes, temporarily replace LOD children for saving, handle saveResult and in-memory serialization to create PackFile bytes robustly, and restore scene state in a finally block. These changes improve filename consistency, material output, displacement generation quality, and reliability of quick exports. --- .../DependencyInjectionContainer.cs | 2 + .../Exporters/RmvToObj/RmvToObjExporter.cs | 99 ++++++++++++++++--- .../RmvToObj/RmvToObjExporterViewModel.cs | 28 ++++++ .../UiCommands/QuickExportPosedMeshCommand.cs | 90 +++++++++++++++-- 4 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index 9f4dce86b..96287173f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -10,6 +10,7 @@ using Editors.ImportExport.Exporting.Presentation.DdsToNormalPng; using Editors.ImportExport.Exporting.Presentation.DdsToPng; using Editors.ImportExport.Exporting.Presentation.RmvToGltf; +using Editors.ImportExport.Exporting.Presentation.RmvToObj; using Editors.ImportExport.Importing; using Editors.ImportExport.Importing.Importers.GltfToRmv; using Editors.ImportExport.Importing.Importers.GltfToRmv.Helper; @@ -33,6 +34,7 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); // Exporters services.AddTransient(); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs index b1b5c96de..ed0c23fb8 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs @@ -147,11 +147,11 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu { try { - var normalMapPath = ExportNormalMapAndCreateDisplacementMap(normalTexture, outputDir, meshIndex); + var normalMapPath = ExportNormalMapAndCreateDisplacementMap(normalTexture, outputDir, rmvModel.Material.ModelName, meshIndex); if (!string.IsNullOrEmpty(normalMapPath)) { var normalFileName = Path.GetFileName(normalMapPath); - var displacementFileName = Path.Combine(outputDir, + var displacementFileName = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(normalMapPath) + "_displacement.png"); var displacementFileNameOnly = Path.GetFileName(displacementFileName); @@ -164,18 +164,19 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu _logger.Warning(ex, "Failed to export normal map for mesh {MeshIndex}", meshIndex); } } - - var diffuseTexture = rmvModel.Material.GetTexture(TextureType.Diffuse) ?? + var diffuseTexture = rmvModel.Material.GetTexture(TextureType.Diffuse) ?? rmvModel.Material.GetTexture(TextureType.BaseColour); if (diffuseTexture != null) { try { - var diffuseMapPath = ExportDiffuseMap(diffuseTexture, outputDir, meshIndex); + var diffuseMapPath = ExportDiffuseMap(diffuseTexture, outputDir, rmvModel.Material.ModelName, meshIndex); if (!string.IsNullOrEmpty(diffuseMapPath)) { var diffuseFileName = Path.GetFileName(diffuseMapPath); sb.AppendLine($"map_Kd {diffuseFileName}"); + // Also set Kd to use the diffuse texture color + sb.AppendLine($"Kd 1.0 1.0 1.0"); } } catch (Exception ex) @@ -191,7 +192,7 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu File.WriteAllText(mtlPath, sb.ToString(), Encoding.UTF8); } - private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, string outputDir, int meshIndex) + private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, string outputDir, string meshName, int meshIndex) { if (!texture.HasValue) return null; @@ -199,7 +200,7 @@ private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, stri try { // Create temporary path for PNG export - var normalMapFileName = $"mesh_{meshIndex}_normal.png"; + var normalMapFileName = $"{meshName}_normal.png"; var normalMapPath = Path.Combine(outputDir, normalMapFileName); // Export DDS to PNG using existing exporter @@ -228,14 +229,14 @@ private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, stri return null; } - private string ExportDiffuseMap(RmvTexture? texture, string outputDir, int meshIndex) + private string ExportDiffuseMap(RmvTexture? texture, string outputDir, string meshName, int meshIndex) { if (!texture.HasValue) return null; try { - var diffuseMapFileName = $"mesh_{meshIndex}_diffuse.png"; + var diffuseMapFileName = $"{meshName}_diffuse.png"; var diffuseMapPath = Path.Combine(outputDir, diffuseMapFileName); // Export DDS to PNG using existing exporter @@ -254,8 +255,10 @@ private string ExportDiffuseMap(RmvTexture? texture, string outputDir, int meshI /// Converts a normal map to a height/displacement map. /// Extracts the Z component (blue channel) from the normal map to create height data. /// - private Bitmap ConvertNormalMapToHeightMap(Bitmap normalMap) + private Bitmap ConvertNormalMapToHeightMap(Bitmap normalMap, float strength = 0.5f, float contrast = 0.0f, int blurRadius = 0) { + // Strength controls how strongly the normal's Z axis affects height (0..1) + // Contrast adjusts the final curve (-1..1) var heightMap = new Bitmap(normalMap.Width, normalMap.Height); for (int y = 0; y < normalMap.Height; y++) @@ -263,18 +266,86 @@ private Bitmap ConvertNormalMapToHeightMap(Bitmap normalMap) for (int x = 0; x < normalMap.Width; x++) { var normal = normalMap.GetPixel(x, y); - - // Extract Z component (blue channel) as height - byte heightValue = normal.B; - // Create grayscale height map (same value for R, G, B) + // Use luminance of the normal as a simpler proxy for displacement + // (weights: Rec. 601) and remap around mid-gray with strength. + float r = normal.R / 255f; + float g = normal.G / 255f; + float b = normal.B / 255f; + float lum = 0.299f * r + 0.587f * g + 0.114f * b; + + // Remap so that 0.5 -> mid-gray baseline, and apply strength + float h = (lum - 0.5f) * strength + 0.5f; + + // Apply simple contrast tweak: contrast in [-1,1] + if (Math.Abs(contrast) > 0.0001f) + { + h = 0.5f + (h - 0.5f) * (1f + contrast); + } + + // Clamp and convert + h = MathF.Min(1f, MathF.Max(0f, h)); + byte heightValue = (byte)(h * 255f); + var heightColor = Color.FromArgb(normal.A, heightValue, heightValue, heightValue); heightMap.SetPixel(x, y, heightColor); } } + // Optional simple box blur (if requested) + if (blurRadius > 0) + { + return BoxBlur(heightMap, blurRadius); + } + return heightMap; } + + // Very small and simple box blur implementation (separable) to avoid external deps + private Bitmap BoxBlur(Bitmap src, int radius) + { + var w = src.Width; + var h = src.Height; + var tmp = new Bitmap(w, h); + + // horizontal pass + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + int r = 0, g = 0, b = 0, a = 0, count = 0; + for (int k = -radius; k <= radius; k++) + { + int sx = x + k; + if (sx < 0 || sx >= w) continue; + var c = src.GetPixel(sx, y); + r += c.R; g += c.G; b += c.B; a += c.A; count++; + } + tmp.SetPixel(x, y, Color.FromArgb(a / Math.Max(1, count), r / Math.Max(1, count), g / Math.Max(1, count), b / Math.Max(1, count))); + } + } + + var dst = new Bitmap(w, h); + // vertical pass + for (int x = 0; x < w; x++) + { + for (int y = 0; y < h; y++) + { + int r = 0, g = 0, b = 0, a = 0, count = 0; + for (int k = -radius; k <= radius; k++) + { + int sy = y + k; + if (sy < 0 || sy >= h) continue; + var c = tmp.GetPixel(x, sy); + r += c.R; g += c.G; b += c.B; a += c.A; count++; + } + dst.SetPixel(x, y, Color.FromArgb(a / Math.Max(1, count), r / Math.Max(1, count), g / Math.Max(1, count), b / Math.Max(1, count))); + } + } + + tmp.Dispose(); + return dst; + } } public class RmvToObjExporterSettings diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs new file mode 100644 index 000000000..3202ab8df --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs @@ -0,0 +1,28 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Editors.ImportExport.Exporting.Exporters; +using Editors.ImportExport.Misc; +using Shared.Core.PackFiles.Models; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToObj +{ + internal partial class RmvToObjExporterViewModel : ObservableObject, Editors.ImportExport.Exporting.Exporters.IExporterViewModel + { + private readonly RmvToObjExporter _exporter; + + public string DisplayName => "Rmv_to_Obj"; + public string OutputExtension => ".obj"; + + public RmvToObjExporterViewModel(RmvToObjExporter exporter) + { + _exporter = exporter; + } + + public ExportSupportEnum CanExportFile(PackFile file) => _exporter.CanExportFile(file); + + public void Execute(PackFile exportSource, string outputPath, bool generateImporter) + { + var settings = new RmvToObjExporterSettings { InputModelFile = exportSource, OutputPath = outputPath }; + _exporter.Export(settings); + } + } +} diff --git a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs index 73e6f4801..e02198e59 100644 --- a/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs +++ b/Editors/Kitbashing/KitbasherEditor/UiCommands/QuickExportPosedMeshCommand.cs @@ -14,6 +14,8 @@ using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; using Shared.Ui.Common.MenuSystem; +using Shared.GameFormats.RigidModel; +using Shared.Ui.BaseDialogs.PackFileTree.ContextMenu.External; // <- added using MessageBox = System.Windows.MessageBox; namespace Editors.KitbasherEditor.UiCommands @@ -82,11 +84,20 @@ public void Execute() var lod0 = root.GetLodNodes()[0]; lod0.AddObject(groupNodeContainer); + // Hide originals while we create posed copies so they are not included in the save + var originalVisibility = new Dictionary(); foreach (var meshNode in selectedMeshNodes) { - var cpy = SceneNodeHelper.CloneNode(meshNode); - groupNodeContainer.AddObject(cpy); - meshes.Add(cpy); + originalVisibility[meshNode] = meshNode.IsVisible; + meshNode.IsVisible = false; + + var cpy = SceneNodeHelper.CloneNode(meshNode) as Rmv2MeshNode; + if (cpy != null) + { + cpy.IsVisible = true; + groupNodeContainer.AddObject(cpy); + meshes.Add(cpy); + } } // Step 2: Pose the meshes at the current animation frame @@ -101,9 +112,32 @@ public void Execute() // Update save settings with temp path _saveSettings.OutputName = tempPath; + // Temporarily replace LOD children so only the posed group is saved + var lodNodes = root.GetLodNodes(); + var originalLodChildren = new Dictionary>(); + foreach (var lod in lodNodes) + { + originalLodChildren[lod] = new List(lod.Children); + lod.Children.Clear(); + } + + // Add posed group to lod0 for saving + if (lodNodes.Count > 0) + lodNodes[0].AddObject(groupNodeContainer); + var saveResult = _saveService.Save(root, _saveSettings); - if (saveResult is null || !File.Exists(tempPath)) + if (saveResult is null || saveResult.Status == false) { + // Restore LOD children before returning + foreach (var kv in originalLodChildren) + { + var lod = kv.Key; + var list = kv.Value; + lod.Children.Clear(); + foreach (var child in list) + lod.AddObject(child); + } + MessageBox.Show("Failed to save posed mesh"); return; } @@ -111,8 +145,30 @@ public void Execute() // Step 4: Create a PackFile from the temporary file and export try { - var posedPackFile = new PackFile(Path.GetFileName(tempPath), - new MemoryPackFileDataSource(File.ReadAllBytes(tempPath))); + // If the save returned an in-memory RmvFile, serialize that; otherwise read the file bytes + byte[] fileBytes; + if (saveResult.GeneratedMesh != null) + { + // Serialize RmvFile to bytes + fileBytes = ModelFactory.Create().Save(saveResult.GeneratedMesh); + } + else if (!string.IsNullOrWhiteSpace(saveResult.GeneratedMeshPath) && File.Exists(saveResult.GeneratedMeshPath)) + { + fileBytes = File.ReadAllBytes(saveResult.GeneratedMeshPath); + } + else + { + // Fallback: try reading tempPath if it exists + if (File.Exists(tempPath)) + fileBytes = File.ReadAllBytes(tempPath); + else + { + MessageBox.Show("Unable to obtain generated mesh bytes for export"); + return; + } + } + + var posedPackFile = PackFile.CreateFromBytes(Path.GetFileName(tempPath), fileBytes); _exportFileContextMenuHelper.ShowDialog(posedPackFile); @@ -126,6 +182,28 @@ public void Execute() { // Ignore cleanup errors } + finally + { + // Restore original visibility and remove the temporary posed group + foreach (var kv in originalVisibility) + { + kv.Key.IsVisible = kv.Value; + } + + // Remove posed group from scene + var parent = groupNodeContainer.Parent; + if (parent != null) + parent.RemoveObject(groupNodeContainer); + // Restore original LOD children + foreach (var kv in originalLodChildren) + { + var lod = kv.Key; + var list = kv.Value; + lod.Children.Clear(); + foreach (var child in list) + lod.AddObject(child); + } + } } catch (Exception ex) { From 9441bf17adbf5bd0f381154299286e4a8ca04d96 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Wed, 11 Feb 2026 14:17:33 -0800 Subject: [PATCH 05/12] changed menu item to a better name --- .../KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs index 1b247be96..7865471df 100644 --- a/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs +++ b/Editors/Kitbashing/KitbasherEditor/Core/MenuBarViews/MenuBarViewModel.cs @@ -106,7 +106,7 @@ ObservableCollection CreateToolbarMenu() var fileToolbar = builder.CreateRootToolBar("File"); builder.CreateToolBarItem(fileToolbar, "Save"); builder.CreateToolBarItem(fileToolbar, "Save As"); - builder.CreateToolBarItem(fileToolbar, "Quick Export as OBJ"); + builder.CreateToolBarItem(fileToolbar, "Advanced Export (Current Frame)"); builder.CreateToolBarSeparator(fileToolbar); builder.CreateToolBarItem(fileToolbar, "Import Reference model"); From 36723fdd91e9818efa7cc5e9c748f043d68e008a Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Wed, 11 Feb 2026 18:47:43 -0800 Subject: [PATCH 06/12] Ensure PNG quality and apply diffuse mask Re-encode exported PNGs to high-quality 32bpp and apply mask premultiplication for diffuse textures. Adds System.Drawing.Drawing2D import, EnsurePngHighQuality helper to re-save PNGs with high-quality drawing and 32bpp settings, and PremultiplyDiffuseWithMask to combine an exported mask's luminance into diffuse alpha. Calls to re-encode normals, displacements, diffuse maps and exporter outputs were added (wrapped in try/catch to avoid breaking exports). Logging preserved and failures are ignored to keep exports resilient. --- .../Exporters/RmvToObj/RmvToObjExporter.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs index ed0c23fb8..04a08c31d 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs @@ -11,6 +11,7 @@ using Shared.Core.PackFiles.Models; using System.Drawing; using System.Drawing.Imaging; +using System.Drawing.Drawing2D; namespace Editors.ImportExport.Exporting.Exporters { @@ -157,6 +158,10 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu sb.AppendLine($"map_bump {normalFileName}"); sb.AppendLine($"disp {displacementFileNameOnly}"); + + // Re-encode exported normal and displacement to high-quality PNG + try { EnsurePngHighQuality(normalMapPath); } catch { } + try { if (File.Exists(displacementFileName)) EnsurePngHighQuality(displacementFileName); } catch { } } } catch (Exception ex) @@ -164,6 +169,7 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu _logger.Warning(ex, "Failed to export normal map for mesh {MeshIndex}", meshIndex); } } + var diffuseTexture = rmvModel.Material.GetTexture(TextureType.Diffuse) ?? rmvModel.Material.GetTexture(TextureType.BaseColour); if (diffuseTexture != null) @@ -173,10 +179,32 @@ private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outpu var diffuseMapPath = ExportDiffuseMap(diffuseTexture, outputDir, rmvModel.Material.ModelName, meshIndex); if (!string.IsNullOrEmpty(diffuseMapPath)) { + // If there's a mask texture or skin mask, export it and premultiply into diffuse alpha + var maskTex = rmvModel.Material.GetTexture(TextureType.Mask) ?? rmvModel.Material.GetTexture(TextureType.Skin_mask); + if (maskTex != null) + { + try + { + var maskPath = _ddsToMaterialPngExporter.Export(maskTex.Value.Path, Path.Combine(outputDir, rmvModel.Material.ModelName + "_mask.png"), false); + if (!string.IsNullOrEmpty(maskPath) && File.Exists(maskPath) && File.Exists(diffuseMapPath)) + { + var premultPath = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(diffuseMapPath) + "_premult.png"); + PremultiplyDiffuseWithMask(diffuseMapPath, maskPath, premultPath); + diffuseMapPath = premultPath; + } + } + catch (Exception ex) + { + _logger.Here().Warning(ex, "Failed to export or apply mask for mesh {MeshIndex}", meshIndex); + } + } + var diffuseFileName = Path.GetFileName(diffuseMapPath); sb.AppendLine($"map_Kd {diffuseFileName}"); // Also set Kd to use the diffuse texture color sb.AppendLine($"Kd 1.0 1.0 1.0"); + + try { EnsurePngHighQuality(diffuseMapPath); } catch { } } } catch (Exception ex) @@ -208,6 +236,8 @@ private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, stri if (File.Exists(exportedPath)) { + // Ensure exported PNG is high-quality (32bpp, proper encoding) + try { EnsurePngHighQuality(exportedPath); } catch { } // Load the normal map and create displacement map using (var normalImage = new Bitmap(exportedPath)) { @@ -241,6 +271,8 @@ private string ExportDiffuseMap(RmvTexture? texture, string outputDir, string me // Export DDS to PNG using existing exporter var exportedPath = _ddsToMaterialPngExporter.Export(texture.Value.Path, diffuseMapPath, convertToBlenderFormat: true); + // Ensure exported PNG is high-quality + try { EnsurePngHighQuality(exportedPath); } catch { } return exportedPath; } catch (Exception ex) @@ -346,6 +378,68 @@ private Bitmap BoxBlur(Bitmap src, int radius) tmp.Dispose(); return dst; } + + private void EnsurePngHighQuality(string path) + { + try + { + using var bmp = new Bitmap(path); + using var high = new Bitmap(bmp.Width, bmp.Height, PixelFormat.Format32bppArgb); + using var g = Graphics.FromImage(high); + g.CompositingMode = CompositingMode.SourceCopy; + g.CompositingQuality = CompositingQuality.HighQuality; + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + g.SmoothingMode = SmoothingMode.HighQuality; + g.PixelOffsetMode = PixelOffsetMode.HighQuality; + g.DrawImage(bmp, 0, 0, high.Width, high.Height); + + var encoder = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Png.Guid); + var encParams = new System.Drawing.Imaging.EncoderParameters(1); + encParams.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.ColorDepth, (long)32); + high.Save(path, encoder, encParams); + } + catch + { + // ignore + } + } + + private void PremultiplyDiffuseWithMask(string diffusePath, string maskPath, string outputPath) + { + try + { + using var diffuse = new Bitmap(diffusePath); + using var mask = new Bitmap(maskPath); + + int w = Math.Min(diffuse.Width, mask.Width); + int h = Math.Min(diffuse.Height, mask.Height); + using var outBmp = new Bitmap(w, h, PixelFormat.Format32bppArgb); + + for (int y = 0; y < h; y++) + { + for (int x = 0; x < w; x++) + { + var dc = diffuse.GetPixel(x, y); + var mc = mask.GetPixel(x, y); + // use mask luminance as alpha + float alpha = (mc.R * 0.299f + mc.G * 0.587f + mc.B * 0.114f) / 255f; + // premultiply color + int r = (int)(dc.R * alpha); + int g = (int)(dc.G * alpha); + int b = (int)(dc.B * alpha); + int a = (int)(alpha * 255); + outBmp.SetPixel(x, y, Color.FromArgb(a, r, g, b)); + } + } + + outBmp.Save(outputPath, ImageFormat.Png); + try { EnsurePngHighQuality(outputPath); } catch { } + } + catch + { + // ignore + } + } } public class RmvToObjExporterSettings From 2829a6bf132480ac93ef5b8b421fe261c317540f Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Sat, 14 Feb 2026 14:52:00 -0800 Subject: [PATCH 07/12] added alpha mask, static gltf exporter, and 3d print optimized export --- Docs/Static_Mesh_Export_Guide.md | 217 +++++++++ .../DependencyInjectionContainer.cs | 11 +- .../Editors.ImportExport.csproj | 6 + .../RmvToGltf/Helpers/AlphaMaskCombiner.cs | 88 ++++ .../RmvToGltf/Helpers/GltfMeshBuilder.cs | 90 +++- .../Helpers/GltfStaticMeshBuilder.cs | 203 ++++++++ .../RmvToGltf/Helpers/GltfTextureHandler.cs | 91 +++- .../RmvToGltf/Helpers/MeshOptimizer3DPrint.cs | 176 +++++++ .../Exporters/RmvToGltf/RmvToGltfExporter.cs | 5 +- .../RmvToGltf/RmvToGltfStaticExporter.cs | 113 +++++ .../Exporters/RmvToObj/RmvToObjExporter.cs | 450 ------------------ .../RmvToGltfStaticExporterViewModel.cs | 37 ++ .../RmvToObj/RmvToObjExporterViewModel.cs | 28 -- GltfMeshBuilder.cs | 16 + RmvToGltfExporter.cs | 1 + 15 files changed, 1018 insertions(+), 514 deletions(-) create mode 100644 Docs/Static_Mesh_Export_Guide.md create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs delete mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs delete mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs create mode 100644 GltfMeshBuilder.cs create mode 100644 RmvToGltfExporter.cs diff --git a/Docs/Static_Mesh_Export_Guide.md b/Docs/Static_Mesh_Export_Guide.md new file mode 100644 index 000000000..9c5ff4d2e --- /dev/null +++ b/Docs/Static_Mesh_Export_Guide.md @@ -0,0 +1,217 @@ +# GLTF Static Mesh Export - 3D Printing Guide + +## What's New + +Automatic enhancements to static mesh export: +- **Vertex validation** - Normals/tangents validated and corrected +- **Alpha masks** - Baked into diffuse as RGBA texture +- **PBR materials** - Smart setup with automatic alpha modes +- **Full textures** - Emissive, Occlusion, Normal, Metallic/Roughness + +**All automatic, zero configuration.** + +--- + +## Quick Start + +### Export +1. Select model → Export GLTF Static +2. Choose output folder +3. All optimizations applied automatically + +### Output +``` +model.gltf +model.bin +texture_diffuse.png +texture_diffuse_with_alpha.png (if masked) +texture_normal.png +texture_occlusion.png +``` + +--- + +## Blender Workflow + +### Import +``` +File → Import → glTF 2.0 +Select model → Import glTF 2.0 +``` + +### Setup Transparency (If Needed) +1. Select material +2. Blend Mode → "Alpha Clip" (sharp) or "Blend" (soft) +3. Adjust Alpha Clip Threshold (default 0.5) + +### Prepare for Printing +``` +1. Check scale (Object Properties → Scale) +2. Object → Transform → Apply All Transforms +3. Mesh → Cleanup → Smart Cleanup + ✓ Degenerate Faces + ✓ Unused Vertices +``` + +### Export to Slicer +**STL (most common):** +``` +File → Export As → Stereolithography (.stl) +Export +``` + +**3MF (better):** +``` +File → Export As → 3D Manufacturing Format (.3mf) +Export +``` + +--- + +## Features + +### Vertex Validation +- Zero normals → Safe default (0, 0, 1) +- Non-unit normals → Auto-normalized +- Invalid tangents → Generated perpendicular to normal +- Bad handedness → Fixed to ±1 + +**Result:** Clean rendering, no artifacts + +### Alpha Masks +- **Detects:** Diffuse + mask textures together +- **Combines:** RGB + grayscale into RGBA +- **Exports:** `texture_diffuse_with_alpha.png` +- **Sets:** Material to MASK mode + +**Result:** Transparent parts (capes, fur, wings) import correctly + +### Smart Materials +**Detection:** +- By texture type (has mask) +- By material name (cape, fur, wing, feather, hair, foliage, leaf, chain) +- By texture filename patterns + +**Modes:** +- **OPAQUE** - Solid (default) +- **MASK** - Sharp edges (capes, leaves) +- **BLEND** - Soft transparency + +### Textures Exported +| Type | Format | Color Space | +|------|--------|-------------| +| Diffuse | PNG RGB | sRGB | +| Diffuse+Alpha | PNG RGBA | sRGB | +| Normal | PNG RGB | Linear | +| Metallic/Roughness | PNG RGB | Linear | +| Occlusion | PNG RGB | Linear | +| Emissive | PNG RGB | sRGB | + +--- + +## Troubleshooting + +### Geometry Distorted +``` +Mesh → Normals → Recalculate +Object → Transform → Apply All Transforms +Check scale: 1, 1, 1 +``` + +### Alpha Not Showing +``` +1. Material Properties → Blend Mode: "Alpha Clip" +2. Viewport: Material Preview (Z key) +3. Adjust Alpha Clip Threshold (0.5 default) +``` + +### Normal Maps Wrong +``` +Image Texture (normal): + ✓ Color Space: Linear (NOT sRGB!) +Principled BSDF: + ✓ Normal input: Connected +``` + +### Slicer Errors +``` +Mesh → Cleanup → Smart Cleanup +(Run all options) +Export fresh STL +Try again +``` + +### Wrong Size +``` +Press S (scale) +Type new scale (2 = double, 0.5 = half) +Press Enter +Object → Transform → Apply All Transforms +``` + +--- + +## Technical + +### Normal Validation +``` +1. length² < 0.0001 → Use (0,0,1) +2. |length² - 1.0| > 0.001 → Normalize +Result: Valid unit-length normal +``` + +### Tangent Validation +``` +1. length² < 0.0001 → Generate perpendicular +2. |length² - 1.0| > 0.001 → Normalize +3. W component → Ensure ±1 +Result: Valid tangent with handedness +``` + +### Alpha Combining +``` +Input: Diffuse DDS + Mask DDS +1. Convert to bitmaps +2. Take diffuse RGB +3. Use mask grayscale as alpha +4. Save PNG RGBA +Output: texture_diffuse_with_alpha.png +``` + +--- + +--- + +## Material Detection Examples + +**"character_cape_Material"** → Contains "cape" → MASK mode +**"wolf_fur_Material"** → Contains "fur" → MASK mode +**"dragon_wing_Material"** → Contains "wing" → MASK mode +**Diffuse + Mask textures** → Both present → MASK mode +**Diffuse only** → No mask → OPAQUE mode + +--- + +## Color Space + +Set in Blender Image Texture nodes: + +| Texture | Color Space | +|---------|-------------| +| Diffuse/Color | sRGB | +| Normal | Linear | +| Metallic/Roughness | Linear | +| Occlusion | Linear | +| Emissive | sRGB | + +--- + +## Files Modified + +- `GltfStaticMeshBuilder.cs` - Mesh building + validation +- `GltfTextureHandler.cs` - Texture handling + combining +- `AlphaMaskCombiner.cs` - Diffuse + mask merging + +--- + +**All improvements automatic and transparent. Nothing breaks existing workflows.** diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index 96287173f..e70f63704 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -2,6 +2,7 @@ using Editors.ImportExport.Exporting.Exporters; using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; +using Editors.ImportExport.Exporting.Exporters.DdsToDisplacementMap; using Editors.ImportExport.Exporting.Exporters.DdsToPng; using Editors.ImportExport.Exporting.Exporters.RmvToGltf; using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; @@ -10,7 +11,6 @@ using Editors.ImportExport.Exporting.Presentation.DdsToNormalPng; using Editors.ImportExport.Exporting.Presentation.DdsToPng; using Editors.ImportExport.Exporting.Presentation.RmvToGltf; -using Editors.ImportExport.Exporting.Presentation.RmvToObj; using Editors.ImportExport.Importing; using Editors.ImportExport.Importing.Importers.GltfToRmv; using Editors.ImportExport.Importing.Importers.GltfToRmv.Helper; @@ -34,17 +34,14 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); // Exporters services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(sp => new RmvToObjExporter( - sp.GetRequiredService(), - sp.GetRequiredService() - )); + services.AddTransient(); // Importer ViewModels RegisterWindow(services); @@ -66,11 +63,13 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); RegisterAllAsInterface(services, ServiceLifetime.Transient); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj b/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj index 518dfa331..ed2fad83f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj +++ b/Editors/ImportExportEditor/Editors.ImportExport/Editors.ImportExport.csproj @@ -22,4 +22,10 @@ + + + + + + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs new file mode 100644 index 000000000..80ce037ff --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs @@ -0,0 +1,88 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using Pfim; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + /// + /// Combines a mask texture with a diffuse texture to create a single RGBA texture + /// where the mask becomes the alpha channel. + /// + public class AlphaMaskCombiner + { + /// + /// Combines a diffuse (RGB) and mask (Grayscale) into a single RGBA texture. + /// + public static byte[] CombineDiffuseWithMask(byte[] diffuseDdsBytes, byte[] maskDdsBytes) + { + try + { + // Convert both DDS to bitmaps + var diffuseBitmap = ConvertDdsToBitmap(diffuseDdsBytes); + var maskBitmap = ConvertDdsToBitmap(maskDdsBytes); + + // Ensure same dimensions + if (diffuseBitmap.Width != maskBitmap.Width || diffuseBitmap.Height != maskBitmap.Height) + { + throw new InvalidOperationException( + $"Diffuse and mask textures have different dimensions: " + + $"diffuse {diffuseBitmap.Width}x{diffuseBitmap.Height}, " + + $"mask {maskBitmap.Width}x{maskBitmap.Height}"); + } + + // Create RGBA bitmap + using var combinedBitmap = new Bitmap(diffuseBitmap.Width, diffuseBitmap.Height, PixelFormat.Format32bppArgb); + + // Combine pixels + for (int x = 0; x < diffuseBitmap.Width; x++) + { + for (int y = 0; y < diffuseBitmap.Height; y++) + { + var diffusePixel = diffuseBitmap.GetPixel(x, y); + var maskPixel = maskBitmap.GetPixel(x, y); + + // Use mask's grayscale value as alpha + int alpha = maskPixel.R; // Grayscale uses same value for R, G, B + var combinedPixel = Color.FromArgb(alpha, diffusePixel.R, diffusePixel.G, diffusePixel.B); + + combinedBitmap.SetPixel(x, y, combinedPixel); + } + } + + diffuseBitmap.Dispose(); + maskBitmap.Dispose(); + + // Export to PNG with alpha + using var pngStream = new MemoryStream(); + combinedBitmap.Save(pngStream, ImageFormat.Png); + return pngStream.ToArray(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to combine diffuse and mask textures: {ex.Message}", ex); + } + } + + private static Bitmap ConvertDdsToBitmap(byte[] ddsBytes) + { + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(ddsBytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfimage.FromStream(m); + + PixelFormat pixelFormat = image.Format == Pfim.ImageFormat.Rgba32 + ? PixelFormat.Format32bppArgb + : PixelFormat.Format24bppRgb; + + var bitmap = new Bitmap(image.Width, image.Height, pixelFormat); + var bitmapData = bitmap.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.WriteOnly, pixelFormat); + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + bitmap.UnlockBits(bitmapData); + + return bitmap; + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs index 9e473d1b9..d80d16729 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfMeshBuilder.cs @@ -12,10 +12,10 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { public class GltfMeshBuilder { - public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willHaveSkeleton = true) { var lodLevel = rmv2.ModelList.First(); - var hasSkeleton = string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; + var hasSkeleton = willHaveSkeleton && string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; var meshes = new List>(); for(var i = 0; i < lodLevel.Length; i++) @@ -32,7 +32,9 @@ public List> Build(RmvFile rmv2, List GenerateMesh(RmvMesh rmvMesh, string modelName, MaterialBuilder material, bool hasSkeleton, bool doMirror) { var mesh = new MeshBuilder(modelName); - if (hasSkeleton) + // Only enable skinning validation if the model has a skeleton and this mesh actually contains weight data + var hasAnyWeights = rmvMesh.VertexList.Any(v => v.WeightCount > 0); + if (hasSkeleton && hasAnyWeights) mesh.VertexPreprocessor.SetValidationPreprocessors(); var prim = mesh.UsePrimitive(material); @@ -53,12 +55,25 @@ MeshBuilder Generate if (hasSkeleton) { - glTfvertex = SetVertexInfluences(vertex, glTfvertex); + if (vertex.WeightCount > 0) + { + glTfvertex = SetVertexInfluences(vertex, glTfvertex); + } + else if (hasAnyWeights) + { + // If some vertices have weights in this mesh we enabled validation. + // Ensure vertices without weights get a default binding so validation passes. + glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); + } } - else + else if (hasAnyWeights) { + // Model has weight data but no skeleton is available. + // Set default binding to prevent validation errors. glTfvertex.Skinning.SetBindings((0, 1), (0, 0), (0, 0), (0, 0)); } + + // For static meshes or vertices handled above, add the vertex vertexList.Add(glTfvertex); } @@ -88,28 +103,63 @@ MeshBuilder Generate VertexBuilder SetVertexInfluences(CommonVertex vertex, VertexBuilder glTfvertex) { - if (vertex.WeightCount == 2) + // Support 1,2,3,4 weight counts and normalize/handle degenerate cases so SharpGLTF validation won't fail. + var weights = new float[4]; + var indices = new int[4]; + + var count = Math.Clamp(vertex.WeightCount, 0, 4); + for (int i = 0; i < count; ++i) + { + indices[i] = vertex.BoneIndex[i]; + weights[i] = vertex.BoneWeight[i]; + // guard against negative weights from malformed data + if (weights[i] < 0) weights[i] = 0f; + } + + // If there are fewer than 4 influences, remaining indices default to 0 and weights to 0 + for (int i = count; i < 4; ++i) { - var rigging = new (int, float)[2] { - (vertex.BoneIndex[0], vertex.BoneWeight[0]), - (vertex.BoneIndex[1], 1.0f - vertex.BoneWeight[0]) - }; + indices[i] = 0; + weights[i] = 0f; + } - glTfvertex.Skinning.SetBindings(rigging); + float sum = weights[0] + weights[1] + weights[2] + weights[3]; + if (sum <= float.Epsilon) + { + // Degenerate: no meaningful weights. Fall back to binding to the first available bone or to bone 0. + if (count > 0) + { + indices[0] = vertex.BoneIndex[0]; + weights[0] = 1f; + weights[1] = weights[2] = weights[3] = 0f; + } + else + { + indices[0] = 0; + weights[0] = 1f; + weights[1] = weights[2] = weights[3] = 0f; + } } - else if (vertex.WeightCount == 4) + else { - var rigging = new (int, float)[4] { - (vertex.BoneIndex[0], vertex.BoneWeight[0]), - (vertex.BoneIndex[1], vertex.BoneWeight[1]), - (vertex.BoneIndex[2], vertex.BoneWeight[2]), - (vertex.BoneIndex[3], 1.0f - (vertex.BoneWeight[0] + vertex.BoneWeight[1] + vertex.BoneWeight[2])) - }; - - glTfvertex.Skinning.SetBindings(rigging); + // Normalize weights so they sum to 1 + weights[0] /= sum; + weights[1] /= sum; + weights[2] /= sum; + weights[3] /= sum; } + var rigging = new (int, float)[4] + { + (indices[0], weights[0]), + (indices[1], weights[1]), + (indices[2], weights[2]), + (indices[3], weights[3]) + }; + + glTfvertex.Skinning.SetBindings(rigging); + return glTfvertex; } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs new file mode 100644 index 000000000..f2d0f1acf --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfStaticMeshBuilder.cs @@ -0,0 +1,203 @@ +using System.IO; +using System.Numerics; +using Editors.ImportExport.Common; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; +using SharpGLTF.Materials; +using AlphaMode = SharpGLTF.Materials.AlphaMode; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + public class GltfStaticMeshBuilder + { + public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings) + { + var lodLevel = rmv2.ModelList.First(); + + var meshes = new List>(); + for (var i = 0; i < lodLevel.Length; i++) + { + var rmvMesh = lodLevel[i]; + var meshTextures = textures.Where(x => x.MeshIndex == i).ToList(); + var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); + var gltfMesh = GenerateStaticMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, settings.MirrorMesh); + meshes.Add(gltfMesh); + } + return meshes; + } + + MeshBuilder GenerateStaticMesh(RmvMesh rmvMesh, string modelName, MaterialBuilder material, bool doMirror) + { + var mesh = new MeshBuilder(modelName); + var prim = mesh.UsePrimitive(material); + + var vertexList = new List>(); + foreach (var vertex in rmvMesh.VertexList) + { + var glTfvertex = new VertexBuilder(); + glTfvertex.Geometry.Position = new Vector3(vertex.Position.X, vertex.Position.Y, vertex.Position.Z); + glTfvertex.Geometry.Normal = new Vector3(vertex.Normal.X, vertex.Normal.Y, vertex.Normal.Z); + glTfvertex.Geometry.Tangent = new Vector4(vertex.Tangent.X, vertex.Tangent.Y, vertex.Tangent.Z, 1); + glTfvertex.Material.TexCoord = new Vector2(vertex.Uv.X, vertex.Uv.Y); + + // Apply geometric transformations + glTfvertex.Geometry.Position = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Position), doMirror)); + glTfvertex.Geometry.Normal = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Normal), doMirror)); + glTfvertex.Geometry.Tangent = VecConv.GetSys(GlobalSceneTransforms.FlipVector(VecConv.GetXna(glTfvertex.Geometry.Tangent), doMirror)); + + // Validate and fix normals and tangents for quality + glTfvertex.Geometry.Normal = ValidateAndFixNormal(glTfvertex.Geometry.Normal); + glTfvertex.Geometry.Tangent = ValidateAndFixTangent(glTfvertex.Geometry.Tangent, glTfvertex.Geometry.Normal); + + vertexList.Add(glTfvertex); + } + + var triangleCount = rmvMesh.IndexList.Length; + for (var i = 0; i < triangleCount; i += 3) + { + ushort i0, i1, i2; + if (doMirror) // if mirrored, flip the winding order + { + i0 = rmvMesh.IndexList[i + 0]; + i1 = rmvMesh.IndexList[i + 2]; + i2 = rmvMesh.IndexList[i + 1]; + } + else + { + i0 = rmvMesh.IndexList[i + 0]; + i1 = rmvMesh.IndexList[i + 1]; + i2 = rmvMesh.IndexList[i + 2]; + } + + prim.AddTriangle(vertexList[i0], vertexList[i1], vertexList[i2]); + } + return mesh; + } + + Vector3 ValidateAndFixNormal(Vector3 normal) + { + float lengthSquared = normal.LengthSquared(); + + // Check for zero or near-zero length normals + if (lengthSquared < 0.0001f) + { + // Return a safe default normal pointing up + return new Vector3(0, 0, 1); + } + + // Check if normalization is needed (tolerance for floating point precision) + if (Math.Abs(lengthSquared - 1.0f) > 0.001f) + { + return Vector3.Normalize(normal); + } + + return normal; + } + + Vector4 ValidateAndFixTangent(Vector4 tangent, Vector3 normal) + { + var tangentXYZ = new Vector3(tangent.X, tangent.Y, tangent.Z); + float lengthSquared = tangentXYZ.LengthSquared(); + + // Check for zero or near-zero length tangents - generate a perpendicular vector + if (lengthSquared < 0.0001f) + { + tangentXYZ = GeneratePerpendicularVector(normal); + return new Vector4(tangentXYZ.X, tangentXYZ.Y, tangentXYZ.Z, 1); + } + + // Normalize tangent if needed + if (Math.Abs(lengthSquared - 1.0f) > 0.001f) + { + tangentXYZ = Vector3.Normalize(tangentXYZ); + } + + // Ensure tangent handedness is valid (W should be ±1, typically 1 for right-handed) + float handedness = tangent.W; + if (Math.Abs(handedness) < 0.5f) + { + handedness = 1.0f; + } + else + { + handedness = handedness > 0 ? 1.0f : -1.0f; + } + + return new Vector4(tangentXYZ.X, tangentXYZ.Y, tangentXYZ.Z, handedness); + } + + Vector3 GeneratePerpendicularVector(Vector3 normal) + { + Vector3 tangent; + + // Choose axis that's most perpendicular to normal + if (Math.Abs(normal.X) > 0.9f) + { + tangent = Vector3.Cross(normal, new Vector3(0, 1, 0)); + } + else + { + tangent = Vector3.Cross(normal, new Vector3(1, 0, 0)); + } + + return Vector3.Normalize(tangent); + } + + MaterialBuilder Create(RmvToGltfExporterSettings settings, string materialName, List texturesForModel) + { + // Option 4: Material Enhancement with proper PBR setup + var material = new MaterialBuilder(materialName) + .WithDoubleSide(true) + .WithMetallicRoughness(); + + // Enhanced alpha detection for masked geometry (capes, fur, wings) + bool hasAlphaMaskedTexture = texturesForModel.Any(t => t.HasAlphaChannel); + bool hasMaskInName = texturesForModel.Any(t => + t.SystemFilePath.Contains("mask", StringComparison.OrdinalIgnoreCase) || + t.SystemFilePath.Contains("_m.", StringComparison.OrdinalIgnoreCase)); + bool hasTransparency = texturesForModel.Any(t => + t.SystemFilePath.Contains("alpha", StringComparison.OrdinalIgnoreCase) || + t.SystemFilePath.Contains("transparent", StringComparison.OrdinalIgnoreCase)); + + // Detect common alpha-masked mesh types + bool isAlphaMaskedMesh = materialName.Contains("cape", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("fur", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("wing", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("feather", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("hair", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("foliage", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("leaf", StringComparison.OrdinalIgnoreCase) || + materialName.Contains("chain", StringComparison.OrdinalIgnoreCase); + + // Set appropriate alpha mode + if (hasTransparency) + { + material.WithAlpha(AlphaMode.BLEND); + } + else if (hasAlphaMaskedTexture || hasMaskInName || isAlphaMaskedMesh) + { + // Use MASK mode with alpha cutoff for sharp edges (better for fur/capes) + material.WithAlpha(AlphaMode.MASK, 0.5f); + } + else + { + material.WithAlpha(AlphaMode.OPAQUE); + } + + foreach (var texture in texturesForModel) + { + material.WithChannelImage(texture.GlftTexureType, texture.SystemFilePath); + + var channel = material.UseChannel(texture.GlftTexureType); + if (channel?.Texture?.PrimaryImage != null) + { + // Set SharpGLTF to re-resave textures with specified paths + channel.Texture.PrimaryImage.AlternateWriteFileName = Path.GetFileName(texture.SystemFilePath); + } + } + + return material; + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 0034d7e9f..1c107a3a0 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -2,11 +2,12 @@ using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Types; +using Shared.Core.PackFiles; using SharpGLTF.Materials; namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { - public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType); + public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType, bool HasAlphaChannel = false); public interface IGltfTextureHandler { @@ -17,11 +18,15 @@ public class GltfTextureHandler : IGltfTextureHandler { private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; + private readonly IDisplacementMapGenerator _displacementMapGenerator; + private readonly IPackFileService _packFileService; - public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter) + public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IDisplacementMapGenerator displacementMapGenerator = null, IPackFileService packFileService = null) { _ddsToNormalPngExporter = ddsToNormalPngExporter; _ddsToMaterialPngExporter = ddsToMaterialPngExporter; + _displacementMapGenerator = displacementMapGenerator ?? new DisplacementMapGenerator(); + _packFileService = packFileService; } public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSettings settings) @@ -41,25 +46,48 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett var model = rmvFile.ModelList[lodIndex][meshIndex]; var textures = ExtractTextures(model); + // Check if this mesh has both diffuse and mask textures - if so, combine them + var diffuseTexture = textures.FirstOrDefault(t => t.Type == TextureType.Diffuse || t.Type == TextureType.BaseColour); + var maskTexture = textures.FirstOrDefault(t => t.Type == TextureType.Mask); + + bool shouldCombineMask = diffuseTexture != null && maskTexture != null; + foreach (var tex in textures) { + // Skip the mask if we're combining it with diffuse + if (shouldCombineMask && tex.Type == TextureType.Mask) + continue; + switch (tex.Type) { case TextureType.Normal: DoTextureConversionNormalMap(settings, output, exportedTextures, meshIndex, tex); break; case TextureType.MaterialMap: DoTextureConversionMaterialMap(settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.BaseColour: DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); break; - case TextureType.Diffuse: DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); break; + case TextureType.BaseColour: + if (shouldCombineMask) + DoCombinedDiffuseWithMask(settings, output, exportedTextures, meshIndex, tex, maskTexture); + else + DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); + break; + case TextureType.Diffuse: + if (shouldCombineMask) + DoCombinedDiffuseWithMask(settings, output, exportedTextures, meshIndex, tex, maskTexture); + else + DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); + break; case TextureType.Specular: DoTextureDefault(KnownChannel.SpecularColor, settings, output, exportedTextures, meshIndex, tex); break; case TextureType.Gloss: DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, exportedTextures, meshIndex, tex); break; + case TextureType.Ambient_occlusion: DoTextureDefault(KnownChannel.Occlusion, settings, output, exportedTextures, meshIndex, tex); break; + case TextureType.Emissive: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; + case TextureType.EmissiveDistortion: DoTextureDefault(KnownChannel.Emissive, settings, output, exportedTextures, meshIndex, tex); break; } } - } + } + - } return output; - } + } interface IDDsToPngExporter { public string Export(string path, string outputPath, bool convertToBlender) @@ -91,11 +119,56 @@ private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { if (exportedTextures.ContainsKey(text.Path) == false) - exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); // TODO: exchange export with a default one + exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); var systemPath = exportedTextures[text.Path]; if (systemPath != null) - output.Add(new TextureResult(meshIndex, systemPath, textureType)); + output.Add(new TextureResult(meshIndex, systemPath, textureType, hasAlphaChannel: false)); + } + + private void DoCombinedDiffuseWithMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput diffuseTexture, MaterialBuilderTextureInput maskTexture) + { + var combinedKey = $"{diffuseTexture.Path}+{maskTexture.Path}"; + + if (exportedTextures.ContainsKey(combinedKey) == false) + { + try + { + // Get the pack files + var diffusePackFile = _packFileService.FindFile(diffuseTexture.Path); + var maskPackFile = _packFileService.FindFile(maskTexture.Path); + + if (diffusePackFile == null || maskPackFile == null) + { + throw new InvalidOperationException($"Could not find diffuse or mask texture in pack files"); + } + + // Read DDS data + var diffuseDdsBytes = diffusePackFile.DataSource.ReadData(); + var maskDdsBytes = maskPackFile.DataSource.ReadData(); + + // Combine diffuse and mask + var combinedPngBytes = AlphaMaskCombiner.CombineDiffuseWithMask(diffuseDdsBytes, maskDdsBytes); + + // Save combined texture + var fileName = Path.GetFileNameWithoutExtension(diffuseTexture.Path) + "_with_alpha.png"; + var outDirectory = Path.GetDirectoryName(settings.OutputPath); + var outFilePath = Path.Combine(outDirectory, fileName); + + File.WriteAllBytes(outFilePath, combinedPngBytes); + exportedTextures[combinedKey] = outFilePath; + } + catch (Exception ex) + { + // If combining fails, fall back to just the diffuse + exportedTextures[combinedKey] = _ddsToMaterialPngExporter.Export(diffuseTexture.Path, settings.OutputPath, false); + } + } + + var systemPath = exportedTextures[combinedKey]; + if (systemPath != null) + // Mark as having alpha channel since we combined the mask into it + output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.BaseColor, hasAlphaChannel: true)); } private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.cs new file mode 100644 index 000000000..35b4243b1 --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/MeshOptimizer3DPrint.cs @@ -0,0 +1,176 @@ +using System.IO; +using System.Numerics; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Geometry.VertexTypes; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers +{ + /// + /// Optimizes mesh data for 3D printing quality. + /// Includes vertex deduplication, normal calculation, and topology improvements. + /// + public class MeshOptimizer3DPrint + { + /// + /// Analyzes and optimizes mesh topology for 3D printing + /// + public MeshOptimizationReport AnalyzeMesh(RmvMesh rmvMesh) + { + var report = new MeshOptimizationReport(); + + // Check for degenerate triangles + CheckDegenerateTriangles(rmvMesh, report); + + // Check for duplicate vertices + CheckDuplicateVertices(rmvMesh, report); + + // Check for mesh manifold issues + CheckManifoldIssues(rmvMesh, report); + + // Analyze normal consistency + CheckNormalConsistency(rmvMesh, report); + + return report; + } + + /// + /// Detects and reports degenerate triangles (zero area) + /// + private void CheckDegenerateTriangles(RmvMesh rmvMesh, MeshOptimizationReport report) + { + for (int i = 0; i < rmvMesh.IndexList.Length; i += 3) + { + var i0 = rmvMesh.IndexList[i]; + var i1 = rmvMesh.IndexList[i + 1]; + var i2 = rmvMesh.IndexList[i + 2]; + + var v0 = rmvMesh.VertexList[i0].Position; + var v1 = rmvMesh.VertexList[i1].Position; + var v2 = rmvMesh.VertexList[i2].Position; + + // Calculate area using cross product + var edge1 = new Vector3(v1.X - v0.X, v1.Y - v0.Y, v1.Z - v0.Z); + var edge2 = new Vector3(v2.X - v0.X, v2.Y - v0.Y, v2.Z - v0.Z); + + var cross = Vector3.Cross(edge1, edge2); + float area = cross.Length() * 0.5f; + + if (area < 0.0001f) + { + report.DegenerateTriangles++; + } + } + } + + /// + /// Detects duplicate vertices that could be merged + /// + private void CheckDuplicateVertices(RmvMesh rmvMesh, MeshOptimizationReport report) + { + const float positionThreshold = 0.001f; // 1mm threshold + var processedIndices = new HashSet(); + + for (int i = 0; i < rmvMesh.VertexList.Length; i++) + { + if (processedIndices.Contains(i)) + continue; + + var vertex = rmvMesh.VertexList[i]; + processedIndices.Add(i); + + for (int j = i + 1; j < rmvMesh.VertexList.Length; j++) + { + if (processedIndices.Contains(j)) + continue; + + var other = rmvMesh.VertexList[j]; + float distance = Vector3.Distance( + new Vector3(vertex.Position.X, vertex.Position.Y, vertex.Position.Z), + new Vector3(other.Position.X, other.Position.Y, other.Position.Z)); + + if (distance < positionThreshold) + { + report.DuplicateVertices++; + processedIndices.Add(j); + } + } + } + } + + /// + /// Checks for non-manifold edges and issues + /// + private void CheckManifoldIssues(RmvMesh rmvMesh, MeshOptimizationReport report) + { + var edgeCount = new Dictionary<(ushort, ushort), int>(); + + for (int i = 0; i < rmvMesh.IndexList.Length; i += 3) + { + var i0 = rmvMesh.IndexList[i]; + var i1 = rmvMesh.IndexList[i + 1]; + var i2 = rmvMesh.IndexList[i + 2]; + + // Count each edge (normalize to lower, higher order) + AddEdge(edgeCount, i0, i1); + AddEdge(edgeCount, i1, i2); + AddEdge(edgeCount, i2, i0); + } + + // Non-manifold edges appear more than twice + foreach (var edgeCount_kvp in edgeCount) + { + if (edgeCount_kvp.Value > 2) + report.NonManifoldEdges++; + } + } + + private void AddEdge(Dictionary<(ushort, ushort), int> edgeCount, ushort a, ushort b) + { + var edge = a < b ? (a, b) : (b, a); + if (edgeCount.ContainsKey(edge)) + edgeCount[edge]++; + else + edgeCount[edge] = 1; + } + + /// + /// Validates normal vector consistency for shading + /// + private void CheckNormalConsistency(RmvMesh rmvMesh, MeshOptimizationReport report) + { + const float normalThreshold = 0.1f; + + foreach (var vertex in rmvMesh.VertexList) + { + var normal = new Vector3(vertex.Normal.X, vertex.Normal.Y, vertex.Normal.Z); + float length = normal.Length(); + + // Normals should be normalized (length ~1.0) + if (length < 1.0f - normalThreshold || length > 1.0f + normalThreshold) + { + report.AbnormalNormals++; + } + } + } + } + + /// + /// Report of mesh optimization analysis for 3D printing + /// + public class MeshOptimizationReport + { + public int DegenerateTriangles { get; set; } + public int DuplicateVertices { get; set; } + public int NonManifoldEdges { get; set; } + public int AbnormalNormals { get; set; } + + public bool HasIssues => DegenerateTriangles > 0 || DuplicateVertices > 0 || NonManifoldEdges > 0 || AbnormalNormals > 0; + + public override string ToString() + { + return $"Degenerate: {DegenerateTriangles}, Duplicates: {DuplicateVertices}, " + + $"NonManifold: {NonManifoldEdges}, AbnormalNormals: {AbnormalNormals}"; + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs index c6b2e4845..f056f9e07 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporter.cs @@ -50,6 +50,8 @@ public void Export(RmvToGltfExporterSettings settings) var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); var outputScene = ModelRoot.CreateModel(); + // Determine skeleton availability before building meshes to avoid weight validation issues + bool willHaveSkeleton = false; ProcessedGltfSkeleton? gltfSkeleton = null; if (settings.ExportAnimations && !string.IsNullOrEmpty(rmv2.Header.SkeletonName)) { @@ -64,6 +66,7 @@ public void Export(RmvToGltfExporterSettings settings) } else { + willHaveSkeleton = true; gltfSkeleton = _gltfSkeletonBuilder.CreateSkeleton(skeletonAnimFile, outputScene, settings); _gltfAnimationBuilder.Build(skeletonAnimFile, settings, gltfSkeleton, outputScene); } @@ -71,7 +74,7 @@ public void Export(RmvToGltfExporterSettings settings) var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); - var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings); + var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings, willHaveSkeleton); _logger.Here().Information($"MeshCount={meshes.Count()} TextureCount={textures.Count()} Skeleton={gltfSkeleton?.Data.Count}"); BuildGltfScene(meshes, gltfSkeleton, settings, outputScene); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs new file mode 100644 index 000000000..094646cdb --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs @@ -0,0 +1,113 @@ +using System.IO; +using Editors.ImportExport.Common; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; +using Editors.ImportExport.Misc; +using GameWorld.Core.Services; +using Serilog; +using Shared.Core.ErrorHandling; +using Shared.Core.PackFiles.Models; +using Shared.GameFormats.RigidModel; +using SharpGLTF.Geometry; +using SharpGLTF.Materials; +using SharpGLTF.Schema2; + +namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf +{ + public class RmvToGltfStaticExporter + { + private readonly ILogger _logger = Logging.Create(); + private readonly IGltfSceneSaver _gltfSaver; + private readonly GltfStaticMeshBuilder _gltfMeshBuilder; + private readonly IGltfTextureHandler _gltfTextureHandler; + + public RmvToGltfStaticExporter(IGltfSceneSaver gltfSaver, GltfStaticMeshBuilder gltfMeshBuilder, IGltfTextureHandler gltfTextureHandler) + { + _gltfSaver = gltfSaver; + _gltfMeshBuilder = gltfMeshBuilder; + _gltfTextureHandler = gltfTextureHandler; + } + + internal ExportSupportEnum CanExportFile(PackFile file) + { + if (FileExtensionHelper.IsRmvFile(file.Name)) + return ExportSupportEnum.Supported; + if (FileExtensionHelper.IsWsModelFile(file.Name)) + return ExportSupportEnum.NotSupported; + return ExportSupportEnum.NotSupported; + } + + public void Export(RmvToGltfExporterSettings settings, bool generateDisplacementMaps = false) + { + LogSettings(settings); + + var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); + var outputScene = ModelRoot.CreateModel(); + + var textures = _gltfTextureHandler.HandleTextures(rmv2, settings); + var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings); + + _logger.Here().Information($"Static Export - MeshCount={meshes.Count()} TextureCount={textures.Count()}"); + BuildGltfScene(meshes, settings, outputScene); + + // Generate displacement maps if requested + if (generateDisplacementMaps) + { + GenerateDisplacementMapsForTextures(settings, textures); + } + } + + void BuildGltfScene(List> meshBuilders, RmvToGltfExporterSettings settings, ModelRoot outputScene) + { + var scene = outputScene.UseScene("default"); + foreach (var meshBuilder in meshBuilders) + { + var mesh = outputScene.CreateMesh(meshBuilder); + scene.CreateNode(mesh.Name).WithMesh(mesh); + } + + _gltfSaver.Save(outputScene, settings.OutputPath); + } + + void GenerateDisplacementMapsForTextures(RmvToGltfExporterSettings settings, List textures) + { + try + { + var outputDir = Path.GetDirectoryName(settings.OutputPath); + if (string.IsNullOrEmpty(outputDir)) + return; + + // Find normal map textures and generate displacement maps + var normalMaps = textures.Where(t => t.GlftTexureType.ToString().Contains("Normal")).ToList(); + + foreach (var texture in normalMaps) + { + try + { + _displacementMapExporter.Export(texture.SystemFilePath, outputDir); + _logger.Here().Information($"Generated displacement map for: {Path.GetFileName(texture.SystemFilePath)}"); + } + catch (Exception ex) + { + _logger.Here().Warning($"Failed to generate displacement map for {texture.SystemFilePath}: {ex.Message}"); + } + } + } + catch (Exception ex) + { + _logger.Here().Warning($"Error generating displacement maps: {ex.Message}"); + } + } + + void LogSettings(RmvToGltfExporterSettings settings) + { + var str = $"Exporting using {nameof(RmvToGltfStaticExporter)} (Static Mesh Export)\n"; + str += $"\tInputModelFile:{settings.InputModelFile?.Name}\n"; + str += $"\tOutputPath:{settings.OutputPath}\n"; + str += $"\tConvertMaterialTextureToBlender:{settings.ConvertMaterialTextureToBlender}\n"; + str += $"\tConvertNormalTextureToBlue:{settings.ConvertNormalTextureToBlue}\n"; + str += $"\tMirrorMesh:{settings.MirrorMesh}\n"; + + _logger.Here().Information(str); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs deleted file mode 100644 index 04a08c31d..000000000 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToObj/RmvToObjExporter.cs +++ /dev/null @@ -1,450 +0,0 @@ -using System.Globalization; -using System.IO; -using System.Text; -using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; -using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; -using Editors.ImportExport.Misc; -using Shared.GameFormats.RigidModel; -using Shared.GameFormats.RigidModel.Types; -using Serilog; -using Shared.Core.ErrorHandling; -using Shared.Core.PackFiles.Models; -using System.Drawing; -using System.Drawing.Imaging; -using System.Drawing.Drawing2D; - -namespace Editors.ImportExport.Exporting.Exporters -{ - public class RmvToObjExporter - { - private readonly ILogger _logger = Logging.Create(); - private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; - private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; - - public RmvToObjExporter(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter) - { - _ddsToNormalPngExporter = ddsToNormalPngExporter; - _ddsToMaterialPngExporter = ddsToMaterialPngExporter; - } - - public ExportSupportEnum CanExportFile(PackFile file) - { - var name = file.Name.ToLower(); - if (name.EndsWith(".rigidmodel") || name.EndsWith(".rmv2") || name.EndsWith(".rigid_model_v2")) - return ExportSupportEnum.HighPriority; - return ExportSupportEnum.NotSupported; - } - - public void Export(RmvToObjExporterSettings settings) - { - try - { - _logger.Information($"Exporting RMV to OBJ: {settings.OutputPath}"); - - var rmv2 = new ModelFactory().Load(settings.InputModelFile.DataSource.ReadData()); - var lodLevel = rmv2.ModelList.First(); - var outputDir = Path.GetDirectoryName(settings.OutputPath)!; - var baseName = Path.GetFileNameWithoutExtension(settings.OutputPath); - - var sb = new StringBuilder(); - sb.AppendLine("# OBJ file exported from Total War RigidModel V2"); - sb.AppendLine($"# Model: {settings.InputModelFile.Name}"); - sb.AppendLine(); - - int globalVertexOffset = 1; // OBJ uses 1-based indexing - int meshIndex = 0; - - foreach (var rmvModel in lodLevel) - { - var meshName = rmvModel.Material.ModelName; - sb.AppendLine($"# Mesh {meshName}"); - sb.AppendLine($"o {meshName}"); - sb.AppendLine($"usemtl {meshName}_Material"); - sb.AppendLine(); - - var mesh = rmvModel.Mesh; - var vertices = mesh.VertexList; - - // Write vertices - foreach (var vertex in vertices) - { - var pos = vertex.GetPosistionAsVec3(); - sb.AppendLine($"v {pos.X.ToString(CultureInfo.InvariantCulture)} {pos.Y.ToString(CultureInfo.InvariantCulture)} {pos.Z.ToString(CultureInfo.InvariantCulture)}"); - } - - sb.AppendLine(); - - // Write normals - foreach (var vertex in vertices) - { - var normal = vertex.Normal; - sb.AppendLine($"vn {normal.X.ToString(CultureInfo.InvariantCulture)} {normal.Y.ToString(CultureInfo.InvariantCulture)} {normal.Z.ToString(CultureInfo.InvariantCulture)}"); - } - - sb.AppendLine(); - - // Write UVs - foreach (var vertex in vertices) - { - var uv = vertex.Uv; - sb.AppendLine($"vt {uv.X.ToString(CultureInfo.InvariantCulture)} {(1.0f - uv.Y).ToString(CultureInfo.InvariantCulture)}"); - } - - sb.AppendLine(); - - // Write faces (indices) - var indices = mesh.IndexList; - for (int i = 0; i < indices.Length; i += 3) - { - int i0 = indices[i] + globalVertexOffset; - int i1 = indices[i + 1] + globalVertexOffset; - int i2 = indices[i + 2] + globalVertexOffset; - - // Format: f v/vt/vn v/vt/vn v/vt/vn - sb.AppendLine($"f {i0}/{i0}/{i0} {i1}/{i1}/{i1} {i2}/{i2}/{i2}"); - } - - sb.AppendLine(); - globalVertexOffset += vertices.Length; - meshIndex++; - } - - // Write OBJ file - File.WriteAllText(settings.OutputPath, sb.ToString(), Encoding.UTF8); - - // Write MTL file with texture references - var mtlPath = Path.Combine(outputDir, baseName + ".mtl"); - WriteMaterialFile(lodLevel, mtlPath, outputDir, baseName); - - _logger.Information($"Successfully exported to {settings.OutputPath}"); - } - catch (Exception ex) - { - _logger.Error(ex, "Error exporting RMV to OBJ"); - throw; - } - } - - private void WriteMaterialFile(RmvModel[] lodLevel, string mtlPath, string outputDir, string baseName) - { - var sb = new StringBuilder(); - sb.AppendLine("# MTL file for OBJ export"); - sb.AppendLine(); - - int meshIndex = 0; - foreach (var rmvModel in lodLevel) - { - var materialName = rmvModel.Material.ModelName + "_Material"; - sb.AppendLine($"newmtl {materialName}"); - sb.AppendLine("Ka 1.0 1.0 1.0"); - sb.AppendLine("Kd 0.8 0.8 0.8"); - sb.AppendLine("Ks 0.5 0.5 0.5"); - sb.AppendLine("Ns 32.0"); - sb.AppendLine("illum 2"); - - // Export textures and add references to MTL - var normalTexture = rmvModel.Material.GetTexture(TextureType.Normal); - if (normalTexture != null) - { - try - { - var normalMapPath = ExportNormalMapAndCreateDisplacementMap(normalTexture, outputDir, rmvModel.Material.ModelName, meshIndex); - if (!string.IsNullOrEmpty(normalMapPath)) - { - var normalFileName = Path.GetFileName(normalMapPath); - var displacementFileName = Path.Combine(outputDir, - Path.GetFileNameWithoutExtension(normalMapPath) + "_displacement.png"); - var displacementFileNameOnly = Path.GetFileName(displacementFileName); - - sb.AppendLine($"map_bump {normalFileName}"); - sb.AppendLine($"disp {displacementFileNameOnly}"); - - // Re-encode exported normal and displacement to high-quality PNG - try { EnsurePngHighQuality(normalMapPath); } catch { } - try { if (File.Exists(displacementFileName)) EnsurePngHighQuality(displacementFileName); } catch { } - } - } - catch (Exception ex) - { - _logger.Warning(ex, "Failed to export normal map for mesh {MeshIndex}", meshIndex); - } - } - - var diffuseTexture = rmvModel.Material.GetTexture(TextureType.Diffuse) ?? - rmvModel.Material.GetTexture(TextureType.BaseColour); - if (diffuseTexture != null) - { - try - { - var diffuseMapPath = ExportDiffuseMap(diffuseTexture, outputDir, rmvModel.Material.ModelName, meshIndex); - if (!string.IsNullOrEmpty(diffuseMapPath)) - { - // If there's a mask texture or skin mask, export it and premultiply into diffuse alpha - var maskTex = rmvModel.Material.GetTexture(TextureType.Mask) ?? rmvModel.Material.GetTexture(TextureType.Skin_mask); - if (maskTex != null) - { - try - { - var maskPath = _ddsToMaterialPngExporter.Export(maskTex.Value.Path, Path.Combine(outputDir, rmvModel.Material.ModelName + "_mask.png"), false); - if (!string.IsNullOrEmpty(maskPath) && File.Exists(maskPath) && File.Exists(diffuseMapPath)) - { - var premultPath = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(diffuseMapPath) + "_premult.png"); - PremultiplyDiffuseWithMask(diffuseMapPath, maskPath, premultPath); - diffuseMapPath = premultPath; - } - } - catch (Exception ex) - { - _logger.Here().Warning(ex, "Failed to export or apply mask for mesh {MeshIndex}", meshIndex); - } - } - - var diffuseFileName = Path.GetFileName(diffuseMapPath); - sb.AppendLine($"map_Kd {diffuseFileName}"); - // Also set Kd to use the diffuse texture color - sb.AppendLine($"Kd 1.0 1.0 1.0"); - - try { EnsurePngHighQuality(diffuseMapPath); } catch { } - } - } - catch (Exception ex) - { - _logger.Warning(ex, "Failed to export diffuse map for mesh {MeshIndex}", meshIndex); - } - } - - sb.AppendLine(); - meshIndex++; - } - - File.WriteAllText(mtlPath, sb.ToString(), Encoding.UTF8); - } - - private string ExportNormalMapAndCreateDisplacementMap(RmvTexture? texture, string outputDir, string meshName, int meshIndex) - { - if (!texture.HasValue) - return null; - - try - { - // Create temporary path for PNG export - var normalMapFileName = $"{meshName}_normal.png"; - var normalMapPath = Path.Combine(outputDir, normalMapFileName); - - // Export DDS to PNG using existing exporter - var exportedPath = _ddsToNormalPngExporter.Export(texture.Value.Path, normalMapPath, convertToBlueNormalMap: true); - - if (File.Exists(exportedPath)) - { - // Ensure exported PNG is high-quality (32bpp, proper encoding) - try { EnsurePngHighQuality(exportedPath); } catch { } - // Load the normal map and create displacement map - using (var normalImage = new Bitmap(exportedPath)) - { - var displacementMap = ConvertNormalMapToHeightMap(normalImage); - var displacementPath = Path.Combine(outputDir, - Path.GetFileNameWithoutExtension(normalMapPath) + "_displacement.png"); - displacementMap.Save(displacementPath, ImageFormat.Png); - displacementMap.Dispose(); - } - - return exportedPath; - } - } - catch (Exception ex) - { - _logger.Error(ex, "Error exporting normal map for mesh {MeshIndex}", meshIndex); - } - - return null; - } - - private string ExportDiffuseMap(RmvTexture? texture, string outputDir, string meshName, int meshIndex) - { - if (!texture.HasValue) - return null; - - try - { - var diffuseMapFileName = $"{meshName}_diffuse.png"; - var diffuseMapPath = Path.Combine(outputDir, diffuseMapFileName); - - // Export DDS to PNG using existing exporter - var exportedPath = _ddsToMaterialPngExporter.Export(texture.Value.Path, diffuseMapPath, convertToBlenderFormat: true); - // Ensure exported PNG is high-quality - try { EnsurePngHighQuality(exportedPath); } catch { } - return exportedPath; - } - catch (Exception ex) - { - _logger.Error(ex, "Error exporting diffuse map for mesh {MeshIndex}", meshIndex); - } - - return null; - } - - /// - /// Converts a normal map to a height/displacement map. - /// Extracts the Z component (blue channel) from the normal map to create height data. - /// - private Bitmap ConvertNormalMapToHeightMap(Bitmap normalMap, float strength = 0.5f, float contrast = 0.0f, int blurRadius = 0) - { - // Strength controls how strongly the normal's Z axis affects height (0..1) - // Contrast adjusts the final curve (-1..1) - var heightMap = new Bitmap(normalMap.Width, normalMap.Height); - - for (int y = 0; y < normalMap.Height; y++) - { - for (int x = 0; x < normalMap.Width; x++) - { - var normal = normalMap.GetPixel(x, y); - - // Use luminance of the normal as a simpler proxy for displacement - // (weights: Rec. 601) and remap around mid-gray with strength. - float r = normal.R / 255f; - float g = normal.G / 255f; - float b = normal.B / 255f; - float lum = 0.299f * r + 0.587f * g + 0.114f * b; - - // Remap so that 0.5 -> mid-gray baseline, and apply strength - float h = (lum - 0.5f) * strength + 0.5f; - - // Apply simple contrast tweak: contrast in [-1,1] - if (Math.Abs(contrast) > 0.0001f) - { - h = 0.5f + (h - 0.5f) * (1f + contrast); - } - - // Clamp and convert - h = MathF.Min(1f, MathF.Max(0f, h)); - byte heightValue = (byte)(h * 255f); - - var heightColor = Color.FromArgb(normal.A, heightValue, heightValue, heightValue); - heightMap.SetPixel(x, y, heightColor); - } - } - - // Optional simple box blur (if requested) - if (blurRadius > 0) - { - return BoxBlur(heightMap, blurRadius); - } - - return heightMap; - } - - // Very small and simple box blur implementation (separable) to avoid external deps - private Bitmap BoxBlur(Bitmap src, int radius) - { - var w = src.Width; - var h = src.Height; - var tmp = new Bitmap(w, h); - - // horizontal pass - for (int y = 0; y < h; y++) - { - for (int x = 0; x < w; x++) - { - int r = 0, g = 0, b = 0, a = 0, count = 0; - for (int k = -radius; k <= radius; k++) - { - int sx = x + k; - if (sx < 0 || sx >= w) continue; - var c = src.GetPixel(sx, y); - r += c.R; g += c.G; b += c.B; a += c.A; count++; - } - tmp.SetPixel(x, y, Color.FromArgb(a / Math.Max(1, count), r / Math.Max(1, count), g / Math.Max(1, count), b / Math.Max(1, count))); - } - } - - var dst = new Bitmap(w, h); - // vertical pass - for (int x = 0; x < w; x++) - { - for (int y = 0; y < h; y++) - { - int r = 0, g = 0, b = 0, a = 0, count = 0; - for (int k = -radius; k <= radius; k++) - { - int sy = y + k; - if (sy < 0 || sy >= h) continue; - var c = tmp.GetPixel(x, sy); - r += c.R; g += c.G; b += c.B; a += c.A; count++; - } - dst.SetPixel(x, y, Color.FromArgb(a / Math.Max(1, count), r / Math.Max(1, count), g / Math.Max(1, count), b / Math.Max(1, count))); - } - } - - tmp.Dispose(); - return dst; - } - - private void EnsurePngHighQuality(string path) - { - try - { - using var bmp = new Bitmap(path); - using var high = new Bitmap(bmp.Width, bmp.Height, PixelFormat.Format32bppArgb); - using var g = Graphics.FromImage(high); - g.CompositingMode = CompositingMode.SourceCopy; - g.CompositingQuality = CompositingQuality.HighQuality; - g.InterpolationMode = InterpolationMode.HighQualityBicubic; - g.SmoothingMode = SmoothingMode.HighQuality; - g.PixelOffsetMode = PixelOffsetMode.HighQuality; - g.DrawImage(bmp, 0, 0, high.Width, high.Height); - - var encoder = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Png.Guid); - var encParams = new System.Drawing.Imaging.EncoderParameters(1); - encParams.Param[0] = new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.ColorDepth, (long)32); - high.Save(path, encoder, encParams); - } - catch - { - // ignore - } - } - - private void PremultiplyDiffuseWithMask(string diffusePath, string maskPath, string outputPath) - { - try - { - using var diffuse = new Bitmap(diffusePath); - using var mask = new Bitmap(maskPath); - - int w = Math.Min(diffuse.Width, mask.Width); - int h = Math.Min(diffuse.Height, mask.Height); - using var outBmp = new Bitmap(w, h, PixelFormat.Format32bppArgb); - - for (int y = 0; y < h; y++) - { - for (int x = 0; x < w; x++) - { - var dc = diffuse.GetPixel(x, y); - var mc = mask.GetPixel(x, y); - // use mask luminance as alpha - float alpha = (mc.R * 0.299f + mc.G * 0.587f + mc.B * 0.114f) / 255f; - // premultiply color - int r = (int)(dc.R * alpha); - int g = (int)(dc.G * alpha); - int b = (int)(dc.B * alpha); - int a = (int)(alpha * 255); - outBmp.SetPixel(x, y, Color.FromArgb(a, r, g, b)); - } - } - - outBmp.Save(outputPath, ImageFormat.Png); - try { EnsurePngHighQuality(outputPath); } catch { } - } - catch - { - // ignore - } - } - } - - public class RmvToObjExporterSettings - { - public PackFile InputModelFile { get; set; } - public string OutputPath { get; set; } - } -} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs new file mode 100644 index 000000000..36ee1f52b --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -0,0 +1,37 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Editors.ImportExport.Exporting.Exporters; +using Editors.ImportExport.Exporting.Exporters.RmvToGltf; +using Editors.ImportExport.Misc; +using Shared.Core.PackFiles.Models; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToGltf +{ + public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExporterViewModel + { + private readonly RmvToGltfStaticExporter _exporter; + + [ObservableProperty] bool _exportTextures = true; + [ObservableProperty] bool _convertMaterialTextureToBlender = false; + [ObservableProperty] bool _convertNormalTextureToBlue = false; + [ObservableProperty] bool _generateDisplacementMaps = true; + + public string DisplayName => "GLTF (Static Mesh)"; + public string OutputExtension => ".gltf"; + + public RmvToGltfStaticExporterViewModel(RmvToGltfStaticExporter exporter) + { + _exporter = exporter; + } + + public ExportSupportEnum CanExportFile(PackFile file) + { + return _exporter.CanExportFile(file); + } + + public void Execute(PackFile exportSource, string outputPath, bool generateImporter) + { + var settings = new RmvToGltfExporterSettings(exportSource, [], outputPath, ExportTextures, ConvertMaterialTextureToBlender, ConvertNormalTextureToBlue, false, true); + _exporter.Export(settings, GenerateDisplacementMaps); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs deleted file mode 100644 index 3202ab8df..000000000 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToObj/RmvToObjExporterViewModel.cs +++ /dev/null @@ -1,28 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using Editors.ImportExport.Exporting.Exporters; -using Editors.ImportExport.Misc; -using Shared.Core.PackFiles.Models; - -namespace Editors.ImportExport.Exporting.Presentation.RmvToObj -{ - internal partial class RmvToObjExporterViewModel : ObservableObject, Editors.ImportExport.Exporting.Exporters.IExporterViewModel - { - private readonly RmvToObjExporter _exporter; - - public string DisplayName => "Rmv_to_Obj"; - public string OutputExtension => ".obj"; - - public RmvToObjExporterViewModel(RmvToObjExporter exporter) - { - _exporter = exporter; - } - - public ExportSupportEnum CanExportFile(PackFile file) => _exporter.CanExportFile(file); - - public void Execute(PackFile exportSource, string outputPath, bool generateImporter) - { - var settings = new RmvToObjExporterSettings { InputModelFile = exportSource, OutputPath = outputPath }; - _exporter.Export(settings); - } - } -} diff --git a/GltfMeshBuilder.cs b/GltfMeshBuilder.cs new file mode 100644 index 000000000..bf7137519 --- /dev/null +++ b/GltfMeshBuilder.cs @@ -0,0 +1,16 @@ +public List> Build(RmvFile rmv2, List textures, RmvToGltfExporterSettings settings, bool willUseSkeleton = false) +{ + var lodLevel = rmv2.ModelList.First(); + var hasSkeleton = string.IsNullOrWhiteSpace(rmv2.Header.SkeletonName) == false; + + var meshes = new List>(); + for(var i = 0; i < lodLevel.Length; i++) + { + var rmvMesh = lodLevel[i]; + var meshTextures = textures.Where(x=>x.MeshIndex == i).ToList(); + var gltfMaterial = Create(settings, rmvMesh.Material.ModelName + "_Material", meshTextures); + var gltfMesh = GenerateMesh(rmvMesh.Mesh, rmvMesh.Material.ModelName, gltfMaterial, hasSkeleton && willUseSkeleton, settings.MirrorMesh); + meshes.Add(gltfMesh); + } + return meshes; +} diff --git a/RmvToGltfExporter.cs b/RmvToGltfExporter.cs new file mode 100644 index 000000000..58ca22c01 --- /dev/null +++ b/RmvToGltfExporter.cs @@ -0,0 +1 @@ +var meshes = _gltfMeshBuilder.Build(rmv2, textures, settings, gltfSkeleton != null); From 941e6b17b43ca1ca65b03e60d632077399123998 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Fri, 6 Mar 2026 20:14:43 -0800 Subject: [PATCH 08/12] Fix DDS channel order and improve texture export Fixes DDS color channel ordering and restructures texture export flow. - Correct Pfim BGRA/BGR -> Bitmap ARGB/RGB conversion in TextureHelper and AlphaMaskCombiner to ensure colors/alpha are written correctly. - DdsToNormalPng: save raw normal PNG and optionally produce a blue normal map variant; return appropriate output path. - GltfTextureHandler: stop combining diffuse+mask into a single RGBA; export mask as a separate inverted _mask.png, add explicit handling for mask textures, and add export of additional normal map variants (raw and offset). - Remove displacement map generation: drop IDdsToDisplacementMap registration and displacement generation from exporter and UI (RmvToGltfStaticExporter and ViewModel updated). - Minor cleanup: explicit System.IO using, fully-qualified ImageFormat usage, small whitespace fixes. - Remove obsolete Docs/Static_Mesh_Export_Guide.md. These changes resolve incorrect color channel outputs from Pfim, provide more explicit texture artifacts (raw/blue/offset normal maps and separate masks), and simplify the exporter by removing automatic displacement map generation. --- Docs/Static_Mesh_Export_Guide.md | 217 ---------------- .../DependencyInjectionContainer.cs | 2 - .../DdsToNormalPng/DdsToNormalPngExporter.cs | 19 +- .../RmvToGltf/Helpers/AlphaMaskCombiner.cs | 39 ++- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 231 ++++++++++++++---- .../RmvToGltf/RmvToGltfStaticExporter.cs | 38 +-- .../RmvToGltfStaticExporterViewModel.cs | 2 +- .../Editors.ImportExport/TextureHelper.cs | 35 ++- 8 files changed, 260 insertions(+), 323 deletions(-) delete mode 100644 Docs/Static_Mesh_Export_Guide.md diff --git a/Docs/Static_Mesh_Export_Guide.md b/Docs/Static_Mesh_Export_Guide.md deleted file mode 100644 index 9c5ff4d2e..000000000 --- a/Docs/Static_Mesh_Export_Guide.md +++ /dev/null @@ -1,217 +0,0 @@ -# GLTF Static Mesh Export - 3D Printing Guide - -## What's New - -Automatic enhancements to static mesh export: -- **Vertex validation** - Normals/tangents validated and corrected -- **Alpha masks** - Baked into diffuse as RGBA texture -- **PBR materials** - Smart setup with automatic alpha modes -- **Full textures** - Emissive, Occlusion, Normal, Metallic/Roughness - -**All automatic, zero configuration.** - ---- - -## Quick Start - -### Export -1. Select model → Export GLTF Static -2. Choose output folder -3. All optimizations applied automatically - -### Output -``` -model.gltf -model.bin -texture_diffuse.png -texture_diffuse_with_alpha.png (if masked) -texture_normal.png -texture_occlusion.png -``` - ---- - -## Blender Workflow - -### Import -``` -File → Import → glTF 2.0 -Select model → Import glTF 2.0 -``` - -### Setup Transparency (If Needed) -1. Select material -2. Blend Mode → "Alpha Clip" (sharp) or "Blend" (soft) -3. Adjust Alpha Clip Threshold (default 0.5) - -### Prepare for Printing -``` -1. Check scale (Object Properties → Scale) -2. Object → Transform → Apply All Transforms -3. Mesh → Cleanup → Smart Cleanup - ✓ Degenerate Faces - ✓ Unused Vertices -``` - -### Export to Slicer -**STL (most common):** -``` -File → Export As → Stereolithography (.stl) -Export -``` - -**3MF (better):** -``` -File → Export As → 3D Manufacturing Format (.3mf) -Export -``` - ---- - -## Features - -### Vertex Validation -- Zero normals → Safe default (0, 0, 1) -- Non-unit normals → Auto-normalized -- Invalid tangents → Generated perpendicular to normal -- Bad handedness → Fixed to ±1 - -**Result:** Clean rendering, no artifacts - -### Alpha Masks -- **Detects:** Diffuse + mask textures together -- **Combines:** RGB + grayscale into RGBA -- **Exports:** `texture_diffuse_with_alpha.png` -- **Sets:** Material to MASK mode - -**Result:** Transparent parts (capes, fur, wings) import correctly - -### Smart Materials -**Detection:** -- By texture type (has mask) -- By material name (cape, fur, wing, feather, hair, foliage, leaf, chain) -- By texture filename patterns - -**Modes:** -- **OPAQUE** - Solid (default) -- **MASK** - Sharp edges (capes, leaves) -- **BLEND** - Soft transparency - -### Textures Exported -| Type | Format | Color Space | -|------|--------|-------------| -| Diffuse | PNG RGB | sRGB | -| Diffuse+Alpha | PNG RGBA | sRGB | -| Normal | PNG RGB | Linear | -| Metallic/Roughness | PNG RGB | Linear | -| Occlusion | PNG RGB | Linear | -| Emissive | PNG RGB | sRGB | - ---- - -## Troubleshooting - -### Geometry Distorted -``` -Mesh → Normals → Recalculate -Object → Transform → Apply All Transforms -Check scale: 1, 1, 1 -``` - -### Alpha Not Showing -``` -1. Material Properties → Blend Mode: "Alpha Clip" -2. Viewport: Material Preview (Z key) -3. Adjust Alpha Clip Threshold (0.5 default) -``` - -### Normal Maps Wrong -``` -Image Texture (normal): - ✓ Color Space: Linear (NOT sRGB!) -Principled BSDF: - ✓ Normal input: Connected -``` - -### Slicer Errors -``` -Mesh → Cleanup → Smart Cleanup -(Run all options) -Export fresh STL -Try again -``` - -### Wrong Size -``` -Press S (scale) -Type new scale (2 = double, 0.5 = half) -Press Enter -Object → Transform → Apply All Transforms -``` - ---- - -## Technical - -### Normal Validation -``` -1. length² < 0.0001 → Use (0,0,1) -2. |length² - 1.0| > 0.001 → Normalize -Result: Valid unit-length normal -``` - -### Tangent Validation -``` -1. length² < 0.0001 → Generate perpendicular -2. |length² - 1.0| > 0.001 → Normalize -3. W component → Ensure ±1 -Result: Valid tangent with handedness -``` - -### Alpha Combining -``` -Input: Diffuse DDS + Mask DDS -1. Convert to bitmaps -2. Take diffuse RGB -3. Use mask grayscale as alpha -4. Save PNG RGBA -Output: texture_diffuse_with_alpha.png -``` - ---- - ---- - -## Material Detection Examples - -**"character_cape_Material"** → Contains "cape" → MASK mode -**"wolf_fur_Material"** → Contains "fur" → MASK mode -**"dragon_wing_Material"** → Contains "wing" → MASK mode -**Diffuse + Mask textures** → Both present → MASK mode -**Diffuse only** → No mask → OPAQUE mode - ---- - -## Color Space - -Set in Blender Image Texture nodes: - -| Texture | Color Space | -|---------|-------------| -| Diffuse/Color | sRGB | -| Normal | Linear | -| Metallic/Roughness | Linear | -| Occlusion | Linear | -| Emissive | sRGB | - ---- - -## Files Modified - -- `GltfStaticMeshBuilder.cs` - Mesh building + validation -- `GltfTextureHandler.cs` - Texture handling + combining -- `AlphaMaskCombiner.cs` - Diffuse + mask merging - ---- - -**All improvements automatic and transparent. Nothing breaks existing workflows.** diff --git a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs index e70f63704..5951f30bc 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/DependencyInjectionContainer.cs @@ -2,7 +2,6 @@ using Editors.ImportExport.Exporting.Exporters; using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; -using Editors.ImportExport.Exporting.Exporters.DdsToDisplacementMap; using Editors.ImportExport.Exporting.Exporters.DdsToPng; using Editors.ImportExport.Exporting.Exporters.RmvToGltf; using Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers; @@ -69,7 +68,6 @@ public override void Register(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); RegisterAllAsInterface(services, ServiceLifetime.Transient); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs index dafbf1cf2..d5fdac80e 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs @@ -43,7 +43,7 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma var fileName = Path.GetFileNameWithoutExtension(filePath); var outDirectory = Path.GetDirectoryName(outputPath); - var fullFilePath = outDirectory + "/" + fileName + ".png"; + var rawFilePath = outDirectory + "/" + fileName + "_raw.png"; var bytes = packFile.DataSource.ReadData(); if (bytes == null || !bytes.Any()) @@ -53,11 +53,18 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma if (imgBytes == null || !imgBytes.Any()) throw new Exception($"image data invalid/empty. imgBytes.Count = {imgBytes?.Length}"); + // Save raw version + _imageSaveHandler.Save(imgBytes, rawFilePath); + if (convertToBlueNormalMap) - imgBytes = ConvertToBlueNormalMap(imgBytes, fullFilePath); + { + // Convert and save blue normal map version + var blueImgBytes = ConvertToBlueNormalMap(imgBytes, outDirectory); + _imageSaveHandler.Save(blueImgBytes, blueFilePath); + return blueFilePath; + } - _imageSaveHandler.Save(imgBytes, fullFilePath); - return fullFilePath; + return rawFilePath; } @@ -99,7 +106,7 @@ private byte[] ConvertToBlueNormalMap(byte[] imgBytes, string fileDirectory) // calculte z, using an orthogonal projection blueMapPixel.Z = (float)Math.Sqrt(1.0f - blueMapPixel.X * blueMapPixel.X - blueMapPixel.Y * blueMapPixel.Y); - + // convert the float values back to bytes, interval [0; 255] var newColor = Color.FromArgb( @@ -108,7 +115,7 @@ private byte[] ConvertToBlueNormalMap(byte[] imgBytes, string fileDirectory) (byte)((blueMapPixel.Y + 1.0f) * 0.5f * 255.0f), (byte)((blueMapPixel.Z + 1.0f) * 0.5f * 255.0f) ); - + bitmap.SetPixel(x, y, newColor); } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs index 80ce037ff..d0d0c3367 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/AlphaMaskCombiner.cs @@ -55,7 +55,7 @@ public static byte[] CombineDiffuseWithMask(byte[] diffuseDdsBytes, byte[] maskD // Export to PNG with alpha using var pngStream = new MemoryStream(); - combinedBitmap.Save(pngStream, ImageFormat.Png); + combinedBitmap.Save(pngStream, System.Drawing.Imaging.ImageFormat.Png); return pngStream.ToArray(); } catch (Exception ex) @@ -70,16 +70,47 @@ private static Bitmap ConvertDdsToBitmap(byte[] ddsBytes) using var w = new BinaryWriter(m); w.Write(ddsBytes); m.Seek(0, SeekOrigin.Begin); - + var image = Pfimage.FromStream(m); - + + // Pfim returns BGRA data for Rgba32, but Bitmap expects ARGB + // We need to swap the R and B channels + byte[] correctedData = new byte[image.DataLen]; + + if (image.Format == Pfim.ImageFormat.Rgba32) + { + // BGRA -> ARGB conversion + for (int i = 0; i < image.DataLen; i += 4) + { + correctedData[i] = image.Data[i + 2]; // B -> R + correctedData[i + 1] = image.Data[i + 1]; // G -> G + correctedData[i + 2] = image.Data[i]; // R -> B + correctedData[i + 3] = image.Data[i + 3]; // A -> A + } + } + else if (image.Format == Pfim.ImageFormat.Rgb24) + { + // BGR -> RGB conversion + for (int i = 0; i < image.DataLen; i += 3) + { + correctedData[i] = image.Data[i + 2]; // B -> R + correctedData[i + 1] = image.Data[i + 1]; // G -> G + correctedData[i + 2] = image.Data[i]; // R -> B + } + } + else + { + // For other formats, use the data as-is + correctedData = image.Data; + } + PixelFormat pixelFormat = image.Format == Pfim.ImageFormat.Rgba32 ? PixelFormat.Format32bppArgb : PixelFormat.Format24bppRgb; var bitmap = new Bitmap(image.Width, image.Height, pixelFormat); var bitmapData = bitmap.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.WriteOnly, pixelFormat); - System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + System.Runtime.InteropServices.Marshal.Copy(correctedData, 0, bitmapData.Scan0, correctedData.Length); bitmap.UnlockBits(bitmapData); return bitmap; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 1c107a3a0..448f70c7f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -1,4 +1,5 @@ -using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; +using System.IO; +using Editors.ImportExport.Exporting.Exporters.DdsToMaterialPng; using Editors.ImportExport.Exporting.Exporters.DdsToNormalPng; using Shared.GameFormats.RigidModel; using Shared.GameFormats.RigidModel.Types; @@ -8,6 +9,7 @@ namespace Editors.ImportExport.Exporting.Exporters.RmvToGltf.Helpers { public record TextureResult(int MeshIndex, string SystemFilePath, KnownChannel GlftTexureType, bool HasAlphaChannel = false); + public record MaskTextureResult(int MeshIndex, string SystemFilePath); public interface IGltfTextureHandler { @@ -18,14 +20,13 @@ public class GltfTextureHandler : IGltfTextureHandler { private readonly IDdsToNormalPngExporter _ddsToNormalPngExporter; private readonly IDdsToMaterialPngExporter _ddsToMaterialPngExporter; - private readonly IDisplacementMapGenerator _displacementMapGenerator; private readonly IPackFileService _packFileService; - public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IDisplacementMapGenerator displacementMapGenerator = null, IPackFileService packFileService = null) + public GltfTextureHandler(IDdsToNormalPngExporter ddsToNormalPngExporter, IDdsToMaterialPngExporter ddsToMaterialPngExporter, IPackFileService packFileService = null) { _ddsToNormalPngExporter = ddsToNormalPngExporter; _ddsToMaterialPngExporter = ddsToMaterialPngExporter; - _displacementMapGenerator = displacementMapGenerator ?? new DisplacementMapGenerator(); + _packFileService = packFileService; } @@ -46,34 +47,17 @@ public List HandleTextures(RmvFile rmvFile, RmvToGltfExporterSett var model = rmvFile.ModelList[lodIndex][meshIndex]; var textures = ExtractTextures(model); - // Check if this mesh has both diffuse and mask textures - if so, combine them - var diffuseTexture = textures.FirstOrDefault(t => t.Type == TextureType.Diffuse || t.Type == TextureType.BaseColour); - var maskTexture = textures.FirstOrDefault(t => t.Type == TextureType.Mask); - - bool shouldCombineMask = diffuseTexture != null && maskTexture != null; - foreach (var tex in textures) { - // Skip the mask if we're combining it with diffuse - if (shouldCombineMask && tex.Type == TextureType.Mask) - continue; - switch (tex.Type) { case TextureType.Normal: DoTextureConversionNormalMap(settings, output, exportedTextures, meshIndex, tex); break; case TextureType.MaterialMap: DoTextureConversionMaterialMap(settings, output, exportedTextures, meshIndex, tex); break; case TextureType.BaseColour: - if (shouldCombineMask) - DoCombinedDiffuseWithMask(settings, output, exportedTextures, meshIndex, tex, maskTexture); - else - DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); - break; case TextureType.Diffuse: - if (shouldCombineMask) - DoCombinedDiffuseWithMask(settings, output, exportedTextures, meshIndex, tex, maskTexture); - else - DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); + DoTextureDefault(KnownChannel.BaseColor, settings, output, exportedTextures, meshIndex, tex); break; + case TextureType.Mask: DoTextureMask(settings, output, exportedTextures, meshIndex, tex); break; case TextureType.Specular: DoTextureDefault(KnownChannel.SpecularColor, settings, output, exportedTextures, meshIndex, tex); break; case TextureType.Gloss: DoTextureDefault(KnownChannel.MetallicRoughness, settings, output, exportedTextures, meshIndex, tex); break; case TextureType.Ambient_occlusion: DoTextureDefault(KnownChannel.Occlusion, settings, output, exportedTextures, meshIndex, tex); break; @@ -123,62 +107,201 @@ private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSetting var systemPath = exportedTextures[text.Path]; if (systemPath != null) - output.Add(new TextureResult(meshIndex, systemPath, textureType, hasAlphaChannel: false)); + output.Add(new TextureResult(meshIndex, systemPath, textureType, false)); } - private void DoCombinedDiffuseWithMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput diffuseTexture, MaterialBuilderTextureInput maskTexture) + private void DoTextureMask(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { - var combinedKey = $"{diffuseTexture.Path}+{maskTexture.Path}"; - - if (exportedTextures.ContainsKey(combinedKey) == false) + if (exportedTextures.ContainsKey(text.Path) == false) { - try - { - // Get the pack files - var diffusePackFile = _packFileService.FindFile(diffuseTexture.Path); - var maskPackFile = _packFileService.FindFile(maskTexture.Path); + // Export mask as separate PNG - name it with _mask suffix for clarity + var exportedPath = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); - if (diffusePackFile == null || maskPackFile == null) + if (exportedPath != null) + { + // Invert the mask values for proper alpha channel usage + // Game masks are often inverted (black=show, white=hide) + // Alpha channels need (black=transparent, white=opaque) + InvertMaskImage(exportedPath); + + // Rename to have _mask suffix + var directory = Path.GetDirectoryName(exportedPath); + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(exportedPath); + var newFileName = fileNameWithoutExt + "_mask.png"; + var newPath = Path.Combine(directory, newFileName); + + if (File.Exists(exportedPath)) { - throw new InvalidOperationException($"Could not find diffuse or mask texture in pack files"); + if (File.Exists(newPath)) + File.Delete(newPath); + File.Move(exportedPath, newPath); + exportedPath = newPath; } + } - // Read DDS data - var diffuseDdsBytes = diffusePackFile.DataSource.ReadData(); - var maskDdsBytes = maskPackFile.DataSource.ReadData(); + exportedTextures[text.Path] = exportedPath; + } - // Combine diffuse and mask - var combinedPngBytes = AlphaMaskCombiner.CombineDiffuseWithMask(diffuseDdsBytes, maskDdsBytes); + var systemPath = exportedTextures[text.Path]; + if (systemPath != null) + { + // Export mask as a regular texture - user will connect it manually in Blender + // We'll add it as a separate texture that doesn't get auto-connected but is available + output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.BaseColor, false)); + } + } - // Save combined texture - var fileName = Path.GetFileNameWithoutExtension(diffuseTexture.Path) + "_with_alpha.png"; - var outDirectory = Path.GetDirectoryName(settings.OutputPath); - var outFilePath = Path.Combine(outDirectory, fileName); + private void InvertMaskImage(string imagePath) + { + byte[] imageBytes; - File.WriteAllBytes(outFilePath, combinedPngBytes); - exportedTextures[combinedKey] = outFilePath; - } - catch (Exception ex) + // Load image into memory to avoid file lock + using (var fs = File.OpenRead(imagePath)) + using (var ms = new MemoryStream()) + { + fs.CopyTo(ms); + imageBytes = ms.ToArray(); + } + + // Process the image from memory + using var imageStream = new MemoryStream(imageBytes); + using var image = System.Drawing.Image.FromStream(imageStream); + using var bitmap = new System.Drawing.Bitmap(image); + + // Invert all pixel values (255 - value) + for (int x = 0; x < bitmap.Width; x++) + { + for (int y = 0; y < bitmap.Height; y++) { - // If combining fails, fall back to just the diffuse - exportedTextures[combinedKey] = _ddsToMaterialPngExporter.Export(diffuseTexture.Path, settings.OutputPath, false); + var pixel = bitmap.GetPixel(x, y); + var invertedR = 255 - pixel.R; + var invertedG = 255 - pixel.G; + var invertedB = 255 - pixel.B; + var invertedColor = System.Drawing.Color.FromArgb(pixel.A, invertedR, invertedG, invertedB); + bitmap.SetPixel(x, y, invertedColor); } } - var systemPath = exportedTextures[combinedKey]; - if (systemPath != null) - // Mark as having alpha channel since we combined the mask into it - output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.BaseColor, hasAlphaChannel: true)); + // Save back to the same file + bitmap.Save(imagePath, System.Drawing.Imaging.ImageFormat.Png); } private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { if (exportedTextures.ContainsKey(text.Path) == false) + { exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + ExportNormalMapVariants(text.Path, settings.OutputPath); + } + var systemPath = exportedTextures[text.Path]; if (systemPath != null) output.Add(new TextureResult(meshIndex, systemPath, KnownChannel.Normal)); } + + private void ExportNormalMapVariants(string packFilePath, string outputPath) + { + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(packFilePath); + if (packFile == null) + return; + + var fileName = Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath); + + var bytes = packFile.DataSource.ReadData(); + if (bytes != null && bytes.Any()) + { + ExportRawNormalMapPng(bytes, outDirectory, fileName); + ExportOffsetNormalMapPng(bytes, outDirectory, fileName); + } + } + + private void ExportRawNormalMapPng(byte[] ddsBytes, string outDirectory, string fileName) + { + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(ddsBytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfim.Pfimage.FromStream(m); + + var pixelFormat = System.Drawing.Imaging.PixelFormat.Format32bppArgb; + if (image.Format == Pfim.ImageFormat.Rgba32) + { + pixelFormat = System.Drawing.Imaging.PixelFormat.Format32bppArgb; + } + else if (image.Format == Pfim.ImageFormat.Rgb24) + { + pixelFormat = System.Drawing.Imaging.PixelFormat.Format24bppRgb; + } + else + { + return; + } + + using var tempBitmap = new System.Drawing.Bitmap(image.Width, image.Height, pixelFormat); + + var bitmapData = tempBitmap.LockBits( + new System.Drawing.Rectangle(0, 0, image.Width, image.Height), + System.Drawing.Imaging.ImageLockMode.WriteOnly, + pixelFormat); + + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + tempBitmap.UnlockBits(bitmapData); + + using var decodedBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + for (int y = 0; y < tempBitmap.Height; y++) + { + for (int x = 0; x < tempBitmap.Width; x++) + { + var pixel = tempBitmap.GetPixel(x, y); + + float r = pixel.R / 255.0f; + float g = pixel.G / 255.0f; + float a = pixel.A / 255.0f; + + float decodedX = r * a; + float decodedY = g; + + decodedX = decodedX * 2.0f - 1.0f; + decodedY = decodedY * 2.0f - 1.0f; + + float decodedZ = (float)Math.Sqrt(Math.Max(0, 1.0f - decodedX * decodedX - decodedY * decodedY)); + + byte finalR = (byte)((decodedX + 1.0f) * 0.5f * 255.0f); + byte finalG = (byte)((decodedY + 1.0f) * 0.5f * 255.0f); + byte finalB = (byte)((decodedZ + 1.0f) * 0.5f * 255.0f); + byte finalA = (byte)(a * 255.0f); + + decodedBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(finalA, finalR, finalG, finalB)); + } + } + + var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + decodedBitmap.Save(rawPngPath, System.Drawing.Imaging.ImageFormat.Png); + } + + private void ExportOffsetNormalMapPng(byte[] ddsBytes, string outDirectory, string fileName) + { + var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + + if (!File.Exists(rawPngPath)) + return; + + using var rawImage = System.Drawing.Image.FromFile(rawPngPath); + using var outputBitmap = new System.Drawing.Bitmap(rawImage.Width, rawImage.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + using var graphics = System.Drawing.Graphics.FromImage(outputBitmap); + + graphics.Clear(System.Drawing.Color.FromArgb(255, 128, 128, 255)); + graphics.DrawImage(rawImage, 0, 0); + + var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); + outputBitmap.Save(offsetPngPath, System.Drawing.Imaging.ImageFormat.Png); + } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs index 094646cdb..b12aa0a4f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfStaticExporter.cs @@ -36,7 +36,7 @@ internal ExportSupportEnum CanExportFile(PackFile file) return ExportSupportEnum.NotSupported; } - public void Export(RmvToGltfExporterSettings settings, bool generateDisplacementMaps = false) + public void Export(RmvToGltfExporterSettings settings) { LogSettings(settings); @@ -48,12 +48,6 @@ public void Export(RmvToGltfExporterSettings settings, bool generateDisplacement _logger.Here().Information($"Static Export - MeshCount={meshes.Count()} TextureCount={textures.Count()}"); BuildGltfScene(meshes, settings, outputScene); - - // Generate displacement maps if requested - if (generateDisplacementMaps) - { - GenerateDisplacementMapsForTextures(settings, textures); - } } void BuildGltfScene(List> meshBuilders, RmvToGltfExporterSettings settings, ModelRoot outputScene) @@ -68,36 +62,6 @@ void BuildGltfScene(List> meshBuilders, RmvToGltfE _gltfSaver.Save(outputScene, settings.OutputPath); } - void GenerateDisplacementMapsForTextures(RmvToGltfExporterSettings settings, List textures) - { - try - { - var outputDir = Path.GetDirectoryName(settings.OutputPath); - if (string.IsNullOrEmpty(outputDir)) - return; - - // Find normal map textures and generate displacement maps - var normalMaps = textures.Where(t => t.GlftTexureType.ToString().Contains("Normal")).ToList(); - - foreach (var texture in normalMaps) - { - try - { - _displacementMapExporter.Export(texture.SystemFilePath, outputDir); - _logger.Here().Information($"Generated displacement map for: {Path.GetFileName(texture.SystemFilePath)}"); - } - catch (Exception ex) - { - _logger.Here().Warning($"Failed to generate displacement map for {texture.SystemFilePath}: {ex.Message}"); - } - } - } - catch (Exception ex) - { - _logger.Here().Warning($"Error generating displacement maps: {ex.Message}"); - } - } - void LogSettings(RmvToGltfExporterSettings settings) { var str = $"Exporting using {nameof(RmvToGltfStaticExporter)} (Static Mesh Export)\n"; diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs index 36ee1f52b..b58067b9d 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -31,7 +31,7 @@ public ExportSupportEnum CanExportFile(PackFile file) public void Execute(PackFile exportSource, string outputPath, bool generateImporter) { var settings = new RmvToGltfExporterSettings(exportSource, [], outputPath, ExportTextures, ConvertMaterialTextureToBlender, ConvertNormalTextureToBlue, false, true); - _exporter.Export(settings, GenerateDisplacementMaps); + _exporter.Export(settings); } } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs index 9c0d41a5f..e21253e78 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/TextureHelper.cs @@ -29,10 +29,41 @@ public static byte[] ConvertDdsToPng(byte[] ddsbyteSteam) throw new NotSupportedException($"Unsupported DDS format: {image.Format}"); } + // Pfim returns BGRA data for Rgba32, but Bitmap expects ARGB + // We need to swap the R and B channels + byte[] correctedData = new byte[image.DataLen]; + + if (image.Format == Pfim.ImageFormat.Rgba32) + { + // BGRA -> ARGB conversion + for (int i = 0; i < image.DataLen; i += 4) + { + correctedData[i] = image.Data[i + 2]; // B -> R + correctedData[i + 1] = image.Data[i + 1]; // G -> G + correctedData[i + 2] = image.Data[i]; // R -> B + correctedData[i + 3] = image.Data[i + 3]; // A -> A + } + } + else if (image.Format == Pfim.ImageFormat.Rgb24) + { + // BGR -> RGB conversion + for (int i = 0; i < image.DataLen; i += 3) + { + correctedData[i] = image.Data[i + 2]; // B -> R + correctedData[i + 1] = image.Data[i + 1]; // G -> G + correctedData[i + 2] = image.Data[i]; // R -> B + } + } + else + { + // For other formats, use the data as-is + correctedData = image.Data; + } + using var bitmap = new Bitmap(image.Width, image.Height, pixelFormat); - + var bitmapData = bitmap.LockBits(new Rectangle(0, 0, image.Width, image.Height), ImageLockMode.WriteOnly, pixelFormat); - System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + System.Runtime.InteropServices.Marshal.Copy(correctedData, 0, bitmapData.Scan0, correctedData.Length); bitmap.UnlockBits(bitmapData); using var b = new MemoryStream(); From bd938e2052c0ba91d7bba8032ed275a6c22c0a03 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Fri, 6 Mar 2026 20:40:15 -0800 Subject: [PATCH 09/12] Drop blue normal conversion, add displacement map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the optional blue normal-map conversion from DdsToNormalPngExporter — it now always saves the raw PNG and the ConvertToBlueNormalMap method and blue-file save path were removed. Add ExportDisplacementMapPng to GltfTextureHandler which reads the existing raw PNG, computes a height/displacement map by converting to luminance, applying iterative smoothing, contrast adjustment and sharpening, normalizes the result, and saves a *_displacement.png alongside the raw and offset outputs. --- .../DdsToNormalPng/DdsToNormalPngExporter.cs | 71 ---------- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 134 ++++++++++++++++++ 2 files changed, 134 insertions(+), 71 deletions(-) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs index d5fdac80e..601f013e7 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/DdsToNormalPng/DdsToNormalPngExporter.cs @@ -53,80 +53,9 @@ public string Export(string filePath, string outputPath, bool convertToBlueNorma if (imgBytes == null || !imgBytes.Any()) throw new Exception($"image data invalid/empty. imgBytes.Count = {imgBytes?.Length}"); - // Save raw version _imageSaveHandler.Save(imgBytes, rawFilePath); - if (convertToBlueNormalMap) - { - // Convert and save blue normal map version - var blueImgBytes = ConvertToBlueNormalMap(imgBytes, outDirectory); - _imageSaveHandler.Save(blueImgBytes, blueFilePath); - return blueFilePath; - } - return rawFilePath; } - - - private byte[] ConvertToBlueNormalMap(byte[] imgBytes, string fileDirectory) - { - var inMs = new MemoryStream(imgBytes); - using Image inImg = Image.FromStream(inMs); - - using Bitmap bitmap = new Bitmap(inImg); - { - for (int x = 0; x < bitmap.Width; x++) - { - for (int y = 0; y < bitmap.Height; y++) - { - // get pixel from orange map - var orangeMapRawPixel = bitmap.GetPixel(x, y); - - // convert bytes to float to interval [0; 1] - Vector4 orangeMapVector = new Vector4() - { - X = (float)orangeMapRawPixel.R / 255.0f, - Y = (float)orangeMapRawPixel.G / 255.0f, - Z = (float)orangeMapRawPixel.B / 255.0f, - W = (float)orangeMapRawPixel.A / 255.0f, - }; - - // fill blue map pixels - Vector3 blueMapPixel = new Vector3() - { - X = orangeMapVector.X * orangeMapVector.W, - Y = orangeMapVector.Y, - Z = 0 - }; - - // scale bluemap into interval [-1; 1] - blueMapPixel *= 2.0f; - blueMapPixel -= new Vector3(1, 1, 1); - - - // calculte z, using an orthogonal projection - blueMapPixel.Z = (float)Math.Sqrt(1.0f - blueMapPixel.X * blueMapPixel.X - blueMapPixel.Y * blueMapPixel.Y); - - - // convert the float values back to bytes, interval [0; 255] - var newColor = Color.FromArgb( - 255, - (byte)((blueMapPixel.X + 1.0f) * 0.5f * 255.0f), - (byte)((blueMapPixel.Y + 1.0f) * 0.5f * 255.0f), - (byte)((blueMapPixel.Z + 1.0f) * 0.5f * 255.0f) - ); - - bitmap.SetPixel(x, y, newColor); - } - } - - // get raw PNG bytes - using var b = new MemoryStream(); - bitmap.Save(b, System.Drawing.Imaging.ImageFormat.Png); - - return b.ToArray(); - } - } - } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 448f70c7f..538368693 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -217,6 +217,7 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) { ExportRawNormalMapPng(bytes, outDirectory, fileName); ExportOffsetNormalMapPng(bytes, outDirectory, fileName); + ExportDisplacementMapPng(bytes, outDirectory, fileName); } } @@ -303,5 +304,138 @@ private void ExportOffsetNormalMapPng(byte[] ddsBytes, string outDirectory, stri var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); outputBitmap.Save(offsetPngPath, System.Drawing.Imaging.ImageFormat.Png); } + + private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, string fileName) + { + var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + + if (!File.Exists(rawPngPath)) + return; + + using var rawImage = System.Drawing.Image.FromFile(rawPngPath); + using var rawBitmap = new System.Drawing.Bitmap(rawImage); + + int width = rawBitmap.Width; + int height = rawBitmap.Height; + + // Convert normal map to initial grayscale using luminance + float[,] heightMap = new float[width, height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + // Use standard luminance weights + float gray = (pixel.R * 0.299f + pixel.G * 0.587f + pixel.B * 0.114f) / 255.0f; + heightMap[x, y] = gray; + } + } + + // Apply iterative smoothing (relaxation) to generate height from normals + // This mimics the shader's multi-pass approach + const int iterations = 10; + float[,] tempMap = new float[width, height]; + + for (int iter = 0; iter < iterations; iter++) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float sum = 0; + int count = 0; + + // Sample neighbors (above, left, right, below) + if (y > 0) { sum += heightMap[x, y - 1]; count++; } + if (x > 0) { sum += heightMap[x - 1, y]; count++; } + if (x < width - 1) { sum += heightMap[x + 1, y]; count++; } + if (y < height - 1) { sum += heightMap[x, y + 1]; count++; } + + tempMap[x, y] = count > 0 ? sum / count : heightMap[x, y]; + } + } + + // Swap buffers + Array.Copy(tempMap, heightMap, width * height); + } + + // Apply contrast adjustment (factor 0.1) + const float contrastFactor = 0.1f; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float value = heightMap[x, y]; + heightMap[x, y] = Math.Clamp((value - 0.5f) * (1.0f + contrastFactor) + 0.5f, 0, 1); + } + } + + // Apply sharpening filter (strength 1) + const float sharpenStrength = 1.0f; + for (int y = 1; y < height - 1; y++) + { + for (int x = 1; x < width - 1; x++) + { + float center = heightMap[x, y]; + float top = heightMap[x, y - 1]; + float bottom = heightMap[x, y + 1]; + float left = heightMap[x - 1, y]; + float right = heightMap[x + 1, y]; + + float sharpened = center * (1.0f + 4.0f * sharpenStrength) + - (top + bottom + left + right) * sharpenStrength; + + tempMap[x, y] = Math.Clamp(sharpened, 0, 1); + } + } + + // Copy sharpened values back (skip edges) + for (int y = 1; y < height - 1; y++) + { + for (int x = 1; x < width - 1; x++) + { + heightMap[x, y] = tempMap[x, y]; + } + } + + // Normalize to 0-255 range + float minHeight = float.MaxValue; + float maxHeight = float.MinValue; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + minHeight = Math.Min(minHeight, heightMap[x, y]); + maxHeight = Math.Max(maxHeight, heightMap[x, y]); + } + } + + using var displacementBitmap = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float normalizedHeight; + if (maxHeight > minHeight) + { + normalizedHeight = (heightMap[x, y] - minHeight) / (maxHeight - minHeight); + } + else + { + normalizedHeight = 0.5f; + } + + byte grayscale = (byte)Math.Clamp(normalizedHeight * 255.0f, 0, 255); + + displacementBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, grayscale, grayscale, grayscale)); + } + } + + var displacementPngPath = Path.Combine(outDirectory, fileName + "_displacement.png"); + displacementBitmap.Save(displacementPngPath, System.Drawing.Imaging.ImageFormat.Png); + } } } From bb86a319bb5c59f9a37e6188c04c321ba3fdd6b1 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Sat, 7 Mar 2026 12:05:54 -0800 Subject: [PATCH 10/12] Add displacement generation and UI settings Generate displacement maps from normal textures and expose quality settings in the static GLTF exporter. Implemented ExportDisplacementFromNormalMap, improved raw/offset normal handling and alpha compositing, and added a displacement processing pipeline (standard, Poisson, multi-scale), contrast/sharpening, bilateral filtering and 8/16-bit export helpers. Added new RmvToGltfExporterSettings fields for displacement control, a new RmvToGltfStaticExporterView XAML + code-behind and updated RmvToGltfStaticExporterViewModel to surface settings and pass them to the exporter. Minor fix in RmvToGltfExporterView XAML whitespace. --- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 440 ++++++++++++++---- .../RmvToGltf/RmvToGltfExporterSettings.cs | 10 +- .../RmvToGltf/RmvToGltfExporterView.xaml | 2 +- .../RmvToGltfStaticExporterView.xaml | 61 +++ .../RmvToGltfStaticExporterView.xaml.cs | 12 + .../RmvToGltfStaticExporterViewModel.cs | 31 +- 6 files changed, 463 insertions(+), 93 deletions(-) create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml create mode 100644 Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 538368693..992ad343b 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -190,9 +190,14 @@ private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, Li { if (exportedTextures.ContainsKey(text.Path) == false) { - exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); - + // Export normal map variants with proper YCoCg decoding ExportNormalMapVariants(text.Path, settings.OutputPath); + ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings); + + // Set the path to the raw normal map + var fileName = Path.GetFileNameWithoutExtension(text.Path); + var outDirectory = Path.GetDirectoryName(settings.OutputPath); + exportedTextures[text.Path] = Path.Combine(outDirectory, fileName + "_raw.png"); } var systemPath = exportedTextures[text.Path]; @@ -217,7 +222,25 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) { ExportRawNormalMapPng(bytes, outDirectory, fileName); ExportOffsetNormalMapPng(bytes, outDirectory, fileName); - ExportDisplacementMapPng(bytes, outDirectory, fileName); + } + } + + public void ExportDisplacementFromNormalMap(string normalMapPath, string outputPath, RmvToGltfExporterSettings settings) + { + var fileName = Path.GetFileNameWithoutExtension(normalMapPath); + var outDirectory = Path.GetDirectoryName(outputPath); + + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(normalMapPath); + if (packFile == null) + return; + + var bytes = packFile.DataSource.ReadData(); + if (bytes != null && bytes.Any()) + { + ExportDisplacementMapPng(bytes, outDirectory, fileName, settings); } } @@ -244,47 +267,18 @@ private void ExportRawNormalMapPng(byte[] ddsBytes, string outDirectory, string return; } - using var tempBitmap = new System.Drawing.Bitmap(image.Width, image.Height, pixelFormat); + using var rawBitmap = new System.Drawing.Bitmap(image.Width, image.Height, pixelFormat); - var bitmapData = tempBitmap.LockBits( + var bitmapData = rawBitmap.LockBits( new System.Drawing.Rectangle(0, 0, image.Width, image.Height), System.Drawing.Imaging.ImageLockMode.WriteOnly, pixelFormat); System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); - tempBitmap.UnlockBits(bitmapData); - - using var decodedBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); - - for (int y = 0; y < tempBitmap.Height; y++) - { - for (int x = 0; x < tempBitmap.Width; x++) - { - var pixel = tempBitmap.GetPixel(x, y); - - float r = pixel.R / 255.0f; - float g = pixel.G / 255.0f; - float a = pixel.A / 255.0f; - - float decodedX = r * a; - float decodedY = g; - - decodedX = decodedX * 2.0f - 1.0f; - decodedY = decodedY * 2.0f - 1.0f; - - float decodedZ = (float)Math.Sqrt(Math.Max(0, 1.0f - decodedX * decodedX - decodedY * decodedY)); - - byte finalR = (byte)((decodedX + 1.0f) * 0.5f * 255.0f); - byte finalG = (byte)((decodedY + 1.0f) * 0.5f * 255.0f); - byte finalB = (byte)((decodedZ + 1.0f) * 0.5f * 255.0f); - byte finalA = (byte)(a * 255.0f); - - decodedBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(finalA, finalR, finalG, finalB)); - } - } + rawBitmap.UnlockBits(bitmapData); var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); - decodedBitmap.Save(rawPngPath, System.Drawing.Imaging.ImageFormat.Png); + rawBitmap.Save(rawPngPath, System.Drawing.Imaging.ImageFormat.Png); } private void ExportOffsetNormalMapPng(byte[] ddsBytes, string outDirectory, string fileName) @@ -295,48 +289,110 @@ private void ExportOffsetNormalMapPng(byte[] ddsBytes, string outDirectory, stri return; using var rawImage = System.Drawing.Image.FromFile(rawPngPath); - using var outputBitmap = new System.Drawing.Bitmap(rawImage.Width, rawImage.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); - using var graphics = System.Drawing.Graphics.FromImage(outputBitmap); + using var rawBitmap = new System.Drawing.Bitmap(rawImage); + using var outputBitmap = new System.Drawing.Bitmap(rawBitmap.Width, rawBitmap.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + const int bgR = 128; + const int bgG = 128; + const int bgB = 255; + + // Manually composite pixel-by-pixel for proper alpha blending + for (int y = 0; y < rawBitmap.Height; y++) + { + for (int x = 0; x < rawBitmap.Width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + + // Note: Swap R and B because raw PNG is in BGRA format from Pfim + float alpha = pixel.A / 255.0f; + float invAlpha = 1.0f - alpha; + + int compositeR = (int)(pixel.B * alpha + bgR * invAlpha); // Use B for R + int compositeG = (int)(pixel.G * alpha + bgG * invAlpha); + int compositeB = (int)(pixel.R * alpha + bgB * invAlpha); // Use R for B - graphics.Clear(System.Drawing.Color.FromArgb(255, 128, 128, 255)); - graphics.DrawImage(rawImage, 0, 0); + compositeR = Math.Clamp(compositeR, 0, 255); + compositeG = Math.Clamp(compositeG, 0, 255); + compositeB = Math.Clamp(compositeB, 0, 255); + + outputBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, compositeR, compositeG, compositeB)); + } + } var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); outputBitmap.Save(offsetPngPath, System.Drawing.Imaging.ImageFormat.Png); } - private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, string fileName) + private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, string fileName, RmvToGltfExporterSettings settings) { - var rawPngPath = Path.Combine(outDirectory, fileName + "_raw.png"); + var offsetPngPath = Path.Combine(outDirectory, fileName + "_offset.png"); - if (!File.Exists(rawPngPath)) + if (!File.Exists(offsetPngPath)) return; - using var rawImage = System.Drawing.Image.FromFile(rawPngPath); - using var rawBitmap = new System.Drawing.Bitmap(rawImage); + using var offsetImage = System.Drawing.Image.FromFile(offsetPngPath); + using var offsetBitmap = new System.Drawing.Bitmap(offsetImage); + + int width = offsetBitmap.Width; + int height = offsetBitmap.Height; + float[,] heightMap; + + if (settings.UseMultiScaleProcessing) + { + // Feature 4: Multi-scale processing for better detail preservation + heightMap = ProcessMultiScale(offsetBitmap, settings); + } + else if (settings.UsePoissonReconstruction) + { + // Feature 2: Poisson reconstruction for better gradient integration + heightMap = PoissonReconstruction(offsetBitmap, settings.DisplacementIterations); + } + else + { + // Standard processing + heightMap = StandardHeightMapGeneration(offsetBitmap, settings.DisplacementIterations); + } + + // Feature 3: Apply contrast adjustment (configurable) + ApplyContrast(heightMap, settings.DisplacementContrast); + + // Feature 5: Bilateral filter for edge-aware sharpening + heightMap = ApplyBilateralFilter(heightMap, settings.DisplacementSharpness); + + // Normalize to 0-1 range + NormalizeHeightMap(heightMap, out float minHeight, out float maxHeight); + + // Feature 1: Export as 16-bit or 8-bit based on settings + if (settings.Export16BitDisplacement) + { + Save16BitDisplacementMap(heightMap, outDirectory, fileName); + } + else + { + Save8BitDisplacementMap(heightMap, outDirectory, fileName); + } + } + + private float[,] StandardHeightMapGeneration(System.Drawing.Bitmap rawBitmap, int iterations) + { int width = rawBitmap.Width; int height = rawBitmap.Height; - - // Convert normal map to initial grayscale using luminance float[,] heightMap = new float[width, height]; + // Convert normal map to initial grayscale using luminance (matching NormalMap-Online approach) for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { var pixel = rawBitmap.GetPixel(x, y); - // Use standard luminance weights float gray = (pixel.R * 0.299f + pixel.G * 0.587f + pixel.B * 0.114f) / 255.0f; heightMap[x, y] = gray; } } - // Apply iterative smoothing (relaxation) to generate height from normals - // This mimics the shader's multi-pass approach - const int iterations = 10; + // Apply iterative smoothing (relaxation/diffusion) float[,] tempMap = new float[width, height]; - for (int iter = 0; iter < iterations; iter++) { for (int y = 0; y < height; y++) @@ -355,86 +411,298 @@ private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, stri tempMap[x, y] = count > 0 ? sum / count : heightMap[x, y]; } } + Array.Copy(tempMap, heightMap, width * height); + } + + return heightMap; + } + + private float[,] PoissonReconstruction(System.Drawing.Bitmap rawBitmap, int iterations) + { + int width = rawBitmap.Width; + int height = rawBitmap.Height; + float[,] gradientX = new float[width, height]; + float[,] gradientY = new float[width, height]; + float[,] heightMap = new float[width, height]; + + // Extract gradients from normal map + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + // Convert from [0,255] to [-1,1] + gradientX[x, y] = (pixel.R / 255.0f) * 2.0f - 1.0f; + gradientY[x, y] = (pixel.G / 255.0f) * 2.0f - 1.0f; + } + } - // Swap buffers + // Solve Poisson equation using Jacobi iteration + float[,] tempMap = new float[width, height]; + for (int iter = 0; iter < iterations * 5; iter++) // More iterations for Poisson + { + for (int y = 1; y < height - 1; y++) + { + for (int x = 1; x < width - 1; x++) + { + // Divergence of gradient field + float div = (gradientX[x, y] - gradientX[x - 1, y]) + + (gradientY[x, y] - gradientY[x, y - 1]); + + // Laplacian: average of neighbors + float laplacian = (heightMap[x - 1, y] + heightMap[x + 1, y] + + heightMap[x, y - 1] + heightMap[x, y + 1]) * 0.25f; + + tempMap[x, y] = laplacian - div * 0.25f; + } + } Array.Copy(tempMap, heightMap, width * height); } - // Apply contrast adjustment (factor 0.1) - const float contrastFactor = 0.1f; + // Normalize the Poisson result to 0-1 range before returning + // This prevents extreme values from dominating + float minVal = float.MaxValue; + float maxVal = float.MinValue; + for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - float value = heightMap[x, y]; - heightMap[x, y] = Math.Clamp((value - 0.5f) * (1.0f + contrastFactor) + 0.5f, 0, 1); + minVal = Math.Min(minVal, heightMap[x, y]); + maxVal = Math.Max(maxVal, heightMap[x, y]); } } - // Apply sharpening filter (strength 1) - const float sharpenStrength = 1.0f; - for (int y = 1; y < height - 1; y++) + if (maxVal > minVal) { - for (int x = 1; x < width - 1; x++) + for (int y = 0; y < height; y++) { - float center = heightMap[x, y]; - float top = heightMap[x, y - 1]; - float bottom = heightMap[x, y + 1]; - float left = heightMap[x - 1, y]; - float right = heightMap[x + 1, y]; + for (int x = 0; x < width; x++) + { + heightMap[x, y] = (heightMap[x, y] - minVal) / (maxVal - minVal); + } + } + } + + return heightMap; + } + + private float[,] ProcessMultiScale(System.Drawing.Bitmap rawBitmap, RmvToGltfExporterSettings settings) + { + int width = rawBitmap.Width; + int height = rawBitmap.Height; + + // Process at full resolution + var fullRes = settings.UsePoissonReconstruction + ? PoissonReconstruction(rawBitmap, settings.DisplacementIterations) + : StandardHeightMapGeneration(rawBitmap, settings.DisplacementIterations); - float sharpened = center * (1.0f + 4.0f * sharpenStrength) - - (top + bottom + left + right) * sharpenStrength; + // Process at half resolution + using var halfBitmap = new System.Drawing.Bitmap(rawBitmap, width / 2, height / 2); + var halfRes = settings.UsePoissonReconstruction + ? PoissonReconstruction(halfBitmap, settings.DisplacementIterations) + : StandardHeightMapGeneration(halfBitmap, settings.DisplacementIterations); - tempMap[x, y] = Math.Clamp(sharpened, 0, 1); + // Upscale half resolution + var halfUpscaled = UpscaleHeightMap(halfRes, width, height); + + // Blend full and upscaled half (70% full, 30% half for detail preservation) + float[,] blended = new float[width, height]; + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + blended[x, y] = fullRes[x, y] * 0.7f + halfUpscaled[x, y] * 0.3f; } } - // Copy sharpened values back (skip edges) - for (int y = 1; y < height - 1; y++) + return blended; + } + + private float[,] UpscaleHeightMap(float[,] input, int targetWidth, int targetHeight) + { + int srcWidth = input.GetLength(0); + int srcHeight = input.GetLength(1); + float[,] output = new float[targetWidth, targetHeight]; + + for (int y = 0; y < targetHeight; y++) { - for (int x = 1; x < width - 1; x++) + for (int x = 0; x < targetWidth; x++) { - heightMap[x, y] = tempMap[x, y]; + float srcX = x * (srcWidth - 1f) / (targetWidth - 1f); + float srcY = y * (srcHeight - 1f) / (targetHeight - 1f); + + int x0 = (int)srcX; + int y0 = (int)srcY; + int x1 = Math.Min(x0 + 1, srcWidth - 1); + int y1 = Math.Min(y0 + 1, srcHeight - 1); + + float fx = srcX - x0; + float fy = srcY - y0; + + // Bilinear interpolation + output[x, y] = input[x0, y0] * (1 - fx) * (1 - fy) + + input[x1, y0] * fx * (1 - fy) + + input[x0, y1] * (1 - fx) * fy + + input[x1, y1] * fx * fy; } } - // Normalize to 0-255 range - float minHeight = float.MaxValue; - float maxHeight = float.MinValue; + return output; + } + + private void ApplyContrast(float[,] heightMap, float contrastFactor) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - minHeight = Math.Min(minHeight, heightMap[x, y]); - maxHeight = Math.Max(maxHeight, heightMap[x, y]); + float value = heightMap[x, y]; + heightMap[x, y] = Math.Clamp((value - 0.5f) * (1.0f + contrastFactor) + 0.5f, 0, 1); } } + } - using var displacementBitmap = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + private float[,] ApplyBilateralFilter(float[,] heightMap, float strength) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + float[,] output = new float[width, height]; + + const int radius = 2; + float sigmaSpatial = 2.0f; + float sigmaRange = 0.1f * strength; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + float sum = 0; + float totalWeight = 0; + float centerValue = heightMap[x, y]; + + for (int dy = -radius; dy <= radius; dy++) + { + for (int dx = -radius; dx <= radius; dx++) + { + int nx = Math.Clamp(x + dx, 0, width - 1); + int ny = Math.Clamp(y + dy, 0, height - 1); + + float neighborValue = heightMap[nx, ny]; + + // Spatial weight (Gaussian based on distance) + float spatialDist = dx * dx + dy * dy; + float spatialWeight = (float)Math.Exp(-spatialDist / (2 * sigmaSpatial * sigmaSpatial)); + + // Range weight (Gaussian based on intensity difference) + float rangeDist = (centerValue - neighborValue) * (centerValue - neighborValue); + float rangeWeight = (float)Math.Exp(-rangeDist / (2 * sigmaRange * sigmaRange)); + + float weight = spatialWeight * rangeWeight; + sum += neighborValue * weight; + totalWeight += weight; + } + } + + output[x, y] = totalWeight > 0 ? sum / totalWeight : centerValue; + } + } + + return output; + } + + private void NormalizeHeightMap(float[,] heightMap, out float minHeight, out float maxHeight) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + minHeight = float.MaxValue; + maxHeight = float.MinValue; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { - float normalizedHeight; - if (maxHeight > minHeight) + minHeight = Math.Min(minHeight, heightMap[x, y]); + maxHeight = Math.Max(maxHeight, heightMap[x, y]); + } + } + + // Simple normalization: map the actual range to 0-1 + // This preserves the relative values without forcing expansion to extremes + if (maxHeight > minHeight) + { + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) { - normalizedHeight = (heightMap[x, y] - minHeight) / (maxHeight - minHeight); + heightMap[x, y] = (heightMap[x, y] - minHeight) / (maxHeight - minHeight); } - else + } + } + else + { + // All values are the same - set to middle grey + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) { - normalizedHeight = 0.5f; + heightMap[x, y] = 0.5f; } + } + } + } + + private void Save16BitDisplacementMap(float[,] heightMap, string outDirectory, string fileName) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); + + // Create 16-bit grayscale data + byte[] pixelData = new byte[width * height * 2]; // 2 bytes per pixel + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + ushort value = (ushort)(heightMap[x, y] * 65535); + int index = (y * width + x) * 2; + pixelData[index] = (byte)(value >> 8); // High byte + pixelData[index + 1] = (byte)(value & 0xFF); // Low byte + } + } + + // Save as 16-bit PNG using custom encoding + var displacementPngPath = Path.Combine(outDirectory, fileName + "_displacement_16bit.png"); + + // For now, save as 8-bit with note - true 16-bit PNG requires external library + // System.Drawing doesn't support 16-bit grayscale directly + Save8BitDisplacementMap(heightMap, outDirectory, fileName + "_displacement"); + + // Also save raw 16-bit data for advanced users + File.WriteAllBytes(Path.Combine(outDirectory, fileName + "_displacement_16bit.raw"), pixelData); + } + + private void Save8BitDisplacementMap(float[,] heightMap, string outDirectory, string fileName) + { + int width = heightMap.GetLength(0); + int height = heightMap.GetLength(1); - byte grayscale = (byte)Math.Clamp(normalizedHeight * 255.0f, 0, 255); + using var displacementBitmap = new System.Drawing.Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + byte grayscale = (byte)Math.Clamp(heightMap[x, y] * 255.0f, 0, 255); displacementBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, grayscale, grayscale, grayscale)); } } - var displacementPngPath = Path.Combine(outDirectory, fileName + "_displacement.png"); + var displacementPngPath = Path.Combine(outDirectory, fileName + ".png"); displacementBitmap.Save(displacementPngPath, System.Drawing.Imaging.ImageFormat.Png); } } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs index 283511251..95d242352 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs @@ -11,6 +11,14 @@ public record RmvToGltfExporterSettings( bool ConvertMaterialTextureToBlender, bool ConvertNormalTextureToBlue, bool ExportAnimations, - bool MirrorMesh + bool MirrorMesh, + + // Displacement map quality settings for 3D printing + int DisplacementIterations = 10, + float DisplacementContrast = 0.1f, + float DisplacementSharpness = 1.0f, + bool Export16BitDisplacement = true, + bool UseMultiScaleProcessing = true, + bool UsePoissonReconstruction = true ); } diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml index 37d88422e..8bde283c7 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfExporterView.xaml @@ -43,5 +43,5 @@ - + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml new file mode 100644 index 000000000..1bcc338ac --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs new file mode 100644 index 000000000..6e284bb9a --- /dev/null +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterView.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows.Controls; + +namespace Editors.ImportExport.Exporting.Presentation.RmvToGltf +{ + public partial class RmvToGltfStaticExporterView : UserControl + { + public RmvToGltfStaticExporterView() + { + InitializeComponent(); + } + } +} diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs index b58067b9d..90325885f 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -3,17 +3,23 @@ using Editors.ImportExport.Exporting.Exporters.RmvToGltf; using Editors.ImportExport.Misc; using Shared.Core.PackFiles.Models; +using Shared.Ui.Common.DataTemplates; namespace Editors.ImportExport.Exporting.Presentation.RmvToGltf { - public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExporterViewModel + public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExporterViewModel, IViewProvider { private readonly RmvToGltfStaticExporter _exporter; [ObservableProperty] bool _exportTextures = true; - [ObservableProperty] bool _convertMaterialTextureToBlender = false; - [ObservableProperty] bool _convertNormalTextureToBlue = false; - [ObservableProperty] bool _generateDisplacementMaps = true; + + // Displacement map quality settings for 3D printing + [ObservableProperty] int _displacementIterations = 10; + [ObservableProperty] float _displacementContrast = 0.1f; // Slight contrast boost for detail + [ObservableProperty] float _displacementSharpness = 0.0f; // Start with no extra sharpening + [ObservableProperty] bool _export16BitDisplacement = true; + [ObservableProperty] bool _useMultiScaleProcessing = false; // Disable by default + [ObservableProperty] bool _usePoissonReconstruction = false; // Disable by default public string DisplayName => "GLTF (Static Mesh)"; public string OutputExtension => ".gltf"; @@ -30,7 +36,22 @@ public ExportSupportEnum CanExportFile(PackFile file) public void Execute(PackFile exportSource, string outputPath, bool generateImporter) { - var settings = new RmvToGltfExporterSettings(exportSource, [], outputPath, ExportTextures, ConvertMaterialTextureToBlender, ConvertNormalTextureToBlue, false, true); + var settings = new RmvToGltfExporterSettings( + exportSource, + [], + outputPath, + ExportTextures, + false, // ConvertMaterialTextureToBlender - not used for static export + false, // ConvertNormalTextureToBlue - not used, handled automatically + false, // ExportAnimations - static mesh has no animations + true, // MirrorMesh + DisplacementIterations, + DisplacementContrast, + DisplacementSharpness, + Export16BitDisplacement, + UseMultiScaleProcessing, + UsePoissonReconstruction + ); _exporter.Export(settings); } } From d6ef7f41ff233b7e3f8e60964dee9301b674a75c Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Sat, 7 Mar 2026 15:07:32 -0800 Subject: [PATCH 11/12] Add optional displacement map export for 3D printing Introduce a new ExportDisplacementMaps setting and gate displacement export behind it. When enabled, normal-map variants and displacement maps are generated (standard pipeline, optional 16-bit output, Poisson and multi-scale comparison variants), with contrast, bilateral sharpening and normalization steps. Poisson reconstruction was tuned (fewer iterations, reduced divergence weight) and multi-scale processing now uses the standard height-map backend. When disabled, the code falls back to the regular DDS->PNG normal exporter. Also update the static GLTF exporter ViewModel to rename the display to "GLTF for 3D Printing" and enable ExportDisplacementMaps by default for that workflow. File outputs include _raw.png, _displacement, _displacement_poisson and _displacement_multiscale variants. --- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 122 ++++++++++++------ .../RmvToGltf/RmvToGltfExporterSettings.cs | 1 + .../RmvToGltfStaticExporterViewModel.cs | 3 +- 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 992ad343b..790f16038 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -190,14 +190,23 @@ private void DoTextureConversionNormalMap(RmvToGltfExporterSettings settings, Li { if (exportedTextures.ContainsKey(text.Path) == false) { - // Export normal map variants with proper YCoCg decoding - ExportNormalMapVariants(text.Path, settings.OutputPath); - ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings); - - // Set the path to the raw normal map - var fileName = Path.GetFileNameWithoutExtension(text.Path); - var outDirectory = Path.GetDirectoryName(settings.OutputPath); - exportedTextures[text.Path] = Path.Combine(outDirectory, fileName + "_raw.png"); + // Only export displacement maps for 3D printing workflow + if (settings.ExportDisplacementMaps) + { + // Export normal map variants with proper YCoCg decoding + ExportNormalMapVariants(text.Path, settings.OutputPath); + ExportDisplacementFromNormalMap(text.Path, settings.OutputPath, settings); + + // Set the path to the raw normal map + var fileName = Path.GetFileNameWithoutExtension(text.Path); + var outDirectory = Path.GetDirectoryName(settings.OutputPath); + exportedTextures[text.Path] = Path.Combine(outDirectory, fileName + "_raw.png"); + } + else + { + // Regular export: use the standard DDS to PNG exporter + exportedTextures[text.Path] = _ddsToNormalPngExporter.Export(text.Path, settings.OutputPath, settings.ConvertNormalTextureToBlue); + } } var systemPath = exportedTextures[text.Path]; @@ -336,41 +345,58 @@ private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, stri int width = offsetBitmap.Width; int height = offsetBitmap.Height; - float[,] heightMap; + // Export standard displacement map (luminance + smoothing) + var standardHeightMap = StandardHeightMapGeneration(offsetBitmap, settings.DisplacementIterations); + ApplyContrast(standardHeightMap, settings.DisplacementContrast); - if (settings.UseMultiScaleProcessing) + if (settings.DisplacementSharpness > 0) { - // Feature 4: Multi-scale processing for better detail preservation - heightMap = ProcessMultiScale(offsetBitmap, settings); + standardHeightMap = ApplyBilateralFilter(standardHeightMap, settings.DisplacementSharpness); } - else if (settings.UsePoissonReconstruction) + + NormalizeHeightMap(standardHeightMap, out float minHeight, out float maxHeight); + + if (settings.Export16BitDisplacement) { - // Feature 2: Poisson reconstruction for better gradient integration - heightMap = PoissonReconstruction(offsetBitmap, settings.DisplacementIterations); + Save16BitDisplacementMap(standardHeightMap, outDirectory, fileName); } else { - // Standard processing - heightMap = StandardHeightMapGeneration(offsetBitmap, settings.DisplacementIterations); + Save8BitDisplacementMap(standardHeightMap, outDirectory, fileName + "_displacement"); } - // Feature 3: Apply contrast adjustment (configurable) - ApplyContrast(heightMap, settings.DisplacementContrast); + // Export Poisson reconstruction version for comparison (if enabled) + if (settings.UsePoissonReconstruction) + { + var poissonHeightMap = PoissonReconstruction(offsetBitmap, settings.DisplacementIterations); + ApplyContrast(poissonHeightMap, settings.DisplacementContrast); - // Feature 5: Bilateral filter for edge-aware sharpening - heightMap = ApplyBilateralFilter(heightMap, settings.DisplacementSharpness); + if (settings.DisplacementSharpness > 0) + { + poissonHeightMap = ApplyBilateralFilter(poissonHeightMap, settings.DisplacementSharpness); + } - // Normalize to 0-1 range - NormalizeHeightMap(heightMap, out float minHeight, out float maxHeight); + NormalizeHeightMap(poissonHeightMap, out float poissonMin, out float poissonMax); - // Feature 1: Export as 16-bit or 8-bit based on settings - if (settings.Export16BitDisplacement) - { - Save16BitDisplacementMap(heightMap, outDirectory, fileName); + // Save Poisson as 8-bit for comparison + Save8BitDisplacementMap(poissonHeightMap, outDirectory, fileName + "_displacement_poisson"); } - else + + // Export multi-scale version for comparison (if enabled) + if (settings.UseMultiScaleProcessing) { - Save8BitDisplacementMap(heightMap, outDirectory, fileName); + var multiScaleHeightMap = ProcessMultiScale(offsetBitmap, settings); + ApplyContrast(multiScaleHeightMap, settings.DisplacementContrast); + + if (settings.DisplacementSharpness > 0) + { + multiScaleHeightMap = ApplyBilateralFilter(multiScaleHeightMap, settings.DisplacementSharpness); + } + + NormalizeHeightMap(multiScaleHeightMap, out float multiMin, out float multiMax); + + // Save multi-scale as 8-bit for comparison + Save8BitDisplacementMap(multiScaleHeightMap, outDirectory, fileName + "_displacement_multiscale"); } } @@ -421,25 +447,39 @@ private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, stri { int width = rawBitmap.Width; int height = rawBitmap.Height; + + // Start with the same luminance-based initial height as standard method + float[,] heightMap = new float[width, height]; + + for (int y = 0; y < height; y++) + { + for (int x = 0; x < width; x++) + { + var pixel = rawBitmap.GetPixel(x, y); + float gray = (pixel.R * 0.299f + pixel.G * 0.587f + pixel.B * 0.114f) / 255.0f; + heightMap[x, y] = gray; + } + } + + // Extract gradients from normal map for refinement float[,] gradientX = new float[width, height]; float[,] gradientY = new float[width, height]; - float[,] heightMap = new float[width, height]; - // Extract gradients from normal map for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { var pixel = rawBitmap.GetPixel(x, y); - // Convert from [0,255] to [-1,1] + // Convert from [0,255] to [-1,1] - normal map encoding gradientX[x, y] = (pixel.R / 255.0f) * 2.0f - 1.0f; gradientY[x, y] = (pixel.G / 255.0f) * 2.0f - 1.0f; } } // Solve Poisson equation using Jacobi iteration + // Use fewer iterations and dampen the gradient influence to avoid noise amplification float[,] tempMap = new float[width, height]; - for (int iter = 0; iter < iterations * 5; iter++) // More iterations for Poisson + for (int iter = 0; iter < iterations; iter++) // Reduced from iterations * 5 { for (int y = 1; y < height - 1; y++) { @@ -453,14 +493,14 @@ private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, stri float laplacian = (heightMap[x - 1, y] + heightMap[x + 1, y] + heightMap[x, y - 1] + heightMap[x, y + 1]) * 0.25f; - tempMap[x, y] = laplacian - div * 0.25f; + // Dampen the divergence influence to reduce noise (0.1 instead of 0.25) + tempMap[x, y] = laplacian - div * 0.1f; } } Array.Copy(tempMap, heightMap, width * height); } // Normalize the Poisson result to 0-1 range before returning - // This prevents extreme values from dominating float minVal = float.MaxValue; float maxVal = float.MinValue; @@ -492,16 +532,12 @@ private void ExportDisplacementMapPng(byte[] ddsBytes, string outDirectory, stri int width = rawBitmap.Width; int height = rawBitmap.Height; - // Process at full resolution - var fullRes = settings.UsePoissonReconstruction - ? PoissonReconstruction(rawBitmap, settings.DisplacementIterations) - : StandardHeightMapGeneration(rawBitmap, settings.DisplacementIterations); + // Process at full resolution - always use standard method for multi-scale + var fullRes = StandardHeightMapGeneration(rawBitmap, settings.DisplacementIterations); - // Process at half resolution + // Process at half resolution - always use standard method for multi-scale using var halfBitmap = new System.Drawing.Bitmap(rawBitmap, width / 2, height / 2); - var halfRes = settings.UsePoissonReconstruction - ? PoissonReconstruction(halfBitmap, settings.DisplacementIterations) - : StandardHeightMapGeneration(halfBitmap, settings.DisplacementIterations); + var halfRes = StandardHeightMapGeneration(halfBitmap, settings.DisplacementIterations); // Upscale half resolution var halfUpscaled = UpscaleHeightMap(halfRes, width, height); diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs index 95d242352..74f559cab 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/RmvToGltfExporterSettings.cs @@ -14,6 +14,7 @@ public record RmvToGltfExporterSettings( bool MirrorMesh, // Displacement map quality settings for 3D printing + bool ExportDisplacementMaps = false, // NEW: Control whether to export displacement variants int DisplacementIterations = 10, float DisplacementContrast = 0.1f, float DisplacementSharpness = 1.0f, diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs index 90325885f..c9c461974 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Presentation/RmvToGltf/RmvToGltfStaticExporterViewModel.cs @@ -21,7 +21,7 @@ public partial class RmvToGltfStaticExporterViewModel : ObservableObject, IExpor [ObservableProperty] bool _useMultiScaleProcessing = false; // Disable by default [ObservableProperty] bool _usePoissonReconstruction = false; // Disable by default - public string DisplayName => "GLTF (Static Mesh)"; + public string DisplayName => "GLTF for 3D Printing"; public string OutputExtension => ".gltf"; public RmvToGltfStaticExporterViewModel(RmvToGltfStaticExporter exporter) @@ -45,6 +45,7 @@ public void Execute(PackFile exportSource, string outputPath, bool generateImpor false, // ConvertNormalTextureToBlue - not used, handled automatically false, // ExportAnimations - static mesh has no animations true, // MirrorMesh + true, // ExportDisplacementMaps - ENABLED for 3D printing! DisplacementIterations, DisplacementContrast, DisplacementSharpness, From 4cd0a77970005204d09bd66564512f6ac84ccf02 Mon Sep 17 00:00:00 2001 From: Ben McChesney Date: Sat, 7 Mar 2026 15:16:43 -0800 Subject: [PATCH 12/12] Export alpha mask for base color textures When exporting base color textures, export an additional grayscale alpha mask PNG for 3D printing workflows. Adds a call in DoTextureDefault to ExportAlphaMask when settings.ExportDisplacementMaps is enabled and the texture is KnownChannel.BaseColor. Implements ExportAlphaMask to locate the source pack file, decode DDS via Pfim, verify RGBA32 format, extract the alpha channel into a black/white grayscale mask, and save it as _alphamask.png next to the export output. The method includes early returns for missing services/files or unsupported formats. --- .../RmvToGltf/Helpers/GltfTextureHandler.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs index 790f16038..a07658884 100644 --- a/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs +++ b/Editors/ImportExportEditor/Editors.ImportExport/Exporting/Exporters/RmvToGltf/Helpers/GltfTextureHandler.cs @@ -103,8 +103,16 @@ private void DoTextureConversionMaterialMap(RmvToGltfExporterSettings settings, private void DoTextureDefault(KnownChannel textureType, RmvToGltfExporterSettings settings, List output, Dictionary exportedTextures, int meshIndex, MaterialBuilderTextureInput text) { if (exportedTextures.ContainsKey(text.Path) == false) + { exportedTextures[text.Path] = _ddsToMaterialPngExporter.Export(text.Path, settings.OutputPath, false); + // For 3D printing: Export alpha channel as a separate mask for base color/diffuse + if (settings.ExportDisplacementMaps && textureType == KnownChannel.BaseColor) + { + ExportAlphaMask(text.Path, settings.OutputPath); + } + } + var systemPath = exportedTextures[text.Path]; if (systemPath != null) output.Add(new TextureResult(meshIndex, systemPath, textureType, false)); @@ -234,6 +242,63 @@ private void ExportNormalMapVariants(string packFilePath, string outputPath) } } + private void ExportAlphaMask(string packFilePath, string outputPath) + { + if (_packFileService == null) + return; + + var packFile = _packFileService.FindFile(packFilePath); + if (packFile == null) + return; + + var fileName = Path.GetFileNameWithoutExtension(packFilePath); + var outDirectory = Path.GetDirectoryName(outputPath); + + var bytes = packFile.DataSource.ReadData(); + if (bytes == null || !bytes.Any()) + return; + + // Convert DDS to bitmap + using var m = new MemoryStream(); + using var w = new BinaryWriter(m); + w.Write(bytes); + m.Seek(0, SeekOrigin.Begin); + + var image = Pfim.Pfimage.FromStream(m); + + if (image.Format != Pfim.ImageFormat.Rgba32) + return; // No alpha channel + + using var sourceBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + var bitmapData = sourceBitmap.LockBits( + new System.Drawing.Rectangle(0, 0, image.Width, image.Height), + System.Drawing.Imaging.ImageLockMode.WriteOnly, + System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + System.Runtime.InteropServices.Marshal.Copy(image.Data, 0, bitmapData.Scan0, image.DataLen); + sourceBitmap.UnlockBits(bitmapData); + + // Extract alpha channel as black and white mask + using var maskBitmap = new System.Drawing.Bitmap(image.Width, image.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); + + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + var pixel = sourceBitmap.GetPixel(x, y); + byte alpha = pixel.A; + + // Create grayscale mask from alpha channel + // White = opaque (alpha 255), Black = transparent (alpha 0) + maskBitmap.SetPixel(x, y, System.Drawing.Color.FromArgb(255, alpha, alpha, alpha)); + } + } + + var maskPath = Path.Combine(outDirectory, fileName + "_alphamask.png"); + maskBitmap.Save(maskPath, System.Drawing.Imaging.ImageFormat.Png); + } + public void ExportDisplacementFromNormalMap(string normalMapPath, string outputPath, RmvToGltfExporterSettings settings) { var fileName = Path.GetFileNameWithoutExtension(normalMapPath);