Skip to content

Commit

Permalink
Merge pull request #32 from derail-valley-modding/bc5-normal-maps
Browse files Browse the repository at this point in the history
Use BC5 compression for normal maps
  • Loading branch information
katycat5e authored Mar 5, 2024
2 parents 0250f94 + ec9c44c commit 1dcc70a
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 53 deletions.
2 changes: 1 addition & 1 deletion SkinManagerMod/SkinProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ private static int ReloadSkinMod(UnityModManager.ModEntry mod, bool forceSync =
{
if (ResourcePack.LoadFromFile(file) is ResourcePack resourceConfig)
{
BeginLoadResources(resourceConfig);
BeginLoadResources(resourceConfig, forceSync);
newConfig.ResourcePacks.Add(resourceConfig);
}
}
Expand Down
15 changes: 12 additions & 3 deletions SkinManagerMod/StbImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@ public static class StbImage
[DllImport(Lib, EntryPoint = nameof(GetImageInfo))]
private static extern int GetImageInfo(string filename, out uint x, out uint y, out uint comp);
[DllImport(Lib, EntryPoint = nameof(ReadImageAsBCx))]
private static extern int ReadImageAsBCx(string filename, int flipVertically, int useAlpha, IntPtr dest, int destSize);
private static extern int ReadImageAsBCx(string filename, int flipVertically, int format, IntPtr dest, int destSize);
[DllImport(Lib, EntryPoint = nameof(ReadImageAsRGBA))]
private static extern int ReadImageAsRGBA(string filename, int flipVertically, IntPtr dest, int destSize);

public enum TextureFormat
{
BC1 = 1,
BC3 = 3,
BC5 = 5,
}

public struct ImageInfo
{
public int height;
Expand All @@ -34,9 +41,11 @@ public static ImageInfo GetImageInfo(string filename)
};
}

public static void ReadAndCompressImageWithMipmaps(string filename, bool flipVertically, bool useAlpha, IntPtr dest, int destSize)
public static void ReadAndCompressImageWithMipmaps(string filename, bool flipVertically, TextureFormat format, IntPtr dest, int destSize)
{
bool success = ReadImageAsBCx(filename, flipVertically ? 1 : 0, useAlpha ? 1 : 0, dest, destSize) != 0;
if (Array.IndexOf(Enum.GetValues(typeof(TextureFormat)), format) < 0)
throw new ArgumentOutOfRangeException("format", $"Unknown texture format {format}");
bool success = ReadImageAsBCx(filename, flipVertically ? 1 : 0, (int)format, dest, destSize) != 0;
if (!success)
throw new Exception($"Unable to read {filename}");
}
Expand Down
Binary file modified SkinManagerMod/StbImage.dll
Binary file not shown.
218 changes: 169 additions & 49 deletions SkinManagerMod/TextureLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public static void BustCache(ResourceConfigJson skin, string texturePath)
}
}

private static Task<Texture2D> TryLoadFromCache(ResourceConfigJson skin, string texturePath, bool linear)
private static Task<Texture2D> TryLoadFromCache(ResourceConfigJson skin, string texturePath, bool isNormalMap)
{
var texFile = new FileInfo(texturePath);
var cached = new FileInfo(GetCachePath(skin, texturePath));
Expand All @@ -37,48 +37,55 @@ private static Task<Texture2D> TryLoadFromCache(ResourceConfigJson skin, string

if (cached.LastWriteTimeUtc < texFile.LastWriteTimeUtc)
{
cached.Delete();
Main.LogVerbose($"Cached texture {cached.FullName} is out of date");
BustCache(skin, texturePath);
return Task.FromResult<Texture2D>(null);
}

try
{
return DDSUtils.ReadDDSGz(cached, linear);
return DDSUtils.ReadDDSGz(cached, isNormalMap);
}
catch (DDSReadException e)
{
Main.Warning($"Error loading cached skin {skin.Name}: {e.Message}");
Main.Warning($"Error loading cached texture {cached.FullName}: {e.Message}");
BustCache(skin, texturePath);
return Task.FromResult<Texture2D>(null);
}
}

public static Task<Texture2D> LoadAsync(ResourceConfigJson skin, string texturePath, bool linear)
public static Task<Texture2D> LoadAsync(ResourceConfigJson skin, string texturePath, bool isNormalMap)
{
var cached = TryLoadFromCache(skin, texturePath, linear);
var cached = TryLoadFromCache(skin, texturePath, isNormalMap);
if (!cached.IsCompleted || cached.Result != null)
{
return cached;
}

var info = StbImage.GetImageInfo(texturePath);
var texture = new Texture2D(info.width, info.height,
info.componentCount > 3 ? TextureFormat.DXT5 : TextureFormat.DXT1,
mipChain: true, linear);
var format = isNormalMap ? TextureFormat.BC5 :
info.componentCount > 3 ? TextureFormat.DXT5 :
TextureFormat.DXT1;

var texture = new Texture2D(info.width, info.height, format,
mipChain: true, linear: isNormalMap);
var nativeArray = texture.GetRawTextureData<byte>();
Main.LogVerbose($"Loading texture {texturePath} as {format} with StbImage");
return Task.Run(() =>
{
PopulateTexture(texturePath, info.componentCount > 3, nativeArray);
PopulateTexture(texturePath, format, nativeArray);
string cachePath = GetCachePath(skin, texturePath);
Directory.CreateDirectory(Path.GetDirectoryName(cachePath));
DDSUtils.WriteDDSGz(new FileInfo(cachePath), texture);
return texture;
});
}

public static Texture2D LoadSync(ResourceConfigJson skin, string texturePath, bool linear)
public static Texture2D LoadSync(ResourceConfigJson skin, string texturePath, bool isNormalMap)
{
var texture = new Texture2D(0, 0, textureFormat: TextureFormat.RGBA32, mipChain: true, linear: linear);
var textureFormat = TextureFormat.RGBA32;
var texture = new Texture2D(0, 0, textureFormat, mipChain: true, linear: isNormalMap);
Main.LogVerbose($"Loading texture {texturePath} as {textureFormat} with LoadImage");
texture.LoadImage(File.ReadAllBytes(texturePath));

return texture;
Expand All @@ -91,14 +98,30 @@ private static string GetCachePath(ResourceConfigJson skin, string texturePath)
return Path.Combine(Main.CacheFolderPath, skin.CarId, skin.Name, cacheFileName);
}

private static void PopulateTexture(string path, bool hasAlpha, NativeArray<byte> dest)
private static void PopulateTexture(string path, TextureFormat textureFormat, NativeArray<byte> dest)
{
StbImage.TextureFormat format;
switch (textureFormat)
{
case TextureFormat.DXT1:
format = StbImage.TextureFormat.BC1;
break;
case TextureFormat.DXT5:
format = StbImage.TextureFormat.BC3;
break;
case TextureFormat.BC5:
format = StbImage.TextureFormat.BC5;
break;
default:
throw new ArgumentException("textureFormat", $"Unsupported TextureFormat {textureFormat}");
}

unsafe
{
StbImage.ReadAndCompressImageWithMipmaps(
path,
flipVertically: true,
useAlpha: hasAlpha,
format,
(IntPtr)dest.GetUnsafePtr(),
dest.Length);
}
Expand All @@ -112,16 +135,34 @@ public DDSReadException(string message) : base(message) { }

internal static class DDSUtils
{
private static int Mipmap0SizeInBytes(int width, int height, bool hasAlpha)
private static int Mipmap0SizeInBytes(int width, int height, TextureFormat textureFormat)
{
var blockWidth = (width + 3) / 4;
var blockHeight = (height + 3) / 4;
return blockWidth * blockHeight * (hasAlpha ? 16 : 8);
int bytesPerBlock;
switch (textureFormat)
{
case TextureFormat.DXT1:
bytesPerBlock = 8;
break;
case TextureFormat.DXT5:
case TextureFormat.BC5:
bytesPerBlock = 16;
break;
default:
throw new ArgumentException("textureFormat", $"Unsupported TextureFormat {textureFormat}");
}

return blockWidth * blockHeight * bytesPerBlock;
}

private static byte[] DDSHeader(int width, int height, bool hasAlpha, int numMipmaps)
private const int DDS_HEADER_SIZE = 128;
private const int DDS_HEADER_DXT10_SIZE = 20;
private static byte[] DDSHeader(int width, int height, TextureFormat textureFormat, int numMipmaps)
{
var header = new byte[128];
var needsDXGIHeader = textureFormat != TextureFormat.DXT1 && textureFormat != TextureFormat.DXT5;
var headerSize = needsDXGIHeader ? DDS_HEADER_SIZE + DDS_HEADER_DXT10_SIZE : DDS_HEADER_SIZE;
var header = new byte[headerSize];
using (var stream = new MemoryStream(header))
{
stream.Write(Encoding.ASCII.GetBytes("DDS "), 0, 4);
Expand All @@ -130,80 +171,159 @@ private static byte[] DDSHeader(int width, int height, bool hasAlpha, int numMip
stream.Write(BitConverter.GetBytes(0x1 | 0x2 | 0x4 | 0x1000 | 0x20000 | 0x80000), 0, 4);
stream.Write(BitConverter.GetBytes(height), 0, 4);
stream.Write(BitConverter.GetBytes(width), 0, 4);
stream.Write(BitConverter.GetBytes(Mipmap0SizeInBytes(width, height, hasAlpha)), 0, 4); // dwPitchOrLinearSize
stream.Write(BitConverter.GetBytes(Mipmap0SizeInBytes(width, height, textureFormat)), 0, 4); // dwPitchOrLinearSize
stream.Write(BitConverter.GetBytes(0), 0, 4); // dwDepth
stream.Write(BitConverter.GetBytes(numMipmaps), 0, 4); // dwMipMapCount
for (int i = 0; i < 11; i++)
stream.Write(BitConverter.GetBytes(0), 0, 4); // dwReserved1
var pixelFormat = PixelFormat(hasAlpha);
var pixelFormat = PixelFormat(textureFormat);
stream.Write(pixelFormat, 0, pixelFormat.Length);
// dwCaps = COMPLEX | MIPMAP | TEXTURE
stream.Write(BitConverter.GetBytes(0x401008), 0, 4);

if (needsDXGIHeader)
stream.Write(DDSHeaderDXT10(textureFormat), 0, DDS_HEADER_DXT10_SIZE);
}
return header;
}

private static byte[] PixelFormat(bool hasAlpha)
private static byte[] PixelFormat(TextureFormat textureFormat)
{
string fourCC;
switch (textureFormat)
{
case TextureFormat.DXT1:
fourCC = "DXT1";
break;
case TextureFormat.DXT5:
fourCC = "DXT5";
break;
default:
fourCC = "DX10";
break;
}

var pixelFormat = new byte[32];
var stream = new MemoryStream(pixelFormat);
stream.Write(BitConverter.GetBytes(32), 0, 4); // dwSize
stream.Write(BitConverter.GetBytes(0x4), 0, 4); // dwFlags = FOURCC
stream.Write(Encoding.ASCII.GetBytes(hasAlpha ? "DXT5" : "DXT1"), 0, 4); // dwFourCC
stream.Close();
using (var stream = new MemoryStream(pixelFormat))
{
stream.Write(BitConverter.GetBytes(32), 0, 4); // dwSize
stream.Write(BitConverter.GetBytes(0x4), 0, 4); // dwFlags = FOURCC
stream.Write(Encoding.ASCII.GetBytes(fourCC), 0, 4); // dwFourCC
}
return pixelFormat;
}

private static int DXGIFormat(TextureFormat textureFormat)
{
switch (textureFormat)
{
case TextureFormat.BC5: return 83;
default:
throw new ArgumentException("textureFormat", $"Unsupported TextureFormat {textureFormat}");
}
}

private static byte[] DDSHeaderDXT10(TextureFormat textureFormat)
{
var headerDXT10 = new byte[DDS_HEADER_DXT10_SIZE];
using (var stream = new MemoryStream(headerDXT10))
{
stream.Write(BitConverter.GetBytes(DXGIFormat(textureFormat)), 0, 4); // dxgiFormat
stream.Write(BitConverter.GetBytes(3), 0, 4); // resourceDimension = 3 = DDS_DIMENSION_TEXTURE2D
stream.Write(BitConverter.GetBytes(0), 0, 4); // miscFlag
stream.Write(BitConverter.GetBytes(1), 0, 4); // arraySize = 1
stream.Write(BitConverter.GetBytes(0), 0, 4); // miscFlags2 = 0 = DDS_ALPHA_MODE_UNKNOWN
}
return headerDXT10;
}

public static void WriteDDSGz(FileInfo fileInfo, Texture2D texture)
{
Main.Log($"Writing to {fileInfo.FullName}");
using (var fileStream = fileInfo.OpenWrite())
using (var outfile = new GZipStream(fileStream, CompressionLevel.Optimal))
{
outfile.Write(DDSHeader(texture.width, texture.height, texture.format == TextureFormat.DXT5, texture.mipmapCount), 0, 128);
var header = DDSHeader(texture.width, texture.height, texture.format, texture.mipmapCount);
outfile.Write(header, 0, header.Length);

var data = texture.GetRawTextureData<byte>().ToArray();
outfile.Write(data, 0, data.Length);
}
}

public static Task<Texture2D> ReadDDSGz(FileInfo fileInfo, bool linear)
private static Texture2D ReadDDSHeader(Stream infile, bool linear)
{
var buf = new byte[4096];
var bytesRead = infile.Read(buf, 0, DDS_HEADER_SIZE);
if (bytesRead != 128 || Encoding.ASCII.GetString(buf, 0, 4) != "DDS ")
throw new DDSReadException("File is not a DDS file");

int height = BitConverter.ToInt32(buf, 12);
int width = BitConverter.ToInt32(buf, 16);

int pixelFormatFlags = BitConverter.ToInt32(buf, 80);
if ((pixelFormatFlags & 0x4) == 0)
throw new DDSReadException("DDS header does not have a FourCC");
string fourCC = Encoding.ASCII.GetString(buf, 84, 4);
TextureFormat textureFormat;
switch (fourCC)
{
case "DXT1":
textureFormat = TextureFormat.DXT1;
break;
case "DXT5":
textureFormat = TextureFormat.DXT5;
break;
case "DX10":
// read DDS_HEADER_DXT10 header extension
bytesRead = infile.Read(buf, 0, DDS_HEADER_DXT10_SIZE);
if (bytesRead != DDS_HEADER_DXT10_SIZE)
throw new DDSReadException("Could not read DXT10 header from DDS file");
int dxgiFormat = BitConverter.ToInt32(buf, 0);
switch (dxgiFormat)
{
case 83:
textureFormat = TextureFormat.BC5;
break;
default:
throw new DDSReadException($"Unsupported DXGI_FORMAT {dxgiFormat}");
}
break;
default:
throw new DDSReadException($"Unknown FourCC: {fourCC}");
}

var texture = new Texture2D(width, height, textureFormat, true, linear);
return texture;
}

public static Task<Texture2D> ReadDDSGz(FileInfo fileInfo, bool isNormalMap)
{
FileStream fileStream = null;
GZipStream infile = null;
try
{
Main.LogVerbose($"Reading from {fileInfo.FullName}");
fileStream = fileInfo.OpenRead();
infile = new GZipStream(fileStream, CompressionMode.Decompress);

var buf = new byte[4096];
var bytesRead = infile.Read(buf, 0, 128);
if (bytesRead != 128 || Encoding.ASCII.GetString(buf, 0, 4) != "DDS ")
throw new DDSReadException("File is not a DDS file");

int height = BitConverter.ToInt32(buf, 12);
int width = BitConverter.ToInt32(buf, 16);

int pixelFormatFlags = BitConverter.ToInt32(buf, 80);
if ((pixelFormatFlags & 0x4) == 0)
throw new DDSReadException("DDS header does not have a FourCC");
string fourCC = Encoding.ASCII.GetString(buf, 84, 4);
TextureFormat pixelFormat;
switch (fourCC)
var texture = ReadDDSHeader(infile, isNormalMap);
if (isNormalMap && texture.format != TextureFormat.BC5)
{
case "DXT1": pixelFormat = TextureFormat.DXT1; break;
case "DXT5": pixelFormat = TextureFormat.DXT5; break;
default: throw new DDSReadException($"Unknown FourCC: {fourCC}");
Main.LogVerbose($"Cached normal map texture {fileInfo.FullName} has old format {texture.format}");
infile.Close();
fileStream.Close();
File.Delete(fileInfo.FullName);
return Task.FromResult<Texture2D>(null);
}

var texture = new Texture2D(width, height, pixelFormat, true, linear);
Main.LogVerbose($"Reading cached {texture.format} texture from {fileInfo.FullName}");
var nativeArray = texture.GetRawTextureData<byte>();
return Task.Run(() =>
{
try
{
buf = new byte[nativeArray.Length];
bytesRead = infile.Read(buf, 0, nativeArray.Length);
var buf = new byte[nativeArray.Length];
var bytesRead = infile.Read(buf, 0, nativeArray.Length);
if (bytesRead < nativeArray.Length)
throw new DDSReadException($"{fileInfo.FullName}: Expected {nativeArray.Length} bytes, but file contained {bytesRead}");
nativeArray.CopyFrom(buf);
Expand Down

0 comments on commit 1dcc70a

Please sign in to comment.