Add Ghost.Nvtt C# wrapper and integrate nvtt texture pipeline

- Introduce full managed C# wrapper for NVIDIA Texture Tools (nvtt) with safe handle classes, idiomatic APIs, and managed callback support.
- Integrate Ghost.Nvtt into Ghost.Editor.Core and Ghost.MicroTest; update TextureAssetHandler to use the new nvtt wrapper for texture compression.
- Add comprehensive end-to-end binding test (NvttBindingTest).
- Refactor D3D12 resource management: add deferred/immediate release APIs, update allocator/database usage, and ensure proper resource cleanup.
- Update project files for new native DLL layout and dependency versions.
- Minor API cleanups: EditorApplication properties, D3D12 input layout, and removal of obsolete code.
- Update shaders, tests, and documentation for new APIs and usage patterns.
This commit is contained in:
2026-02-23 17:13:10 +09:00
parent 78e3b4ef31
commit 93c58fa7fb
91 changed files with 3124 additions and 313 deletions

View File

@@ -1,8 +1,6 @@
using Ghost.Core;
using Ghost.Editor.Core.Contracts;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
namespace Ghost.Editor.Core.AssetHandler;
@@ -178,8 +176,4 @@ public readonly struct AssetReference : IEquatable<AssetReference>
}
}
public interface IAssetSettings
{
ValueTask<Result<long>> WriteToStreamAsync(Stream stream, CancellationToken token = default);
ValueTask<Result<IAssetSettings>> ReadFromStreamAsync(Stream stream, CancellationToken token = default);
}
public interface IAssetSettings;

View File

@@ -11,29 +11,23 @@ public sealed class CustomAssetHandlerAttribute : Attribute
get; init;
}
public bool AllowCaching
{
get; init;
} = true;
public required string[] SupportedExtensions
{
get; init;
}
}
public enum DependencyUpdateType
{
Add,
Remove
public bool AllowCaching
{
get; init;
} = true;
}
public interface IAssetExportOptions;
public interface IAssetHandler
{
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default);
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default);
ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default);
ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default);
}
public interface IImportableAssetHandler : IAssetHandler

View File

@@ -0,0 +1,37 @@
using Ghost.Editor.Core.Contracts;
namespace Ghost.Editor.Core.AssetHandler;
[AttributeUsage(AttributeTargets.Class)]
public sealed class CustomAssetProcesserAttribute<T> : Attribute
{
public Type Type => typeof(T);
}
public readonly struct AssetProcesserContext
{
public IAssetRegistry Registry
{
get; init;
}
public string AssetPath
{
get; init;
}
public Asset Asset
{
get; init;
}
public IAssetHandler Handler
{
get; init;
}
}
public interface IAssetProcesser
{
ValueTask ProcessAsync(AssetProcesserContext ctx);
}

View File

@@ -41,13 +41,6 @@ public enum TextureCompressionLevel : uint
High
}
public enum TextureCompressionEffort : uint
{
Fastest,
Normal,
Production
}
public enum MipmapFilter : uint
{
Box,
@@ -59,14 +52,17 @@ public enum MipmapFilter : uint
public class TextureAsset : Asset
{
internal const string _TYPE_ID = "0906F4EB-C3F0-431B-BCEA-132C88AB0C3F";
internal static readonly Guid s_typeGuid = Guid.Parse(_TYPE_ID);
public override Guid TypeID => s_typeGuid;
private readonly Handle<Texture> _texture;
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings)
public override Guid TypeID => s_typeGuid;
public Handle<Texture> Texture => _texture;
public TextureAsset(Guid id, Guid[] dependencies, IAssetSettings? settings, Handle<Texture> texture)
: base(id, dependencies, settings)
{
_texture = texture;
}
}
@@ -142,21 +138,16 @@ public class TextureAssetSettings : IAssetSettings
get; set;
} = TextureCompressionLevel.Normal;
public TextureCompressionEffort CompressionEffort
{
get; set;
} = TextureCompressionEffort.Normal;
public bool UseBorderColor
{
get; set;
} = false;
public Color32 BorderColor
public Color128 BorderColor
{
get; set;
} = new Color32(0, 0, 0, 0);
} = new Color128(0, 0, 0, 0);
public bool ZeroAlphaBorder
{
get; set;
@@ -254,11 +245,12 @@ public class TextureAssetSettings : IAssetSettings
try
{
ref byte address = ref MemoryMarshal.GetReference(tempArray);
await stream.ReadAsync(tempArray.AsMemory(0, size), token).ConfigureAwait(false);
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref address);
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>()));
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref Unsafe.Add(ref address, Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()));
// Use index-based reads after the await to avoid 'ref across await' errors.
var basic = Unsafe.ReadUnaligned<BasicSettings>(ref tempArray[0]);
var advanced = Unsafe.ReadUnaligned<AdvancedSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>()]);
var sampler = Unsafe.ReadUnaligned<SamplerSettings>(ref tempArray[Unsafe.SizeOf<BasicSettings>() + Unsafe.SizeOf<AdvancedSettings>()]);
var settings = new TextureAssetSettings
{
@@ -280,6 +272,7 @@ public class TextureAssetSettings : IAssetSettings
}
}
[CustomAssetHandler(ID = TextureAsset._TYPE_ID, SupportedExtensions = new[] { ".png", ".jpg", ".jpeg", ".tga", ".bmp", ".hdr" })]
internal class TextureAssetHandler : IImportableAssetHandler
{
private const int _CURRENT_VERSION = 1;
@@ -291,87 +284,112 @@ internal class TextureAssetHandler : IImportableAssetHandler
public async ValueTask<Result> ImportAsync(Stream sourceStream, Stream targetStream, Guid id, CancellationToken token = default)
{
// ---- 1. Probe image info -----------------------------------------------
var info = ImageInfo.FromStream(sourceStream);
if (info.BitsPerChannel <= 0)
{
return Result.Failure($"Unsupported image format with {info.BitsPerChannel} bits per channel.");
}
ref byte pData = ref Unsafe.NullRef<byte>();
var imageSize = 0ul;
var isFloat = info.BitsPerChannel > 8;
var width = info.Width;
var height = info.Height;
var colorComponents = info.ColorComponents;
// ---- 2. Decode pixels into a managed byte[] ----------------------------
byte[] pixelBytes;
if (isFloat)
{
using var image = ImageResultFloat.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
using var image = ImageResultFloat.FromStream(sourceStream, colorComponents);
var span = MemoryMarshal.AsBytes(image.AsSpan());
pixelBytes = new byte[span.Length];
span.CopyTo(pixelBytes);
}
else
{
using var image = ImageResult.FromStream(sourceStream, info.ColorComponents);
pData = ref MemoryMarshal.GetReference(MemoryMarshal.AsBytes(image.AsSpan()));
imageSize = image.Size;
using var image = ImageResult.FromStream(sourceStream, colorComponents);
var span = MemoryMarshal.AsBytes(image.AsSpan());
pixelBytes = new byte[span.Length];
span.CopyTo(pixelBytes);
}
// ---- 3. Run NVTT compression on a thread-pool thread (side-effect only) -
// The cache path is derivable at any time from (id, settingsHash), so we
// do NOT store it in the asset file. LoadAsync/SaveAsync will recompute it.
var settings = new TextureAssetSettings();
await Task.Run(() =>
TextureProcessor.CompressToCache(
EditorApplication.CachesFolderPath,
id,
pixelBytes,
width,
height,
isFloat,
colorComponents,
settings),
token).ConfigureAwait(false);
// ---- 4. Write asset file: header + settings + raw image data -----------
// Content layout (all little-endian):
// int32 width
// int32 height
// byte isFloat (0 = byte, 1 = float)
// int32 colorComponents (cast of ColorComponents enum)
// byte[] pixelBytes
const int _CONTENT_HEADER_SIZE = 4 + 4 + 1 + 4; // 13 bytes
var contentSize = _CONTENT_HEADER_SIZE + pixelBytes.Length;
var header = new AssetMetadata(id, TextureAsset.s_typeGuid)
{
HandlerVersion = _CURRENT_VERSION,
SettingsOffset = AssetMetadata.SIZE,
};
// Reserve space for the header, then write settings
targetStream.Seek(0, SeekOrigin.Begin);
AssetMetadata.WriteToStream(targetStream, ref header);
targetStream.Seek(header.SettingsOffset, SeekOrigin.Begin);
var settings = new TextureAssetSettings();
var sizeResult = await settings.WriteToStreamAsync(targetStream, token).ConfigureAwait(false);
if (sizeResult.IsFailure)
{
return Result.Failure($"Failed to write texture asset settings: {sizeResult.Message}");
}
header.SettingsSize = sizeResult.Value;
header.SettingsSize = sizeResult.Value;
header.ContentOffset = header.SettingsOffset + sizeResult.Value;
header.ContentSize = (long)imageSize;
header.ContentSize = contentSize;
// Write raw image content
targetStream.Seek(header.ContentOffset, SeekOrigin.Begin);
var offset = 0;
var tempArray = ArrayPool<byte>.Shared.Rent((int)Math.Min(imageSize, 40960ul));
var remaining = imageSize;
var contentHeader = ArrayPool<byte>.Shared.Rent(_CONTENT_HEADER_SIZE);
try
{
while (remaining > 0)
{
var chunkSize = (int)Math.Min(remaining, (ulong)tempArray.Length);
Unsafe.CopyBlockUnaligned(ref tempArray[0], ref Unsafe.Add(ref pData, offset), (uint)chunkSize);
await targetStream.WriteAsync(tempArray.AsMemory(0, chunkSize), token).ConfigureAwait(false);
offset += chunkSize;
remaining -= (ulong)chunkSize;
}
return Result.Success();
}
catch (Exception ex)
{
return Result.Failure($"Failed to write texture asset content to stream: {ex.Message}");
BitConverter.TryWriteBytes(contentHeader.AsSpan(0, 4), width);
BitConverter.TryWriteBytes(contentHeader.AsSpan(4, 4), height);
contentHeader[8] = isFloat ? (byte)1 : (byte)0;
BitConverter.TryWriteBytes(contentHeader.AsSpan(9, 4), (int)colorComponents);
await targetStream.WriteAsync(contentHeader.AsMemory(0, _CONTENT_HEADER_SIZE), token).ConfigureAwait(false);
}
finally
{
ArrayPool<byte>.Shared.Return(tempArray);
ArrayPool<byte>.Shared.Return(contentHeader);
}
await targetStream.WriteAsync(pixelBytes, token).ConfigureAwait(false);
await targetStream.FlushAsync(token).ConfigureAwait(false);
// Patch header now that all sizes are known
targetStream.Seek(0, SeekOrigin.Begin);
AssetMetadata.WriteToStream(targetStream, ref header);
return Result.Success();
}
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetDatabase, CancellationToken token = default)
public ValueTask<Result<Asset>> LoadAsync(Stream sourceStream, IAssetRegistry assetRegistry, CancellationToken token = default)
{
throw new NotImplementedException();
}
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetDatabase, CancellationToken token = default)
public ValueTask<Result> SaveAsync(Asset asset, Stream targetStream, IAssetRegistry assetRegistry, CancellationToken token = default)
{
throw new NotImplementedException();
}

View File

@@ -0,0 +1,259 @@
using Ghost.Nvtt;
using Ghost.Nvtt.Native;
using Misaki.HighPerformance.Image;
using System.IO.Hashing;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Editor.Core.AssetHandler;
/// <summary>
/// Drives the NVTT compression + mipmap pipeline for a single texture asset.
///
/// Responsibilities:
/// 1. Accept raw decoded pixel bytes + settings.
/// 2. Determine the cache file path (<c>CachesFolderPath/TextureCache/&lt;guid&gt;_&lt;hash&gt;.dds</c>).
/// 3. If the cache is already valid (hash matches), skip compression.
/// 4. Otherwise run the full NVTT pipeline and write the DDS to the cache file.
///
/// The caller owns opening/closing all streams; this class only takes spans and paths.
/// </summary>
internal static unsafe class TextureProcessor
{
private const string _TEXTURE_CACHE_SUBFOLDER = "TextureCache";
// -------------------------------------------------------------------------
// Public entry point
// -------------------------------------------------------------------------
/// <summary>
/// Compresses <paramref name="pixelData"/> according to <paramref name="settings"/>
/// and writes the result to the texture cache.
///
/// Returns the absolute path of the cache file on success.
/// The cache file is skipped if it already exists with a matching content hash.
/// </summary>
public static string CompressToCache(
string cachesFolderPath,
Guid assetId,
ReadOnlySpan<byte> pixelData,
int width,
int height,
bool isFloat,
ColorComponents colorComponents,
TextureAssetSettings settings)
{
// --- derive cache path --------------------------------------------------
var cacheDir = Path.Combine(cachesFolderPath, _TEXTURE_CACHE_SUBFOLDER);
Directory.CreateDirectory(cacheDir);
var settingsHash = ComputeSettingsHash(settings);
var cacheFileName = $"{assetId:N}_{settingsHash:X16}.dds";
var cachePath = Path.Combine(cacheDir, cacheFileName);
// --- check validity: same file name = same settings hash = already done -
if (File.Exists(cachePath))
{
return cachePath;
}
// --- delete any stale cache entries for this asset ----------------------
foreach (var stale in Directory.EnumerateFiles(cacheDir, $"{assetId:N}_*.dds"))
{
File.Delete(stale);
}
// --- run NVTT pipeline --------------------------------------------------
RunNvttPipeline(cachePath, pixelData, width, height, isFloat, colorComponents, settings);
return cachePath;
}
// -------------------------------------------------------------------------
// NVTT pipeline
// -------------------------------------------------------------------------
private static void RunNvttPipeline(
string outputPath,
ReadOnlySpan<byte> pixelData,
int width,
int height,
bool isFloat,
ColorComponents colorComponents,
TextureAssetSettings settings)
{
using var surface = new NvttSurfaceHandle();
using var compOpts = new NvttCompressionOptionsHandle();
using var outOpts = new NvttOutputOptionsHandle();
using var ctx = new NvttContextHandle();
// ---- 1. load pixels into NVTT -----------------------------------------
// Misaki.HighPerformance.Image always decodes to RGBA channel order.
// Float images → RGBA_32F, byte images → BGRA_8UB.
// NOTE: NVTT BGRA_8UB expects Blue in byte[0]; stb decodes RGBA so we need
// to pass RGBA. There is no RGBA_8UB enum — we swizzle after load instead.
var inputFormat = isFloat
? NvttInputFormat.NVTT_InputFormat_RGBA_32F
: NvttInputFormat.NVTT_InputFormat_BGRA_8UB; // we'll swizzle RB below
surface.SetImageData(inputFormat, width, height, 1, pixelData);
// stb gives us RGBA byte order; NVTT BGRA_8UB reads it as BGRA,
// so channels R and B are swapped — fix with swizzle(2,1,0,3).
if (!isFloat)
{
surface.Swizzle(2, 1, 0, 3);
}
// ---- 2. resize ---------------------------------------------------------
var maxExtent = (int)settings.Sampler.MaxSize;
if (settings.Advanced.StretchToPowerOfTwo)
{
surface.ResizeMakeSquare(maxExtent,
NvttRoundMode.NVTT_RoundMode_ToPreviousPowerOfTwo,
NvttResizeFilter.NVTT_ResizeFilter_Box);
}
else if (surface.Width > maxExtent || surface.Height > maxExtent)
{
surface.ResizeMax(maxExtent,
NvttRoundMode.NVTT_RoundMode_None,
NvttResizeFilter.NVTT_ResizeFilter_Box);
}
// ---- 2b. border color --------------------------------------------------
if (settings.Advanced.UseBorderColor)
{
var c = settings.Advanced.BorderColor;
surface.SetBorder(c.r, c.g, c.b, c.a);
}
else if (settings.Advanced.ZeroAlphaBorder)
{
surface.SetBorder(0f, 0f, 0f, 0f);
}
// ---- 3. colour-space: convert to linear before mip filtering -----------
if (settings.Basic.IsSRGB && settings.Advanced.GammaCorrection)
{
surface.ToLinearFromSrgb();
}
// ---- 4. premultiply alpha (before mip chain) ---------------------------
if (settings.Advanced.PremultiplyAlpha)
{
surface.PremultiplyAlpha();
}
// ---- 5. configure compression options ----------------------------------
compOpts.Format = SelectFormat(settings);
compOpts.Quality = SelectQuality(settings.Advanced.CompressionLevel);
if (settings.Advanced.CutoutAlpha)
{
compOpts.SetQuantization(false, false, true,
settings.Advanced.CutoutAlphaThreshold);
}
// ---- 6. configure output options ---------------------------------------
outOpts.OutputHeader = true;
outOpts.Srgb = settings.Basic.IsSRGB;
outOpts.Container = NvttContainer.NVTT_Container_DDS10;
outOpts.FileName = outputPath;
// ---- 7. mipmap count ---------------------------------------------------
var nvttFilter = SelectMipmapFilter(settings.Advanced.MipmapFilter);
int mipmapCount;
if (!settings.Advanced.GenerateMipmaps)
{
mipmapCount = 1;
}
else if (settings.Advanced.MipmapLevelCount == 0)
{
mipmapCount = surface.CountMipmaps();
}
else
{
mipmapCount = (int)settings.Advanced.MipmapLevelCount;
}
// ---- 8. enable CUDA if available ---------------------------------------
ctx.SetCudaAcceleration(Ghost.Nvtt.NvttGlobal.IsCudaSupported);
// ---- 9. write DDS header -----------------------------------------------
ctx.OutputHeader(surface, mipmapCount, compOpts, outOpts);
// ---- 10. compress mip chain using a working clone ----------------------
using var mip = surface.Clone();
for (int level = 0; level < mipmapCount; level++)
{
// Scale alpha for coverage on each mip (if requested)
if (settings.Advanced.ScaleAlphaForMipCoverage && level > 0)
{
float refCoverage = mip.AlphaTestCoverage(
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f);
mip.ScaleAlphaToCoverage(refCoverage,
settings.Advanced.ScaleAlphaForMipCoverageThreshold / 255f);
}
ctx.Compress(mip, face: 0, mipmap: level, compOpts, outOpts);
if (level + 1 < mipmapCount)
{
mip.BuildNextMipmap(nvttFilter);
}
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static NvttFormat SelectFormat(TextureAssetSettings settings)
=> settings.Basic.TextureType switch
{
TextureType.Normal => NvttFormat.NVTT_Format_BC5, // RG normal map
TextureType.SingleChannel => NvttFormat.NVTT_Format_BC4, // single channel
TextureType.Lightmap => NvttFormat.NVTT_Format_BC6U, // HDR lightmap (unsigned)
_ => NvttFormat.NVTT_Format_BC7, // default colour
};
private static NvttQuality SelectQuality(TextureCompressionLevel level)
=> level switch
{
TextureCompressionLevel.Low => NvttQuality.NVTT_Quality_Fastest,
TextureCompressionLevel.High => NvttQuality.NVTT_Quality_Production,
_ => NvttQuality.NVTT_Quality_Normal,
};
private static NvttMipmapFilter SelectMipmapFilter(MipmapFilter filter)
=> filter switch
{
MipmapFilter.Box => NvttMipmapFilter.NVTT_MipmapFilter_Box,
MipmapFilter.Triangle => NvttMipmapFilter.NVTT_MipmapFilter_Triangle,
MipmapFilter.MitchellNetravali => NvttMipmapFilter.NVTT_MipmapFilter_Mitchell,
_ => NvttMipmapFilter.NVTT_MipmapFilter_Kaiser,
};
/// <summary>
/// Produces a stable 64-bit hash of the settings structs so the cache file
/// name changes whenever any setting changes.
/// </summary>
private static ulong ComputeSettingsHash(TextureAssetSettings s)
{
var basicSize = Unsafe.SizeOf<TextureAssetSettings.BasicSettings>();
var advancedSize = Unsafe.SizeOf<TextureAssetSettings.AdvancedSettings>();
var samplerSize = Unsafe.SizeOf<TextureAssetSettings.SamplerSettings>();
var total = basicSize + advancedSize + samplerSize;
Span<byte> buf = stackalloc byte[total];
var basic = s.Basic;
var advanced = s.Advanced;
var sampler = s.Sampler;
MemoryMarshal.Write(buf, in basic);
MemoryMarshal.Write(buf.Slice(basicSize), in advanced);
MemoryMarshal.Write(buf.Slice(basicSize + advancedSize), in sampler);
return XxHash64.HashToUInt64(buf);
}
}