diff --git a/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs index 3e60601b..16bd6dfe 100644 --- a/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs +++ b/Penumbra/Import/Textures/CombinedTexture.Manipulation.cs @@ -46,13 +46,9 @@ public partial class CombinedTexture var left = DataLeft( offset ); var right = DataRight( x, y ); var alpha = right.W + left.W * ( 1 - right.W ); - if( alpha == 0 ) - { - return; - } - - var sum = ( right * right.W + left * left.W * ( 1 - right.W ) ) / alpha; - var rgba = new Rgba32( sum with { W = alpha } ); + var rgba = alpha == 0 + ? new Rgba32() + : new Rgba32( ( ( right * right.W + left * left.W * ( 1 - right.W ) ) / alpha ) with { W = alpha } ); _centerStorage.RGBAPixels[ offset ] = rgba.R; _centerStorage.RGBAPixels[ offset + 1 ] = rgba.G; _centerStorage.RGBAPixels[ offset + 2 ] = rgba.B; diff --git a/Penumbra/Import/Textures/CombinedTexture.cs b/Penumbra/Import/Textures/CombinedTexture.cs index 8688ce2f..61c3dbd5 100644 --- a/Penumbra/Import/Textures/CombinedTexture.cs +++ b/Penumbra/Import/Textures/CombinedTexture.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.Numerics; +using Lumina.Data.Files; using OtterTex; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; @@ -10,6 +12,14 @@ namespace Penumbra.Import.Textures; public partial class CombinedTexture : IDisposable { + public enum TextureSaveType + { + AsIs, + Bitmap, + BC5, + BC7, + } + private enum Mode { Empty, @@ -29,6 +39,8 @@ public partial class CombinedTexture : IDisposable public bool IsLoaded => _mode != Mode.Empty; + public Exception? SaveException { get; private set; } = null; + public void Draw( Vector2 size ) { if( _mode == Mode.Custom && !_centerStorage.IsLoaded ) @@ -44,81 +56,123 @@ public partial class CombinedTexture : IDisposable public void SaveAsPng( string path ) { - if( !IsLoaded || _current == null ) + if( !IsLoaded || _current == null ) { return; } - var image = Image.LoadPixelData< Rgba32 >( _current.RGBAPixels, _current.TextureWrap!.Width, - _current.TextureWrap!.Height ); - image.Save( path, new PngEncoder() { CompressionLevel = PngCompressionLevel.NoCompression } ); + try + { + var image = Image.LoadPixelData< Rgba32 >( _current.RGBAPixels, _current.TextureWrap!.Width, + _current.TextureWrap!.Height ); + image.Save( path, new PngEncoder() { CompressionLevel = PngCompressionLevel.NoCompression } ); + SaveException = null; + } + catch( Exception e ) + { + SaveException = e; + } } - public void SaveAsDDS( string path, DXGIFormat format, bool fast, float threshold = 0.5f ) + private void SaveAs( string path, TextureSaveType type, bool mipMaps, bool writeTex ) { - if( _current == null ) - return; - switch( _mode ) + if( _current == null || _mode == Mode.Empty ) { - case Mode.Empty: return; - case Mode.LeftCopy: - case Mode.RightCopy: - if( _centerStorage.BaseImage is ScratchImage s ) - { - if( format != s.Meta.Format ) - { - s = s.Convert( format, threshold ); - } + return; + } - s.SaveDDS( path ); - } - else - { - var image = ScratchImage.FromRGBA( _current.RGBAPixels, _current.TextureWrap!.Width, - _current.TextureWrap!.Height, out var i ).ThrowIfError( i ); - image.SaveDDS( path ).ThrowIfError(); - } + try + { + if( _current.BaseImage is not ScratchImage s ) + { + s = ScratchImage.FromRGBA( _current.RGBAPixels, _current.TextureWrap!.Width, + _current.TextureWrap!.Height, out var i ).ThrowIfError( i ); + } - break; + var tex = type switch + { + TextureSaveType.AsIs => _current.Type is Texture.FileType.Bitmap or Texture.FileType.Png ? CreateUncompressed(s, mipMaps ) : s, + TextureSaveType.Bitmap => CreateUncompressed( s, mipMaps ), + TextureSaveType.BC5 => CreateCompressed( s, mipMaps, false ), + TextureSaveType.BC7 => CreateCompressed( s, mipMaps, true ), + _ => throw new ArgumentOutOfRangeException( nameof( type ), type, null ), + }; + + if( !writeTex ) + { + tex.SaveDDS( path ); + } + else + { + SaveTex( path, tex ); + } + + SaveException = null; + } + catch( Exception e ) + { + SaveException = e; } } - //private void SaveAs( bool success, string path, int type ) - //{ - // if( !success || _imageCenter == null || _wrapCenter == null ) - // { - // return; - // } - // - // try - // { - // switch( type ) - // { - // case 0: - // var img = Image.LoadPixelData< Rgba32 >( _imageCenter, _wrapCenter.Width, _wrapCenter.Height ); - // img.Save( path, new PngEncoder() { CompressionLevel = PngCompressionLevel.NoCompression } ); - // break; - // case 1: - // if( TextureImporter.RgbaBytesToTex( _imageCenter, _wrapCenter.Width, _wrapCenter.Height, out var tex ) ) - // { - // File.WriteAllBytes( path, tex ); - // } - // - // break; - // case 2: - // //ScratchImage.LoadDDS( _imageCenter, ) - // //if( TextureImporter.RgbaBytesToDds( _imageCenter, _wrapCenter.Width, _wrapCenter.Height, out var dds ) ) - ////{ - // // File.WriteAllBytes( path, dds ); - ////} - // - // break; - // } - // } - // catch( Exception e ) - // { - // PluginLog.Error( $"Could not save image to {path}:\n{e}" ); - // } + private static void SaveTex( string path, ScratchImage input ) + { + var header = input.Meta.ToTexHeader(); + if( header.Format == TexFile.TextureFormat.Unknown ) + { + throw new Exception( $"Could not save tex file with format {input.Meta.Format}, not convertible to a valid .tex formats." ); + } + + using var stream = File.OpenWrite( path ); + using var w = new BinaryWriter( stream ); + header.Write( w ); + w.Write( input.Pixels ); + } + + private static ScratchImage AddMipMaps( ScratchImage input, bool mipMaps ) + => mipMaps ? input.GenerateMipMaps() : input; + + private static ScratchImage CreateUncompressed( ScratchImage input, bool mipMaps ) + { + if( input.Meta.Format == DXGIFormat.B8G8R8A8UNorm) + return AddMipMaps(input, mipMaps); + + if( input.Meta.Format.IsCompressed() ) + { + input = input.Decompress( DXGIFormat.B8G8R8A8UNorm ); + } + else + { + input = input.Convert( DXGIFormat.B8G8R8A8UNorm ); + } + + return AddMipMaps( input, mipMaps ); + } + + private static ScratchImage CreateCompressed( ScratchImage input, bool mipMaps, bool bc7 ) + { + var format = bc7 ? DXGIFormat.BC7UNorm : DXGIFormat.BC5UNorm; + if( input.Meta.Format == format) + { + return input; + } + + if( input.Meta.Format.IsCompressed() ) + { + input = input.Decompress( DXGIFormat.B8G8R8A8UNorm ); + } + + input = AddMipMaps( input, mipMaps ); + + return input.Compress( format, CompressFlags.BC7Quick | CompressFlags.Parallel ); + } + + public void SaveAsTex( string path, TextureSaveType type, bool mipMaps ) + => SaveAs( path, type, mipMaps, true ); + + public void SaveAsDds( string path, TextureSaveType type, bool mipMaps ) + => SaveAs( path, type, mipMaps, false ); + public CombinedTexture( Texture left, Texture right ) { diff --git a/Penumbra/Import/Textures/TexFileParser.cs b/Penumbra/Import/Textures/TexFileParser.cs index e3d06198..1d416f02 100644 --- a/Penumbra/Import/Textures/TexFileParser.cs +++ b/Penumbra/Import/Textures/TexFileParser.cs @@ -43,6 +43,26 @@ public static class TexFileParser } } + public static void Write( this TexFile.TexHeader header, BinaryWriter w ) + { + w.Write( ( uint )header.Type ); + w.Write( ( uint )header.Format ); + w.Write( header.Width ); + w.Write( header.Height ); + w.Write( header.Depth ); + w.Write( header.MipLevels ); + unsafe + { + w.Write( header.LodOffset[ 0 ] ); + w.Write( header.LodOffset[ 1 ] ); + w.Write( header.LodOffset[ 2 ] ); + for( var i = 0; i < 13; ++i ) + { + w.Write( header.OffsetToSurface[ i ] ); + } + } + } + public static TexFile.TexHeader ToTexHeader( this TexMeta meta ) { var ret = new TexFile.TexHeader() @@ -50,7 +70,7 @@ public static class TexFileParser Height = ( ushort )meta.Height, Width = ( ushort )meta.Width, Depth = ( ushort )Math.Max( meta.Depth, 1 ), - MipLevels = ( ushort )Math.Min(meta.MipLevels, 13), + MipLevels = ( ushort )Math.Min( meta.MipLevels, 12 ), Format = meta.Format.ToTexFormat(), Type = meta.Dimension switch { @@ -61,6 +81,31 @@ public static class TexFileParser _ => 0, }, }; + unsafe + { + ret.LodOffset[ 0 ] = 0; + ret.LodOffset[ 1 ] = 1; + ret.LodOffset[ 2 ] = 2; + + ret.OffsetToSurface[ 0 ] = 80; + var size = meta.Format.BitsPerPixel() * meta.Width * meta.Height / 8; + for( var i = 1; i < meta.MipLevels; ++i ) + { + ret.OffsetToSurface[ i ] = ( uint )( 80 + size ); + size >>= 2; + if( size == 0 ) + { + ret.MipLevels = ( ushort )i; + break; + } + } + + for( var i = ret.MipLevels; i < 13; ++i ) + { + ret.OffsetToSurface[ i ] = 0; + } + } + return ret; } diff --git a/Penumbra/Import/Textures/Texture.cs b/Penumbra/Import/Textures/Texture.cs index 5be06adb..c55f4df1 100644 --- a/Penumbra/Import/Textures/Texture.cs +++ b/Penumbra/Import/Textures/Texture.cs @@ -17,66 +17,6 @@ using Image = SixLabors.ImageSharp.Image; namespace Penumbra.Import.Textures; -//public static class ScratchImageExtensions -//{ -// public static Exception? SaveAsTex( this ScratchImage image, string path ) -// { -// try -// { -// using var fileStream = File.OpenWrite( path ); -// using var bw = new BinaryWriter( fileStream ); -// -// bw.Write( ( uint )image.Meta.GetAttribute() ); -// bw.Write( ( uint )image.Meta.GetFormat() ); -// bw.Write( ( ushort )image.Meta.Width ); -// bw.Write( ( ushort )image.Meta.Height ); -// bw.Write( ( ushort )image.Meta.Depth ); -// bw.Write( ( ushort )image.Meta.MipLevels ); -// } -// catch( Exception e ) -// { -// return e; -// } -// -// return null; -// } -// -// public static unsafe TexFile.TexHeader ToTexHeader( this ScratchImage image ) -// { -// var ret = new TexFile.TexHeader() -// { -// Type = image.Meta.GetAttribute(), -// Format = image.Meta.GetFormat(), -// Width = ( ushort )image.Meta.Width, -// Height = ( ushort )image.Meta.Height, -// Depth = ( ushort )image.Meta.Depth, -// }; -// ret.LodOffset[0] = 0; -// ret.LodOffset[1] = 1; -// ret.LodOffset[2] = 2; -// //foreach(var surface in image.Images) -// // ret.OffsetToSurface[ 0 ] = 80 + (image.P); -// return ret; -// } -// -// // Get all known flags for the TexFile.Attribute from the scratch image. -// private static TexFile.Attribute GetAttribute( this TexMeta meta ) -// { -// var ret = meta.Dimension switch -// { -// TexDimension.Tex1D => TexFile.Attribute.TextureType1D, -// TexDimension.Tex2D => TexFile.Attribute.TextureType2D, -// TexDimension.Tex3D => TexFile.Attribute.TextureType3D, -// _ => ( TexFile.Attribute )0, -// }; -// if( meta.IsCubeMap ) -// ret |= TexFile.Attribute.TextureTypeCube; -// if( meta.Format.IsDepthStencil() ) -// ret |= TexFile.Attribute.TextureDepthStencil; -// return ret; -// } -//} - public sealed class Texture : IDisposable { public enum FileType @@ -112,9 +52,6 @@ public sealed class Texture : IDisposable public bool IsLoaded => TextureWrap != null; - public Texture() - { } - public void Draw( Vector2 size ) { if( TextureWrap != null ) @@ -195,12 +132,15 @@ public sealed class Texture : IDisposable Clean(); try { + if( !File.Exists( path ) ) + throw new FileNotFoundException(); + var _ = System.IO.Path.GetExtension( Path ) switch { ".dds" => LoadDds(), ".png" => LoadPng(), ".tex" => LoadTex(), - _ => true, + _ => throw new Exception($"Extension {System.IO.Path.GetExtension( Path )} unknown."), }; Loaded?.Invoke( true ); } diff --git a/Penumbra/UI/Classes/ModEditWindow.Textures.cs b/Penumbra/UI/Classes/ModEditWindow.Textures.cs index 49e541e5..43307773 100644 --- a/Penumbra/UI/Classes/ModEditWindow.Textures.cs +++ b/Penumbra/UI/Classes/ModEditWindow.Textures.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Linq; using System.Numerics; +using Dalamud.Interface; using Dalamud.Interface.ImGuiFileDialog; using ImGuiNET; using OtterGui; @@ -19,7 +20,20 @@ public partial class ModEditWindow private readonly FileDialogManager _dialogManager = ConfigWindow.SetupFileManager(); private bool _overlayCollapsed = true; - private DXGIFormat _currentFormat = DXGIFormat.R8G8B8A8UNorm; + + private bool _addMipMaps = true; + private int _currentSaveAs = 0; + + private static readonly (string, string)[] SaveAsStrings = + { + ( "As Is", "Save the current texture with its own format without additional conversion or compression, if possible." ), + ( "RGBA (Uncompressed)", + "Save the current texture as an uncompressed BGRA bitmap. This requires the most space but technically offers the best quality." ), + ( "BC3 (Simple Compression)", + "Save the current texture compressed via BC3/DXT5 compression. This offers a 4:1 compression ratio and is quick with acceptable quality." ), + ( "BC7 (Complex Compression)", + "Save the current texture compressed via BC7 compression. This offers a 4:1 compression ratio and has almost indistinguishable quality, but may take a while." ), + }; private void DrawInputChild( string label, Texture tex, Vector2 size, Vector2 imageSize ) { @@ -37,7 +51,8 @@ public partial class ModEditWindow _dialogManager ); var files = _editor!.TexFiles.Select( f => f.File.FullName ) .Concat( _editor.TexFiles.SelectMany( f => f.SubModUsage.Select( p => p.Item2.ToString() ) ) ); - tex.PathSelectBox( "##combo", "Select the textures included in this mod on your drive or the ones they replace from the game files.", files); + tex.PathSelectBox( "##combo", "Select the textures included in this mod on your drive or the ones they replace from the game files.", + files ); if( tex == _left ) { @@ -52,6 +67,35 @@ public partial class ModEditWindow tex.Draw( imageSize ); } + private void SaveAsCombo() + { + var (text, desc) = SaveAsStrings[ _currentSaveAs ]; + ImGui.SetNextItemWidth( -ImGui.GetFrameHeight() - ImGui.GetStyle().ItemSpacing.X ); + using var combo = ImRaii.Combo( "##format", text ); + ImGuiUtil.HoverTooltip( desc ); + if( !combo ) + { + return; + } + + foreach( var ((newText, newDesc), idx) in SaveAsStrings.WithIndex() ) + { + if( ImGui.Selectable( newText, idx == _currentSaveAs ) ) + { + _currentSaveAs = idx; + } + + ImGuiUtil.HoverTooltip( newDesc ); + } + } + + private void MipMapInput() + { + ImGui.Checkbox( "##mipMaps", ref _addMipMaps ); + ImGuiUtil.HoverTooltip( + "Add the appropriate number of MipMaps to the file." ); + } + private void DrawOutputChild( Vector2 size, Vector2 imageSize ) { using var child = ImRaii.Child( "Output", size, true ); @@ -62,27 +106,57 @@ public partial class ModEditWindow if( _center.IsLoaded ) { + SaveAsCombo(); + ImGui.SameLine(); + MipMapInput(); if( ImGui.Button( "Save as TEX", -Vector2.UnitX ) ) { var fileName = Path.GetFileNameWithoutExtension( _left.Path.Length > 0 ? _left.Path : _right.Path ); - _dialogManager.SaveFileDialog( "Save Texture as TEX...", ".tex", fileName, ".tex", ( a, b ) => { }, _mod!.ModPath.FullName ); + _dialogManager.SaveFileDialog( "Save Texture as TEX...", ".tex", fileName, ".tex", ( a, b ) => + { + if( a ) + { + _center.SaveAsTex( b, ( CombinedTexture.TextureSaveType )_currentSaveAs, _addMipMaps ); + } + }, _mod!.ModPath.FullName ); } if( ImGui.Button( "Save as DDS", -Vector2.UnitX ) ) { var fileName = Path.GetFileNameWithoutExtension( _right.Path.Length > 0 ? _right.Path : _left.Path ); - _dialogManager.SaveFileDialog( "Save Texture as DDS...", ".dds", fileName, ".dds", ( a, b ) => { if( a ) _center.SaveAsDDS( b, _currentFormat, false ); }, _mod!.ModPath.FullName ); + _dialogManager.SaveFileDialog( "Save Texture as DDS...", ".dds", fileName, ".dds", ( a, b ) => + { + if( a ) + { + _center.SaveAsDds( b, ( CombinedTexture.TextureSaveType )_currentSaveAs, _addMipMaps ); + } + }, _mod!.ModPath.FullName ); } + ImGui.NewLine(); + if( ImGui.Button( "Save as PNG", -Vector2.UnitX ) ) { var fileName = Path.GetFileNameWithoutExtension( _right.Path.Length > 0 ? _right.Path : _left.Path ); - _dialogManager.SaveFileDialog( "Save Texture as PNG...", ".png", fileName, ".png", ( a, b ) => { if (a) _center.SaveAsPng( b ); }, _mod!.ModPath.FullName ); + _dialogManager.SaveFileDialog( "Save Texture as PNG...", ".png", fileName, ".png", ( a, b ) => + { + if( a ) + { + _center.SaveAsPng( b ); + } + }, _mod!.ModPath.FullName ); } ImGui.NewLine(); } + if( _center.SaveException != null ) + { + ImGui.TextUnformatted( "Could not save file:" ); + using var color = ImRaii.PushColor( ImGuiCol.Text, 0xFF0000FF ); + ImGuiUtil.TextWrapped( _center.SaveException.ToString() ); + } + _center.Draw( imageSize ); }