2 Commits

Author SHA1 Message Date
e7fedfd35a Update asset system for deferred allocation & add unit tests
Modernize Misaki.HighPerformance dependencies. Refactor texture asset creation to use deferred resource slots via CreateEmpty(). Remove fallback resource fields and update texture resolution logic. Add CreateEmpty() to resource database interfaces. Introduce comprehensive unit tests with mocks for asset management. Enable unsafe code in tests.
2026-05-02 22:54:58 +09:00
e384a2f38c feat(meshlet): add cluster LOD hierarchy & API upgrades
Implemented meshlet cluster LOD hierarchy with binary-to-4-ary conversion. Updated MeshletHierarchyNode to 4-ary structure. Enhanced SIMD optimizations in GGX mipmap generation. ResourceManager mesh/material creation now supports dynamic buffers and optional naming. Upgraded SPMD package to 1.3.2. Performed minor code cleanups and doc improvements.
2026-05-01 15:06:27 +09:00
17 changed files with 972 additions and 105 deletions

View File

@@ -1,7 +0,0 @@
Use this instructions when writing a git commit message
The first line should be a single line with no more than 50 characters that summary the changes. The second line should be blank. Start at the third line for actual changes.
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Commits MUST be prefixed with a type, which consists of a noun, feat, fix, etc., followed by the OPTIONAL scope, OPTIONAL !, and REQUIRED terminal colon and space. The type feat MUST be used when a commit adds a new feature to your application or library. The type fix MUST be used when a commit represents a bug fix for your application. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., fix(parser) A description MUST immediately follow the colon and space after the typescope prefix. The description is a short summary of the code changes, e.g., fix array parsing issue when multiple spaces were contained in string. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description. A commit body is free-form and MAY consist of any number of newline separated paragraphs. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a or # separator, followed by a string value (this is inspired by the git trailer convention). A footers token MUST use - in place of whitespace characters, e.g., Acked-by (this helps differentiate the footer section from a multi-paragraph body). An exception is made for BREAKING CHANGE, which MAY also be used as a token. A footers value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer tokenseparator pair is observed. Breaking changes MUST be indicated in the typescope prefix of a commit, or as an entry in the footer. If included as a footer, a breaking change MUST consist of the uppercase text BREAKING CHANGE, followed by a colon, space, and description, e.g., BREAKING CHANGE environment variables now take precedence over config files. If included in the typescope prefix, breaking changes MUST be indicated by a ! immediately before the . If ! is used, BREAKING CHANGE MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change. Types other than feat and fix MAY be used in your commit messages, e.g., docs update ref docs. The units of information that make up Conventional Commits MUST NOT be treated as case-sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.

View File

@@ -758,7 +758,7 @@ public static unsafe partial class MeshProcessor
} }
} }
return 0; return pMeshletData->groups.Count - 1;
} }
/// <summary> /// <summary>
@@ -846,8 +846,266 @@ public static unsafe partial class MeshProcessor
pMeshletData->materialSlotCount = maxMaterialSlot + 1; pMeshletData->materialSlotCount = maxMaterialSlot + 1;
} }
public static void BuildClusterLodHierarchy() private struct TempBinaryNode
{ {
// TODO: Implement a function that builds a cluster LOD hierarchy for a mesh, which can be used for efficient rendering of large meshes with varying levels of detail. public AABB bounds;
public float maxParentError;
public int leftChild;
public int rightChild;
public int meshletIndex;
}
private static int BuildBinaryTree(UnsafeList<TempBinaryNode> nodes, UnsafeArray<int> meshletIndices, int start, int end, ReadOnlySpan<Meshlet> meshlets)
{
if (start == end - 1)
{
var meshletIndex = meshletIndices[start];
ref readonly var m = ref meshlets[meshletIndex];
var node = new TempBinaryNode
{
bounds = m.boundingBox,
maxParentError = m.parentError,
leftChild = -1,
rightChild = -1,
meshletIndex = meshletIndex
};
var nodeIndex = nodes.Count;
nodes.Add(node);
return nodeIndex;
}
// Compute centroid bounds
var centroidMin = new float3(float.MaxValue);
var centroidMax = new float3(float.MinValue);
for (var i = start; i < end; i++)
{
var m = meshlets[meshletIndices[i]];
var center = m.boundingBox.Center;
centroidMin = math.min(centroidMin, center);
centroidMax = math.max(centroidMax, center);
}
var extents = centroidMax - centroidMin;
var splitAxis = 0;
if (extents.y > extents.x && extents.y > extents.z) splitAxis = 1;
if (extents.z > extents.x && extents.z > extents.y) splitAxis = 2;
var splitPoint = centroidMin[splitAxis] + extents[splitAxis] * 0.5f;
// Partition
var mid = start;
for (var i = start; i < end; i++)
{
var center = meshlets[meshletIndices[i]].boundingBox.Center;
if (center[splitAxis] < splitPoint)
{
var temp = meshletIndices[mid];
meshletIndices[mid] = meshletIndices[i];
meshletIndices[i] = temp;
mid++;
}
}
if (mid == start || mid == end)
{
mid = start + (end - start) / 2;
}
var left = BuildBinaryTree(nodes, meshletIndices, start, mid, meshlets);
var right = BuildBinaryTree(nodes, meshletIndices, mid, end, meshlets);
var leftNode = nodes[left];
var rightNode = nodes[right];
var mergedBounds = new AABB(
math.min(leftNode.bounds.Min, rightNode.bounds.Min),
math.max(leftNode.bounds.Max, rightNode.bounds.Max)
);
var internalNodeIndex = nodes.Count;
nodes.Add(new TempBinaryNode
{
bounds = mergedBounds,
maxParentError = Math.Max(leftNode.maxParentError, rightNode.maxParentError),
leftChild = left,
rightChild = right,
meshletIndex = -1
});
return internalNodeIndex;
}
private static void GatherChildren(UnsafeList<TempBinaryNode> binaryNodes, int nodeIndex, UnsafeList<int> gathered)
{
gathered.Clear();
var node = binaryNodes[nodeIndex];
if (node.leftChild != -1) gathered.Add(node.leftChild);
if (node.rightChild != -1) gathered.Add(node.rightChild);
while (gathered.Count < 4)
{
var largestInternalIndex = -1;
var maxSurfaceArea = -1.0f;
var listIndexToRemove = -1;
for (var i = 0; i < gathered.Count; i++)
{
var childIdx = gathered[i];
var childNode = binaryNodes[childIdx];
if (childNode.leftChild != -1) // is internal
{
var extents = childNode.bounds.Extents;
var sa = extents.x * extents.y + extents.y * extents.z + extents.z * extents.x;
if (sa > maxSurfaceArea)
{
maxSurfaceArea = sa;
largestInternalIndex = childIdx;
listIndexToRemove = i;
}
}
}
if (largestInternalIndex == -1) break; // all gathered are leaves
gathered.RemoveAt(listIndexToRemove);
var largestNode = binaryNodes[largestInternalIndex];
if (largestNode.leftChild != -1) gathered.Add(largestNode.leftChild);
if (largestNode.rightChild != -1) gathered.Add(largestNode.rightChild);
}
}
private static int CollapseTo4Ary(UnsafeList<TempBinaryNode> binaryNodes, int binaryNodeIndex, UnsafeList<MeshletHierarchyNode> hierarchyNodes)
{
var node = binaryNodes[binaryNodeIndex];
if (node.leftChild == -1)
{
return -1;
}
using var gathered = new UnsafeList<int>(4, AllocationHandle.FreeList);
GatherChildren(binaryNodes, binaryNodeIndex, gathered);
var bvhNode = new MeshletHierarchyNode();
var minX = new float4(float.PositiveInfinity);
var minY = new float4(float.PositiveInfinity);
var minZ = new float4(float.PositiveInfinity);
var maxX = new float4(float.NegativeInfinity);
var maxY = new float4(float.NegativeInfinity);
var maxZ = new float4(float.NegativeInfinity);
var maxParentError = new float4(0);
var nodeData = new uint4(0xFFFFFFFF);
var outNodeIndex = hierarchyNodes.Count;
hierarchyNodes.Add(bvhNode); // Reserve slot
for (var i = 0; i < gathered.Count; i++)
{
var childIdx = gathered[i];
var childNode = binaryNodes[childIdx];
uint data = 0;
if (childNode.leftChild == -1)
{
data = (uint)childNode.meshletIndex;
}
else
{
var child4AryIndex = CollapseTo4Ary(binaryNodes, childIdx, hierarchyNodes);
data = (1u << 31) | (uint)child4AryIndex;
}
if (i == 0)
{
minX.x = childNode.bounds.Min.x; minY.x = childNode.bounds.Min.y; minZ.x = childNode.bounds.Min.z;
maxX.x = childNode.bounds.Max.x; maxY.x = childNode.bounds.Max.y; maxZ.x = childNode.bounds.Max.z;
maxParentError.x = childNode.maxParentError;
nodeData.x = data;
}
else if (i == 1)
{
minX.y = childNode.bounds.Min.x; minY.y = childNode.bounds.Min.y; minZ.y = childNode.bounds.Min.z;
maxX.y = childNode.bounds.Max.x; maxY.y = childNode.bounds.Max.y; maxZ.y = childNode.bounds.Max.z;
maxParentError.y = childNode.maxParentError;
nodeData.y = data;
}
else if (i == 2)
{
minX.z = childNode.bounds.Min.x; minY.z = childNode.bounds.Min.y; minZ.z = childNode.bounds.Min.z;
maxX.z = childNode.bounds.Max.x; maxY.z = childNode.bounds.Max.y; maxZ.z = childNode.bounds.Max.z;
maxParentError.z = childNode.maxParentError;
nodeData.z = data;
}
else if (i == 3)
{
minX.w = childNode.bounds.Min.x; minY.w = childNode.bounds.Min.y; minZ.w = childNode.bounds.Min.z;
maxX.w = childNode.bounds.Max.x; maxY.w = childNode.bounds.Max.y; maxZ.w = childNode.bounds.Max.z;
maxParentError.w = childNode.maxParentError;
nodeData.w = data;
}
}
bvhNode.minX = minX;
bvhNode.minY = minY;
bvhNode.minZ = minZ;
bvhNode.maxX = maxX;
bvhNode.maxY = maxY;
bvhNode.maxZ = maxZ;
bvhNode.maxParentError = maxParentError;
bvhNode.nodeData = nodeData;
hierarchyNodes[outNodeIndex] = bvhNode;
return outNodeIndex;
}
public static void BuildClusterLodHierarchy(MeshletMeshData* pMeshletData)
{
if (pMeshletData->meshletCount == 0) return;
using var meshletIndices = new UnsafeArray<int>(pMeshletData->meshletCount, AllocationHandle.FreeList);
for (var i = 0; i < pMeshletData->meshletCount; i++)
{
meshletIndices[i] = i;
}
var meshletsSpan = new ReadOnlySpan<Meshlet>(pMeshletData->meshlets.GetUnsafePtr(), pMeshletData->meshlets.Count);
using var binaryNodes = new UnsafeList<TempBinaryNode>(pMeshletData->meshletCount * 2, AllocationHandle.FreeList);
var rootIndex = BuildBinaryTree(binaryNodes, meshletIndices, 0, meshletIndices.Length, meshletsSpan);
if (!pMeshletData->hierarchyNodes.IsCreated)
{
pMeshletData->hierarchyNodes = new UnsafeList<MeshletHierarchyNode>(pMeshletData->meshletCount, AllocationHandle.Persistent);
}
if (binaryNodes[rootIndex].leftChild == -1)
{
var bvhNode = new MeshletHierarchyNode();
bvhNode.minX = new float4(float.PositiveInfinity);
bvhNode.minY = new float4(float.PositiveInfinity);
bvhNode.minZ = new float4(float.PositiveInfinity);
bvhNode.maxX = new float4(float.NegativeInfinity);
bvhNode.maxY = new float4(float.NegativeInfinity);
bvhNode.maxZ = new float4(float.NegativeInfinity);
bvhNode.maxParentError = new float4(0);
bvhNode.nodeData = new uint4(0xFFFFFFFF);
var childNode = binaryNodes[rootIndex];
bvhNode.minX.x = childNode.bounds.Min.x;
bvhNode.minY.x = childNode.bounds.Min.y;
bvhNode.minZ.x = childNode.bounds.Min.z;
bvhNode.maxX.x = childNode.bounds.Max.x;
bvhNode.maxY.x = childNode.bounds.Max.y;
bvhNode.maxZ.x = childNode.bounds.Max.z;
bvhNode.maxParentError.x = childNode.maxParentError;
bvhNode.nodeData.x = (uint)childNode.meshletIndex;
pMeshletData->hierarchyNodes.Add(bvhNode);
}
else
{
CollapseTo4Ary(binaryNodes, rootIndex, pMeshletData->hierarchyNodes);
}
} }
} }

View File

@@ -34,7 +34,7 @@ internal static partial class TextureProcessor
public int numMipLevels; public int numMipLevels;
public int channelCount; public int channelCount;
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut) private static Vector2<TFloat, float> Hammersley(TFloat i, int N, float* lut)
{ {
var x = i / N; var x = i / N;
@@ -43,23 +43,18 @@ internal static partial class TextureProcessor
} }
// GGX Importance Sampling // GGX Importance Sampling
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness) private static Vector3<TFloat, float> ImportanceSampleGGX(Vector2<TFloat, float> Xi, Vector3<TFloat, float> N, float roughness)
{ {
var a = roughness * roughness; // Disney remap roughness for better visual linearity var a = roughness * roughness; // Disney remap roughness for better visual linearity
var phi = 2.0f * PI * Xi.x; var phi = 2.0f * PI * Xi.x;
// Clamp the inside of the cosTheta Sqrt to prevent NaN on division precision edges var cosTheta = TFloat.Sqrt((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y));
var cosThetaInner = TFloat.Max((1.0f - Xi.y) / (1.0f + (a * a - 1.0f) * Xi.y), TFloat.Zero); var sinTheta = TFloat.Sqrt(1.0f - cosTheta * cosTheta);
var cosTheta = TFloat.Sqrt(cosThetaInner);
// Clamp the inside of sinTheta to prevent sqrt of negative floating-point errors
var sinThetaInner = TFloat.Max(1.0f - cosTheta * cosTheta, TFloat.Zero);
var sinTheta = TFloat.Sqrt(sinThetaInner);
// Spherical to Cartesian coordinates (Halfway vector) // Spherical to Cartesian coordinates (Halfway vector)
var (sinPhi, cosPhi) = TFloat.SinCos(phi); TFloat.SinCos(phi, out var sinPhi, out var cosPhi);
var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta); var H = MathV.Create<TFloat, float>(cosPhi * sinTheta, sinPhi * sinTheta, cosTheta);
// Tangent space to World space // Tangent space to World space
@@ -73,13 +68,13 @@ internal static partial class TextureProcessor
return MathV.Normalize(sampleVec); return MathV.Normalize(sampleVec);
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private static float3 CubemapUVToDir(int face, float u, float v) private static float3 CubemapUVToDir(int face, float u, float v)
{ {
var sc = 2.0f * u - 1.0f; var sc = 2.0f * u - 1.0f;
var tc = 1.0f - 2.0f * v; var tc = 1.0f - 2.0f * v;
float x = 0, y = 0, z = 0; float x = 0.0f, y = 0.0f, z = 0.0f;
switch (face) switch (face)
{ {
case 0: x = 1.0f; y = tc; z = -sc; break; case 0: x = 1.0f; y = tc; z = -sc; break;
@@ -93,7 +88,7 @@ internal static partial class TextureProcessor
return normalize(float3(x, y, z)); return normalize(float3(x, y, z));
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.AggressiveOptimization)]
private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir) private static Vector3<TFloat, float> SampleCubemap(float* img, int edge, int c, Vector3<TFloat, float> dir)
{ {
var absX = TFloat.Abs(dir.x); var absX = TFloat.Abs(dir.x);
@@ -140,6 +135,7 @@ internal static partial class TextureProcessor
return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1); return MathV.GatherVector3<TFloat, float>(img, idx.GetUnsafePtr(), 1);
} }
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
public void Execute(int loopIndex, ref readonly JobExecutionContext ctx) public void Execute(int loopIndex, ref readonly JobExecutionContext ctx)
{ {
var m = 0; var m = 0;
@@ -226,7 +222,7 @@ internal static partial class TextureProcessor
} }
var totalWeight = 0.0f; var totalWeight = 0.0f;
var prefilteredColor = float3(0, 0, 0); var prefilteredColor = float3(0.0f, 0.0f, 0.0f);
for (var i = 0; i < TFloat.LaneWidth; i++) for (var i = 0; i < TFloat.LaneWidth; i++)
{ {

View File

@@ -20,14 +20,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Misaki.HighPerformance" Version="1.0.8" /> <PackageReference Include="Misaki.HighPerformance" Version="1.0.9" />
<PackageReference Include="Misaki.HighPerformance.Jobs" Version="3.1.2" /> <PackageReference Include="Misaki.HighPerformance.Jobs" Version="3.1.3" />
<PackageReference Include="Misaki.HighPerformance.LowLevel" Version="1.6.16"> <PackageReference Include="Misaki.HighPerformance.LowLevel" Version="1.6.17">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Misaki.HighPerformance.Mathematics" Version="1.3.3" /> <PackageReference Include="Misaki.HighPerformance.Mathematics" Version="1.3.3" />
<PackageReference Include="Misaki.HighPerformance.Mathematics.SPMD" Version="1.3.0" /> <PackageReference Include="Misaki.HighPerformance.Mathematics.SPMD" Version="1.3.4" />
<PackageReference Include="System.IO.Hashing" Version="10.0.7" /> <PackageReference Include="System.IO.Hashing" Version="10.0.7" />
<PackageReference Include="TerraFX.Interop.Mimalloc" Version="1.6.7.2" /> <PackageReference Include="TerraFX.Interop.Mimalloc" Version="1.6.7.2" />
<PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.6" /> <PackageReference Include="TerraFX.Interop.Windows" Version="10.0.26100.6" />

View File

@@ -34,7 +34,7 @@ internal partial class AssetEntry
{ {
// This will create a new slot in the database, but not allocation any GPU resource. // This will create a new slot in the database, but not allocation any GPU resource.
// Everything in the slot will have the same value as the fallback texture, expect the slot will be marked as shared. // Everything in the slot will have the same value as the fallback texture, expect the slot will be marked as shared.
var handle = e._resourceDatabase.CreateShared(e._assetManager.FallbackTexture.AsResource()).AsTexture(); var handle = e._resourceDatabase.CreateEmpty().AsTexture();
e.SetStorage(handle); e.SetStorage(handle);
}; };
@@ -155,7 +155,7 @@ internal partial class AssetManager
{ {
if (assetID == Guid.Empty) if (assetID == Guid.Empty)
{ {
return _fallbackTexture; return Handle<GPUTexture>.Invalid;
} }
var entry = GetOrCreateEntry(assetID); var entry = GetOrCreateEntry(assetID);

View File

@@ -295,17 +295,9 @@ internal partial class AssetManager : IDisposable
private readonly ConcurrentDictionary<Guid, AssetEntry> _entries; private readonly ConcurrentDictionary<Guid, AssetEntry> _entries;
// TODO
private Handle<GPUTexture> _fallbackTexture;
private Handle<GPUTexture> _fallbackNormalMap;
private Handle<Mesh> _fallbackMesh;
private Handle<Material> _fallbackMaterial;
public IContentProvider ContentProvider => _contentProvider; public IContentProvider ContentProvider => _contentProvider;
public ResourceStreamingProcessor StreamingProcessor => _streamingProcessor; public ResourceStreamingProcessor StreamingProcessor => _streamingProcessor;
public Handle<GPUTexture> FallbackTexture => _fallbackTexture;
internal AssetManager(IResourceDatabase resourceDatabase, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler) internal AssetManager(IResourceDatabase resourceDatabase, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler)
{ {
_resourceDatabase = resourceDatabase; _resourceDatabase = resourceDatabase;
@@ -378,10 +370,10 @@ internal partial class AssetManager : IDisposable
for (var i = list.Count - 1; i >= 0; i--) for (var i = list.Count - 1; i >= 0; i--)
{ {
// This should create the entry and schedule the job on those assets does not have any dependency first. // This should create the entry and schedule the job on those assets does not have any dependency first.
var handle = GetOrCreateEntry(list[i]).LoadJobHandle; var depHandle = GetOrCreateEntry(list[i]).LoadJobHandle;
Logger.DebugAssert(handle.IsValid); Logger.DebugAssert(depHandle.IsValid);
depHandles.Add(handle); depHandles.Add(depHandle);
} }
dependency = _jobScheduler.CombineDependencies(depHandles); dependency = _jobScheduler.CombineDependencies(depHandles);
@@ -394,7 +386,8 @@ internal partial class AssetManager : IDisposable
assetManager = this, assetManager = this,
}; };
entry.SetLoadJobHandle(_jobScheduler.Schedule(ref job, JobPriority.Low, dependency)); // Use low priority to avoid blocking main thread critical tasks like rendering and physics. var handle = _jobScheduler.Schedule(ref job, JobPriority.Low, dependency);
entry.SetLoadJobHandle(handle); // Use low priority to avoid blocking main thread critical tasks like rendering and physics.
} }
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]

View File

@@ -1,6 +1,5 @@
using Ghost.Core; using Ghost.Core;
using Ghost.Graphics; using Ghost.Graphics;
using SharpCompress.Common;
using System.Collections.Concurrent; using System.Collections.Concurrent;
namespace Ghost.Engine; namespace Ghost.Engine;

View File

@@ -317,7 +317,7 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase
_resources.Remove(handle.ID, handle.Generation); _resources.Remove(handle.ID, handle.Generation);
#if DEBUG || GHOST_EDITOR #if DEBUG || GHOST_EDITOR
_resourceName.Remove(handle, out var name); _resourceName.Remove(handle, out _);
#endif #endif
} }
@@ -442,6 +442,15 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase
} }
} }
public Handle<GPUResource> CreateEmpty()
{
lock (_writeLock)
{
var id = _resources.Add(default, out var generation);
return new Handle<GPUResource>(id, generation);
}
}
public void* MapResource(Handle<GPUResource> handle, uint subResource, ResourceRange? readRange) public void* MapResource(Handle<GPUResource> handle, uint subResource, ResourceRange? readRange)
{ {
var r = GetResourceRecord(handle); var r = GetResourceRecord(handle);

View File

@@ -142,6 +142,12 @@ public unsafe interface IResourceDatabase : IDisposable
/// <returns>The handle to the newly created shared resource.</returns> /// <returns>The handle to the newly created shared resource.</returns>
Handle<GPUResource> CreateShared(Handle<GPUResource> src); Handle<GPUResource> CreateShared(Handle<GPUResource> src);
/// <summary>
/// Creates a slot in the resource database that contains no GPU resource, and returns a handle to that slot. This can be used as a placeholder for a resource that will be created or assigned later, allowing for deferred resource creation and management.
/// </summary>
/// <returns>The handle to the newly created empty resource slot.</returns>
Handle<GPUResource> CreateEmpty();
/// <summary> /// <summary>
/// Maps a subresource of a GPU resource for CPU access, specifying read and write ranges. /// Maps a subresource of a GPU resource for CPU access, specifying read and write ranges.
/// </summary> /// </summary>

View File

@@ -41,10 +41,20 @@ public struct MeshletGroup
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
public struct MeshletHierarchyNode public struct MeshletHierarchyNode
{ {
public SphereBounds boundingSphere; // 16 bytes public float4 minX;
public AABB boundingBox; // 24 bytes public float4 minY;
public float maxParentError; // maximum error in this subtree public float4 minZ;
public uint nodeData; // packed leaf/internal metadata public float4 maxX;
public float4 maxY;
public float4 maxZ;
public float4 maxParentError;
// x,y,z,w correspond to children 0,1,2,3.
// MSB (1 << 31) indicates it's an Internal Node.
// If MSB is 0, the remaining 31 bits are the MeshletIndex.
// If MSB is 1, the remaining 31 bits are the child MeshletHierarchyNode index.
// 0xFFFFFFFF means invalid/empty slot.
public uint4 nodeData;
} }
[StructLayout(LayoutKind.Sequential)] [StructLayout(LayoutKind.Sequential)]
@@ -183,18 +193,6 @@ public struct Mesh : IResourceReleasable
get; internal set; get; internal set;
} }
internal Mesh(ReadOnlySpan<Vertex> vertices, ReadOnlySpan<uint> indices, Handle<GPUBuffer> vertexBuffer, Handle<GPUBuffer> indexBuffer)
{
Vertices = new UnsafeList<Vertex>(vertices.Length, AllocationHandle.Persistent);
Indices = new UnsafeList<uint>(indices.Length, AllocationHandle.Persistent);
Vertices.CopyFrom(vertices);
Indices.CopyFrom(indices);
VertexBuffer = vertexBuffer;
IndexBuffer = indexBuffer;
this.ComputeBounds();
}
public void ReleaseCpuResources() public void ReleaseCpuResources()
{ {
_vertices.Dispose(); _vertices.Dispose();

View File

@@ -110,21 +110,21 @@ public sealed partial class ResourceManager : IDisposable
/// <summary> /// <summary>
/// Creates a new mesh from the specified vertex and index data. /// Creates a new mesh from the specified vertex and index data.
/// </summary> /// </summary>
/// <param name="vertices">A UnsafeList containing the vertices that define the geometry of the mesh. Must contain at least one vertex.</param> /// <param name="vertices">A UnsafeList containing the vertices that define the geometry of the mesh.</param>
/// <param name="indices">A UnsafeList containing the indices that specify how vertices are connected to form primitives. Must contain at least one index.</param> /// <param name="indices">A UnsafeList containing the indices that specify how vertices are connected to form primitives.</param>
/// <param name="dynamic">Indicates whether the mesh is expected to be updated frequently. If true, the underlying GPU buffers will be created with upload heap type for better CPU write performance.</param>
/// <param name="name">The name of the mesh.</param>
/// <returns>An <see cref="Identifier{Mesh}"/> representing the newly created mesh.</returns> /// <returns>An <see cref="Identifier{Mesh}"/> representing the newly created mesh.</returns>
public unsafe Handle<Mesh> CreateMesh(UnsafeList<Vertex> vertices, UnsafeList<uint> indices) public unsafe Handle<Mesh> CreateMesh(UnsafeList<Vertex> vertices, UnsafeList<uint> indices, bool dynamic = false, string? name = null)
{ {
Logger.DebugAssert(!_disposed); Logger.DebugAssert(!_disposed);
lock (_meshWriteLock)
{
var vertexBufferDesc = new BufferDesc var vertexBufferDesc = new BufferDesc
{ {
Size = (uint)(vertices.Count * sizeof(Vertex)), Size = (uint)(vertices.Count * sizeof(Vertex)),
Stride = (uint)sizeof(Vertex), Stride = (uint)sizeof(Vertex),
Usage = BufferUsage.Vertex | BufferUsage.ShaderResource | BufferUsage.Raw, Usage = BufferUsage.Vertex | BufferUsage.ShaderResource | BufferUsage.Raw,
HeapType = HeapType.Default, HeapType = dynamic ? HeapType.Upload : HeapType.Default,
}; };
var indexBufferDesc = new BufferDesc var indexBufferDesc = new BufferDesc
@@ -132,20 +132,21 @@ public sealed partial class ResourceManager : IDisposable
Size = (uint)(indices.Count * sizeof(uint)), Size = (uint)(indices.Count * sizeof(uint)),
Stride = sizeof(uint), Stride = sizeof(uint),
Usage = BufferUsage.Index | BufferUsage.ShaderResource | BufferUsage.Raw, Usage = BufferUsage.Index | BufferUsage.ShaderResource | BufferUsage.Raw,
HeapType = HeapType.Default, HeapType = dynamic ? HeapType.Upload : HeapType.Default,
}; };
var objectBufferDesc = new BufferDesc var meshDataBufferDesc = new BufferDesc
{ {
Size = (uint)sizeof(MeshData), Size = (uint)sizeof(MeshData),
Stride = (uint)sizeof(MeshData), Stride = (uint)sizeof(MeshData),
Usage = BufferUsage.Raw | BufferUsage.ShaderResource, Usage = BufferUsage.Raw | BufferUsage.ShaderResource,
HeapType = HeapType.Default, HeapType = dynamic ? HeapType.Upload : HeapType.Default,
}; };
var vertexBuffer = _resourceAllocator.CreateBuffer(in vertexBufferDesc, "VertexBuffer"); var hasName = name != null;
var indexBuffer = _resourceAllocator.CreateBuffer(in indexBufferDesc, "IndexBuffer"); var vertexBuffer = _resourceAllocator.CreateBuffer(in vertexBufferDesc, hasName ? $"{name}_VertexBuffer" : "VertexBuffer");
var objectBuffer = _resourceAllocator.CreateBuffer(in objectBufferDesc, "ObjectBuffer"); var indexBuffer = _resourceAllocator.CreateBuffer(in indexBufferDesc, hasName ? $"{name}_IndexBuffer" : "IndexBuffer");
var meshDataBuffer = _resourceAllocator.CreateBuffer(in meshDataBufferDesc, hasName ? $"{name}_MeshDataBuffer" : "MeshDataBuffer");
var mesh = new Mesh var mesh = new Mesh
{ {
@@ -153,9 +154,12 @@ public sealed partial class ResourceManager : IDisposable
Indices = indices, Indices = indices,
VertexBuffer = vertexBuffer, VertexBuffer = vertexBuffer,
IndexBuffer = indexBuffer, IndexBuffer = indexBuffer,
MeshDataBuffer = objectBuffer, MeshDataBuffer = meshDataBuffer,
}; };
lock (_meshWriteLock)
{
var id = _meshes.Add(mesh, out var generation); var id = _meshes.Add(mesh, out var generation);
return new Handle<Mesh>(id, generation); return new Handle<Mesh>(id, generation);
} }
@@ -165,20 +169,20 @@ public sealed partial class ResourceManager : IDisposable
/// Creates a new material instance using the specified shader. /// Creates a new material instance using the specified shader.
/// </summary> /// </summary>
/// <param name="shader">The identifier of the shader to associate with the new material.</param> /// <param name="shader">The identifier of the shader to associate with the new material.</param>
/// <param name="name">The name of the material.</param>
/// <returns>An <see cref="Handle{Material}"/> representing the newly created material.</returns> /// <returns>An <see cref="Handle{Material}"/> representing the newly created material.</returns>
public Handle<Material> CreateMaterial(Handle<Shader> shader) public Handle<Material> CreateMaterial(Handle<Shader> shader, string? name = null)
{ {
Logger.DebugAssert(!_disposed); Logger.DebugAssert(!_disposed);
var material = new Material(); var material = new Material();
lock (_materialWriteLock)
{
if (material.SetShader(shader, this, _resourceDatabase, _resourceAllocator) != Error.None) if (material.SetShader(shader, this, _resourceDatabase, _resourceAllocator) != Error.None)
{ {
return Handle<Material>.Invalid; return Handle<Material>.Invalid;
} }
lock (_materialWriteLock)
{
var id = _materials.Add(material, out var generation); var id = _materials.Add(material, out var generation);
return new Handle<Material>(id, generation); return new Handle<Material>(id, generation);
} }

View File

@@ -0,0 +1,60 @@
using Ghost.Engine;
using Ghost.UnitTest.MockingEnvironment;
using Misaki.HighPerformance.Jobs;
namespace Ghost.UnitTest.AssetSystem;
[TestClass]
public class AssetManagerTest
{
private MockingResourceDatabase _resourceDatabase = null!;
private MockingResourceAllocator _resourceAllocator = null!;
private MockingCommandBuffer _commandBuffer = null!;
private MockingContentProvider _provider = null!;
private ResourceStreamingProcessor _processor = null!;
private JobScheduler _jobScheduler = null!;
private AssetManager _assetManager = null!;
[TestInitialize]
public void Setup()
{
_resourceDatabase = new MockingResourceDatabase();
_resourceAllocator = new MockingResourceAllocator(_resourceDatabase);
_commandBuffer = new MockingCommandBuffer(_resourceDatabase);
_provider = new MockingContentProvider();
_processor = new ResourceStreamingProcessor();
var schedulerDesc = new JobSchedulerDesc
{
ThreadCount = 1,
ThreadPriority = ThreadPriority.Normal,
DependencyChainCapacity = 1024,
};
_jobScheduler = new JobScheduler(in schedulerDesc);
_assetManager = new AssetManager(_resourceDatabase, _provider, _processor, _jobScheduler);
}
[TestCleanup]
public void Cleanup()
{
_assetManager.Dispose();
_jobScheduler.Dispose();
_commandBuffer.Dispose();
_resourceAllocator.Dispose();
_resourceDatabase.Dispose();
}
[TestMethod]
public void AssetManager_GetsAssetSuccessfully()
{
var assetID = Guid.NewGuid();
_provider.AddMockTexture(assetID, readDelayMs: Random.Shared.Next(10, 50));
var handle = _assetManager.ResolveTexture(assetID);
Assert.IsTrue(handle.IsValid);
}
}

View File

@@ -7,6 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Platforms>x64;x86;ARM64</Platforms> <Platforms>x64;x86;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers> <RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,193 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.Graphics.RHI;
namespace Ghost.UnitTest.MockingEnvironment;
internal class MockingCommandBuffer : ICommandBuffer
{
private readonly IResourceDatabase _resourceDatabase;
private string _name = "MockCommandBuffer";
private bool _isEmpty = true;
// Tracking properties for test assertions
public int DrawCallCount { get; private set; }
public int CopyCallCount { get; private set; }
public int UpdateSubResourcesCount { get; private set; }
public CommandBufferType Type => default;
public bool IsEmpty => _isEmpty;
public string Name
{
get => _name;
set => _name = value;
}
public MockingCommandBuffer(IResourceDatabase resourceDatabase)
{
_resourceDatabase = resourceDatabase;
}
public void Barrier(params scoped ReadOnlySpan<BarrierDesc> barrierDescs)
{
_isEmpty = false;
lock (this)
{
foreach (var desc in barrierDescs)
{
var data = new ResourceBarrierData
{
access = desc.AccessAfter,
layout = desc.LayoutAfter,
sync = desc.SyncAfter
};
_resourceDatabase.SetResourceBarrierData(desc.Resource, data);
}
}
}
public void Begin(ICommandAllocator allocator)
{
_isEmpty = true;
DrawCallCount = 0;
CopyCallCount = 0;
UpdateSubResourcesCount = 0;
}
public void BeginRenderPass(ReadOnlySpan<PassRenderTargetDesc> rtDescs, ref readonly PassDepthStencilDesc depthDesc, bool allowUAVWrites = false)
{
_isEmpty = false;
}
public void ClearDepthStencilView(Handle<GPUTexture> depthStencil, bool inlcludeDepth, bool includeStencil, float clearDepth = 1, byte clearStencil = 0)
{
_isEmpty = false;
}
public void ClearRenderTargetView(Handle<GPUTexture> renderTarget, Color128 clearColor)
{
_isEmpty = false;
}
public void CopyBuffer(Handle<GPUBuffer> dest, Handle<GPUBuffer> src, ulong destOffset = 0, ulong srcOffset = 0, ulong numBytes = 0)
{
_isEmpty = false;
CopyCallCount++;
}
public void CopyTexture(Handle<GPUTexture> dst, TextureRegion? dstRegion, Handle<GPUTexture> src, TextureRegion? srcRegion)
{
_isEmpty = false;
CopyCallCount++;
}
public void DispatchCompute(uint threadGroupCountX, uint threadGroupCountY, uint threadGroupCountZ)
{
_isEmpty = false;
}
public void DispatchGraph(ref readonly DispatchGraphDesc desc)
{
_isEmpty = false;
}
public void DispatchMesh(uint threadGroupCountX, uint threadGroupCountY, uint threadGroupCountZ)
{
_isEmpty = false;
}
public void DispatchRay()
{
_isEmpty = false;
}
public void Dispose()
{
}
public void Draw(uint vertexCount, uint instanceCount = 1, uint startVertex = 0, uint startInstance = 0)
{
_isEmpty = false;
DrawCallCount++;
}
public void DrawIndexed(uint indexCount, uint instanceCount = 1, uint startIndex = 0, int baseVertex = 0, uint startInstance = 0)
{
_isEmpty = false;
DrawCallCount++;
}
public Result End()
{
return Result.Success();
}
public void EndRenderPass()
{
}
public void ExecuteIndirect(ICommandSignature commandSignature, Handle<GPUBuffer> argumentBuffer, ulong argumentOffset, Handle<GPUBuffer> countBuffer, ulong countBufferOffset)
{
_isEmpty = false;
}
public void SetConstantBufferView(uint slot, Handle<GPUBuffer> buffer)
{
_isEmpty = false;
}
public void SetGraphicsRoot32Constants(uint rootIndex, ReadOnlySpan<uint> constantBuffer, uint offsetIn32Bits = 0)
{
_isEmpty = false;
}
public void SetIndexBuffer(Handle<GPUBuffer> buffer, IndexType type, ulong offset = 0)
{
_isEmpty = false;
}
public void SetPipelineState(Key128<PipelineState> pipelineKey)
{
_isEmpty = false;
}
public void SetPrimitiveTopology(PrimitiveTopology topology)
{
_isEmpty = false;
}
public void SetProgram(ref readonly SetProgramDesc desc)
{
_isEmpty = false;
}
public void SetRenderTargets(ReadOnlySpan<Handle<GPUTexture>> renderTargets, Handle<GPUTexture> depthTarget)
{
_isEmpty = false;
}
public void SetScissorRect(ScissorRectDesc rect)
{
_isEmpty = false;
}
public void SetVertexBuffer(uint slot, Handle<GPUBuffer> buffer, ulong offset = 0)
{
_isEmpty = false;
}
public void SetViewport(ViewportDesc viewport)
{
_isEmpty = false;
}
public void UpdateSubResources(Handle<GPUResource> resource, Handle<GPUResource> intermediate, params scoped ReadOnlySpan<SubResourceData> subResources)
{
_isEmpty = false;
UpdateSubResourcesCount++;
}
}

View File

@@ -0,0 +1,95 @@
using Ghost.Core;
using Ghost.Engine;
using System.Collections.Concurrent;
namespace Ghost.UnitTest.MockingEnvironment;
internal class MockingContentProvider : IContentProvider
{
public class MockAssetData
{
public AssetType type;
public byte[] data = Array.Empty<byte>();
public Guid[] dependencies = Array.Empty<Guid>();
// This is crucial for multi-threaded testing: we can inject random or fixed
// delays to ensure our locking and state machines actually get stressed.
public int readDelayMs = 0;
}
private readonly ConcurrentDictionary<Guid, MockAssetData> _assets = new();
public void AddMockAsset(Guid guid, MockAssetData data)
{
_assets[guid] = data;
}
/// <summary>
/// Helper method to create a valid dummy texture byte stream that the AssetEntry can parse.
/// </summary>
public unsafe void AddMockTexture(Guid guid, uint width = 4, uint height = 4, int readDelayMs = 0)
{
var header = new TextureContentHeader
{
width = width,
height = height,
bpc = 8,
mipLevels = 1,
dimension = 2, // Texture2D
colorComponents = 4
};
// Header size is strictly 64 bytes due to [StructLayout(LayoutKind.Sequential, Size = 64)]
var headerSize = 64;
var pixelDataSize = width * height * 4;
var buffer = new byte[headerSize + pixelDataSize];
fixed (byte* pBuffer = buffer)
{
*(TextureContentHeader*)pBuffer = header;
// The rest of the array remains 0 (black/transparent pixels) which is fine for tests
}
AddMockAsset(guid, new MockAssetData
{
type = AssetType.Texture,
data = buffer,
readDelayMs = readDelayMs
});
}
public AssetType GetAssetType(Guid guid)
{
return _assets.TryGetValue(guid, out var asset) ? asset.type : AssetType.Unknown;
}
public Guid[] GetDependencies(Guid guid)
{
return _assets.TryGetValue(guid, out var asset) ? asset.dependencies : Array.Empty<Guid>();
}
public bool HasAsset(Guid guid)
{
return _assets.ContainsKey(guid);
}
public Result<Stream> OpenRead(Guid guid, CancellationToken token = default)
{
if (_assets.TryGetValue(guid, out var asset))
{
if (asset.readDelayMs > 0)
{
// Inject our simulated I/O latency to widen race condition windows.
// In a real multi-threaded test, this forces the executing thread to yield
// and lets other threads interact with the AssetManager in the meantime.
Thread.Sleep(asset.readDelayMs);
}
// Return a fast, in-memory stream representing our file
return Result<Stream>.Success(new MemoryStream(asset.data, writable: false));
}
return Result<Stream>.Failure($"Mock asset {guid} not found.");
}
}

View File

@@ -0,0 +1,56 @@
using Ghost.Core;
using Ghost.Graphics.RHI;
namespace Ghost.UnitTest.MockingEnvironment;
internal class MockingResourceAllocator : IResourceAllocator
{
private readonly MockingResourceDatabase _database;
public MockingResourceAllocator(MockingResourceDatabase database)
{
_database = database;
}
public Handle<GPUResource> Allocate(ref readonly AllocationDesc desc, string? name = null)
{
var barrier = new ResourceBarrierData { layout = BarrierLayout.Common, access = BarrierAccess.NoAccess, sync = BarrierSync.None };
// Passing a mock buffer desc for raw allocation representation
var bufferDesc = new BufferDesc { Size = desc.Size, Usage = BufferUsage.None };
return _database.AddMockResource(ResourceDesc.Buffer(bufferDesc), barrier, name);
}
public Handle<GPUBuffer> CreateBuffer(ref readonly BufferDesc desc, string? name = null, CreationOptions options = default)
{
var barrier = new ResourceBarrierData { layout = BarrierLayout.Undefined, access = BarrierAccess.Common, sync = BarrierSync.None };
var handle = _database.AddMockResource(ResourceDesc.Buffer(desc), barrier, name);
return handle.AsBuffer();
}
public Identifier<Sampler> CreateSampler(ref readonly SamplerDesc desc)
{
return _database.AddSampler(in desc, 1);
}
public Handle<GPUTexture> CreateTexture(ref readonly TextureDesc desc, string? name = null, CreationOptions options = default, AdditionalTextureDesc additionalDesc = default)
{
var barrier = new ResourceBarrierData { layout = BarrierLayout.Common, access = BarrierAccess.Common, sync = BarrierSync.None };
var handle = _database.AddMockResource(ResourceDesc.Texture(desc), barrier, name);
return handle.AsTexture();
}
public ResourceSizeInfo GetSizeInfo(ResourceDesc desc)
{
return new ResourceSizeInfo
{
Size = 1048576, // 1MB mock
Alignment = 65536, // 64KB aligned
Offset = 0
};
}
public void Dispose()
{
// Handled by dependency injection usually.
}
}

View File

@@ -0,0 +1,206 @@
using Ghost.Core;
using Ghost.Graphics.RHI;
using System.Collections.Concurrent;
namespace Ghost.UnitTest.MockingEnvironment;
internal unsafe class MockingResourceDatabase : IResourceDatabase
{
internal class MockResourceRecord
{
public ResourceDesc desc;
public ResourceBarrierData barrierData;
public string? name;
public int refCount = 1;
public bool isShared;
}
private readonly ConcurrentDictionary<ulong, MockResourceRecord> _resources = new();
private readonly ConcurrentDictionary<Identifier<Sampler>, SamplerDesc> _samplers = new();
private int _nextToken = 0;
private int _samplerToken = 0;
private static ulong GetKey(Handle<GPUResource> handle) => ((ulong)handle.Generation << 32) | (uint)handle.ID;
private static ulong GetKey<T>(Handle<T> handle) where T : unmanaged => ((ulong)handle.Generation << 32) | (uint)handle.ID;
public Handle<GPUResource> AddMockResource(ResourceDesc desc, ResourceBarrierData barrierData, string? name)
{
var id = Interlocked.Increment(ref _nextToken);
var generation = 1;
var handle = new Handle<GPUResource>(id, generation);
_resources.TryAdd(GetKey(handle), new MockResourceRecord
{
desc = desc,
barrierData = barrierData,
name = name
});
return handle;
}
public Identifier<Sampler> AddSampler(ref readonly SamplerDesc desc, int id)
{
var newId = new Identifier<Sampler>(id);
_samplers.TryAdd(newId, desc);
return newId;
}
public Handle<GPUResource> CreateShared(Handle<GPUResource> src)
{
if (_resources.TryGetValue(GetKey(src), out var record))
{
lock (record)
{
record.refCount++;
record.isShared = true;
}
// To simulate sharing, we create a new handle mapping to the same conceptual resource.
// For simplicity, we just clone the dict entry with a new ID
var id = Interlocked.Increment(ref _nextToken);
var generation = 1;
var handle = new Handle<GPUResource>(id, generation);
_resources.TryAdd(GetKey(handle), record);
return handle;
}
return Handle<GPUResource>.Invalid;
}
public Handle<GPUResource> CreateEmpty()
{
var id = Interlocked.Increment(ref _nextToken);
var generation = 1;
var handle = new Handle<GPUResource>(id, generation);
_resources.TryAdd(GetKey(handle), new MockResourceRecord());
return handle;
}
public uint GetBindlessIndex(Handle<GPUResource> handle, BindlessAccess access = BindlessAccess.ShaderResource)
{
// Mock bindless index
return (uint)handle.ID;
}
public ulong GetIntermediateResourceSize(Handle<GPUResource> resource, uint firstSubResource, uint numSubResources)
{
return 1024 * 1024; // Mock size 1MB
}
public Result<ResourceBarrierData, Error> GetResourceBarrierData(Handle<GPUResource> handle)
{
if (_resources.TryGetValue(GetKey(handle), out var record))
return record.barrierData;
return Error.NotFound;
}
public Result<ResourceDesc, Error> GetResourceDescription(Handle<GPUResource> handle)
{
if (_resources.TryGetValue(GetKey(handle), out var record))
return record.desc;
return Error.NotFound;
}
public string? GetResourceName(Handle<GPUResource> handle)
{
if (_resources.TryGetValue(GetKey(handle), out var record))
return record.name;
return null;
}
public bool HasResource(Handle<GPUResource> handle)
{
return _resources.ContainsKey(GetKey(handle));
}
public void* MapResource(Handle<GPUResource> handle, uint subResource, ResourceRange? readRange)
{
// Real pointers are tricky in mocks unless native mem is allocated.
// Usually unit tests don't do CPU readbacks directly on the raw pointer unless necessary.
throw new NotSupportedException("MapResource is not supported in MockingResourceDatabase. Use a custom mechanism for tests.");
}
public void ReleaseResource(Handle<GPUResource> handle)
{
ReleaseResourceImmediately(handle); // Simplified for testing
}
public void ReleaseResourceImmediately(Handle<GPUResource> handle)
{
if (_resources.TryGetValue(GetKey(handle), out var record))
{
lock (record)
{
record.refCount--;
if (record.refCount <= 0)
{
_resources.TryRemove(GetKey(handle), out _);
}
}
}
}
public void ReleaseSampler(Identifier<Sampler> id)
{
_samplers.TryRemove(id, out _);
}
public Handle<GPUResource> Replace(Handle<GPUResource> dst, Handle<GPUResource> src)
{
// For tests, replacing means taking the new handle (src) and disposing the old (dst)
ReleaseResource(dst);
return src;
}
public Error SetResourceBarrierData(Handle<GPUResource> handle, ResourceBarrierData data)
{
if (_resources.TryGetValue(GetKey(handle), out var record))
{
lock (record)
{
record.barrierData = data;
}
return Error.None;
}
return Error.NotFound;
}
public Error Swap(Handle<GPUResource> handleA, Handle<GPUResource> handleB)
{
if (_resources.TryGetValue(GetKey(handleA), out var recordA) &&
_resources.TryGetValue(GetKey(handleB), out var recordB))
{
_resources[GetKey(handleA)] = recordB;
_resources[GetKey(handleB)] = recordA;
return Error.None;
}
return Error.NotFound;
}
public bool TryGetSampler(ref readonly SamplerDesc desc, out Identifier<Sampler> id)
{
foreach (var kvp in _samplers)
{
// Simple generic mock check
id = kvp.Key;
return true;
}
id = default;
return false;
}
public Error UnmapResource(Handle<GPUResource> handle, uint subResource, ResourceRange? writtenRange)
{
return Error.None;
}
public void Dispose()
{
_resources.Clear();
_samplers.Clear();
}
}