diff --git a/Readme.md b/Readme.md index c12d0f5..651ba47 100644 --- a/Readme.md +++ b/Readme.md @@ -9,6 +9,7 @@ These C# libraries parse the formats you'll find in your Half-Life install direc [![Nuget](https://img.shields.io/nuget/v/Sledge.Formats.Map?color=277ACE&label=Sledge.Formats.Map&logo=nuget)](https://www.nuget.org/packages/Sledge.Formats.Map/) [![Nuget](https://img.shields.io/nuget/v/Sledge.Formats.Packages?color=9C23D3&label=Sledge.Formats.Packages&logo=nuget)](https://www.nuget.org/packages/Sledge.Formats.Packages/) [![Nuget](https://img.shields.io/nuget/v/Sledge.Formats.Texture?color=06CCBB&label=Sledge.Formats.Texture&logo=nuget)](https://www.nuget.org/packages/Sledge.Formats.Texture/) +[![Nuget](https://img.shields.io/nuget/v/Sledge.Formats.Texture.ImageSharp?color=037F73&label=Sledge.Formats.Texture.ImageSharp&logo=nuget)](https://www.nuget.org/packages/Sledge.Formats.Texture.ImageSharp/) [![Nuget](https://img.shields.io/nuget/v/Sledge.Formats.GameData?color=FF42D9&label=Sledge.Formats.GameData&logo=nuget)](https://www.nuget.org/packages/Sledge.Formats.GameData/) @@ -51,7 +52,9 @@ These C# libraries parse the formats you'll find in your Half-Life install direc - Quake 1's gfx.wad contains a lump called "CONCHARS", which has an invalid type. There's special logic to handle this lump. - Vtf - **VtfFile** - The VTF format used by the Source engine. - - Currently supports all formats that VtfLib supports (read only, not write) + - Currently supports all formats that VtfLib supports +- Sledge.Formats.Texture.ImageSharp - ImageSharp extensions to allow creation of textures + - Currently only supports VTF formats - Sledge.Formats.GameData - Game data formats used by level editors - Fgd - The FGD format used by Worldcraft, Valve Hammer Editor, JACK, TrenchBroom, Sledge, and other editors - Source 1 & 2 FGDs will load as long as they are valid, but their use is not fully tested. diff --git a/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rg88.cs b/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rg88.cs new file mode 100644 index 0000000..46518d9 --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rg88.cs @@ -0,0 +1,91 @@ +using System.Numerics; +using SixLabors.ImageSharp.PixelFormats; + +namespace Sledge.Formats.Texture.ImageSharp.PixelFormats; + +public struct Rg88 : IPixel, IPackedVector +{ + public ushort PackedValue { get; set; } + + public Rg88(Vector3 vector) + { + PackedValue = Pack(vector); + } + + private static ushort Pack(Vector3 vector) + { + vector = Vector3.Clamp(vector, Vector3.Zero, Vector3.One); + + var r = (int)(vector.X * byte.MaxValue); + var g = (int)(vector.Y * byte.MaxValue); + + return (ushort)(g << 8 | r); + } + + public readonly PixelOperations CreatePixelOperations() + { + return new PixelOperations(); + } + + public void FromScaledVector4(Vector4 vector) + { + FromVector4(vector); + } + + public readonly Vector4 ToScaledVector4() + { + return ToVector4(); + } + + public void FromVector4(Vector4 vector) + { + PackedValue = Pack(new Vector3(vector.X, vector.Y, vector.Z)); + } + + public readonly Vector4 ToVector4() + { + var r = (PackedValue & 0xFF); + var g = (PackedValue & 0xFF00) >> 8; + return new Vector4(r / (float) byte.MaxValue, g / (float) byte.MaxValue, 1, 1); + } + + public readonly bool Equals(Rg88 other) + { + return PackedValue == other.PackedValue; + } + + public readonly override bool Equals(object? obj) + { + return obj is Rg88 other && Equals(other); + } + + public readonly override int GetHashCode() + { + return PackedValue.GetHashCode(); + } + + public static bool operator ==(Rg88 left, Rg88 right) + { + return left.Equals(right); + } + + public static bool operator !=(Rg88 left, Rg88 right) + { + return !left.Equals(right); + } + + public void FromArgb32(Argb32 source) => throw new NotImplementedException(); + public void FromBgra5551(Bgra5551 source) => throw new NotImplementedException(); + public void FromBgr24(Bgr24 source) => throw new NotImplementedException(); + public void FromBgra32(Bgra32 source) => throw new NotImplementedException(); + public void FromAbgr32(Abgr32 source) => throw new NotImplementedException(); + public void FromL8(L8 source) => throw new NotImplementedException(); + public void FromL16(L16 source) => throw new NotImplementedException(); + public void FromLa16(La16 source) => throw new NotImplementedException(); + public void FromLa32(La32 source) => throw new NotImplementedException(); + public void FromRgb24(Rgb24 source) => throw new NotImplementedException(); + public void FromRgba32(Rgba32 source) => throw new NotImplementedException(); + public void ToRgba32(ref Rgba32 dest) => throw new NotImplementedException(); + public void FromRgb48(Rgb48 source) => throw new NotImplementedException(); + public void FromRgba64(Rgba64 source) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rgb565.cs b/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rgb565.cs new file mode 100644 index 0000000..000381d --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/PixelFormats/Rgb565.cs @@ -0,0 +1,96 @@ +using System.Numerics; +using SixLabors.ImageSharp.PixelFormats; + +namespace Sledge.Formats.Texture.ImageSharp.PixelFormats; + +public struct Rgb565 : IPixel, IPackedVector +{ + public ushort PackedValue { get; set; } + + public Rgb565(Vector3 vector) + { + PackedValue = Pack(vector); + } + + private static ushort Pack(Vector3 vector) + { + vector = Vector3.Clamp(vector, Vector3.Zero, Vector3.One); + + return (ushort)( + (((int)Math.Round(vector.Z * 31F) & 0x1F) << 11) + | (((int)Math.Round(vector.Y * 63F) & 0x3F) << 5) + | ((int)Math.Round(vector.X * 31F) & 0x1F) + ); + } + + public readonly Vector3 ToVector3() => new( + (PackedValue & 0x1F) * (1F / 31F), + ((PackedValue >> 5) & 0x3F) * (1F / 63F), + ((PackedValue >> 11) & 0x1F) * (1F / 31F) + ); + + public readonly PixelOperations CreatePixelOperations() + { + return new PixelOperations(); + } + + public void FromScaledVector4(Vector4 vector) + { + FromVector4(vector); + } + + public readonly Vector4 ToScaledVector4() + { + return ToVector4(); + } + + public void FromVector4(Vector4 vector) + { + PackedValue = Pack(new Vector3(vector.X, vector.Y, vector.Z)); + } + + public readonly Vector4 ToVector4() + { + return new Vector4(ToVector3(), 1); + } + + public readonly bool Equals(Rgb565 other) + { + return PackedValue == other.PackedValue; + } + + public readonly override bool Equals(object? obj) + { + return obj is Rgb565 other && Equals(other); + } + + public readonly override int GetHashCode() + { + return PackedValue.GetHashCode(); + } + + public static bool operator ==(Rgb565 left, Rgb565 right) + { + return left.Equals(right); + } + + public static bool operator !=(Rgb565 left, Rgb565 right) + { + return !left.Equals(right); + } + + public void FromArgb32(Argb32 source) => throw new NotImplementedException(); + public void FromBgra5551(Bgra5551 source) => throw new NotImplementedException(); + public void FromBgr24(Bgr24 source) => throw new NotImplementedException(); + public void FromBgra32(Bgra32 source) => throw new NotImplementedException(); + public void FromAbgr32(Abgr32 source) => throw new NotImplementedException(); + public void FromL8(L8 source) => throw new NotImplementedException(); + public void FromL16(L16 source) => throw new NotImplementedException(); + public void FromLa16(La16 source) => throw new NotImplementedException(); + public void FromLa32(La32 source) => throw new NotImplementedException(); + public void FromRgb24(Rgb24 source) => throw new NotImplementedException(); + public void FromRgba32(Rgba32 source) => throw new NotImplementedException(); + public void ToRgba32(ref Rgba32 dest) => throw new NotImplementedException(); + public void FromRgb48(Rgb48 source) => throw new NotImplementedException(); + public void FromRgba64(Rgba64 source) => throw new NotImplementedException(); +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.ImageSharp/Sledge.Formats.Texture.ImageSharp.csproj b/Sledge.Formats.Texture.ImageSharp/Sledge.Formats.Texture.ImageSharp.csproj new file mode 100644 index 0000000..9f7c7c7 --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/Sledge.Formats.Texture.ImageSharp.csproj @@ -0,0 +1,36 @@ + + + + net6.0 + enable + enable + LogicAndTrick + ImageSharp extensions for texture formats used by Quake, Half-Life, and the Source engine. Currently supported format is Source's VTF. + 2024 LogicAndTrick + MIT + https://github.com/LogicAndTrick/sledge-formats + sledge-logo.png + https://github.com/LogicAndTrick/sledge-formats + Git + half-life quake source valve wad vtf + Initial release + 1.0.0 + + + + + + + + + + + + + + + True + + + + diff --git a/Sledge.Formats.Texture.ImageSharp/VtfExtensions.cs b/Sledge.Formats.Texture.ImageSharp/VtfExtensions.cs new file mode 100644 index 0000000..40550b8 --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/VtfExtensions.cs @@ -0,0 +1,45 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Sledge.Formats.Texture.Vtf; + +namespace Sledge.Formats.Texture.ImageSharp; + +public static class VtfExtensions +{ + /// + /// Convert a vtf image to an ImageSharp image. + /// + public static Image ToImage(this VtfImage vtfImage) where T : unmanaged, IPixel + { + using var conv = Image.LoadPixelData(vtfImage.GetBgra32Data(), vtfImage.Width, vtfImage.Height); + return conv.CloneAs(); + } + + /// + /// Convert a vtf file to an ImageSharp image. The largest image will be used, for the first face, and the first frame. + /// + public static Image ToImage(this VtfFile vtfFile) where T : unmanaged, IPixel + { + return ToImage(vtfFile.Images.OrderByDescending(x => x.Width).ThenBy(x => x.Frame).ThenBy(x => x.Face).First()); + } + + /// + /// Convert an ImageSharp image to a vtf image. + /// + public static VtfImage ToVtfImage(this Image image, VtfImageBuilderOptions? options = null) + { + var builder = new VtfImageBuilder(options ?? new VtfImageBuilderOptions()); + return builder.CreateImage(image); + } + + /// + /// Convert an ImageSharp image to a vtf file. + /// + public static VtfFile ToVtfFile(this Image image, VtfImageBuilderOptions? options = null) + { + var vtf = new VtfFile(); + var builder = new VtfImageBuilder(options ?? new VtfImageBuilderOptions()); + foreach (var img in builder.CreateImages(image)) vtf.AddImage(img); + return vtf; + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.ImageSharp/VtfImageBuilder.cs b/Sledge.Formats.Texture.ImageSharp/VtfImageBuilder.cs new file mode 100644 index 0000000..ac4b98d --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/VtfImageBuilder.cs @@ -0,0 +1,217 @@ +using BCnEncoder.Encoder; +using BCnEncoder.ImageSharp; +using BCnEncoder.Shared; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Sledge.Formats.Texture.ImageSharp.PixelFormats; +using Sledge.Formats.Texture.Vtf; + +namespace Sledge.Formats.Texture.ImageSharp; + +public class VtfImageBuilder +{ + public VtfImageBuilderOptions Options { get; set; } + + public VtfImageBuilder() : this(new VtfImageBuilderOptions()) + { + // + } + + public VtfImageBuilder(VtfImageBuilderOptions options) + { + Options = options; + } + + private static int RoundUpToPowerOfTwo(int value) + { + var po2 = 1; + while (po2 < value) po2 *= 2; + return po2; + } + + /// + /// Create multiple vtf images using the width and height of the source image. + /// Frames and mipmaps will be created according to the image builder options. + /// + public IEnumerable CreateImages(Image image) + { + var powWidth = RoundUpToPowerOfTwo(image.Width); + var powHeight = RoundUpToPowerOfTwo(image.Height); + + if (!Options.AutoResizeToPowerOfTwo && (image.Width != powWidth || image.Height != powHeight)) + { + throw new InvalidOperationException("Image size is not a power of two and AutoResizeToPowerOfTwo is false, cannot continue."); + } + + using (image) + { + for (var frameNum = 0; frameNum < image.Frames.Count; frameNum++) + { + var frame = image.Frames[frameNum]; + + // always create the first mip - it's the full-size one + var mipWidth = powWidth; + var mipHeight = powHeight; + var mipIndex = 0; + + while (mipHeight >= Options.MinimumMipmapDimension && mipWidth >= Options.MinimumMipmapDimension) + { + var img = CreateImage(frame, mipWidth, mipHeight); + img.Mipmap = mipIndex; + img.Frame = frameNum; + yield return img; + + mipWidth /= 2; + mipHeight /= 2; + mipIndex++; + + if (!Options.AutoCreateMipmaps) break; + if (Options.NumMipmapLevels > 0 && mipIndex >= Options.NumMipmapLevels) break; + } + } + } + } + + /// + /// Create a single vtf image using the width and height of the source image. + /// + public VtfImage CreateImage(Image image) => CreateImage(image, image.Width, image.Height); + + /// + /// Create a single vtf image with the given width and height. + /// + public VtfImage CreateImage(Image image, int width, int height) + { + var powWidth = RoundUpToPowerOfTwo(width); + var powHeight = RoundUpToPowerOfTwo(height); + + if (!Options.AutoResizeToPowerOfTwo && (width != powWidth || height != powHeight)) + { + throw new InvalidOperationException("Image size is not a power of two and AutoResizeToPowerOfTwo is false, cannot continue."); + } + + return CreateImage(image.Frames[0], powWidth, powHeight); + } + + private VtfImage CreateImage(ImageFrame frame, int width, int height) + { + if ((width & (width - 1)) != 0) throw new ArgumentException("Width must be a power of two.", nameof(width)); + if ((height & (height - 1)) != 0) throw new ArgumentException("Height must be a power of two.", nameof(height)); + + using var image = new Image(frame.Width, frame.Height, new Rgba32(1, 1, 1, 0)); + image.Frames.AddFrame(frame); + image.Frames.RemoveFrame(0); + + using var resized = image.Clone(context => context.Resize(width, height, Options.Resampler)); + var data = Options.ImageFormat switch + { + VtfImageFormat.None => throw new ArgumentException("Can't create an image with a format of None."), + VtfImageFormat.Rgba8888 => GetImageData(resized), + VtfImageFormat.Abgr8888 => GetImageData(resized), + VtfImageFormat.Rgb888 => GetImageData(resized), + VtfImageFormat.Bgr888 => GetImageData(resized), + VtfImageFormat.Rgb565 => GetImageData(resized), + VtfImageFormat.I8 => GetImageData(resized), + VtfImageFormat.Ia88 => GetImageData(resized), + VtfImageFormat.P8 => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.A8 => GetImageData(resized), + VtfImageFormat.Rgb888Bluescreen => GetImageData(resized, Bluescreen), + VtfImageFormat.Bgr888Bluescreen => GetImageData(resized, Bluescreen), + VtfImageFormat.Argb8888 => GetImageData(resized), + VtfImageFormat.Bgra8888 => GetImageData(resized), + VtfImageFormat.Dxt1 => EncodeDxt(resized, CompressionFormat.Bc1), + VtfImageFormat.Dxt3 => EncodeDxt(resized, CompressionFormat.Bc2), + VtfImageFormat.Dxt5 => EncodeDxt(resized, CompressionFormat.Bc3), + VtfImageFormat.Bgrx8888 => GetImageData(resized, DiscardAlphaChannel), + VtfImageFormat.Bgr565 => GetImageData(resized), + VtfImageFormat.Bgrx5551 => GetImageData(resized, DiscardAlphaChannel), + VtfImageFormat.Bgra4444 => GetImageData(resized), + VtfImageFormat.Dxt1Onebitalpha => EncodeDxt(resized, CompressionFormat.Bc1WithAlpha), + VtfImageFormat.Bgra5551 => GetImageData(resized), + VtfImageFormat.Uv88 => GetImageData(resized), + VtfImageFormat.Uvwq8888 => GetImageData(resized), + VtfImageFormat.Rgba16161616F => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.Rgba16161616 => GetImageData(resized), + VtfImageFormat.Uvlx8888 => GetImageData(resized), + VtfImageFormat.R32F => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.Rgb323232F => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.Rgba32323232F => GetImageData(resized), + VtfImageFormat.NvDst16 => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.NvDst24 => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.NvIntz => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.NvRawz => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.AtiDst16 => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.AtiDst24 => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.NvNull => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.Ati2N => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + VtfImageFormat.Ati1N => throw new NotSupportedException($"Format not supported: {Options.ImageFormat}"), + _ => throw new IndexOutOfRangeException() + }; + + return new VtfImage + { + Format = Options.ImageFormat, + Width = width, + Height = height, + Data = data + }; + } + + private static void Bluescreen(IImageProcessingContext context) + { + context.BackgroundColor(Color.Blue); + } + + private static void DiscardAlphaChannel(IImageProcessingContext context) + { + context.ProcessPixelRowsAsVector4(row => + { + for (var i = 0; i < row.Length; i++) + { + row[i].W = 1; + } + }); + } + + private static byte[] GetImageData(Image image, Action? transform = null) where T : unmanaged, IPixel + { + // depending on the type of T we might need to clone the image to convert it to a different format + // if we do that we'll need to dispose of it later, so track if we need to + var dispose = false; + + Image sourceImage; + if (typeof(T) == typeof(Rgba32)) + { + // we know that image uses the same pixel type as T so we can do a cast here + sourceImage = (Image) image; + } + else + { + sourceImage = image.CloneAs(); + dispose = true; + } + + // transform the image if required + if (transform != null) + { + var transformedImage = sourceImage.Clone(transform); + if (dispose) sourceImage.Dispose(); // source image is a clone, dispose of it + // our transformation is now the source image, we should dispose of it at the end + sourceImage = transformedImage; + dispose = true; + } + + var buffer = new byte[sourceImage.Width * sourceImage.Height * sourceImage.PixelType.BitsPerPixel / 8]; + sourceImage.CopyPixelDataTo(buffer); + + if (dispose) sourceImage.Dispose(); + return buffer; + } + + private static byte[] EncodeDxt(Image image, CompressionFormat format) + { + var encoder = new BcEncoder(format); + return encoder.EncodeToRawBytes(image, 0, out _, out _); + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.ImageSharp/VtfImageBuilderOptions.cs b/Sledge.Formats.Texture.ImageSharp/VtfImageBuilderOptions.cs new file mode 100644 index 0000000..c65c585 --- /dev/null +++ b/Sledge.Formats.Texture.ImageSharp/VtfImageBuilderOptions.cs @@ -0,0 +1,51 @@ +using SixLabors.ImageSharp.Processing.Processors.Transforms; +using Sledge.Formats.Texture.Vtf; + +namespace Sledge.Formats.Texture.ImageSharp; + +public class VtfImageBuilderOptions +{ + /// + /// The image format to use when creating vtf images. + /// The default value is Rgba8888. + /// + public VtfImageFormat ImageFormat { get; set; } = VtfImageFormat.Rgba8888; + + /// + /// True to create mipmaps when creating the vtf images. + /// The default value is true. + /// + public bool AutoCreateMipmaps { get; set; } = true; + + /// + /// The maximum number of mipmap levels to create. Set to -1 to have no limit. + /// The default value is -1. + /// AutoCreateMipmaps must be true to create mipmaps. + /// + public int NumMipmapLevels { get; set; } = -1; + + /// + /// The minimum dimension of either width or height before stopping mipmap creation. + /// The default value is 1. + /// AutoCreateMipmaps must be true to create mipmaps. + /// + public int MinimumMipmapDimension { get; set; } = 1; + + /// + /// If the image has multiple frames, they will be added to the vtf image. + /// The default value is true. + /// + public bool CreateMultipleFramesIfPresent { get; set; } = true; + + /// + /// True to automatically resize the image to the closest power of two, if not already. + /// The default value is false. + /// + public bool AutoResizeToPowerOfTwo { get; set; } = false; + + /// + /// Resampler to use when resizing the image, either to power of two dimensions, or for mipmaps. + /// The default value is . + /// + public IResampler Resampler { get; set; } = new BicubicResampler(); +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.Tests/Sledge.Formats.Texture.Tests.csproj b/Sledge.Formats.Texture.Tests/Sledge.Formats.Texture.Tests.csproj index c139b44..d3e3a87 100644 --- a/Sledge.Formats.Texture.Tests/Sledge.Formats.Texture.Tests.csproj +++ b/Sledge.Formats.Texture.Tests/Sledge.Formats.Texture.Tests.csproj @@ -10,9 +10,11 @@ + + diff --git a/Sledge.Formats.Texture.Tests/Vtf/TestImageSharp.cs b/Sledge.Formats.Texture.Tests/Vtf/TestImageSharp.cs new file mode 100644 index 0000000..fa2720b --- /dev/null +++ b/Sledge.Formats.Texture.Tests/Vtf/TestImageSharp.cs @@ -0,0 +1,178 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using Sledge.Formats.Texture.ImageSharp; +using Sledge.Formats.Texture.Vtf; +using Image = SixLabors.ImageSharp.Image; + +namespace Sledge.Formats.Texture.Tests.Vtf; + +[TestClass] +public class TestImageSharp +{ + [DataTestMethod] + [DataRow(VtfImageFormat.Rgba8888)] + [DataRow(VtfImageFormat.Abgr8888)] + [DataRow(VtfImageFormat.Rgb888)] + [DataRow(VtfImageFormat.Bgr888)] + [DataRow(VtfImageFormat.Rgb888Bluescreen)] + [DataRow(VtfImageFormat.Bgr888Bluescreen)] + [DataRow(VtfImageFormat.Argb8888)] + [DataRow(VtfImageFormat.Bgra8888)] + [DataRow(VtfImageFormat.Bgrx8888)] + [DataRow(VtfImageFormat.Rgba16161616)] + [DataRow(VtfImageFormat.Uvwq8888)] + public void TestSimpleImageLosslessFormats(VtfImageFormat imageFormat) + { + var white = Rgba32.ParseHex("FAFBFCFF"); // almost white + var blue = Rgba32.ParseHex("0102FEFF"); // almost blue + + using var source = new Image(1024, 1024, white); + source.Mutate(x => + { + x.Fill(Color.ParseHex(blue.ToHex()), new RectangleF(300, 0, 100, 300)); + }); + + var builder = new VtfImageBuilder(new VtfImageBuilderOptions + { + AutoCreateMipmaps = false, + AutoResizeToPowerOfTwo = true, + ImageFormat = imageFormat, + }); + + var sourceVtf = new VtfFile(); + foreach (var img in builder.CreateImages(source)) + { + sourceVtf.AddImage(img); + } + + var beforeSaveVtfImage = sourceVtf.Images.MaxBy(x => x.Height); + var beforeSaveImage = Image.LoadPixelData(beforeSaveVtfImage.GetBgra32Data(), beforeSaveVtfImage.Width, beforeSaveVtfImage.Height); + CheckImage(beforeSaveImage); + + using var ms = new MemoryStream(); + sourceVtf.Write(ms); + ms.Seek(0, SeekOrigin.Begin); + + var destVtf = new VtfFile(ms); + var afterSaveVtfImage = destVtf.Images.MaxBy(x => x.Height); + var afterSaveImage = Image.LoadPixelData(afterSaveVtfImage.GetBgra32Data(), afterSaveVtfImage.Width, afterSaveVtfImage.Height); + CheckImage(afterSaveImage); + + return; + + void CheckImage(Image img) where T : unmanaged, IPixel + { + img.ProcessPixelRows(accessor => + { + for (var y = 0; y < accessor.Height; y++) + { + var pixelRow = accessor.GetRowSpan(y); + for (var x = 0; x < pixelRow.Length; x++) + { + ref var pixel = ref pixelRow[x]; + Rgba32 convertedPixel = new(); + pixel.ToRgba32(ref convertedPixel); + if (y is >= 0 and < 300 && x is >= 300 and < 400) + { + Assert.AreEqual(blue, convertedPixel, $"\nExpected {blue.ToHex()} for pixel at [{x},{y}], but got {convertedPixel.ToHex()} instead."); + } + else + { + Assert.AreEqual(white, convertedPixel, $"\nExpected {white.ToHex()} for pixel at [{x},{y}], but got {convertedPixel.ToHex()} instead."); + } + } + } + }); + } + } + + [DataTestMethod] + [DataRow(VtfImageFormat.Rgb565)] + [DataRow(VtfImageFormat.Bgr565)] + [DataRow(VtfImageFormat.Bgrx5551)] + [DataRow(VtfImageFormat.Bgra4444)] + [DataRow(VtfImageFormat.Dxt1)] + [DataRow(VtfImageFormat.Dxt1Onebitalpha)] + [DataRow(VtfImageFormat.Dxt3)] + [DataRow(VtfImageFormat.Dxt5)] + [DataRow(VtfImageFormat.Bgra5551)] + public void TestSimpleImageLossyFormats(VtfImageFormat imageFormat) + { + var white = Rgba32.ParseHex("224466FF"); // almost white + var blue = Rgba32.ParseHex("EECCAAFF"); // almost blue + + using var source = new Image(1024, 1024, white); + source.Mutate(x => + { + x.Fill(Color.ParseHex(blue.ToHex()), new RectangleF(300, 0, 100, 300)); + }); + + var builder = new VtfImageBuilder(new VtfImageBuilderOptions + { + AutoCreateMipmaps = false, + AutoResizeToPowerOfTwo = true, + ImageFormat = imageFormat, + }); + + var sourceVtf = new VtfFile(); + foreach (var img in builder.CreateImages(source)) + { + sourceVtf.AddImage(img); + } + + var beforeSaveVtfImage = sourceVtf.Images.MaxBy(x => x.Height); + var beforeSaveImage = Image.LoadPixelData(beforeSaveVtfImage.GetBgra32Data(), beforeSaveVtfImage.Width, beforeSaveVtfImage.Height); + CheckImage(beforeSaveImage); + + using var ms = new MemoryStream(); + sourceVtf.Write(ms); + ms.Seek(0, SeekOrigin.Begin); + + var destVtf = new VtfFile(ms); + var afterSaveVtfImage = destVtf.Images.MaxBy(x => x.Height); + var afterSaveImage = Image.LoadPixelData(afterSaveVtfImage.GetBgra32Data(), afterSaveVtfImage.Width, afterSaveVtfImage.Height); + CheckImage(afterSaveImage); + + return; + + void CheckImage(Image img) where T : unmanaged, IPixel + { + img.ProcessPixelRows(accessor => + { + for (var y = 0; y < accessor.Height; y++) + { + var pixelRow = accessor.GetRowSpan(y); + for (var x = 0; x < pixelRow.Length; x++) + { + ref var pixel = ref pixelRow[x]; + Rgba32 convertedPixel = new(); + pixel.ToRgba32(ref convertedPixel); + if (y is >= 0 and < 300 && x is >= 300 and < 400) + { + IsApproximatelyEqual(blue, convertedPixel, $"\nExpected approximately {blue.ToHex()} for pixel at [{x},{y}], but got {convertedPixel.ToHex()} instead."); + } + else + { + IsApproximatelyEqual(white, convertedPixel, $"\nExpected approximately {white.ToHex()} for pixel at [{x},{y}], but got {convertedPixel.ToHex()} instead."); + } + } + } + }); + } + + void IsApproximatelyEqual(Rgba32 expected, Rgba32 actual, string message) + { + const int variation = 8; + Assert.IsTrue(actual.A >= expected.A - variation && actual.A <= expected.A + variation, message); + Assert.IsTrue(actual.R >= expected.R - variation && actual.R <= expected.R + variation, message); + Assert.IsTrue(actual.G >= expected.G - variation && actual.G <= expected.G + variation, message); + Assert.IsTrue(actual.B >= expected.B - variation && actual.B <= expected.B + variation, message); + } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.Tests/Vtf/TestPixelFormats.cs b/Sledge.Formats.Texture.Tests/Vtf/TestPixelFormats.cs new file mode 100644 index 0000000..b9b4c62 --- /dev/null +++ b/Sledge.Formats.Texture.Tests/Vtf/TestPixelFormats.cs @@ -0,0 +1,54 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Sledge.Formats.Texture.ImageSharp.PixelFormats; + +namespace Sledge.Formats.Texture.Tests.Vtf; + +[TestClass] +public class TestPixelFormats +{ + // sanity check for imagesharp native impl + [TestMethod] + public void TestBgr565ImageSharpNative() + { + var pixel8888 = Rgba32.ParseHex("123456"); + var expectedData = new byte[] { 0xAA, 0x11 }; + + using var src = new Image(1, 1, pixel8888); + using var con = src.CloneAs(); + + var spn = new byte[2]; + con.CopyPixelDataTo(spn); + CollectionAssert.AreEqual(expectedData, spn); + } + + [TestMethod] + public void TestRgb565CustomFormat() + { + var pixel8888 = Rgba32.ParseHex("123456"); + var expectedData = new byte[] { 0xA2, 0x51 }; + + using var src = new Image(1, 1, pixel8888); + using var con = src.CloneAs(); + + var spn = new byte[2]; + con.CopyPixelDataTo(spn); + CollectionAssert.AreEqual(expectedData, spn); + } + + [TestMethod] + public void TestRg88CustomFormat() + { + var pixel8888 = Rgba32.ParseHex("123456"); + var expectedData = new byte[] { 0x12, 0x34 }; + + using var src = new Image(1, 1, pixel8888); + src.SaveAsPng(@"D:\Downloads\test.png"); + using var con = src.CloneAs(); + + var spn = new byte[2]; + con.CopyPixelDataTo(spn); + CollectionAssert.AreEqual(expectedData, spn); + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture.Tests/Vtf/TestVtf.cs b/Sledge.Formats.Texture.Tests/Vtf/TestVtf.cs index 83d7cc1..5bdfe24 100644 --- a/Sledge.Formats.Texture.Tests/Vtf/TestVtf.cs +++ b/Sledge.Formats.Texture.Tests/Vtf/TestVtf.cs @@ -1,7 +1,7 @@ using System.IO; -using System.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; using Sledge.Formats.Texture.Vtf; +using Sledge.Formats.Texture.Vtf.Resources; namespace Sledge.Formats.Texture.Tests.Vtf { @@ -9,12 +9,78 @@ namespace Sledge.Formats.Texture.Tests.Vtf public class TestVtf { [TestMethod] - public void TestLoadVtf() + public void TestRoundTrip72() { - using (var f = File.OpenRead(@"D:\sandbox\16F.vtf")) + var create = new VtfFile(); + create.AddImage(new VtfImage { - var vtf = new VtfFile(f); - } + Format = VtfImageFormat.Rgba8888, + Width = 1, + Height = 1, + Mipmap = 0, + Frame = 0, + Face = 0, + Slice = 0, + Data = new byte[] + { + 0x11, 0x22, 0x33, 0xFF + } + }); + + using var ms = new MemoryStream(); + create.Write(ms); + ms.Seek(0, SeekOrigin.Begin); + + var read = new VtfFile(ms); + Assert.AreEqual(create.Images.Count, read.Images.Count); + Assert.AreEqual(create.Images[0].Width, read.Images[0].Width); + Assert.AreEqual(create.Images[0].Height, read.Images[0].Height); + Assert.AreEqual(create.Images[0].Mipmap, read.Images[0].Mipmap); + Assert.AreEqual(create.Images[0].Frame, read.Images[0].Frame); + Assert.AreEqual(create.Images[0].Face, read.Images[0].Face); + Assert.AreEqual(create.Images[0].Slice, read.Images[0].Slice); + Assert.AreEqual(create.Images[0].Data.Length, read.Images[0].Data.Length); + CollectionAssert.AreEqual(create.Images[0].Data, read.Images[0].Data); + } + [TestMethod] + public void TestRoundTrip73() + { + var create = new VtfFile(7.3m); + create.AddImage(new VtfImage + { + Format = VtfImageFormat.Rgba8888, + Width = 1, + Height = 1, + Mipmap = 0, + Frame = 0, + Face = 0, + Slice = 0, + Data = new byte[] + { + 0x11, 0x22, 0x33, 0xFF + } + }); + create.AddResource(new VtfValueResource { Type = VtfResourceType.Crc, Value = 0x12345 }); + + using var ms = new MemoryStream(); + create.Write(ms); + ms.Seek(0, SeekOrigin.Begin); + + var read = new VtfFile(ms); + Assert.AreEqual(create.Images.Count, read.Images.Count); + Assert.AreEqual(create.Images[0].Width, read.Images[0].Width); + Assert.AreEqual(create.Images[0].Height, read.Images[0].Height); + Assert.AreEqual(create.Images[0].Mipmap, read.Images[0].Mipmap); + Assert.AreEqual(create.Images[0].Frame, read.Images[0].Frame); + Assert.AreEqual(create.Images[0].Face, read.Images[0].Face); + Assert.AreEqual(create.Images[0].Slice, read.Images[0].Slice); + Assert.AreEqual(create.Images[0].Data.Length, read.Images[0].Data.Length); + CollectionAssert.AreEqual(create.Images[0].Data, read.Images[0].Data); + + Assert.AreEqual(create.Resources.Count, read.Resources.Count); + Assert.AreEqual(create.Resources[0].Type, read.Resources[0].Type); + Assert.IsInstanceOfType(read.Resources[0], typeof(VtfValueResource)); + Assert.AreEqual(((VtfValueResource)create.Resources[0]).Value, ((VtfValueResource)read.Resources[0]).Value); } } } \ No newline at end of file diff --git a/Sledge.Formats.Texture/Sledge.Formats.Texture.csproj b/Sledge.Formats.Texture/Sledge.Formats.Texture.csproj index e5a11ec..0e037de 100644 --- a/Sledge.Formats.Texture/Sledge.Formats.Texture.csproj +++ b/Sledge.Formats.Texture/Sledge.Formats.Texture.csproj @@ -11,8 +11,8 @@ https://github.com/LogicAndTrick/sledge-formats Git half-life quake source valve wad vtf - Fix bug/incompatibility with Wally when saving a wad file - 1.0.3 + Support for writing VTF files + 1.1.0 diff --git a/Sledge.Formats.Texture/Vtf/Resources/VtfKeyValueResource.cs b/Sledge.Formats.Texture/Vtf/Resources/VtfKeyValueResource.cs new file mode 100644 index 0000000..8914223 --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/Resources/VtfKeyValueResource.cs @@ -0,0 +1,9 @@ +using Sledge.Formats.Valve; + +namespace Sledge.Formats.Texture.Vtf.Resources +{ + public class VtfKeyValueResource : VtfResource + { + public SerialisedObject KeyValues { get; set; } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/Resources/VtfResource.cs b/Sledge.Formats.Texture/Vtf/Resources/VtfResource.cs new file mode 100644 index 0000000..316fabc --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/Resources/VtfResource.cs @@ -0,0 +1,7 @@ +namespace Sledge.Formats.Texture.Vtf.Resources +{ + public abstract class VtfResource + { + public VtfResourceType Type { get; set; } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/Resources/VtfResourceType.cs b/Sledge.Formats.Texture/Vtf/Resources/VtfResourceType.cs new file mode 100644 index 0000000..a10e31f --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/Resources/VtfResourceType.cs @@ -0,0 +1,13 @@ +namespace Sledge.Formats.Texture.Vtf.Resources +{ + public enum VtfResourceType : uint + { + LowResImage = 0x01, + Image = 0x30, + Sheet = 0x10, + Crc = 'C' | 'R' << 8 | 'C' << 16 | 0x02 << 24, + TextureLodSettings = 'L' | 'O' << 8 | 'D' << 16 | 0x02 << 24, + TextureSettingsEx = 'T' | 'S' << 8 | 'O' << 16 | 0x02 << 24, + KeyValueData = 'K' | 'V' << 8 | 'D' << 16, + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/Resources/VtfUnknownResource.cs b/Sledge.Formats.Texture/Vtf/Resources/VtfUnknownResource.cs new file mode 100644 index 0000000..b356065 --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/Resources/VtfUnknownResource.cs @@ -0,0 +1,7 @@ +namespace Sledge.Formats.Texture.Vtf.Resources +{ + public class VtfUnknownResource : VtfResource + { + public byte[] Data { get; set; } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/Resources/VtfValueResource.cs b/Sledge.Formats.Texture/Vtf/Resources/VtfValueResource.cs new file mode 100644 index 0000000..52b15ce --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/Resources/VtfValueResource.cs @@ -0,0 +1,41 @@ +using System; + +namespace Sledge.Formats.Texture.Vtf.Resources +{ + public class VtfValueResource : VtfResource + { + public uint Value { get; set; } + + public byte TextureLodSettingsResolutionClampU + { + get + { + if (Type != VtfResourceType.TextureLodSettings) + throw new InvalidOperationException($"Cannot set TextureLodSettings if the resource type is not {VtfResourceType.TextureLodSettings}."); + throw new NotImplementedException(); + } + set + { + if (Type != VtfResourceType.TextureLodSettings) + throw new InvalidOperationException($"Cannot set TextureLodSettings if the resource type is not {VtfResourceType.TextureLodSettings}."); + throw new NotImplementedException(); + } + } + + public byte TextureLodSettingsResolutionClampV + { + get + { + if (Type != VtfResourceType.TextureLodSettings) + throw new InvalidOperationException($"Cannot set TextureLodSettings if the resource type is not {VtfResourceType.TextureLodSettings}."); + throw new NotImplementedException(); + } + set + { + if (Type != VtfResourceType.TextureLodSettings) + throw new InvalidOperationException($"Cannot set TextureLodSettings if the resource type is not {VtfResourceType.TextureLodSettings}."); + throw new NotImplementedException(); + } + } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/VtfFile.cs b/Sledge.Formats.Texture/Vtf/VtfFile.cs index b831732..72e1b56 100644 --- a/Sledge.Formats.Texture/Vtf/VtfFile.cs +++ b/Sledge.Formats.Texture/Vtf/VtfFile.cs @@ -1,24 +1,55 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Numerics; using System.Text; +using Sledge.Formats.Texture.Vtf.Resources; +using Sledge.Formats.Valve; namespace Sledge.Formats.Texture.Vtf { public class VtfFile { private const string VtfHeader = "VTF"; + private const int MaxNumberOfResources = 32; public VtfHeader Header { get; set; } - public List Resources { get; set; } + + private readonly List _resources; + public IReadOnlyList Resources => _resources; + public VtfImage LowResImage { get; set; } - public List Images { get; set; } + private readonly List _images; + public IReadOnlyList Images => _images; + + /// + /// Create a blank VTF file + /// + /// The version to use. The version will be upgraded if you add features that require newer versions. The default version is 7.2, which is the lowest public version, and compatible with all Source engine versions. + public VtfFile(decimal version = 7.2m) + { + Header = new VtfHeader + { + Version = version, + BumpmapScale = 1, + Flags = VtfImageFlag.None, + Reflectivity = Vector3.Zero + }; + _resources = new List(); + _images = new List(); + LowResImage = null; + } + + /// + /// Load a vtf file from a stream. + /// public VtfFile(Stream stream) { Header = new VtfHeader(); - Resources = new List(); - Images = new List(); + _resources = new List(); + _images = new List(); using (var br = new BinaryReader(stream)) { @@ -28,6 +59,9 @@ public VtfFile(Stream stream) var v1 = br.ReadUInt32(); var v2 = br.ReadUInt32(); var version = v1 + (v2 / 10m); // e.g. 7.3 + + if (version < 7.0m || version > 7.5m) throw new NotSupportedException($"Unsupported VTF version. Expected 7.0-7.5, got {version}."); + Header.Version = version; var headerSize = br.ReadUInt32(); @@ -64,11 +98,13 @@ public VtfFile(Stream stream) { br.ReadBytes(3); numResources = br.ReadUInt32(); - br.ReadBytes(8); } + // align to multiple of 16 + if (br.BaseStream.Position % 16 != 0) br.BaseStream.Seek(16 - (br.BaseStream.Position % 16), SeekOrigin.Current); + var faces = 1; - if (Header.Flags.HasFlag(VtfImageFlag.Envmap)) + if (Header.Flags.HasFlag(VtfImageFlag.EnvMap)) { faces = version < 7.5m && firstFrame != 0xFFFF ? 7 : 6; } @@ -97,16 +133,39 @@ public VtfFile(Stream stream) // Regular image dataPos = data; break; - case VtfResourceType.Sheet: case VtfResourceType.Crc: case VtfResourceType.TextureLodSettings: case VtfResourceType.TextureSettingsEx: + _resources.Add(new VtfValueResource + { + Type = type, + Value = data + }); + break; case VtfResourceType.KeyValueData: + br.BaseStream.Position = data; + var kvLength = br.ReadInt32(); + var kvString = br.ReadFixedLengthString(Encoding.ASCII, kvLength); + + List kvObjects; + using (var reader = new StringReader(kvString)) + { + kvObjects = SerialisedObjectFormatter.Parse(reader).ToList(); + } + if (kvObjects.Count != 1) throw new NotSupportedException("More than one object found in the keyvalue resource. This is not supported."); + + _resources.Add(new VtfKeyValueResource + { + Type = type, + KeyValues = kvObjects.First() + }); + break; + case VtfResourceType.Sheet: // todo - Resources.Add(new VtfResource + _resources.Add(new VtfValueResource() { Type = type, - Data = data + Value = data // actually a data pointer }); break; default: @@ -140,7 +199,7 @@ public VtfFile(Stream stream) var hei = GetMipSize(height, mip); var size = highResFormatInfo.GetSize(wid, hei); - Images.Add(new VtfImage + _images.Add(new VtfImage { Format = highResImageFormat, Width = wid, @@ -158,11 +217,259 @@ public VtfFile(Stream stream) } } + /// + /// Perform some basic validation checks on the file and return any issues that exist. + /// + public IEnumerable Validate() + { + var largestImage = _images.OrderByDescending(x => x.Width * (long)x.Height).FirstOrDefault(); + foreach (var image in _images) + { + // check width & height for power of 2 + if (image.Width <= 0 || (image.Width & (image.Width - 1)) != 0) yield return $"Error: image width of {image.Width} is not a power of 2."; + if (image.Height <= 0 || (image.Height & (image.Height - 1)) != 0) yield return $"Error: image height of {image.Height} is not a power of 2."; + if (largestImage != null && image.Format != largestImage.Format) yield return $"Error: expected image format is {largestImage.Format}, but instead got {image.Format}."; + if (image.Format == VtfImageFormat.None) yield return "Error: `None` is not a valid image format."; + } + + var numMipmaps = _images.Select(x => x.Mipmap).Distinct().Count(); + var numFrames = _images.Select(x => x.Frame).Distinct().Count(); + var numFaces = _images.Select(x => x.Face).Distinct().Count(); + var numSlices = _images.Select(x => x.Slice).Distinct().Count(); + + if (numFaces > 1) + { + if (!Header.Flags.HasFlag(VtfImageFlag.EnvMap)) yield return "Error: only environment maps support multiple faces, but the `EnvMap` flag is not set on the texture."; + if (numFaces != 6 && numFaces != 7) yield return "Error: when using multiple faces, there must be exactly 6 or 7 of them."; + } + + for (var mi = 0; mi < numMipmaps; mi++) + { + for (var fr = 0; fr < numFrames; fr++) + { + for (var fa = 0; fa < numFaces; fa++) + { + for (var sl = 0; sl < numSlices; sl++) + { + var img = _images.FirstOrDefault(x => x.Mipmap == mi && x.Frame == fr && x.Face == fa && x.Slice == sl); + if (img == null) yield return $"Error: missing image data for mipmap = {mi}, frame = {fr}, face = {fa}, slice = {sl}."; + } + } + } + } + } + + /// + /// Write the file to a stream. Validation will be performed first to ensure that the file is valid. + /// + /// The stream to write to + public void Write(Stream stream) + { + var results = Validate().ToList(); + if (results.Any()) throw new VtfValidationException(results); + + using (var bw = new BinaryWriter(stream, Encoding.ASCII, true)) + { + var numResources = _resources.Count + 1 + (LowResImage == null ? 0 : 1); + var headerSize = 64; + if (Header.Version >= 7.2m) headerSize = 80; + if (Header.Version >= 7.3m) headerSize += numResources * 8; + if (headerSize % 16 != 0) headerSize += 16 - (headerSize % 16); // align to 16 + + var largestImage = _images.OrderByDescending(x => x.Width * (long)x.Height).FirstOrDefault(); + var numFrames = _images.Select(x => x.Frame).Distinct().Count(); + var numMipmaps = _images.Select(x => x.Mipmap).Distinct().Count(); + var numSlices = _images.Select(x => x.Slice).Distinct().Count(); + var firstFrame = _images.OrderBy(x => x.Frame).FirstOrDefault()?.Frame ?? 0; + + // write header + bw.WriteFixedLengthString(Encoding.ASCII, 4, VtfHeader); + bw.Write((uint)Math.Floor(Header.Version)); + bw.Write((uint)Math.Floor((Header.Version - Math.Floor(Header.Version)) * 10m)); + bw.Write(headerSize); + bw.Write((ushort) (largestImage?.Width ?? 0)); + bw.Write((ushort) (largestImage?.Height ?? 0)); + bw.Write((uint) Header.Flags); + bw.Write((ushort) numFrames); + bw.Write((ushort) firstFrame); + bw.Write(0); // padding + bw.WriteVector3(Header.Reflectivity); + bw.Write(0); // padding + bw.Write(Header.BumpmapScale); + bw.Write((uint) (largestImage?.Format ?? VtfImageFormat.None)); + bw.Write((byte) numMipmaps); + bw.Write((uint) (LowResImage?.Format ?? VtfImageFormat.None)); + bw.Write((byte) (LowResImage?.Width ?? 0)); + bw.Write((byte) (LowResImage?.Height ?? 0)); + if (Header.Version >= 7.2m) + { + bw.Write((ushort) numSlices); + } + if (Header.Version >= 7.3m) + { + bw.Write(new byte[] { 0, 0, 0 }); // padding + bw.Write((uint) numResources); + } + + // align to multiple of 16 + if (bw.BaseStream.Position % 16 != 0) bw.Write(new byte[16 - (bw.BaseStream.Position % 16)]); + + // zero out the resources for now + var resourcesStartPos = bw.BaseStream.Position; + if (Header.Version >= 7.3m) + { + for (var i = 0; i < numResources; i++) + { + bw.Write(0L); + } + } + + // align to multiple of 16 again + if (bw.BaseStream.Position % 16 != 0) bw.Write(new byte[16 - (bw.BaseStream.Position % 16)]); + + var resourceData = new List<(VtfResourceType type, uint value)>(); + + // write images + if (LowResImage != null) + { + resourceData.Add((VtfResourceType.LowResImage, (uint) bw.BaseStream.Position)); + bw.Write(LowResImage.Data); + } + + resourceData.Add((VtfResourceType.Image, (uint)bw.BaseStream.Position)); + foreach (var mipGroup in _images.GroupBy(x => x.Mipmap).OrderByDescending(x => x.Key)) + { + foreach (var frameGroup in mipGroup.GroupBy(x => x.Frame).OrderBy(x => x.Key)) + { + foreach (var faceGroup in frameGroup.GroupBy(x => x.Face).OrderBy(x => x.Key)) + { + foreach (var image in faceGroup.OrderBy(x => x.Slice)) + { + bw.Write(image.Data); + } + } + } + } + + // write resources + foreach (var res in _resources) + { + uint value; + switch (res) + { + case VtfKeyValueResource kvr: + value = (uint)bw.BaseStream.Position; + using (var ms = new MemoryStream()) + { + using (var tw = new StreamWriter(ms)) + { + SerialisedObjectFormatter.Print(kvr.KeyValues, tw); + } + + ms.Position = 0; + var arr = ms.ToArray(); + bw.Write(arr.Length); + bw.Write(arr); + } + break; + case VtfValueResource vvr: + value = vvr.Value; + break; + case VtfUnknownResource vur: + value = (uint)bw.BaseStream.Position; + bw.Write(vur.Data); + break; + default: + throw new NotImplementedException($"Unknown resource type: {res.GetType().Name}"); + } + resourceData.Add((res.Type, value)); + } + + // now go back and write the resource data to the header + if (Header.Version >= 7.3m) + { + var end = bw.BaseStream.Position; + bw.BaseStream.Position = resourcesStartPos; + foreach (var (type, value) in resourceData) + { + bw.Write((uint)type); + bw.Write(value); + } + bw.BaseStream.Position = end; + } + } + } + private static int GetMipSize(int input, int level) { var res = input >> level; if (res < 1) res = 1; return res; } + + /// + /// Add an image to the file. If an image with the same properties exists, it will be replaced. + /// + /// The image to add. + public void AddImage(VtfImage image) + { + var matching = _images.FirstOrDefault(x => x.Face == image.Face && x.Frame == image.Frame && x.Mipmap == image.Mipmap && x.Slice == image.Slice); + var existing = _images.FirstOrDefault(x => x != matching); + if (existing != null && existing.Format != image.Format) + { + throw new Exception($"All images must have the same format. Expected {existing.Format}, but got {image.Format}"); + } + + if (image.Slice != 1 && Header.Version < 7.2m) Header.Version = 7.2m; + + _images.Add(image); + } + + /// + /// Remove an image from the file. + /// + public void RemoveImage(VtfImage image) + { + _images.Remove(image); + } + + /// + /// Add a resource to the file. The file will be upgraded to version 7.3, if required. + /// + /// The resource to add. If the resource is of type sheet, crc, texture lod settings, or texture settings ex, the existing resource will be removed. + public void AddResource(VtfResource resource) + { + switch (resource.Type) + { + case VtfResourceType.LowResImage: + case VtfResourceType.Image: + throw new InvalidOperationException("Images cannot be set by adding a resource. Use Images/LowResImage instead."); + case VtfResourceType.Sheet: + case VtfResourceType.Crc: + case VtfResourceType.TextureLodSettings: + case VtfResourceType.TextureSettingsEx: + // only allow one of these resource types + _resources.RemoveAll(x => x.Type == resource.Type); + break; + case VtfResourceType.KeyValueData: + // can have as many of these as we want + break; + default: + throw new ArgumentOutOfRangeException(nameof(resource.Type)); + } + + if (_resources.Count > MaxNumberOfResources) throw new InvalidOperationException($"Cannot add more than {MaxNumberOfResources} resources to a file."); + + if (Header.Version < 7.3m) Header.Version = 7.3m; + _resources.Add(resource); + } + + /// + /// Remove a resource f rom the file. + /// + public void RemoveResource(VtfResource resource) + { + _resources.Remove(resource); + } } } \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/VtfImageFlag.cs b/Sledge.Formats.Texture/Vtf/VtfImageFlag.cs index 3b55469..4b76d8a 100644 --- a/Sledge.Formats.Texture/Vtf/VtfImageFlag.cs +++ b/Sledge.Formats.Texture/Vtf/VtfImageFlag.cs @@ -5,45 +5,46 @@ namespace Sledge.Formats.Texture.Vtf [Flags] public enum VtfImageFlag : uint { - Pointsample = 0x00000001, + None = 0, + PointSample = 0x00000001, Trilinear = 0x00000002, - Clamps = 0x00000004, - Clampt = 0x00000008, + ClampS = 0x00000004, + ClampT = 0x00000008, Anisotropic = 0x00000010, HintDxt5 = 0x00000020, Srgb = 0x00000040, - DeprecatedNocompress = 0x00000040, + [Obsolete] DeprecatedNoCompress = 0x00000040, Normal = 0x00000080, - Nomip = 0x00000100, - Nolod = 0x00000200, - Minmip = 0x00000400, + NoMip = 0x00000100, + NoLod = 0x00000200, + MinMip = 0x00000400, Procedural = 0x00000800, - Onebitalpha = 0x00001000, - Eightbitalpha = 0x00002000, - Envmap = 0x00004000, - Rendertarget = 0x00008000, - Depthrendertarget = 0x00010000, - Nodebugoverride = 0x00020000, - Singlecopy = 0x00040000, + OneBitAlpha = 0x00001000, + EightBitAlpha = 0x00002000, + EnvMap = 0x00004000, + RenderTarget = 0x00008000, + DepthRenderTarget = 0x00010000, + NoDebugOverride = 0x00020000, + SingleCopy = 0x00040000, Unused0 = 0x00080000, - DeprecatedOneovermiplevelinalpha = 0x00080000, - Unused1 = 0x00100000, - DeprecatedPremultcolorbyoneovermiplevel = 0x00100000, - Unused2 = 0x00200000, - DeprecatedNormaltodudv = 0x00200000, - Unused3 = 0x00400000, - DeprecatedAlphatestmipgeneration = 0x00400000, - Nodepthbuffer = 0x00800000, - Unused4 = 0x01000000, - DeprecatedNicefiltered = 0x01000000, - Clampu = 0x02000000, - Vertextexture = 0x04000000, - Ssbump = 0x08000000, - Unused5 = 0x10000000, - DeprecatedUnfilterableOk = 0x10000000, + [Obsolete] DeprecatedOneOverMipLevelInAlpha = 0x00080000, + // Unused1 = 0x00100000, + [Obsolete] DeprecatedPreMultColorByOneOverMipLevel = 0x00100000, + // Unused2 = 0x00200000, + [Obsolete] DeprecatedNormalToDuDv = 0x00200000, + // Unused3 = 0x00400000, + [Obsolete] DeprecatedAlphaTestMipGeneration = 0x00400000, + NoDepthBuffer = 0x00800000, + // Unused4 = 0x01000000, + [Obsolete] DeprecatedNiceFiltered = 0x01000000, + ClampU = 0x02000000, + VertexTexture = 0x04000000, + SsBump = 0x08000000, + // Unused5 = 0x10000000, + [Obsolete] DeprecatedUnfilterableOk = 0x10000000, Border = 0x20000000, - DeprecatedSpecvarRed = 0x40000000, - DeprecatedSpecvarAlpha = 0x80000000, - Last = 0x20000000 + [Obsolete] DeprecatedSpecVarRed = 0x40000000, + [Obsolete] DeprecatedSpecVarAlpha = 0x80000000, + // Last = 0x20000000 } } \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/VtfImageFormat.cs b/Sledge.Formats.Texture/Vtf/VtfImageFormat.cs index 6cecba5..fa83905 100644 --- a/Sledge.Formats.Texture/Vtf/VtfImageFormat.cs +++ b/Sledge.Formats.Texture/Vtf/VtfImageFormat.cs @@ -1,46 +1,125 @@ namespace Sledge.Formats.Texture.Vtf { + // comments from https://developer.valvesoftware.com/wiki/VTF_(Valve_Texture_Format) public enum VtfImageFormat { None = -1, + + /// Uncompressed texture with 8-bit alpha Rgba8888 = 0, + + /// Uncompressed texture with 8-bit alpha Abgr8888, + + /// Uncompressed opaque texture, full color depth Rgb888, + + /// Uncompressed opaque texture, full color depth Bgr888, + + /// Uncompressed texture, limited color depth, similar to Bgr565. Not properly supported in all branches; prefer Bgr565 instead, which always works. Rgb565, + + /// Luminance (Grayscale), no alpha I8, + + /// Luminance (Grayscale), 8-bit alpha Ia88, + + /// 256-color paletted P8, + + /// No color (fully black), 8-bit alpha A8, + + /// Same as Bgr888, but blue pixels (hex color #0000ff) are rendered transparent instead. Rgb888Bluescreen, + + /// Same as Rgb888, but blue pixels (hex color #0000ff) are rendered transparent instead. Bgr888Bluescreen, + + /// Uncompressed texture with 8-bit alpha Argb8888, + + /// Compressed HDR texture with no alpha or uncompressed SDR texture with 8-bit alpha Bgra8888, + + /// Standard compression, optional 1-bit alpha (recommended for opaque). Dxt1, + + /// Standard compression, uninterpolated 4-bit Alpha Dxt3, + + /// Standard compression, interpolated 8-bit alpha (recommended for transparent/translucent) Dxt5, + + /// Like Bgra8888, but the alpha channel is always set to 255, making it functionally equivalent to Bgr888. Bgrx8888, + + /// Uncompressed opaque texture, limited color depth Bgr565, + + /// Like Bgra5551, but the alpha channel is always set to 255. Bgrx5551, + + /// Uncompressed texture with alpha, half color depth Bgra4444, + + /// Dxt1Onebitalpha format does not properly work; use regular Dxt1 with 1-bit alpha flag enabled instead. Dxt1Onebitalpha, + + /// Uncompressed texture, limited color depth, 1-bit alpha Bgra5551, + + /// Uncompressed du/dv Format Uv88, + + /// ?? Uvwq8888, + + /// Floating Point HDR Format Rgba16161616F, + + /// Integer HDR Format Rgba16161616, + + /// ?? Uvlx8888, + + /// ?? R32F, + + /// ?? Rgb323232F, + + /// ?? Rgba32323232F, + + /// ?? NvDst16, - NvDst24, + + /// ?? + NvDst24, + + /// ?? NvIntz, + + /// ?? NvRawz, + + /// ?? AtiDst16, + + /// ?? AtiDst24, + + /// ?? NvNull, - Ati2N, + + /// ?? + Ati2N, + + /// ?? Ati1N, } diff --git a/Sledge.Formats.Texture/Vtf/VtfImageFormatInfo.cs b/Sledge.Formats.Texture/Vtf/VtfImageFormatInfo.cs index de84698..4e22299 100644 --- a/Sledge.Formats.Texture/Vtf/VtfImageFormatInfo.cs +++ b/Sledge.Formats.Texture/Vtf/VtfImageFormatInfo.cs @@ -143,6 +143,9 @@ public byte[] ConvertToBgra32(byte[] data, int width, int height) // No format, return blank array if (Format == VtfImageFormat.None) return buffer; + // Unsupported format + else if (!IsSupported) throw new NotImplementedException($"Unsupported format: {Format}"); + // This is the exact format we want, take the fast path else if (Format == VtfImageFormat.Bgra8888) { @@ -304,7 +307,7 @@ ushort Clamp(double sValue) {VtfImageFormat.A8, new VtfImageFormatInfo(VtfImageFormat.A8, 8, 1, 0, 0, 0, 8, -1, -1, -1, 0, false, true)}, {VtfImageFormat.Rgb888Bluescreen, new VtfImageFormatInfo(VtfImageFormat.Rgb888Bluescreen, 24, 3, 8, 8, 8, 8, 0, 1, 2, -1, false, true, TransformBluescreen)}, {VtfImageFormat.Bgr888Bluescreen, new VtfImageFormatInfo(VtfImageFormat.Bgr888Bluescreen, 24, 3, 8, 8, 8, 8, 2, 1, 0, -1, false, true, TransformBluescreen)}, - {VtfImageFormat.Argb8888, new VtfImageFormatInfo(VtfImageFormat.Argb8888, 32, 4, 8, 8, 8, 8, 3, 0, 1, 2, false, true)}, + {VtfImageFormat.Argb8888, new VtfImageFormatInfo(VtfImageFormat.Argb8888, 32, 4, 8, 8, 8, 8, 1, 2, 3, 0, false, true)}, {VtfImageFormat.Bgra8888, new VtfImageFormatInfo(VtfImageFormat.Bgra8888, 32, 4, 8, 8, 8, 8, 2, 1, 0, 3, false, true)}, {VtfImageFormat.Dxt1, new VtfImageFormatInfo(VtfImageFormat.Dxt1, 4, 0, 0, 0, 0, 0, -1, -1, -1, -1, true, true)}, {VtfImageFormat.Dxt3, new VtfImageFormatInfo(VtfImageFormat.Dxt3, 8, 0, 0, 0, 0, 8, -1, -1, -1, -1, true, true)}, diff --git a/Sledge.Formats.Texture/Vtf/VtfResource.cs b/Sledge.Formats.Texture/Vtf/VtfResource.cs deleted file mode 100644 index 12aa1ab..0000000 --- a/Sledge.Formats.Texture/Vtf/VtfResource.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Sledge.Formats.Texture.Vtf -{ - public class VtfResource - { - public VtfResourceType Type { get; set; } - public uint Data { get; set; } - //public byte[] Data { get; set; } - } -} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/VtfResourceType.cs b/Sledge.Formats.Texture/Vtf/VtfResourceType.cs deleted file mode 100644 index fe1c2ca..0000000 --- a/Sledge.Formats.Texture/Vtf/VtfResourceType.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Sledge.Formats.Texture.Vtf -{ - public enum VtfResourceType : uint - { - LowResImage = 0x01, - Image = 0x30, - Sheet = 0x10, - Crc = 'C' | ('R' << 8) | ('C' << 16) | (0x02 << 24), - TextureLodSettings = 'L' | ('O' << 8) | ('D' << 16) | (0x02 << 24), - TextureSettingsEx = 'T' | ('S' << 8) | ('O' << 16) | (0x02 << 24), - KeyValueData = 'K' | ('V' << 8) | ('D' << 16), - } -} \ No newline at end of file diff --git a/Sledge.Formats.Texture/Vtf/VtfValidationException.cs b/Sledge.Formats.Texture/Vtf/VtfValidationException.cs new file mode 100644 index 0000000..c7bb2eb --- /dev/null +++ b/Sledge.Formats.Texture/Vtf/VtfValidationException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Sledge.Formats.Texture.Vtf +{ + public class VtfValidationException : Exception + { + public List Errors { get; set; } + + public VtfValidationException(IEnumerable errors) : base("VTF validation failed. See the Errors array for more details.") + { + Errors = errors.ToList(); + } + } +} diff --git a/Sledge.Formats.lutconfig b/Sledge.Formats.lutconfig index 3fba319..8da22a4 100644 --- a/Sledge.Formats.lutconfig +++ b/Sledge.Formats.lutconfig @@ -2,5 +2,5 @@ true true - 1000 + 4000 \ No newline at end of file diff --git a/Sledge.Formats.sln b/Sledge.Formats.sln index d4ebfa5..0c78020 100644 --- a/Sledge.Formats.sln +++ b/Sledge.Formats.sln @@ -32,9 +32,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sledge.Formats.GameData.Tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sledge.Formats.Bsp.Tests", "Sledge.Formats.Bsp.Tests\Sledge.Formats.Bsp.Tests.csproj", "{569F399E-8530-465E-AF99-2A56D6A95A07}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sledge.Formats.Model", "Sledge.Formats.Model\Sledge.Formats.Model.csproj", "{DF78E94A-2481-4490-ABAB-3CB4C688C218}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sledge.Formats.Model", "Sledge.Formats.Model\Sledge.Formats.Model.csproj", "{DF78E94A-2481-4490-ABAB-3CB4C688C218}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sledge.Formats.Model.Tests", "Sledge.Formats.Model.Tests\Sledge.Formats.Model.Tests.csproj", "{9596B02D-5C5D-4135-98B6-1142D08A3929}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sledge.Formats.Model.Tests", "Sledge.Formats.Model.Tests\Sledge.Formats.Model.Tests.csproj", "{9596B02D-5C5D-4135-98B6-1142D08A3929}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sledge.Formats.Texture.ImageSharp", "Sledge.Formats.Texture.ImageSharp\Sledge.Formats.Texture.ImageSharp.csproj", "{841D69BB-CED0-4A44-A434-9D1272EBA0BA}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -94,6 +96,10 @@ Global {9596B02D-5C5D-4135-98B6-1142D08A3929}.Debug|Any CPU.Build.0 = Debug|Any CPU {9596B02D-5C5D-4135-98B6-1142D08A3929}.Release|Any CPU.ActiveCfg = Release|Any CPU {9596B02D-5C5D-4135-98B6-1142D08A3929}.Release|Any CPU.Build.0 = Release|Any CPU + {841D69BB-CED0-4A44-A434-9D1272EBA0BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {841D69BB-CED0-4A44-A434-9D1272EBA0BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {841D69BB-CED0-4A44-A434-9D1272EBA0BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {841D69BB-CED0-4A44-A434-9D1272EBA0BA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE