diff --git a/src/Runtime/Ghost.Core/Ghost.Core.csproj b/src/Runtime/Ghost.Core/Ghost.Core.csproj index d734a92..955be26 100644 --- a/src/Runtime/Ghost.Core/Ghost.Core.csproj +++ b/src/Runtime/Ghost.Core/Ghost.Core.csproj @@ -20,14 +20,14 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/src/Runtime/Ghost.Engine/AssetManager.Texture.cs b/src/Runtime/Ghost.Engine/AssetManager.Texture.cs index 4bbb43a..afeb04f 100644 --- a/src/Runtime/Ghost.Engine/AssetManager.Texture.cs +++ b/src/Runtime/Ghost.Engine/AssetManager.Texture.cs @@ -34,7 +34,7 @@ internal partial class AssetEntry { // 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. - var handle = e._resourceDatabase.CreateShared(e._assetManager.FallbackTexture.AsResource()).AsTexture(); + var handle = e._resourceDatabase.CreateEmpty().AsTexture(); e.SetStorage(handle); }; @@ -155,7 +155,7 @@ internal partial class AssetManager { if (assetID == Guid.Empty) { - return _fallbackTexture; + return Handle.Invalid; } var entry = GetOrCreateEntry(assetID); diff --git a/src/Runtime/Ghost.Engine/AssetManager.cs b/src/Runtime/Ghost.Engine/AssetManager.cs index 770b754..22047c1 100644 --- a/src/Runtime/Ghost.Engine/AssetManager.cs +++ b/src/Runtime/Ghost.Engine/AssetManager.cs @@ -295,17 +295,9 @@ internal partial class AssetManager : IDisposable private readonly ConcurrentDictionary _entries; - // TODO - private Handle _fallbackTexture; - private Handle _fallbackNormalMap; - private Handle _fallbackMesh; - private Handle _fallbackMaterial; - public IContentProvider ContentProvider => _contentProvider; public ResourceStreamingProcessor StreamingProcessor => _streamingProcessor; - public Handle FallbackTexture => _fallbackTexture; - internal AssetManager(IResourceDatabase resourceDatabase, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler) { _resourceDatabase = resourceDatabase; @@ -378,10 +370,10 @@ internal partial class AssetManager : IDisposable 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. - var handle = GetOrCreateEntry(list[i]).LoadJobHandle; - Logger.DebugAssert(handle.IsValid); + var depHandle = GetOrCreateEntry(list[i]).LoadJobHandle; + Logger.DebugAssert(depHandle.IsValid); - depHandles.Add(handle); + depHandles.Add(depHandle); } dependency = _jobScheduler.CombineDependencies(depHandles); @@ -394,7 +386,8 @@ internal partial class AssetManager : IDisposable 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)] diff --git a/src/Runtime/Ghost.Engine/ResourceStreamingProcessor.cs b/src/Runtime/Ghost.Engine/ResourceStreamingProcessor.cs index 5db87b7..37bcafd 100644 --- a/src/Runtime/Ghost.Engine/ResourceStreamingProcessor.cs +++ b/src/Runtime/Ghost.Engine/ResourceStreamingProcessor.cs @@ -1,6 +1,5 @@ using Ghost.Core; using Ghost.Graphics; -using SharpCompress.Common; using System.Collections.Concurrent; namespace Ghost.Engine; diff --git a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs index 61f8155..069d922 100644 --- a/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs +++ b/src/Runtime/Ghost.Graphics.D3D12/D3D12ResourceDatabase.cs @@ -442,6 +442,15 @@ internal unsafe class D3D12ResourceDatabase : IResourceDatabase } } + public Handle CreateEmpty() + { + lock (_writeLock) + { + var id = _resources.Add(default, out var generation); + return new Handle(id, generation); + } + } + public void* MapResource(Handle handle, uint subResource, ResourceRange? readRange) { var r = GetResourceRecord(handle); diff --git a/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs b/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs index 0bde9b6..2eb8694 100644 --- a/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs +++ b/src/Runtime/Ghost.Graphics.RHI/IResourceDatabase.cs @@ -142,6 +142,12 @@ public unsafe interface IResourceDatabase : IDisposable /// The handle to the newly created shared resource. Handle CreateShared(Handle src); + /// + /// 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. + /// + /// The handle to the newly created empty resource slot. + Handle CreateEmpty(); + /// /// Maps a subresource of a GPU resource for CPU access, specifying read and write ranges. /// diff --git a/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs b/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs new file mode 100644 index 0000000..4f27c5f --- /dev/null +++ b/src/Test/Ghost.UnitTest/AssetSystem/AssetManagerTest.cs @@ -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); + } +} diff --git a/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj b/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj index 1a6cf36..31f656a 100644 --- a/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj +++ b/src/Test/Ghost.UnitTest/Ghost.UnitTest.csproj @@ -7,6 +7,7 @@ enable x64;x86;ARM64 win-x86;win-x64;win-arm64 + True diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingCommandBuffer.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingCommandBuffer.cs new file mode 100644 index 0000000..67778fc --- /dev/null +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingCommandBuffer.cs @@ -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 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 rtDescs, ref readonly PassDepthStencilDesc depthDesc, bool allowUAVWrites = false) + { + _isEmpty = false; + } + + public void ClearDepthStencilView(Handle depthStencil, bool inlcludeDepth, bool includeStencil, float clearDepth = 1, byte clearStencil = 0) + { + _isEmpty = false; + } + + public void ClearRenderTargetView(Handle renderTarget, Color128 clearColor) + { + _isEmpty = false; + } + + public void CopyBuffer(Handle dest, Handle src, ulong destOffset = 0, ulong srcOffset = 0, ulong numBytes = 0) + { + _isEmpty = false; + CopyCallCount++; + } + + public void CopyTexture(Handle dst, TextureRegion? dstRegion, Handle 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 argumentBuffer, ulong argumentOffset, Handle countBuffer, ulong countBufferOffset) + { + _isEmpty = false; + } + + public void SetConstantBufferView(uint slot, Handle buffer) + { + _isEmpty = false; + } + + public void SetGraphicsRoot32Constants(uint rootIndex, ReadOnlySpan constantBuffer, uint offsetIn32Bits = 0) + { + _isEmpty = false; + } + + public void SetIndexBuffer(Handle buffer, IndexType type, ulong offset = 0) + { + _isEmpty = false; + } + + public void SetPipelineState(Key128 pipelineKey) + { + _isEmpty = false; + } + + public void SetPrimitiveTopology(PrimitiveTopology topology) + { + _isEmpty = false; + } + + public void SetProgram(ref readonly SetProgramDesc desc) + { + _isEmpty = false; + } + + public void SetRenderTargets(ReadOnlySpan> renderTargets, Handle depthTarget) + { + _isEmpty = false; + } + + public void SetScissorRect(ScissorRectDesc rect) + { + _isEmpty = false; + } + + public void SetVertexBuffer(uint slot, Handle buffer, ulong offset = 0) + { + _isEmpty = false; + } + + public void SetViewport(ViewportDesc viewport) + { + _isEmpty = false; + } + + public void UpdateSubResources(Handle resource, Handle intermediate, params scoped ReadOnlySpan subResources) + { + _isEmpty = false; + UpdateSubResourcesCount++; + } +} diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs new file mode 100644 index 0000000..450d734 --- /dev/null +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingContentProvider.cs @@ -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(); + public Guid[] dependencies = Array.Empty(); + + // 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 _assets = new(); + + public void AddMockAsset(Guid guid, MockAssetData data) + { + _assets[guid] = data; + } + + /// + /// Helper method to create a valid dummy texture byte stream that the AssetEntry can parse. + /// + 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(); + } + + public bool HasAsset(Guid guid) + { + return _assets.ContainsKey(guid); + } + + public Result 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.Success(new MemoryStream(asset.data, writable: false)); + } + + return Result.Failure($"Mock asset {guid} not found."); + } +} diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceAllocator.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceAllocator.cs new file mode 100644 index 0000000..900a478 --- /dev/null +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceAllocator.cs @@ -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 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 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 CreateSampler(ref readonly SamplerDesc desc) + { + return _database.AddSampler(in desc, 1); + } + + public Handle 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. + } +} diff --git a/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs new file mode 100644 index 0000000..e7b309e --- /dev/null +++ b/src/Test/Ghost.UnitTest/MockingEnvironment/MockingResourceDatabase.cs @@ -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 _resources = new(); + private readonly ConcurrentDictionary, SamplerDesc> _samplers = new(); + private int _nextToken = 0; + private int _samplerToken = 0; + + private static ulong GetKey(Handle handle) => ((ulong)handle.Generation << 32) | (uint)handle.ID; + private static ulong GetKey(Handle handle) where T : unmanaged => ((ulong)handle.Generation << 32) | (uint)handle.ID; + + public Handle AddMockResource(ResourceDesc desc, ResourceBarrierData barrierData, string? name) + { + var id = Interlocked.Increment(ref _nextToken); + var generation = 1; + var handle = new Handle(id, generation); + + _resources.TryAdd(GetKey(handle), new MockResourceRecord + { + desc = desc, + barrierData = barrierData, + name = name + }); + + return handle; + } + + public Identifier AddSampler(ref readonly SamplerDesc desc, int id) + { + var newId = new Identifier(id); + _samplers.TryAdd(newId, desc); + return newId; + } + + public Handle CreateShared(Handle 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(id, generation); + + _resources.TryAdd(GetKey(handle), record); + return handle; + } + + return Handle.Invalid; + } + + public Handle CreateEmpty() + { + var id = Interlocked.Increment(ref _nextToken); + var generation = 1; + var handle = new Handle(id, generation); + + _resources.TryAdd(GetKey(handle), new MockResourceRecord()); + + return handle; + } + + public uint GetBindlessIndex(Handle handle, BindlessAccess access = BindlessAccess.ShaderResource) + { + // Mock bindless index + return (uint)handle.ID; + } + + public ulong GetIntermediateResourceSize(Handle resource, uint firstSubResource, uint numSubResources) + { + return 1024 * 1024; // Mock size 1MB + } + + public Result GetResourceBarrierData(Handle handle) + { + if (_resources.TryGetValue(GetKey(handle), out var record)) + return record.barrierData; + return Error.NotFound; + } + + public Result GetResourceDescription(Handle handle) + { + if (_resources.TryGetValue(GetKey(handle), out var record)) + return record.desc; + return Error.NotFound; + } + + public string? GetResourceName(Handle handle) + { + if (_resources.TryGetValue(GetKey(handle), out var record)) + return record.name; + return null; + } + + public bool HasResource(Handle handle) + { + return _resources.ContainsKey(GetKey(handle)); + } + + public void* MapResource(Handle 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 handle) + { + ReleaseResourceImmediately(handle); // Simplified for testing + } + + public void ReleaseResourceImmediately(Handle 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 id) + { + _samplers.TryRemove(id, out _); + } + + public Handle Replace(Handle dst, Handle src) + { + // For tests, replacing means taking the new handle (src) and disposing the old (dst) + ReleaseResource(dst); + return src; + } + + public Error SetResourceBarrierData(Handle 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 handleA, Handle 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 id) + { + foreach (var kvp in _samplers) + { + // Simple generic mock check + id = kvp.Key; + return true; + } + id = default; + return false; + } + + public Error UnmapResource(Handle handle, uint subResource, ResourceRange? writtenRange) + { + return Error.None; + } + + public void Dispose() + { + _resources.Clear(); + _samplers.Clear(); + } +}