Initial Texture rework.

This commit is contained in:
Ottermandias 2023-08-08 01:10:57 +02:00
parent 2f836426d6
commit e24a535a93
16 changed files with 831 additions and 489 deletions

View file

@ -0,0 +1,111 @@
using System;
using System.Numerics;
using Lumina.Data.Files;
using OtterTex;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Penumbra.Import.Textures;
public readonly struct BaseImage : IDisposable
{
public readonly object? Image;
public BaseImage(ScratchImage scratch)
=> Image = scratch;
public BaseImage(Image<Rgba32> image)
=> Image = image;
public static implicit operator BaseImage(ScratchImage scratch)
=> new(scratch);
public static implicit operator BaseImage(Image<Rgba32> img)
=> new(img);
public ScratchImage? AsDds
=> Image as ScratchImage;
public Image<Rgba32>? AsPng
=> Image as Image<Rgba32>;
public TexFile? AsTex
=> Image as TexFile;
public TextureType Type
=> Image switch
{
null => TextureType.Unknown,
ScratchImage => TextureType.Dds,
Image<Rgba32> => TextureType.Png,
_ => TextureType.Unknown,
};
public void Dispose()
=> (Image as IDisposable)?.Dispose();
/// <summary> Obtain RGBA pixel data for the given image (not including any mip maps.) </summary>
public (byte[] Rgba, int Width, int Height) GetPixelData()
{
switch (Image)
{
case null: return (Array.Empty<byte>(), 0, 0);
case ScratchImage scratch:
{
var rgba = scratch.GetRGBA(out var f).ThrowIfError(f);
return (rgba.Pixels[..(f.Meta.Width * f.Meta.Height * (f.Meta.Format.BitsPerPixel() / 8))].ToArray(), f.Meta.Width,
f.Meta.Height);
}
case Image<Rgba32> img:
{
var ret = new byte[img.Height * img.Width * 4];
img.CopyPixelDataTo(ret);
return (ret, img.Width, img.Height);
}
default: return (Array.Empty<byte>(), 0, 0);
}
}
public (int Width, int Height) Dimensions
=> Image switch
{
null => (0, 0),
ScratchImage scratch => (scratch.Meta.Width, scratch.Meta.Height),
Image<Rgba32> img => (img.Width, img.Height),
_ => (0, 0),
};
public int Width
=> Dimensions.Width;
public int Height
=> Dimensions.Height;
public Vector2 ImageSize
{
get
{
var (width, height) = Dimensions;
return new Vector2(width, height);
}
}
public DXGIFormat Format
=> Image switch
{
null => DXGIFormat.Unknown,
ScratchImage s => s.Meta.Format,
TexFile t => t.Header.Format.ToDXGI(),
Image<Rgba32> => DXGIFormat.B8G8R8X8UNorm,
_ => DXGIFormat.Unknown,
};
public int MipMaps
=> Image switch
{
null => 0,
ScratchImage s => s.Meta.MipLevels,
TexFile t => t.Header.MipLevels,
_ => 1,
};
}

View file

@ -16,14 +16,13 @@ public partial class CombinedTexture
private bool _invertLeft = false;
private bool _invertRight = false;
private int _offsetX = 0;
private int _offsetY = 0;
private int _offsetY = 0;
private Vector4 DataLeft( int offset )
=> CappedVector( _left.RGBAPixels, offset, _multiplierLeft, _invertLeft );
=> CappedVector( _left.RgbaPixels, offset, _multiplierLeft, _invertLeft );
private Vector4 DataRight( int offset )
=> CappedVector( _right.RGBAPixels, offset, _multiplierRight, _invertRight );
=> CappedVector( _right.RgbaPixels, offset, _multiplierRight, _invertRight );
private Vector4 DataRight( int x, int y )
{
@ -35,7 +34,7 @@ public partial class CombinedTexture
}
var offset = ( y * _right.TextureWrap!.Width + x ) * 4;
return CappedVector( _right.RGBAPixels, offset, _multiplierRight, _invertRight );
return CappedVector( _right.RgbaPixels, offset, _multiplierRight, _invertRight );
}
private void AddPixelsMultiplied( int y, ParallelLoopState _ )
@ -49,10 +48,10 @@ public partial class CombinedTexture
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;
_centerStorage.RGBAPixels[ offset + 3 ] = rgba.A;
_centerStorage.RgbaPixels[ offset ] = rgba.R;
_centerStorage.RgbaPixels[ offset + 1 ] = rgba.G;
_centerStorage.RgbaPixels[ offset + 2 ] = rgba.B;
_centerStorage.RgbaPixels[ offset + 3 ] = rgba.A;
}
}
@ -63,10 +62,10 @@ public partial class CombinedTexture
var offset = ( _left.TextureWrap!.Width * y + x ) * 4;
var left = DataLeft( offset );
var rgba = new Rgba32( left );
_centerStorage.RGBAPixels[ offset ] = rgba.R;
_centerStorage.RGBAPixels[ offset + 1 ] = rgba.G;
_centerStorage.RGBAPixels[ offset + 2 ] = rgba.B;
_centerStorage.RGBAPixels[ offset + 3 ] = rgba.A;
_centerStorage.RgbaPixels[ offset ] = rgba.R;
_centerStorage.RgbaPixels[ offset + 1 ] = rgba.G;
_centerStorage.RgbaPixels[ offset + 2 ] = rgba.B;
_centerStorage.RgbaPixels[ offset + 3 ] = rgba.A;
}
}
@ -77,10 +76,10 @@ public partial class CombinedTexture
var offset = ( _right.TextureWrap!.Width * y + x ) * 4;
var left = DataRight( offset );
var rgba = new Rgba32( left );
_centerStorage.RGBAPixels[ offset ] = rgba.R;
_centerStorage.RGBAPixels[ offset + 1 ] = rgba.G;
_centerStorage.RGBAPixels[ offset + 2 ] = rgba.B;
_centerStorage.RGBAPixels[ offset + 3 ] = rgba.A;
_centerStorage.RgbaPixels[ offset ] = rgba.R;
_centerStorage.RgbaPixels[ offset + 1 ] = rgba.G;
_centerStorage.RgbaPixels[ offset + 2 ] = rgba.B;
_centerStorage.RgbaPixels[ offset + 3 ] = rgba.A;
}
}
@ -90,8 +89,8 @@ public partial class CombinedTexture
var (width, height) = _left.IsLoaded
? ( _left.TextureWrap!.Width, _left.TextureWrap!.Height )
: ( _right.TextureWrap!.Width, _right.TextureWrap!.Height );
_centerStorage.RGBAPixels = new byte[width * height * 4];
_centerStorage.Type = Texture.FileType.Bitmap;
_centerStorage.RgbaPixels = new byte[width * height * 4];
_centerStorage.Type = TextureType.Bitmap;
if( _left.IsLoaded )
{
Parallel.For( 0, height, _right.IsLoaded ? AddPixelsMultiplied : MultiplyPixelsLeft );
@ -103,7 +102,6 @@ public partial class CombinedTexture
return ( width, height );
}
private static Vector4 CappedVector( IReadOnlyList< byte > bytes, int offset, Matrix4x4 transform, bool invert )
{
if( bytes.Count == 0 )

View file

@ -1,14 +1,5 @@
using System;
using System.IO;
using System.Numerics;
using Dalamud.Interface;
using Lumina.Data.Files;
using OtterTex;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using DalamudUtil = Dalamud.Utility.Util;
using Image = SixLabors.ImageSharp.Image;
namespace Penumbra.Import.Textures;
@ -38,170 +29,58 @@ public partial class CombinedTexture : IDisposable
private readonly Texture _centerStorage = new();
public Guid SaveGuid { get; private set; } = Guid.Empty;
public bool IsLoaded
=> _mode != Mode.Empty;
public bool IsLeftCopy
=> _mode == Mode.LeftCopy;
public Exception? SaveException { get; private set; } = null;
public bool IsLeftCopy
=> _mode == Mode.LeftCopy;
public void Draw( UiBuilder builder, Vector2 size )
public void Draw(TextureManager textures, Vector2 size)
{
if( _mode == Mode.Custom && !_centerStorage.IsLoaded )
if (_mode == Mode.Custom && !_centerStorage.IsLoaded)
{
var (width, height) = CombineImage();
_centerStorage.TextureWrap =
builder.LoadImageRaw( _centerStorage.RGBAPixels, width, height, 4 );
var (width, height) = CombineImage();
_centerStorage.TextureWrap = textures.LoadTextureWrap(_centerStorage.RgbaPixels, width, height);
}
_current?.Draw( size );
if (_current != null)
TextureDrawer.Draw(_current, size);
}
public void SaveAsPng( string path )
public void SaveAsPng(TextureManager textures, string path)
{
if( !IsLoaded || _current == null )
{
if (!IsLoaded || _current == null)
return;
}
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;
}
SaveGuid = textures.SavePng(_current.BaseImage, path, _current.RgbaPixels, _current.TextureWrap!.Width, _current.TextureWrap!.Height);
}
private void SaveAs( string path, TextureSaveType type, bool mipMaps, bool writeTex )
private void SaveAs(TextureManager textures, string path, TextureSaveType type, bool mipMaps, bool writeTex)
{
if( _current == null || _mode == Mode.Empty )
{
if (!IsLoaded || _current == null)
return;
}
try
{
if( _current.BaseImage is not ScratchImage s )
{
s = ScratchImage.FromRGBA( _current.RGBAPixels, _current.TextureWrap!.Width,
_current.TextureWrap!.Height, out var i ).ThrowIfError( i );
}
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.BC3 => 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;
}
SaveGuid = textures.SaveAs(type, mipMaps, writeTex, _current.BaseImage, path, _current.RgbaPixels, _current.TextureWrap!.Width,
_current.TextureWrap!.Height);
}
private static void SaveTex( string path, ScratchImage input )
{
var header = input.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." );
}
public void SaveAsTex(TextureManager textures, string path, TextureSaveType type, bool mipMaps)
=> SaveAs(textures, path, type, mipMaps, true);
using var stream = File.Open( path, File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew);
using var w = new BinaryWriter( stream );
header.Write( w );
w.Write( input.Pixels );
}
private static ScratchImage AddMipMaps( ScratchImage input, bool mipMaps )
{
if( !mipMaps )
{
return input;
}
var numMips = Math.Min( 13, 1 + BitOperations.Log2( ( uint )Math.Max( input.Meta.Width, input.Meta.Height ) ) );
var ec = input.GenerateMipMaps( out var ret, numMips, ( DalamudUtil.IsLinux() ? FilterFlags.ForceNonWIC : 0 ) | FilterFlags.SeparateAlpha );
if (ec != ErrorCode.Ok)
{
throw new Exception( $"Could not create the requested {numMips} mip maps, maybe retry with the top-right checkbox unchecked:\n{ec}" );
}
return ret;
}
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.BC3UNorm;
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 void SaveAsDds(TextureManager textures, string path, TextureSaveType type, bool mipMaps)
=> SaveAs(textures, path, type, mipMaps, false);
public CombinedTexture( Texture left, Texture right )
public CombinedTexture(Texture left, Texture right)
{
_left = left;
_right = right;
_left.Loaded += OnLoaded;
_right.Loaded += OnLoaded;
OnLoaded( false );
OnLoaded(false);
}
public void Dispose()
@ -211,20 +90,20 @@ public partial class CombinedTexture : IDisposable
_right.Loaded -= OnLoaded;
}
private void OnLoaded( bool _ )
private void OnLoaded(bool _)
=> Update();
public void Update()
{
Clean();
if( _left.IsLoaded )
if (_left.IsLoaded)
{
if( _right.IsLoaded )
if (_right.IsLoaded)
{
_current = _centerStorage;
_mode = Mode.Custom;
}
else if( !_invertLeft && _multiplierLeft.IsIdentity )
else if (!_invertLeft && _multiplierLeft.IsIdentity)
{
_mode = Mode.LeftCopy;
_current = _left;
@ -235,9 +114,9 @@ public partial class CombinedTexture : IDisposable
_mode = Mode.Custom;
}
}
else if( _right.IsLoaded )
else if (_right.IsLoaded)
{
if( !_invertRight && _multiplierRight.IsIdentity )
if (!_invertRight && _multiplierRight.IsIdentity)
{
_current = _right;
_mode = Mode.RightCopy;
@ -254,6 +133,7 @@ public partial class CombinedTexture : IDisposable
{
_centerStorage.Dispose();
_current = null;
SaveGuid = Guid.Empty;
_mode = Mode.Empty;
}
}
}

View file

@ -101,7 +101,7 @@ public static class TexFileParser
Height = (ushort)meta.Height,
Width = (ushort)meta.Width,
Depth = (ushort)Math.Max(meta.Depth, 1),
MipLevels = (byte)Math.Min(meta.MipLevels, 12),
MipLevels = (byte)Math.Min(meta.MipLevels, 13),
Format = meta.Format.ToTexFormat(),
Type = meta.Dimension switch
{

View file

@ -1,135 +1,61 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using Dalamud.Data;
using Dalamud.Interface;
using ImGuiNET;
using ImGuiScene;
using Lumina.Data.Files;
using OtterGui;
using OtterGui.Raii;
using OtterTex;
using Penumbra.Services;
using Penumbra.String.Classes;
using Penumbra.UI;
using Penumbra.UI.Classes;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace Penumbra.Import.Textures;
public enum TextureType
{
Unknown,
Dds,
Tex,
Png,
Bitmap,
}
public sealed class Texture : IDisposable
{
public enum FileType
{
Unknown,
Dds,
Tex,
Png,
Bitmap,
}
// Path to the file we tried to load.
public string Path = string.Empty;
// Path for changing paths.
internal string? TmpPath;
// If the load failed, an exception is stored.
public Exception? LoadError = null;
// The pixels of the main image in RGBA order.
// Empty if LoadError != null or Path is empty.
public byte[] RGBAPixels = Array.Empty<byte>();
public byte[] RgbaPixels = Array.Empty<byte>();
// The ImGui wrapper to load the image.
// null if LoadError != null or Path is empty.
public TextureWrap? TextureWrap = null;
// The base image in whatever format it has.
public object? BaseImage = null;
public BaseImage BaseImage;
// Original File Type.
public FileType Type = FileType.Unknown;
public TextureType Type = TextureType.Unknown;
// Whether the file is successfully loaded and drawable.
public bool IsLoaded
=> TextureWrap != null;
public DXGIFormat Format
=> BaseImage switch
{
ScratchImage s => s.Meta.Format,
TexFile t => t.Header.Format.ToDXGI(),
_ => DXGIFormat.Unknown,
};
=> BaseImage.Format;
public int MipMaps
=> BaseImage switch
{
ScratchImage s => s.Meta.MipLevels,
TexFile t => t.Header.MipLevels,
_ => 1,
};
public void Draw(Vector2 size)
{
if (TextureWrap != null)
{
size = size.X < TextureWrap.Width
? size with { Y = TextureWrap.Height * size.X / TextureWrap.Width }
: new Vector2(TextureWrap.Width, TextureWrap.Height);
ImGui.Image(TextureWrap.ImGuiHandle, size);
DrawData();
}
else if (LoadError != null)
{
ImGui.TextUnformatted("Could not load file:");
ImGuiUtil.TextColored(Colors.RegexWarningBorder, LoadError.ToString());
}
}
public void DrawData()
{
using var table = ImRaii.Table("##data", 2, ImGuiTableFlags.SizingFixedFit);
ImGuiUtil.DrawTableColumn("Width");
ImGuiUtil.DrawTableColumn(TextureWrap!.Width.ToString());
ImGuiUtil.DrawTableColumn("Height");
ImGuiUtil.DrawTableColumn(TextureWrap!.Height.ToString());
ImGuiUtil.DrawTableColumn("File Type");
ImGuiUtil.DrawTableColumn(Type.ToString());
ImGuiUtil.DrawTableColumn("Bitmap Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(RGBAPixels.Length)} ({RGBAPixels.Length} Bytes)");
switch (BaseImage)
{
case ScratchImage s:
ImGuiUtil.DrawTableColumn("Format");
ImGuiUtil.DrawTableColumn(s.Meta.Format.ToString());
ImGuiUtil.DrawTableColumn("Mip Levels");
ImGuiUtil.DrawTableColumn(s.Meta.MipLevels.ToString());
ImGuiUtil.DrawTableColumn("Data Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(s.Pixels.Length)} ({s.Pixels.Length} Bytes)");
ImGuiUtil.DrawTableColumn("Number of Images");
ImGuiUtil.DrawTableColumn(s.Images.Length.ToString());
break;
case TexFile t:
ImGuiUtil.DrawTableColumn("Format");
ImGuiUtil.DrawTableColumn(t.Header.Format.ToString());
ImGuiUtil.DrawTableColumn("Mip Levels");
ImGuiUtil.DrawTableColumn(t.Header.MipLevels.ToString());
ImGuiUtil.DrawTableColumn("Data Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(t.ImageData.Length)} ({t.ImageData.Length} Bytes)");
break;
}
}
=> BaseImage.MipMaps;
private void Clean()
{
RGBAPixels = Array.Empty<byte>();
RgbaPixels = Array.Empty<byte>();
TextureWrap?.Dispose();
TextureWrap = null;
(BaseImage as IDisposable)?.Dispose();
BaseImage = null;
Type = FileType.Unknown;
BaseImage.Dispose();
BaseImage = new BaseImage();
Type = TextureType.Unknown;
Loaded?.Invoke(false);
}
@ -138,9 +64,9 @@ public sealed class Texture : IDisposable
public event Action<bool>? Loaded;
private void Load(DalamudServices dalamud, string path)
public void Load(TextureManager textures, string path)
{
_tmpPath = null;
TmpPath = null;
if (path == Path)
return;
@ -151,13 +77,9 @@ public sealed class Texture : IDisposable
try
{
var _ = System.IO.Path.GetExtension(Path).ToLowerInvariant() switch
{
".dds" => LoadDds(dalamud),
".png" => LoadPng(dalamud),
".tex" => LoadTex(dalamud),
_ => throw new Exception($"Extension {System.IO.Path.GetExtension(Path)} unknown."),
};
(BaseImage, Type) = textures.Load(path);
(RgbaPixels, var width, var height) = BaseImage.GetPixelData();
TextureWrap = textures.LoadTextureWrap(BaseImage, RgbaPixels);
Loaded?.Invoke(true);
}
catch (Exception e)
@ -167,130 +89,10 @@ public sealed class Texture : IDisposable
}
}
public void Reload(DalamudServices dalamud)
public void Reload(TextureManager textures)
{
var path = Path;
Path = string.Empty;
Load(dalamud, path);
}
private bool LoadDds(DalamudServices dalamud)
{
Type = FileType.Dds;
var scratch = ScratchImage.LoadDDS(Path);
BaseImage = scratch;
var rgba = scratch.GetRGBA(out var f).ThrowIfError(f);
RGBAPixels = rgba.Pixels[..(f.Meta.Width * f.Meta.Height * f.Meta.Format.BitsPerPixel() / 8)].ToArray();
CreateTextureWrap(dalamud.UiBuilder, f.Meta.Width, f.Meta.Height);
return true;
}
private bool LoadPng(DalamudServices dalamud)
{
Type = FileType.Png;
BaseImage = null;
using var stream = File.OpenRead(Path);
using var png = Image.Load<Rgba32>(stream);
RGBAPixels = new byte[png.Height * png.Width * 4];
png.CopyPixelDataTo(RGBAPixels);
CreateTextureWrap(dalamud.UiBuilder, png.Width, png.Height);
return true;
}
private bool LoadTex(DalamudServices dalamud)
{
Type = FileType.Tex;
using var stream = OpenTexStream(dalamud.GameData);
var scratch = TexFileParser.Parse(stream);
BaseImage = scratch;
var rgba = scratch.GetRGBA(out var f).ThrowIfError(f);
RGBAPixels = rgba.Pixels[..(f.Meta.Width * f.Meta.Height * (f.Meta.Format.BitsPerPixel() / 8))].ToArray();
CreateTextureWrap(dalamud.UiBuilder, scratch.Meta.Width, scratch.Meta.Height);
return true;
}
private Stream OpenTexStream(DataManager gameData)
{
if (System.IO.Path.IsPathRooted(Path))
return File.OpenRead(Path);
var file = gameData.GetFile(Path);
return file != null ? new MemoryStream(file.Data) : throw new Exception($"Unable to obtain \"{Path}\" from game files.");
}
private void CreateTextureWrap(UiBuilder builder, int width, int height)
=> TextureWrap = builder.LoadImageRaw(RGBAPixels, width, height, 4);
private string? _tmpPath;
public void PathSelectBox(DalamudServices dalamud, string label, string tooltip, IEnumerable<(string, bool)> paths, int skipPrefix)
{
ImGui.SetNextItemWidth(-0.0001f);
var startPath = Path.Length > 0 ? Path : "Choose a modded texture from this mod here...";
using var combo = ImRaii.Combo(label, startPath);
if (combo)
foreach (var ((path, game), idx) in paths.WithIndex())
{
if (game)
{
if (!dalamud.GameData.FileExists(path))
continue;
}
else if (!File.Exists(path))
{
continue;
}
using var id = ImRaii.PushId(idx);
using (var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value(), game))
{
var p = game ? $"--> {path}" : path[skipPrefix..];
if (ImGui.Selectable(p, path == startPath) && path != startPath)
Load(dalamud, path);
}
ImGuiUtil.HoverTooltip(game
? "This is a game path and refers to an unmanipulated file from your game data."
: "This is a path to a modded file on your file system.");
}
ImGuiUtil.HoverTooltip(tooltip);
}
public void PathInputBox(DalamudServices dalamud, string label, string hint, string tooltip, string startPath, FileDialogService fileDialog,
string defaultModImportPath)
{
_tmpPath ??= Path;
using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing,
new Vector2(UiHelpers.ScaleX3, ImGui.GetStyle().ItemSpacing.Y));
ImGui.SetNextItemWidth(-2 * ImGui.GetFrameHeight() - 7 * UiHelpers.Scale);
ImGui.InputTextWithHint(label, hint, ref _tmpPath, Utf8GamePath.MaxGamePathLength);
if (ImGui.IsItemDeactivatedAfterEdit())
Load(dalamud, _tmpPath);
ImGuiUtil.HoverTooltip(tooltip);
ImGui.SameLine();
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Folder.ToIconString(), new Vector2(ImGui.GetFrameHeight()), string.Empty, false,
true))
{
if (defaultModImportPath.Length > 0)
startPath = defaultModImportPath;
var texture = this;
void UpdatePath(bool success, List<string> paths)
{
if (success && paths.Count > 0)
texture.Load(dalamud, paths[0]);
}
fileDialog.OpenFilePicker("Open Image...", "Textures{.png,.dds,.tex}", UpdatePath, 1, startPath, false);
}
ImGui.SameLine();
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Recycle.ToIconString(), new Vector2(ImGui.GetFrameHeight()),
"Reload the currently selected path.", false,
true))
Reload(dalamud);
Path = string.Empty;
Load(textures, path);
}
}

View file

@ -0,0 +1,139 @@
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using Dalamud.Interface;
using ImGuiNET;
using Lumina.Data.Files;
using OtterGui;
using OtterGui.Raii;
using OtterTex;
using Penumbra.String.Classes;
using Penumbra.UI;
using Penumbra.UI.Classes;
namespace Penumbra.Import.Textures;
public static class TextureDrawer
{
public static void Draw(Texture texture, Vector2 size)
{
if (texture.TextureWrap != null)
{
size = size.X < texture.TextureWrap.Width
? size with { Y = texture.TextureWrap.Height * size.X / texture.TextureWrap.Width }
: new Vector2(texture.TextureWrap.Width, texture.TextureWrap.Height);
ImGui.Image(texture.TextureWrap.ImGuiHandle, size);
DrawData(texture);
}
else if (texture.LoadError != null)
{
ImGui.TextUnformatted("Could not load file:");
ImGuiUtil.TextColored(Colors.RegexWarningBorder, texture.LoadError.ToString());
}
}
public static void PathSelectBox(TextureManager textures, Texture current, string label, string tooltip, IEnumerable<(string, bool)> paths,
int skipPrefix)
{
ImGui.SetNextItemWidth(-0.0001f);
var startPath = current.Path.Length > 0 ? current.Path : "Choose a modded texture from this mod here...";
using var combo = ImRaii.Combo(label, startPath);
if (combo)
foreach (var ((path, game), idx) in paths.WithIndex())
{
if (game)
{
if (!textures.GameFileExists(path))
continue;
}
else if (!File.Exists(path))
{
continue;
}
using var id = ImRaii.PushId(idx);
using (var color = ImRaii.PushColor(ImGuiCol.Text, ColorId.FolderExpanded.Value(), game))
{
var p = game ? $"--> {path}" : path[skipPrefix..];
if (ImGui.Selectable(p, path == startPath) && path != startPath)
current.Load(textures, path);
}
ImGuiUtil.HoverTooltip(game
? "This is a game path and refers to an unmanipulated file from your game data."
: "This is a path to a modded file on your file system.");
}
ImGuiUtil.HoverTooltip(tooltip);
}
public static void PathInputBox(TextureManager textures, Texture current, ref string? tmpPath, string label, string hint, string tooltip,
string startPath, FileDialogService fileDialog, string defaultModImportPath)
{
tmpPath ??= current.Path;
using var spacing = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing,
new Vector2(UiHelpers.ScaleX3, ImGui.GetStyle().ItemSpacing.Y));
ImGui.SetNextItemWidth(-2 * ImGui.GetFrameHeight() - 7 * UiHelpers.Scale);
ImGui.InputTextWithHint(label, hint, ref tmpPath, Utf8GamePath.MaxGamePathLength);
if (ImGui.IsItemDeactivatedAfterEdit())
current.Load(textures, tmpPath);
ImGuiUtil.HoverTooltip(tooltip);
ImGui.SameLine();
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Folder.ToIconString(), new Vector2(ImGui.GetFrameHeight()), string.Empty, false,
true))
{
if (defaultModImportPath.Length > 0)
startPath = defaultModImportPath;
void UpdatePath(bool success, List<string> paths)
{
if (success && paths.Count > 0)
current.Load(textures, paths[0]);
}
fileDialog.OpenFilePicker("Open Image...", "Textures{.png,.dds,.tex}", UpdatePath, 1, startPath, false);
}
ImGui.SameLine();
if (ImGuiUtil.DrawDisabledButton(FontAwesomeIcon.Recycle.ToIconString(), new Vector2(ImGui.GetFrameHeight()),
"Reload the currently selected path.", false,
true))
current.Reload(textures);
}
private static void DrawData(Texture texture)
{
using var table = ImRaii.Table("##data", 2, ImGuiTableFlags.SizingFixedFit);
ImGuiUtil.DrawTableColumn("Width");
ImGuiUtil.DrawTableColumn(texture.TextureWrap!.Width.ToString());
ImGuiUtil.DrawTableColumn("Height");
ImGuiUtil.DrawTableColumn(texture.TextureWrap!.Height.ToString());
ImGuiUtil.DrawTableColumn("File Type");
ImGuiUtil.DrawTableColumn(texture.Type.ToString());
ImGuiUtil.DrawTableColumn("Bitmap Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(texture.RgbaPixels.Length)} ({texture.RgbaPixels.Length} Bytes)");
switch (texture.BaseImage.Image)
{
case ScratchImage s:
ImGuiUtil.DrawTableColumn("Format");
ImGuiUtil.DrawTableColumn(s.Meta.Format.ToString());
ImGuiUtil.DrawTableColumn("Mip Levels");
ImGuiUtil.DrawTableColumn(s.Meta.MipLevels.ToString());
ImGuiUtil.DrawTableColumn("Data Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(s.Pixels.Length)} ({s.Pixels.Length} Bytes)");
ImGuiUtil.DrawTableColumn("Number of Images");
ImGuiUtil.DrawTableColumn(s.Images.Length.ToString());
break;
case TexFile t:
ImGuiUtil.DrawTableColumn("Format");
ImGuiUtil.DrawTableColumn(t.Header.Format.ToString());
ImGuiUtil.DrawTableColumn("Mip Levels");
ImGuiUtil.DrawTableColumn(t.Header.MipLevels.ToString());
ImGuiUtil.DrawTableColumn("Data Size");
ImGuiUtil.DrawTableColumn($"{Functions.HumanReadableSize(t.ImageData.Length)} ({t.ImageData.Length} Bytes)");
break;
}
}
}

View file

@ -1,61 +0,0 @@
using System;
using System.IO;
using Lumina.Data.Files;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Penumbra.Import.Textures;
public static class TextureImporter
{
private static void WriteHeader( byte[] target, int width, int height )
{
using var mem = new MemoryStream( target );
using var bw = new BinaryWriter( mem );
bw.Write( ( uint )TexFile.Attribute.TextureType2D );
bw.Write( ( uint )TexFile.TextureFormat.B8G8R8A8 );
bw.Write( ( ushort )width );
bw.Write( ( ushort )height );
bw.Write( ( ushort )1 );
bw.Write( ( ushort )1 );
bw.Write( 0 );
bw.Write( 1 );
bw.Write( 2 );
bw.Write( 80 );
for( var i = 1; i < 13; ++i )
{
bw.Write( 0 );
}
}
public static bool RgbaBytesToTex( byte[] rgba, int width, int height, out byte[] texData )
{
texData = Array.Empty< byte >();
if( rgba.Length != width * height * 4 )
{
return false;
}
texData = new byte[80 + width * height * 4];
WriteHeader( texData, width, height );
rgba.CopyTo( texData.AsSpan( 80 ) );
for( var i = 80; i < texData.Length; i += 4 )
(texData[ i ], texData[i + 2]) = (texData[ i + 2], texData[i]);
return true;
}
public static bool PngToTex( string inputFile, out byte[] texData )
{
using var file = File.OpenRead( inputFile );
var image = Image.Load< Bgra32 >( file );
var buffer = new byte[80 + image.Height * image.Width * 4];
WriteHeader( buffer, image.Width, image.Height );
var span = new Span< byte >( buffer, 80, buffer.Length - 80 );
image.CopyPixelDataTo( span );
texData = buffer;
return true;
}
}

View file

@ -0,0 +1,438 @@
using System;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Threading;
using Dalamud.Data;
using Dalamud.Interface;
using ImGuiScene;
using Lumina.Data.Files;
using OtterGui.Log;
using OtterGui.Tasks;
using OtterTex;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Image = SixLabors.ImageSharp.Image;
namespace Penumbra.Import.Textures;
public sealed class TextureManager : AsyncTaskManager
{
private readonly UiBuilder _uiBuilder;
private readonly DataManager _gameData;
public TextureManager(Logger logger, UiBuilder uiBuilder, DataManager gameData)
: base(logger)
{
_uiBuilder = uiBuilder;
_gameData = gameData;
}
public Guid SavePng(string input, string output)
=> Enqueue(new SavePngAction(this, input, output));
public Guid SavePng(BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0)
=> Enqueue(new SavePngAction(this, image, path, rgba, width, height));
public Guid SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input, string output)
=> Enqueue(new SaveAsAction(this, type, mipMaps, asTex, input, output));
public Guid SaveAs(CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image, string path, byte[]? rgba = null,
int width = 0, int height = 0)
=> Enqueue(new SaveAsAction(this, type, mipMaps, asTex, image, path, rgba, width, height));
private readonly struct ImageInputData
{
private readonly string? _inputPath;
private readonly BaseImage _image;
private readonly byte[]? _rgba;
private readonly int _width;
private readonly int _height;
public ImageInputData(string inputPath)
{
_inputPath = inputPath;
_image = new BaseImage();
_rgba = null;
_width = 0;
_height = 0;
}
public ImageInputData(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0)
{
_inputPath = null;
_image = image.Width == 0 || image.Height == 0 ? new BaseImage() : image;
_rgba = rgba?.ToArray();
_width = width;
_height = height;
}
public (BaseImage Image, byte[]? Rgba, int Width, int Height) GetData(TextureManager textures)
{
if (_inputPath == null)
return (_image, _rgba, _width, _height);
if (!File.Exists(_inputPath))
throw new FileNotFoundException($"Input texture file {_inputPath} not Found.", _inputPath);
var (image, _) = textures.Load(_inputPath);
return (image, null, 0, 0);
}
public bool Equals(ImageInputData rhs)
{
if (_inputPath != null)
return string.Equals(_inputPath, rhs._inputPath, StringComparison.OrdinalIgnoreCase);
if (rhs._inputPath != null)
return false;
if (_image.Image != null)
return ReferenceEquals(_image.Image, rhs._image.Image);
return _width == rhs._width && _height == rhs._height && _rgba != null && rhs._rgba != null && _rgba.SequenceEqual(rhs._rgba);
}
public override string ToString()
=> _inputPath
?? _image.Type switch
{
TextureType.Unknown => $"Custom {_width} x {_height} RGBA Image",
TextureType.Dds => $"Custom {_width} x {_height} {_image.Format} Image",
TextureType.Tex => $"Custom {_width} x {_height} {_image.Format} Image",
TextureType.Png => $"Custom {_width} x {_height} .png Image",
TextureType.Bitmap => $"Custom {_width} x {_height} RGBA Image",
_ => "Unknown Image",
};
}
private class SavePngAction : IAction
{
private readonly TextureManager _textures;
private readonly string _outputPath;
private readonly ImageInputData _input;
public SavePngAction(TextureManager textures, string input, string output)
{
_textures = textures;
_input = new ImageInputData(input);
_outputPath = output;
}
public SavePngAction(TextureManager textures, BaseImage image, string path, byte[]? rgba = null, int width = 0, int height = 0)
{
_textures = textures;
_input = new ImageInputData(image, rgba, width, height);
_outputPath = path;
}
public void Execute(CancellationToken cancel)
{
_textures.Logger.Information($"[{nameof(TextureManager)}] Saving {_input} as .png to {_outputPath}...");
var (image, rgba, width, height) = _input.GetData(_textures);
cancel.ThrowIfCancellationRequested();
Image<Rgba32>? png = null;
if (image.Type is TextureType.Unknown)
{
if (rgba != null && width > 0 && height > 0)
png = ConvertToPng(rgba, width, height).AsPng!;
}
else
{
png = ConvertToPng(image, cancel, rgba).AsPng!;
}
cancel.ThrowIfCancellationRequested();
png?.SaveAsync(_outputPath, cancel).Wait(cancel);
}
public bool Equals(IAction? other)
{
if (other is not SavePngAction rhs)
return false;
return string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase) && _input.Equals(rhs._input);
}
}
private class SaveAsAction : IAction
{
private readonly TextureManager _textures;
private readonly string _outputPath;
private readonly ImageInputData _input;
private readonly CombinedTexture.TextureSaveType _type;
private readonly bool _mipMaps;
private readonly bool _asTex;
public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, string input,
string output)
{
_textures = textures;
_input = new ImageInputData(input);
_outputPath = output;
_type = type;
_mipMaps = mipMaps;
_asTex = asTex;
}
public SaveAsAction(TextureManager textures, CombinedTexture.TextureSaveType type, bool mipMaps, bool asTex, BaseImage image,
string path,
byte[]? rgba = null, int width = 0, int height = 0)
{
_textures = textures;
_input = new ImageInputData(image, rgba, width, height);
_outputPath = path;
_type = type;
_mipMaps = mipMaps;
_asTex = asTex;
}
public void Execute(CancellationToken cancel)
{
_textures.Logger.Information(
$"[{nameof(TextureManager)}] Saving {_input} as {_type} {(_asTex ? ".tex" : ".dds")} file{(_mipMaps ? " with mip maps" : string.Empty)} to {_outputPath}...");
var (image, rgba, width, height) = _input.GetData(_textures);
if (image.Type is TextureType.Unknown)
{
if (rgba != null && width > 0 && height > 0)
image = ConvertToDds(rgba, width, height);
else
return;
}
var dds = _type switch
{
CombinedTexture.TextureSaveType.AsIs when image.Type is TextureType.Png => ConvertToRgbaDds(image, _mipMaps, cancel, rgba,
width, height),
CombinedTexture.TextureSaveType.AsIs when image.Type is TextureType.Dds => AddMipMaps(image.AsDds!, _mipMaps),
CombinedTexture.TextureSaveType.Bitmap => ConvertToRgbaDds(image, _mipMaps, cancel, rgba, width, height),
CombinedTexture.TextureSaveType.BC3 => ConvertToCompressedDds(image, _mipMaps, false, cancel, rgba, width, height),
CombinedTexture.TextureSaveType.BC7 =>
ConvertToCompressedDds(image, _mipMaps, true, cancel, rgba, width, height),
_ => throw new Exception("Wrong save type."),
};
cancel.ThrowIfCancellationRequested();
if (_asTex)
SaveTex(_outputPath, dds.AsDds!);
else
dds.AsDds!.SaveDDS(_outputPath);
}
public bool Equals(IAction? other)
{
if (other is not SaveAsAction rhs)
return false;
return _type == rhs._type
&& _mipMaps == rhs._mipMaps
&& _asTex == rhs._asTex
&& string.Equals(_outputPath, rhs._outputPath, StringComparison.OrdinalIgnoreCase)
&& _input.Equals(rhs._input);
}
}
/// <summary> Load a texture wrap for a given image. </summary>
public TextureWrap LoadTextureWrap(BaseImage image, byte[]? rgba = null, int width = 0, int height = 0)
{
(rgba, width, height) = GetData(image, rgba, width, height);
return LoadTextureWrap(rgba, width, height);
}
/// <summary> Load a texture wrap for a given image. </summary>
public TextureWrap LoadTextureWrap(byte[] rgba, int width, int height)
=> _uiBuilder.LoadImageRaw(rgba, width, height, 4);
/// <summary> Load any supported file from game data or drive depending on extension and if the path is rooted. </summary>
public (BaseImage, TextureType) Load(string path)
=> Path.GetExtension(path).ToLowerInvariant() switch
{
".dds" => (LoadDds(path), TextureType.Dds),
".png" => (LoadPng(path), TextureType.Png),
".tex" => (LoadTex(path), TextureType.Tex),
_ => throw new Exception($"Extension {Path.GetExtension(path)} unknown."),
};
/// <summary> Load a .tex file from game data or drive depending on if the path is rooted. </summary>
public BaseImage LoadTex(string path)
{
using var stream = OpenTexStream(path);
return TexFileParser.Parse(stream);
}
/// <summary> Load a .dds file from drive using OtterTex. </summary>
public BaseImage LoadDds(string path)
=> ScratchImage.LoadDDS(path);
/// <summary> Load a .png file from drive using ImageSharp. </summary>
public BaseImage LoadPng(string path)
{
using var stream = File.OpenRead(path);
return Image.Load<Rgba32>(stream);
}
/// <summary> Convert an existing image to .png. Does not create a deep copy of an existing .png and just returns the existing one. </summary>
public static BaseImage ConvertToPng(BaseImage input, CancellationToken cancel, byte[]? rgba = null, int width = 0, int height = 0)
{
switch (input.Type)
{
case TextureType.Png: return input;
case TextureType.Dds:
{
(rgba, width, height) = GetData(input, rgba, width, height);
cancel.ThrowIfCancellationRequested();
return ConvertToPng(rgba, width, height);
}
default: return new BaseImage();
}
}
/// <summary> Convert an existing image to a RGBA32 .dds. Does not create a deep copy of an existing RGBA32 dds and just returns the existing one. </summary>
public static BaseImage ConvertToRgbaDds(BaseImage input, bool mipMaps, CancellationToken cancel, byte[]? rgba = null, int width = 0,
int height = 0)
{
switch (input.Type)
{
case TextureType.Png:
{
(rgba, width, height) = GetData(input, rgba, width, height);
cancel.ThrowIfCancellationRequested();
var dds = ConvertToDds(rgba, width, height).AsDds!;
cancel.ThrowIfCancellationRequested();
return AddMipMaps(dds, mipMaps);
}
case TextureType.Dds:
{
var scratch = input.AsDds!;
if (rgba == null)
return CreateUncompressed(scratch, mipMaps, cancel);
(rgba, width, height) = GetData(input, rgba, width, height);
cancel.ThrowIfCancellationRequested();
var dds = ConvertToDds(rgba, width, height).AsDds!;
cancel.ThrowIfCancellationRequested();
return AddMipMaps(dds, mipMaps);
}
default: return new BaseImage();
}
}
/// <summary> Convert an existing image to a block compressed .dds. Does not create a deep copy of an existing dds of the correct format and just returns the existing one. </summary>
public static BaseImage ConvertToCompressedDds(BaseImage input, bool mipMaps, bool bc7, CancellationToken cancel, byte[]? rgba = null,
int width = 0, int height = 0)
{
switch (input.Type)
{
case TextureType.Png:
{
(rgba, width, height) = GetData(input, rgba, width, height);
cancel.ThrowIfCancellationRequested();
var dds = ConvertToDds(rgba, width, height).AsDds!;
cancel.ThrowIfCancellationRequested();
return CreateCompressed(dds, mipMaps, bc7, cancel);
}
case TextureType.Dds:
{
var scratch = input.AsDds!;
return CreateCompressed(scratch, mipMaps, bc7, cancel);
}
default: return new BaseImage();
}
}
public static BaseImage ConvertToPng(byte[] rgba, int width, int height)
=> Image.LoadPixelData<Rgba32>(rgba, width, height);
public static BaseImage ConvertToDds(byte[] rgba, int width, int height)
{
var scratch = ScratchImage.FromRGBA(rgba, width, height, out var i).ThrowIfError(i);
return scratch.Convert(DXGIFormat.B8G8R8A8UNorm);
}
public bool GameFileExists(string path)
=> _gameData.FileExists(path);
/// <summary> Add up to 13 mip maps to the input if mip maps is true, otherwise return input. </summary>
public static ScratchImage AddMipMaps(ScratchImage input, bool mipMaps)
{
var numMips = mipMaps ? Math.Min(13, 1 + BitOperations.Log2((uint)Math.Max(input.Meta.Width, input.Meta.Height))) : 1;
if (numMips == input.Meta.MipLevels)
return input;
var ec = input.GenerateMipMaps(out var ret, numMips,
(Dalamud.Utility.Util.IsLinux() ? FilterFlags.ForceNonWIC : 0) | FilterFlags.SeparateAlpha);
if (ec != ErrorCode.Ok)
throw new Exception($"Could not create the requested {numMips} mip maps, maybe retry with the top-right checkbox unchecked:\n{ec}");
return ret;
}
/// <summary> Create an uncompressed .dds (optionally with mip maps) from the input. Returns input (+ mipmaps) if it is already uncompressed. </summary>
public static ScratchImage CreateUncompressed(ScratchImage input, bool mipMaps, CancellationToken cancel)
{
if (input.Meta.Format == DXGIFormat.B8G8R8A8UNorm)
return AddMipMaps(input, mipMaps);
input = input.Meta.Format.IsCompressed()
? input.Decompress(DXGIFormat.B8G8R8A8UNorm)
: input.Convert(DXGIFormat.B8G8R8A8UNorm);
cancel.ThrowIfCancellationRequested();
return AddMipMaps(input, mipMaps);
}
/// <summary> Create a BC3 or BC7 block-compressed .dds from the input (optionally with mipmaps). Returns input (+ mipmaps) if it is already the correct format. </summary>
public static ScratchImage CreateCompressed(ScratchImage input, bool mipMaps, bool bc7, CancellationToken cancel)
{
var format = bc7 ? DXGIFormat.BC7UNorm : DXGIFormat.BC3UNorm;
if (input.Meta.Format == format)
return input;
if (input.Meta.Format.IsCompressed())
{
input = input.Decompress(DXGIFormat.B8G8R8A8UNorm);
cancel.ThrowIfCancellationRequested();
}
input = AddMipMaps(input, mipMaps);
cancel.ThrowIfCancellationRequested();
return input.Compress(format, CompressFlags.BC7Quick | CompressFlags.Parallel);
}
/// <summary> Load a tex file either from game data if the path is not rooted, or from drive if it is rooted.</summary>
private Stream OpenTexStream(string path)
{
if (Path.IsPathRooted(path))
return File.OpenRead(path);
var file = _gameData.GetFile(path);
return file != null ? new MemoryStream(file.Data) : throw new Exception($"Unable to obtain \"{path}\" from game files.");
}
/// <summary> Obtain the checked rgba data, width and height for an image. </summary>
private static (byte[], int, int) GetData(BaseImage input, byte[]? rgba, int width, int height)
{
if (rgba == null)
return input.GetPixelData();
if (width == 0 || height == 0)
(width, height) = input.Dimensions;
return width * height * 4 != rgba.Length
? input.GetPixelData()
: (rgba, width, height);
}
/// <summary> Save a .dds file as .tex file with appropriately changed header. </summary>
public static void SaveTex(string path, ScratchImage input)
{
var header = input.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 format.");
using var stream = File.Open(path, File.Exists(path) ? FileMode.Truncate : FileMode.CreateNew);
using var w = new BinaryWriter(stream);
header.Write(w);
w.Write(input.Pixels);
}
}