From f02848d7c28620ab0e95eb352a329a57cb8d7c32 Mon Sep 17 00:00:00 2001 From: Passive <20432486+PassiveModding@users.noreply.github.com> Date: Fri, 30 Aug 2024 01:27:34 +1000 Subject: [PATCH 1/2] Terrain handling init --- .../Meddle.Plugin/Services/LayoutService.cs | 195 ++++++++++++++++-- Meddle/Meddle.Plugin/Services/SigUtil.cs | 1 - Meddle/Meddle.Plugin/UI/TerrainTab.cs | 68 ++++++ .../Meddle.Utils/Materials/MaterialUtility.cs | 26 ++- 4 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 Meddle/Meddle.Plugin/UI/TerrainTab.cs diff --git a/Meddle/Meddle.Plugin/Services/LayoutService.cs b/Meddle/Meddle.Plugin/Services/LayoutService.cs index d4fcfec..784edc9 100644 --- a/Meddle/Meddle.Plugin/Services/LayoutService.cs +++ b/Meddle/Meddle.Plugin/Services/LayoutService.cs @@ -1,4 +1,6 @@ -using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Numerics; +using System.Runtime.InteropServices; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game; using FFXIVClientStructs.FFXIV.Client.Game.Object; @@ -14,10 +16,18 @@ using Meddle.Plugin.Utils; using Meddle.Utils; using Meddle.Utils.Export; +using Meddle.Utils.Files; +using Meddle.Utils.Files.SqPack; using Meddle.Utils.Files.Structs.Material; +using Meddle.Utils.Materials; +using Meddle.Utils.Models; using Microsoft.Extensions.Logging; +using SharpGLTF.Materials; +using SharpGLTF.Scenes; +using SkiaSharp; using CustomizeParameter = Meddle.Plugin.Models.Structs.CustomizeParameter; using HousingFurniture = FFXIVClientStructs.FFXIV.Client.Game.HousingFurniture; +using Material = Meddle.Utils.Export.Material; using Transform = Meddle.Plugin.Models.Transform; namespace Meddle.Plugin.Services; @@ -26,18 +36,23 @@ public class LayoutService : IService { private readonly Dictionary itemDict; private readonly ILogger logger; + private readonly IDataManager dataManager; + private readonly SqPack pack; private readonly ParseService parseService; private readonly PbdHooks pbdHooks; private readonly SigUtil sigUtil; private readonly Dictionary stainDict; public LayoutService( - SigUtil sigUtil, ILogger logger, - IDataManager dataManager, + SigUtil sigUtil, ILogger logger, + IDataManager dataManager, + SqPack pack, ParseService parseService, PbdHooks pbdHooks) { this.sigUtil = sigUtil; this.logger = logger; + this.dataManager = dataManager; + this.pack = pack; this.parseService = parseService; this.pbdHooks = pbdHooks; stainDict = dataManager.GetExcelSheet()!.ToDictionary(row => row.RowId, row => row); @@ -52,7 +67,7 @@ public ParsedInstance[] ResolveInstances(ParsedInstance[] instances) { ResolveInstance(instance); } - + return instances; } @@ -67,12 +82,12 @@ public unsafe ParsedInstance ResolveInstance(ParsedInstance instance) characterInstance.CharacterInfo = characterInfo; } } - + foreach (var child in instance.Children) { ResolveInstance(child); } - + return instance; } @@ -96,10 +111,10 @@ public unsafe ParsedInstance ResolveInstance(ParsedInstance instance) layers.AddRange(loadedLayers.SelectMany(x => x.Instances)); layers.AddRange(globalLayers.SelectMany(x => x.Instances)); layers.AddRange(objects); - + return layers.ToArray(); } - + private unsafe HousingTerritory* GetCurrentTerritory() { var housingManager = sigUtil.GetHousingManager(); @@ -112,6 +127,140 @@ public unsafe ParsedInstance ResolveInstance(ParsedInstance instance) return housingManager->CurrentTerritory; } + public unsafe void ParseTerrain(CancellationToken cancellationToken = default) + { + logger.LogInformation("Parsing terrain"); + var layoutWorld = sigUtil.GetLayoutWorld(); + if (layoutWorld == null) return; + var activeLayout = layoutWorld->ActiveLayout; + if (activeLayout == null) return; + + var scene = new SceneBuilder(); + var outDir = Path.Combine(Plugin.TempDirectory, "terrain"); + Directory.CreateDirectory(outDir); + var textureDir = Path.Combine(outDir, "textures"); + Directory.CreateDirectory(textureDir); + + var teraFiles = new Dictionary(); + foreach (var (_, terrainPtr) in activeLayout->Terrains) + { + if (terrainPtr == null || terrainPtr.Value == null) continue; + var terrain = terrainPtr.Value; + var terrainDir = terrain->PathString; + var teraPath = $"{terrainDir}/bgplate/terrain.tera"; + var teraData = dataManager.GetFile(teraPath); + if (teraData == null) throw new Exception($"Failed to load terrain file: {teraPath}"); + var terrainFile = new TeraFile(teraData.Data); + teraFiles.Add(terrainDir, terrainFile); + logger.LogInformation("Loaded terrain {teraPath}", teraPath); + } + + var shpkCache = new Dictionary(); + var texPaths = new Dictionary(); + + foreach (var (dir, file) in teraFiles) + { + for (var i = 0; i < file.Header.PlateCount; i++) + { + if (cancellationToken.IsCancellationRequested) break; + logger.LogInformation("Parsing plate {i}", i); + var mdlPath = $"{dir}/bgplate/{i:D4}.mdl"; + var mdlData = dataManager.GetFile(mdlPath); + if (mdlData == null) throw new Exception($"Failed to load model file: {mdlPath}"); + var mdlFile = new MdlFile(mdlData.Data); + + var platePos = file.GetPlatePosition(i); + var transform = new Transform(new Vector3(platePos.X, 0, platePos.Y), Quaternion.Identity, + Vector3.One); + var materials = mdlFile.GetMaterialNames().Select(x => x.Value).ToArray(); + var materialBuilders = new List(); + foreach (var mtrlPath in materials) + { + var mtrlData = dataManager.GetFile(mtrlPath); + if (mtrlData == null) + throw new Exception($"Failed to load material file: {mtrlPath}"); + var mtrlFile = new MtrlFile(mtrlData.Data); + logger.LogInformation("Loaded material {mtrlPath}", mtrlPath); + + var texturePaths = mtrlFile.GetTexturePaths(); + var shpkPath = $"shader/sm5/shpk/{mtrlFile.GetShaderPackageName()}"; + if (!shpkCache.TryGetValue(shpkPath, out var shpkFile)) + { + var shpkData = dataManager.GetFile(shpkPath); + if (shpkData == null) + throw new Exception($"Failed to load shader package file: {shpkPath}"); + shpkFile = new ShpkFile(shpkData.Data); + logger.LogInformation("Loaded shader package {shpkPath}", shpkPath); + shpkCache.TryAdd(shpkPath, shpkFile); + } + + foreach (var (offset, texPath) in texturePaths) + { + if (!texPaths.ContainsKey(texPath)) + { + var texData = dataManager.GetFile(texPath); + if (texData == null) + throw new Exception($"Failed to load texture file: {texPath}"); + var texFile = new TexFile(texData.Data); + logger.LogInformation("Loaded texture {texPath}", texPath); + var diskPath = Path.Combine(textureDir, Path.GetFileNameWithoutExtension(texPath)) + ".png"; + var texture = Texture.GetResource(texFile).ToTexture(); + byte[] textureBytes; + using (var memoryStream = new MemoryStream()) + { + texture.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100); + textureBytes = memoryStream.ToArray(); + } + + File.WriteAllBytes(diskPath, textureBytes); + texPaths.TryAdd(texPath, diskPath); + } + } + + var output = new MaterialBuilder(Path.GetFileNameWithoutExtension(mtrlPath)) + .WithMetallicRoughnessShader() + .WithBaseColor(Vector4.One); + + var shaderPackage = new ShaderPackage(shpkFile, null!); + for (var samplerIdx = 0; samplerIdx < mtrlFile.Samplers.Length; samplerIdx++) + { + var sampler = mtrlFile.Samplers[samplerIdx]; + if (sampler.TextureIndex != byte.MaxValue) + { + var texture = mtrlFile.TextureOffsets[sampler.TextureIndex]; + var path = texturePaths[texture.Offset]; + if (texPaths.TryGetValue(path, out var tex) && + shaderPackage.TextureLookup.TryGetValue(sampler.SamplerId, out var usage)) + { + var imageBuilder = ImageBuilder.From(tex, Path.GetFileNameWithoutExtension(path)); + var channel = MaterialUtility.MapTextureUsageToChannel(usage); + if (channel != null) + { + output.WithChannelImage(channel.Value, imageBuilder); + } + } + } + } + + materialBuilders.Add(output); + } + + var model = new Model(mdlPath, mdlFile, null); + var meshes = ModelBuilder.BuildMeshes(model, materialBuilders, [], null); + foreach (var mesh in meshes) + { + scene.AddRigidMesh(mesh.Mesh, transform.AffineTransform); + } + } + } + + var sceneGraph = scene.ToGltf2(); + var outputDir = Path.Combine(Plugin.TempDirectory, "terrain"); + Directory.CreateDirectory(outputDir); + var outputPath = Path.Combine(outputDir, "terrain.gltf"); + sceneGraph.SaveGLTF(outputPath); + } + private unsafe ParsedLayer[] Parse(LayoutManager* activeLayout, ParseCtx ctx) { if (activeLayout == null) return []; @@ -295,7 +444,7 @@ private unsafe ParsedLayer[] Parse(LayoutManager* activeLayout, ParseCtx ctx) Path = path }; } - + public unsafe ParsedInstance[] ParseObjects(bool resolveCharacterInfo = false) { var gameObjectManager = sigUtil.GetGameObjectManager(); @@ -305,16 +454,16 @@ public unsafe ParsedInstance[] ParseObjects(bool resolveCharacterInfo = false) { if (objectPtr == null || objectPtr.Value == null) continue; - + var obj = objectPtr.Value; if (objects.Any(o => o.Id == (nint)obj)) continue; - + var type = obj->GetObjectKind(); var drawObject = obj->DrawObject; if (drawObject == null) continue; - + ParsedCharacterInfo? characterInfo = null; if (resolveCharacterInfo) { @@ -341,13 +490,13 @@ public unsafe ParsedInstance[] ParseObjects(bool resolveCharacterInfo = false) { return null; } - + var objectType = drawObject->Object.GetObjectType(); if (objectType != ObjectType.CharacterBase) { return null; } - + var characterBase = (CharacterBase*)drawObject; var colorTableTextures = parseService.ParseColorTableTextures(characterBase); var models = new List(); @@ -369,7 +518,8 @@ public unsafe ParsedInstance[] ParseObjects(bool resolveCharacterInfo = false) if (material == null) continue; var materialPath = material->MaterialResourceHandle->ResourceHandle.FileName.ParseString(); - var materialPathFromModel = model->ModelResourceHandle->GetMaterialFileNameBySlotAsString((uint)mtrlIdx); + var materialPathFromModel = + model->ModelResourceHandle->GetMaterialFileNameBySlotAsString((uint)mtrlIdx); var shaderName = material->MaterialResourceHandle->ShpkNameString; ColorTable? colorTable = null; if (colorTableTextures.TryGetValue((int)(model->SlotIndex * CharacterBase.MaterialsPerSlot) + mtrlIdx, @@ -397,21 +547,24 @@ public unsafe ParsedInstance[] ParseObjects(bool resolveCharacterInfo = false) if (texIdx < material->TextureCount) { var texturePathFromMaterial = material->MaterialResourceHandle->TexturePathString(texIdx); - var (resource, stride) = DXHelper.ExportTextureResource(texturePtr.TextureResourceHandle->Texture); + var (resource, stride) = + DXHelper.ExportTextureResource(texturePtr.TextureResourceHandle->Texture); var textureInfo = new ParsedTextureInfo(texturePath, texturePathFromMaterial, resource); textures.Add(textureInfo); } } - - var materialInfo = new ParsedMaterialInfo(materialPath, materialPathFromModel, shaderName, colorTable, textures); + + var materialInfo = + new ParsedMaterialInfo(materialPath, materialPathFromModel, shaderName, colorTable, textures); materials.Add(materialInfo); } - + var deform = pbdHooks.TryGetDeformer((nint)characterBase, model->SlotIndex); - var modelInfo = new ParsedModelInfo(modelPath, modelPathFromCharacter, deform, shapeAttributeGroup, materials); + var modelInfo = + new ParsedModelInfo(modelPath, modelPathFromCharacter, deform, shapeAttributeGroup, materials); models.Add(modelInfo); } - + var skeleton = StructExtensions.GetParsedSkeleton(characterBase); var modelType = characterBase->GetModelType(); CustomizeData customizeData = new CustomizeData(); diff --git a/Meddle/Meddle.Plugin/Services/SigUtil.cs b/Meddle/Meddle.Plugin/Services/SigUtil.cs index ed280ab..83f6c11 100644 --- a/Meddle/Meddle.Plugin/Services/SigUtil.cs +++ b/Meddle/Meddle.Plugin/Services/SigUtil.cs @@ -74,7 +74,6 @@ public unsafe Vector3 GetLocalPosition() return layoutWorld; } - public unsafe Camera* GetCamera() { var manager = CameraManager.Instance(); diff --git a/Meddle/Meddle.Plugin/UI/TerrainTab.cs b/Meddle/Meddle.Plugin/UI/TerrainTab.cs new file mode 100644 index 0000000..7c0fce4 --- /dev/null +++ b/Meddle/Meddle.Plugin/UI/TerrainTab.cs @@ -0,0 +1,68 @@ +using ImGuiNET; +using Meddle.Plugin.Models; +using Meddle.Plugin.Services; +using Microsoft.Extensions.Logging; + +namespace Meddle.Plugin.UI; + +public class TerrainTab : ITab +{ + private readonly LayoutService layoutService; + private readonly ILogger logger; + + public void Dispose() + { + // TODO release managed resources here + } + + public string Name => "Terrain"; + public int Order => 0; + public MenuType MenuType => MenuType.Debug; + + public TerrainTab(LayoutService layoutService, ILogger logger) + { + this.layoutService = layoutService; + this.logger = logger; + cts = new CancellationTokenSource(); + } + + + CancellationTokenSource cts; + Task task = Task.CompletedTask; + + public unsafe void Draw() + { + if (ImGui.Button("Dump Terrain")) + { + task = Task.Run(() => + { + try + { + layoutService.ParseTerrain(cts.Token); + } + catch (Exception e) + { + logger.LogError(e, "Failed to parse terrain"); + throw; + } + }); + } + + if (task.IsFaulted) + { + var ex = task.Exception; + ImGui.TextWrapped($"Error: {ex}"); + } + + if (task.IsCompleted) + { + cts = new CancellationTokenSource(); + } + + if (ImGui.Button($"Cancel")) + { + cts.Cancel(); + cts = new CancellationTokenSource(); + } + } +} diff --git a/Meddle/Meddle.Utils/Materials/MaterialUtility.cs b/Meddle/Meddle.Utils/Materials/MaterialUtility.cs index 753944f..80f5db2 100644 --- a/Meddle/Meddle.Utils/Materials/MaterialUtility.cs +++ b/Meddle/Meddle.Utils/Materials/MaterialUtility.cs @@ -49,7 +49,8 @@ public static MaterialBuilder BuildFallback(Material material, string name) TextureUsage.g_SamplerGradationMap => "gradationMap", TextureUsage.g_SamplerNormal2 => "normal2", TextureUsage.g_SamplerWrinklesMask => "wrinklesMask", - _ => throw new ArgumentOutOfRangeException() + // _ => throw new ArgumentOutOfRangeException($"Unknown texture usage: {usage}") + _ => $"unknown_{usage}" }; KnownChannel knownChannel = usage switch @@ -95,6 +96,29 @@ public static MaterialBuilder BuildFallback(Material material, string name) return output; } + public static KnownChannel? MapTextureUsageToChannel(TextureUsage usage) + { + return usage switch + { + TextureUsage.g_SamplerDiffuse => KnownChannel.BaseColor, + TextureUsage.g_SamplerNormal => KnownChannel.Normal, + TextureUsage.g_SamplerMask => KnownChannel.SpecularFactor, + TextureUsage.g_SamplerSpecular => KnownChannel.SpecularColor, + TextureUsage.g_SamplerCatchlight => KnownChannel.Emissive, + TextureUsage.g_SamplerColorMap0 => KnownChannel.BaseColor, + TextureUsage.g_SamplerNormalMap0 => KnownChannel.Normal, + TextureUsage.g_SamplerSpecularMap0 => KnownChannel.SpecularColor, + TextureUsage.g_SamplerColorMap1 => KnownChannel.BaseColor, + TextureUsage.g_SamplerNormalMap1 => KnownChannel.Normal, + TextureUsage.g_SamplerSpecularMap1 => KnownChannel.SpecularColor, + TextureUsage.g_SamplerColorMap => KnownChannel.BaseColor, + TextureUsage.g_SamplerNormalMap => KnownChannel.Normal, + TextureUsage.g_SamplerSpecularMap => KnownChannel.SpecularColor, + TextureUsage.g_SamplerNormal2 => KnownChannel.Normal, + _ => null + }; + } + public static MaterialBuilder BuildSharedBase(Material material, string name) { const uint backfaceMask = 0x1; From d74837446682a03020b16877cf8240775a17c0a1 Mon Sep 17 00:00:00 2001 From: Passive <20432486+PassiveModding@users.noreply.github.com> Date: Fri, 30 Aug 2024 20:08:26 +1000 Subject: [PATCH 2/2] Terrain export support --- AttachStuff.rcnet | Bin 15853 -> 18186 bytes .../Models/Composer/InstanceSet.cs | 252 ++++++++++++++++++ .../Models/Composer/ProgressEvent.cs | 3 + .../Models/Composer/TerrainSet.cs | 188 +++++++++++++ .../Meddle.Plugin/Services/LayoutService.cs | 134 ---------- Meddle/Meddle.Plugin/UI/TerrainTab.cs | 225 ++++++++++++++-- .../Meddle.Utils/Materials/MaterialUtility.cs | 21 +- 7 files changed, 665 insertions(+), 158 deletions(-) create mode 100644 Meddle/Meddle.Plugin/Models/Composer/InstanceSet.cs create mode 100644 Meddle/Meddle.Plugin/Models/Composer/ProgressEvent.cs create mode 100644 Meddle/Meddle.Plugin/Models/Composer/TerrainSet.cs diff --git a/AttachStuff.rcnet b/AttachStuff.rcnet index dfd9b0923cfd23ae17d0c6a0287c49d590f1ea64..fe592b162d3d2d73c127194d99ed5b83e741b273 100644 GIT binary patch literal 18186 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�XrV)YD=Tsv(O>OwJF6OJs1!}=3_7$A3{=Nt z@0uj+Bi5&6bl-8tcNM!cbt=}hi07d;?Ym4nAw`l$!*kj%q`gVvCC6{6x-s_qq#^gN zGf0>Jtxz<2G{)o!$%Cd|F@)9+z96|ln}55!_hb4no|=?796S6Wy#T4>*q-~6#wA?5 z&?fL+e9f^C>pFC4FC=DhYwfAP?s#qCf$h^b3yhm~M=c_LeV=k$^QU6crC!KV>P!77 z6#CVoHiQ8{8o$lYBxEFy5Cu(ZJ4l4`4jbY^k$6+qn13s?!6t_?;+U7MEuRybfJ&}jG*RrfcRnZ%Um zzMxAWxi-PyyLR0oMa+$Yb`})YKUPhHxR$o3lcG5jK^+?t!LnS^89J75LAxM)OUu=8 zQtsyO^sg!8s|Po~ow=ia_N~7DosWAE(z1|lWzm~6@zmdiW#V!0;wvfSYFo$7#)9#B zRqy%18@lf(xJsWHO}Cj%UG%RRM0Bo|cde;TGGKh39t(IkOPTBnima-K0&B4267FG8iHG*Kq{!SJRUk;g0ncn=q1jvYyp(WU;ujq!&(K zJN9W3XAwYLTtmwf8l+bshY~~`)5BtUD(!Aqon#~CL_JXN%{ln JQum1y{|AtlFChQ` diff --git a/Meddle/Meddle.Plugin/Models/Composer/InstanceSet.cs b/Meddle/Meddle.Plugin/Models/Composer/InstanceSet.cs new file mode 100644 index 0000000..28e2380 --- /dev/null +++ b/Meddle/Meddle.Plugin/Models/Composer/InstanceSet.cs @@ -0,0 +1,252 @@ +using System.Collections.Concurrent; +using System.Numerics; +using Dalamud.Plugin.Services; +using Meddle.Plugin.Models.Layout; +using Meddle.Utils; +using Meddle.Utils.Export; +using Meddle.Utils.Files; +using Meddle.Utils.Materials; +using Meddle.Utils.Models; +using Microsoft.Extensions.Logging; +using SharpGLTF.Materials; +using SharpGLTF.Memory; +using SharpGLTF.Scenes; +using SkiaSharp; + +namespace Meddle.Plugin.Models.Composer; + +public static class InstanceCache +{ + public static ConcurrentDictionary ShpkCache { get; } = new(); +} + +public class InstanceSet +{ + public InstanceSet(ILogger log, IDataManager manager, ParsedInstance[] instances, string? cacheDir = null, + Action? progress = null, CancellationToken cancellationToken = default) + { + CacheDir = cacheDir ?? Path.GetTempPath(); + Directory.CreateDirectory(CacheDir); + this.instances = instances; + this.log = log; + this.dataManager = manager; + this.progress = progress; + this.cancellationToken = cancellationToken; + this.size = instances.Select(x => x.Flatten().Length).Sum(); + this.count = instances.Length; + } + + private readonly ILogger log; + private readonly IDataManager dataManager; + private readonly Action? progress; + private readonly CancellationToken cancellationToken; + private readonly int size; + private readonly int count; + private int sizeProgress; + private int countProgress; + public string CacheDir { get; } + private readonly ParsedInstance[] instances; + private readonly Dictionary imageCache = new(); + + public void Compose(SceneBuilder scene) + { + progress?.Invoke(new ProgressEvent(0, count)); + foreach (var instance in instances) + { + var node = ComposeInstance(scene, instance); + if (node != null) + { + scene.AddNode(node); + } + + countProgress++; + progress?.Invoke(new ProgressEvent(countProgress, count)); + } + } + + public NodeBuilder? ComposeInstance(SceneBuilder scene, ParsedInstance parsedInstance) + { + if (cancellationToken.IsCancellationRequested) return null; + + var root = new NodeBuilder(); + if (parsedInstance is ParsedHousingInstance housingInstance) + { + root.Name = housingInstance.Name; + } + else + { + root.Name = $"{parsedInstance.Type}_{parsedInstance.Id}"; + } + root.SetLocalTransform(parsedInstance.Transform.AffineTransform, false); + bool added = false; + + if (parsedInstance is ParsedBgPartsInstance {Path: not null} bgPartsInstance) + { + var meshes = ComposeBgPartsInstance(bgPartsInstance); + foreach (var mesh in meshes) + { + scene.AddRigidMesh(mesh.Mesh, root, Matrix4x4.Identity); + } + + added = true; + } + else if (parsedInstance is ParsedLightInstance lightInstance) + { + sizeProgress++; + return null; + var lightBuilder = new LightBuilder.Point + { + Name = $"light_{lightInstance.Id}" + }; + scene.AddLight(lightBuilder, root); + } + else if (parsedInstance is ParsedCharacterInstance characterInstance) + { + sizeProgress++; + return null; + } + else + { + sizeProgress++; + return null; + } + + foreach (var child in parsedInstance.Children) + { + var childNode = ComposeInstance(scene, child); + if (childNode != null) + { + root.AddNode(childNode); + added = true; + } + } + + sizeProgress++; + + if (!added) return null; + return root; + } + + private IReadOnlyList ComposeBgPartsInstance(ParsedBgPartsInstance bgPartsInstance) + { + if (bgPartsInstance.Path == null) + { + return []; + } + + var mdlData = dataManager.GetFile(bgPartsInstance.Path); + if (mdlData == null) + { + log.LogWarning("Failed to load model file: {bgPartsInstance.Path}", bgPartsInstance.Path); + return []; + } + + var mdlFile = new MdlFile(mdlData.Data); + var materials = mdlFile.GetMaterialNames().Select(x => x.Value).ToArray(); + + var materialBuilders = new List(); + foreach (var mtrlPath in materials) + { + var mtrlData = dataManager.GetFile(mtrlPath); + if (mtrlData == null) + { + log.LogWarning("Failed to load material file: {materialPath}", mtrlPath); + // TODO: Stub material + continue; + } + + var mtrlFile = new MtrlFile(mtrlData.Data); + var texturePaths = mtrlFile.GetTexturePaths(); + var shpkPath = $"shader/sm5/shpk/{mtrlFile.GetShaderPackageName()}"; + if (!InstanceCache.ShpkCache.TryGetValue(shpkPath, out var shaderPackage)) + { + var shpkData = dataManager.GetFile(shpkPath); + if (shpkData == null) + throw new Exception($"Failed to load shader package file: {shpkPath}"); + var shpkFile = new ShpkFile(shpkData.Data); + shaderPackage = new ShaderPackage(shpkFile, null!); + InstanceCache.ShpkCache.TryAdd(shpkPath, shaderPackage); + log.LogInformation("Loaded shader package {shpkPath}", shpkPath); + } + else + { + log.LogDebug("Reusing shader package {shpkPath}", shpkPath); + } + + var output = new MaterialBuilder(Path.GetFileNameWithoutExtension(mtrlPath)) + .WithMetallicRoughnessShader() + .WithBaseColor(Vector4.One); + + foreach (var (offset, texPath) in texturePaths) + { + if (imageCache.ContainsKey(texPath)) continue; + ComposeTexture(texPath); + } + + var setTypes = new HashSet(); + foreach (var sampler in mtrlFile.Samplers) + { + if (sampler.TextureIndex == byte.MaxValue) continue; + var textureInfo = mtrlFile.TextureOffsets[sampler.TextureIndex]; + var texturePath = texturePaths[textureInfo.Offset]; + if (!imageCache.TryGetValue(texturePath, out var tex)) continue; + // bg textures can have additional textures, which may be dummy textures, ignore them + if (texturePath.Contains("dummy_")) continue; + if (!shaderPackage.TextureLookup.TryGetValue(sampler.SamplerId, out var usage)) + { + log.LogWarning("Unknown texture usage for texture {texturePath} ({textureUsage})", texturePath, (TextureUsage)sampler.SamplerId); + continue; + } + + var channel = MaterialUtility.MapTextureUsageToChannel(usage); + if (channel != null && setTypes.Add(usage)) + { + var fileName = $"{Path.GetFileNameWithoutExtension(texturePath)}_{usage}_{shaderPackage.Name}"; + var imageBuilder = ImageBuilder.From(tex.MemoryImage, fileName); + imageBuilder.AlternateWriteFileName = $"{fileName}.*"; + output.WithChannelImage(channel.Value, imageBuilder); + } + else if (channel != null) + { + log.LogWarning("Ignoring texture {texturePath} with usage {usage}", texturePath, usage); + } + else + { + log.LogWarning("Unknown texture usage {usage} for texture {texturePath}", usage, texturePath); + } + } + + materialBuilders.Add(output); + } + + var model = new Model(bgPartsInstance.Path, mdlFile, null); + var meshes = ModelBuilder.BuildMeshes(model, materialBuilders, [], null); + return meshes; + } + + private void ComposeTexture(string texPath) + { + var texData = dataManager.GetFile(texPath); + if (texData == null) throw new Exception($"Failed to load texture file: {texPath}"); + log.LogInformation("Loaded texture {texPath}", texPath); + var texFile = new TexFile(texData.Data); + var diskPath = Path.Combine(CacheDir, Path.GetDirectoryName(texPath) ?? "", + Path.GetFileNameWithoutExtension(texPath)) + ".png"; + var texture = Texture.GetResource(texFile).ToTexture(); + byte[] textureBytes; + using (var memoryStream = new MemoryStream()) + { + texture.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100); + textureBytes = memoryStream.ToArray(); + } + + var dirPath = Path.GetDirectoryName(diskPath); + if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + File.WriteAllBytes(diskPath, textureBytes); + imageCache.TryAdd(texPath, (diskPath, new MemoryImage(() => File.ReadAllBytes(diskPath)))); + } +} diff --git a/Meddle/Meddle.Plugin/Models/Composer/ProgressEvent.cs b/Meddle/Meddle.Plugin/Models/Composer/ProgressEvent.cs new file mode 100644 index 0000000..d877c10 --- /dev/null +++ b/Meddle/Meddle.Plugin/Models/Composer/ProgressEvent.cs @@ -0,0 +1,3 @@ +namespace Meddle.Plugin.Models.Composer; + +public record ProgressEvent(int Progress, int Total); diff --git a/Meddle/Meddle.Plugin/Models/Composer/TerrainSet.cs b/Meddle/Meddle.Plugin/Models/Composer/TerrainSet.cs new file mode 100644 index 0000000..5aafddc --- /dev/null +++ b/Meddle/Meddle.Plugin/Models/Composer/TerrainSet.cs @@ -0,0 +1,188 @@ +using System.Numerics; +using Dalamud.Plugin.Services; +using Meddle.Utils; +using Meddle.Utils.Export; +using Meddle.Utils.Files; +using Meddle.Utils.Materials; +using Meddle.Utils.Models; +using Microsoft.Extensions.Logging; +using SharpGLTF.Materials; +using SharpGLTF.Memory; +using SharpGLTF.Scenes; +using SkiaSharp; + +namespace Meddle.Plugin.Models.Composer; + +public class TerrainSet +{ + public TerrainSet(ILogger log, IDataManager manager, string terrainDir, string? cacheDir = null, Action? progress = null, CancellationToken cancellationToken = default) + { + CacheDir = cacheDir ?? Path.GetTempPath(); + Directory.CreateDirectory(CacheDir); + TerrainDir = terrainDir; + this.log = log; + dataManager = manager; + this.progress = progress; + this.cancellationToken = cancellationToken; + var teraPath = $"{TerrainDir}/bgplate/terrain.tera"; + var teraData = dataManager.GetFile(teraPath); + if (teraData == null) throw new Exception($"Failed to load terrain file: {teraPath}"); + teraFile = new TeraFile(teraData.Data); + } + + private readonly ILogger log; + private readonly IDataManager dataManager; + private readonly Action? progress; + private readonly CancellationToken cancellationToken; + public int PlateCount => (int)teraFile.Header.PlateCount; + public int Progress { get; private set; } + + public string TerrainDir { get; } + public string CacheDir { get; } + + private readonly Dictionary imageCache = new(); + private TeraFile teraFile; + + public void Compose(SceneBuilder scene) + { + progress?.Invoke(new ProgressEvent(0, PlateCount)); + var terrainRoot = new NodeBuilder(TerrainDir); + scene.AddNode(terrainRoot); + + for (var i = 0; i < teraFile.Header.PlateCount; i++) + { + if (cancellationToken.IsCancellationRequested) return; + log.LogInformation("Parsing plate {i}", i); + var platePos = teraFile.GetPlatePosition(i); + var plateTransform = + new Transform(new Vector3(platePos.X, 0, platePos.Y), Quaternion.Identity, Vector3.One); + var meshes = ComposePlate(i); + var plateRoot = new NodeBuilder($"Plate{i:D4}"); + foreach (var mesh in meshes) + { + scene.AddRigidMesh(mesh.Mesh, plateRoot, plateTransform.AffineTransform); + } + + terrainRoot.AddNode(plateRoot); + Progress = i; + progress?.Invoke(new ProgressEvent(i, PlateCount)); + } + } + + private IReadOnlyList ComposePlate(int i) + { + var mdlPath = $"{TerrainDir}/bgplate/{i:D4}.mdl"; + var mdlData = dataManager.GetFile(mdlPath); + if (mdlData == null) throw new Exception($"Failed to load model file: {mdlPath}"); + log.LogInformation("Loaded model {mdlPath}", mdlPath); + var mdlFile = new MdlFile(mdlData.Data); + var materials = mdlFile.GetMaterialNames().Select(x => x.Value).ToArray(); + + var materialBuilders = new List(); + foreach (var mtrlPath in materials) + { + var materialBuilder = ComposeMaterial(mtrlPath); + materialBuilders.Add(materialBuilder); + } + + var model = new Model(mdlPath, mdlFile, null); + var meshes = ModelBuilder.BuildMeshes(model, materialBuilders, [], null); + return meshes; + } + + private MaterialBuilder ComposeMaterial(string path) + { + var mtrlData = dataManager.GetFile(path); + if (mtrlData == null) throw new Exception($"Failed to load material file: {path}"); + log.LogInformation("Loaded material {path}", path); + + var mtrlFile = new MtrlFile(mtrlData.Data); + var texturePaths = mtrlFile.GetTexturePaths(); + var shpkPath = $"shader/sm5/shpk/{mtrlFile.GetShaderPackageName()}"; + if (!InstanceCache.ShpkCache.TryGetValue(shpkPath, out var shaderPackage)) + { + var shpkData = dataManager.GetFile(shpkPath); + if (shpkData == null) + throw new Exception($"Failed to load shader package file: {shpkPath}"); + var shpkFile = new ShpkFile(shpkData.Data); + shaderPackage = new ShaderPackage(shpkFile, null!); + InstanceCache.ShpkCache.TryAdd(shpkPath, shaderPackage); + log.LogInformation("Loaded shader package {shpkPath}", shpkPath); + } + else + { + log.LogDebug("Reusing shader package {shpkPath}", shpkPath); + } + + var output = new MaterialBuilder(Path.GetFileNameWithoutExtension(path)) + .WithMetallicRoughnessShader() + .WithBaseColor(Vector4.One); + + foreach (var (offset, texPath) in texturePaths) + { + if (imageCache.ContainsKey(texPath)) continue; + ComposeTexture(texPath); + } + + var setTypes = new HashSet(); + foreach (var sampler in mtrlFile.Samplers) + { + if (sampler.TextureIndex == byte.MaxValue) continue; + var textureInfo = mtrlFile.TextureOffsets[sampler.TextureIndex]; + var texturePath = texturePaths[textureInfo.Offset]; + if (!imageCache.TryGetValue(texturePath, out var tex)) continue; + // bg textures can have additional textures, which may be dummy textures, ignore them + if (texturePath.Contains("dummy_")) continue; + if (!shaderPackage.TextureLookup.TryGetValue(sampler.SamplerId, out var usage)) + { + log.LogWarning("Unknown texture usage for texture {texturePath} ({textureUsage})", texturePath, (TextureUsage)sampler.SamplerId); + continue; + } + + var channel = MaterialUtility.MapTextureUsageToChannel(usage); + if (channel != null && setTypes.Add(usage)) + { + var fileName = $"{Path.GetFileNameWithoutExtension(texturePath)}_{usage}_{shaderPackage.Name}"; + var imageBuilder = ImageBuilder.From(tex.MemoryImage, fileName); + imageBuilder.AlternateWriteFileName = $"{fileName}.*"; + output.WithChannelImage(channel.Value, imageBuilder); + } + else if (channel != null) + { + log.LogWarning("Ignoring texture {texturePath} with usage {usage}", texturePath, usage); + } + else + { + log.LogWarning("Unknown texture usage {usage} for texture {texturePath}", usage, texturePath); + } + } + + return output; + } + + private void ComposeTexture(string texPath) + { + var texData = dataManager.GetFile(texPath); + if (texData == null) throw new Exception($"Failed to load texture file: {texPath}"); + log.LogInformation("Loaded texture {texPath}", texPath); + var texFile = new TexFile(texData.Data); + var diskPath = Path.Combine(CacheDir, Path.GetDirectoryName(texPath) ?? "", + Path.GetFileNameWithoutExtension(texPath)) + ".png"; + var texture = Texture.GetResource(texFile).ToTexture(); + byte[] textureBytes; + using (var memoryStream = new MemoryStream()) + { + texture.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100); + textureBytes = memoryStream.ToArray(); + } + + var dirPath = Path.GetDirectoryName(diskPath); + if (!string.IsNullOrEmpty(dirPath) && !Directory.Exists(dirPath)) + { + Directory.CreateDirectory(dirPath); + } + + File.WriteAllBytes(diskPath, textureBytes); + imageCache.TryAdd(texPath, (diskPath, new MemoryImage(() => File.ReadAllBytes(diskPath)))); + } +} diff --git a/Meddle/Meddle.Plugin/Services/LayoutService.cs b/Meddle/Meddle.Plugin/Services/LayoutService.cs index 784edc9..d550a17 100644 --- a/Meddle/Meddle.Plugin/Services/LayoutService.cs +++ b/Meddle/Meddle.Plugin/Services/LayoutService.cs @@ -127,140 +127,6 @@ public unsafe ParsedInstance ResolveInstance(ParsedInstance instance) return housingManager->CurrentTerritory; } - public unsafe void ParseTerrain(CancellationToken cancellationToken = default) - { - logger.LogInformation("Parsing terrain"); - var layoutWorld = sigUtil.GetLayoutWorld(); - if (layoutWorld == null) return; - var activeLayout = layoutWorld->ActiveLayout; - if (activeLayout == null) return; - - var scene = new SceneBuilder(); - var outDir = Path.Combine(Plugin.TempDirectory, "terrain"); - Directory.CreateDirectory(outDir); - var textureDir = Path.Combine(outDir, "textures"); - Directory.CreateDirectory(textureDir); - - var teraFiles = new Dictionary(); - foreach (var (_, terrainPtr) in activeLayout->Terrains) - { - if (terrainPtr == null || terrainPtr.Value == null) continue; - var terrain = terrainPtr.Value; - var terrainDir = terrain->PathString; - var teraPath = $"{terrainDir}/bgplate/terrain.tera"; - var teraData = dataManager.GetFile(teraPath); - if (teraData == null) throw new Exception($"Failed to load terrain file: {teraPath}"); - var terrainFile = new TeraFile(teraData.Data); - teraFiles.Add(terrainDir, terrainFile); - logger.LogInformation("Loaded terrain {teraPath}", teraPath); - } - - var shpkCache = new Dictionary(); - var texPaths = new Dictionary(); - - foreach (var (dir, file) in teraFiles) - { - for (var i = 0; i < file.Header.PlateCount; i++) - { - if (cancellationToken.IsCancellationRequested) break; - logger.LogInformation("Parsing plate {i}", i); - var mdlPath = $"{dir}/bgplate/{i:D4}.mdl"; - var mdlData = dataManager.GetFile(mdlPath); - if (mdlData == null) throw new Exception($"Failed to load model file: {mdlPath}"); - var mdlFile = new MdlFile(mdlData.Data); - - var platePos = file.GetPlatePosition(i); - var transform = new Transform(new Vector3(platePos.X, 0, platePos.Y), Quaternion.Identity, - Vector3.One); - var materials = mdlFile.GetMaterialNames().Select(x => x.Value).ToArray(); - var materialBuilders = new List(); - foreach (var mtrlPath in materials) - { - var mtrlData = dataManager.GetFile(mtrlPath); - if (mtrlData == null) - throw new Exception($"Failed to load material file: {mtrlPath}"); - var mtrlFile = new MtrlFile(mtrlData.Data); - logger.LogInformation("Loaded material {mtrlPath}", mtrlPath); - - var texturePaths = mtrlFile.GetTexturePaths(); - var shpkPath = $"shader/sm5/shpk/{mtrlFile.GetShaderPackageName()}"; - if (!shpkCache.TryGetValue(shpkPath, out var shpkFile)) - { - var shpkData = dataManager.GetFile(shpkPath); - if (shpkData == null) - throw new Exception($"Failed to load shader package file: {shpkPath}"); - shpkFile = new ShpkFile(shpkData.Data); - logger.LogInformation("Loaded shader package {shpkPath}", shpkPath); - shpkCache.TryAdd(shpkPath, shpkFile); - } - - foreach (var (offset, texPath) in texturePaths) - { - if (!texPaths.ContainsKey(texPath)) - { - var texData = dataManager.GetFile(texPath); - if (texData == null) - throw new Exception($"Failed to load texture file: {texPath}"); - var texFile = new TexFile(texData.Data); - logger.LogInformation("Loaded texture {texPath}", texPath); - var diskPath = Path.Combine(textureDir, Path.GetFileNameWithoutExtension(texPath)) + ".png"; - var texture = Texture.GetResource(texFile).ToTexture(); - byte[] textureBytes; - using (var memoryStream = new MemoryStream()) - { - texture.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100); - textureBytes = memoryStream.ToArray(); - } - - File.WriteAllBytes(diskPath, textureBytes); - texPaths.TryAdd(texPath, diskPath); - } - } - - var output = new MaterialBuilder(Path.GetFileNameWithoutExtension(mtrlPath)) - .WithMetallicRoughnessShader() - .WithBaseColor(Vector4.One); - - var shaderPackage = new ShaderPackage(shpkFile, null!); - for (var samplerIdx = 0; samplerIdx < mtrlFile.Samplers.Length; samplerIdx++) - { - var sampler = mtrlFile.Samplers[samplerIdx]; - if (sampler.TextureIndex != byte.MaxValue) - { - var texture = mtrlFile.TextureOffsets[sampler.TextureIndex]; - var path = texturePaths[texture.Offset]; - if (texPaths.TryGetValue(path, out var tex) && - shaderPackage.TextureLookup.TryGetValue(sampler.SamplerId, out var usage)) - { - var imageBuilder = ImageBuilder.From(tex, Path.GetFileNameWithoutExtension(path)); - var channel = MaterialUtility.MapTextureUsageToChannel(usage); - if (channel != null) - { - output.WithChannelImage(channel.Value, imageBuilder); - } - } - } - } - - materialBuilders.Add(output); - } - - var model = new Model(mdlPath, mdlFile, null); - var meshes = ModelBuilder.BuildMeshes(model, materialBuilders, [], null); - foreach (var mesh in meshes) - { - scene.AddRigidMesh(mesh.Mesh, transform.AffineTransform); - } - } - } - - var sceneGraph = scene.ToGltf2(); - var outputDir = Path.Combine(Plugin.TempDirectory, "terrain"); - Directory.CreateDirectory(outputDir); - var outputPath = Path.Combine(outputDir, "terrain.gltf"); - sceneGraph.SaveGLTF(outputPath); - } - private unsafe ParsedLayer[] Parse(LayoutManager* activeLayout, ParseCtx ctx) { if (activeLayout == null) return []; diff --git a/Meddle/Meddle.Plugin/UI/TerrainTab.cs b/Meddle/Meddle.Plugin/UI/TerrainTab.cs index 7c0fce4..e3a29a6 100644 --- a/Meddle/Meddle.Plugin/UI/TerrainTab.cs +++ b/Meddle/Meddle.Plugin/UI/TerrainTab.cs @@ -1,7 +1,15 @@ -using ImGuiNET; +using System.Diagnostics; +using System.Numerics; +using Dalamud.Interface.ImGuiFileDialog; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Plugin.Services; +using ImGuiNET; using Meddle.Plugin.Models; +using Meddle.Plugin.Models.Composer; using Meddle.Plugin.Services; using Microsoft.Extensions.Logging; +using SharpGLTF.Scenes; +using SharpGLTF.Schema2; namespace Meddle.Plugin.UI; @@ -9,6 +17,13 @@ public class TerrainTab : ITab { private readonly LayoutService layoutService; private readonly ILogger logger; + private readonly SigUtil sigUtil; + private readonly IDataManager dataManager; + + private readonly FileDialogManager fileDialog = new() + { + AddedWindowFlags = ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoDocking + }; public void Dispose() { @@ -18,51 +33,215 @@ public void Dispose() public string Name => "Terrain"; public int Order => 0; public MenuType MenuType => MenuType.Debug; - - public TerrainTab(LayoutService layoutService, ILogger logger) + + public TerrainTab( + LayoutService layoutService, + ILogger logger, + SigUtil sigUtil, + IDataManager dataManager) { this.layoutService = layoutService; this.logger = logger; + this.sigUtil = sigUtil; + this.dataManager = dataManager; cts = new CancellationTokenSource(); } + + + private CancellationTokenSource cts; + private Task task = Task.CompletedTask; + private ExportType exportType = ExportType.GLTF; + public enum ExportType + { + GLTF, + OBJ, + GLB + } - - CancellationTokenSource cts; - Task task = Task.CompletedTask; - - public unsafe void Draw() + public void DrawInner() { - if (ImGui.Button("Dump Terrain")) + ImGui.Text("Note: textures exported from here are not manipulated in any way at this stage."); + ImGui.Text("To combine with world objects, use the layout tab and export with origin mode set to 'Zero'."); + + if (ImGui.BeginCombo("Export Type", exportType.ToString())) { - task = Task.Run(() => + foreach (ExportType type in Enum.GetValues(typeof(ExportType))) { - try + var isSelected = type == exportType; + if (ImGui.Selectable(type.ToString(), isSelected)) { - layoutService.ParseTerrain(cts.Token); + exportType = type; } - catch (Exception e) + + if (isSelected) { - logger.LogError(e, "Failed to parse terrain"); - throw; + ImGui.SetItemDefaultFocus(); } - }); - } + } + ImGui.EndCombo(); + } + if (task.IsFaulted) { var ex = task.Exception; ImGui.TextWrapped($"Error: {ex}"); } - + if (task.IsCompleted) { - cts = new CancellationTokenSource(); + /*using (var disabled = ImRaii.Disabled()) + { + if (ImGui.Button("Export Full Layout")) + { + var dirname = $"Export-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}"; + fileDialog.SaveFolderDialog("Select folder to dump to", dirname, + (result, path) => + { + cts = new CancellationTokenSource(); + if (!result) return; + task = ComposeAll(path, exportType, cts.Token); + }, Plugin.TempDirectory); + } + }*/ + if (ImGui.Button($"Export Terrain")) + { + var dirname = $"Export-{DateTime.Now:yyyy-MM-dd-HH-mm-ss}"; + fileDialog.SaveFolderDialog("Select folder to dump to", dirname, + (result, path) => + { + cts = new CancellationTokenSource(); + if (!result) return; + task = ComposeTerrain(path, exportType, cts.Token); + }, Plugin.TempDirectory); + } } - - if (ImGui.Button($"Cancel")) + else { - cts.Cancel(); - cts = new CancellationTokenSource(); + if (LastProgress != null) + { + var (progress, total) = LastProgress; + ImGui.ProgressBar(progress / (float)total, new Vector2(-1, 0), $"Progress: {progress}/{total}"); + } + + if (ImGui.Button("Cancel")) + { + cts.Cancel(); + } } } + + public void Draw() + { + DrawInner(); + fileDialog.Draw(); + } + + private ProgressEvent? LastProgress { get; set; } + + public unsafe Task ComposeTerrain(string outDir, ExportType exportFormat, CancellationToken cancellationToken = default) + { + logger.LogInformation("Parsing terrain"); + var layoutWorld = sigUtil.GetLayoutWorld(); + if (layoutWorld == null) return Task.CompletedTask; + var activeLayout = layoutWorld->ActiveLayout; + if (activeLayout == null) return Task.CompletedTask; + + var scene = new SceneBuilder(); + Directory.CreateDirectory(outDir); + var cacheDir = Path.Combine(outDir, "cache"); + Directory.CreateDirectory(cacheDir); + + var teraFiles = new HashSet(); + foreach (var (_, terrainPtr) in activeLayout->Terrains) + { + if (terrainPtr == null || terrainPtr.Value == null) continue; + var terrain = terrainPtr.Value; + var terrainDir = terrain->PathString; + teraFiles.Add(terrainDir); + } + + return Task.Run(() => + { + foreach (var dir in teraFiles) + { + var terrainSet = new TerrainSet(logger, dataManager, dir, cacheDir, x => LastProgress = x, cancellationToken); + terrainSet.Compose(scene); + } + + var sceneGraph = scene.ToGltf2(); + switch (exportFormat) + { + case ExportType.GLTF: + sceneGraph.SaveGLTF(Path.Combine(outDir, "composed.gltf")); + break; + case ExportType.OBJ: + sceneGraph.SaveAsWavefront(Path.Combine(outDir, "composed.obj")); + break; + case ExportType.GLB: + sceneGraph.SaveGLB(Path.Combine(outDir, "composed.glb")); + break; + } + + Process.Start("explorer.exe", outDir); + GC.Collect(); + }, cancellationToken); + } + + public unsafe Task ComposeAll(string outDir, ExportType exportFormat, CancellationToken cancellationToken = default) + { + logger.LogInformation("Parsing terrain"); + var layoutWorld = sigUtil.GetLayoutWorld(); + if (layoutWorld == null) return Task.CompletedTask; + var activeLayout = layoutWorld->ActiveLayout; + if (activeLayout == null) return Task.CompletedTask; + + var scene = new SceneBuilder(); + Directory.CreateDirectory(outDir); + var cacheDir = Path.Combine(outDir, "cache"); + Directory.CreateDirectory(cacheDir); + + var teraFiles = new HashSet(); + foreach (var (_, terrainPtr) in activeLayout->Terrains) + { + if (terrainPtr == null || terrainPtr.Value == null) continue; + var terrain = terrainPtr.Value; + var terrainDir = terrain->PathString; + teraFiles.Add(terrainDir); + } + + var layout = layoutService.GetWorldState(); + + return Task.Run(() => + { + foreach (var dir in teraFiles) + { + var terrainSet = new TerrainSet(logger, dataManager, dir, cacheDir, x => LastProgress = x, cancellationToken); + terrainSet.Compose(scene); + } + + if (layout != null) + { + var instanceSet = new InstanceSet(logger, dataManager, layout, cacheDir, x => LastProgress = x, cancellationToken); + instanceSet.Compose(scene); + } + + var sceneGraph = scene.ToGltf2(); + switch (exportFormat) + { + case ExportType.GLTF: + sceneGraph.SaveGLTF(Path.Combine(outDir, "composed.gltf")); + break; + case ExportType.OBJ: + sceneGraph.SaveAsWavefront(Path.Combine(outDir, "composed.obj")); + break; + case ExportType.GLB: + sceneGraph.SaveGLB(Path.Combine(outDir, "composed.glb")); + break; + } + + Process.Start("explorer.exe", outDir); + GC.Collect(); + }, cancellationToken); + } } diff --git a/Meddle/Meddle.Utils/Materials/MaterialUtility.cs b/Meddle/Meddle.Utils/Materials/MaterialUtility.cs index 80f5db2..281c97a 100644 --- a/Meddle/Meddle.Utils/Materials/MaterialUtility.cs +++ b/Meddle/Meddle.Utils/Materials/MaterialUtility.cs @@ -2,6 +2,7 @@ using Meddle.Utils.Export; using Meddle.Utils.Models; using SharpGLTF.Materials; +using SharpGLTF.Memory; using SkiaSharp; namespace Meddle.Utils.Materials; @@ -149,7 +150,7 @@ public static Vector4 Clamp(this Vector4 v, float min, float max) public static SKColor ToSkColor(this Vector3 color) => new((byte)(color.X * 255), (byte)(color.Y * 255), (byte)(color.Z * 255), byte.MaxValue); - public static ImageBuilder BuildImage(SKTexture texture, string materialName, string suffix) + /*public static ImageBuilder BuildImage(SKTexture texture, string materialName, string suffix) { var name = $"{Path.GetFileNameWithoutExtension(materialName)}_{suffix}"; @@ -163,6 +164,24 @@ public static ImageBuilder BuildImage(SKTexture texture, string materialName, st var imageBuilder = ImageBuilder.From(textureBytes, name); imageBuilder.AlternateWriteFileName = $"{name}.*"; return imageBuilder; + }*/ + public static ImageBuilder BuildImage(SKTexture texture, string materialName, string suffix) + { + var name = $"{Path.GetFileNameWithoutExtension(materialName)}_{suffix}"; + + byte[] textureBytes; + using (var memoryStream = new MemoryStream()) + { + texture.Bitmap.Encode(memoryStream, SKEncodedImageFormat.Png, 100); + textureBytes = memoryStream.ToArray(); + } + + var tempPath = Path.GetTempFileName(); + File.WriteAllBytes(tempPath, textureBytes); + + var imageBuilder = ImageBuilder.From(new MemoryImage(() => File.ReadAllBytes(tempPath)), name); + imageBuilder.AlternateWriteFileName = $"{name}.*"; + return imageBuilder; } public static SKTexture MultiplyBitmaps(SKTexture target, SKTexture multiplier, bool preserveTargetAlpha = true)