feat: implement core graphics rendering system and D3D12 RHI backend infrastructure
This commit is contained in:
@@ -3,11 +3,95 @@ using Ghost.Core.Utilities;
|
||||
using Ghost.Engine.AssetLoader;
|
||||
using Ghost.Graphics.RHI;
|
||||
using Ghost.Graphics.Utilities;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
|
||||
namespace Ghost.Engine;
|
||||
|
||||
public partial class AssetManager
|
||||
{
|
||||
private partial class AssetEntry
|
||||
{
|
||||
private static TextureFormat GetTextureFormat(uint depth, uint colorComponents)
|
||||
{
|
||||
return colorComponents switch
|
||||
{
|
||||
1 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8_UNorm,
|
||||
16 => TextureFormat.R16_UNorm,
|
||||
32 => TextureFormat.R32_UInt,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
2 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8G8_UNorm,
|
||||
16 => TextureFormat.R16G16_UNorm,
|
||||
32 => TextureFormat.R32G32_Float,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
3 or 4 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8G8B8A8_UNorm,
|
||||
16 => TextureFormat.R16G16B16A16_Float,
|
||||
32 => TextureFormat.R32G32B32A32_Float,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
_ => TextureFormat.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private unsafe Result RecordTextureUpload(ICommandBuffer commandBuffer)
|
||||
{
|
||||
var pData = (byte*)_rawData.GetUnsafePtr();
|
||||
var reader = new BufferReader(pData, _rawData.Size);
|
||||
|
||||
var header = reader.Read<TextureContentHeader>();
|
||||
|
||||
var textureDesc = new TextureDesc
|
||||
{
|
||||
Width = header.width,
|
||||
Height = header.height,
|
||||
MipLevels = header.mipLevels,
|
||||
Slice = 1,
|
||||
Format = GetTextureFormat(header.depth, header.colorComponents),
|
||||
Dimension = (TextureDimension)header.dimension,
|
||||
Usage = TextureUsage.ShaderResource,
|
||||
};
|
||||
|
||||
var newHandle = RenderingUtility.CreateTexture(
|
||||
ResourceManager,
|
||||
ResourceDatabase,
|
||||
ResourceAllocator,
|
||||
commandBuffer,
|
||||
reader.CurrentAddress,
|
||||
reader.RemainingBytes,
|
||||
in textureDesc);
|
||||
|
||||
if (newHandle.IsInvalid)
|
||||
{
|
||||
return Result.Failure("Failed to create GPU texture.");
|
||||
}
|
||||
|
||||
var oldHandle = GetStorage<Handle<GPUTexture>>();
|
||||
SetStorage((oldHandle, newHandle));
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
private void OnTextureUploadComplete()
|
||||
{
|
||||
var (oldHandle, newHandle) = GetStorage<(Handle<GPUTexture>, Handle<GPUTexture>)>();
|
||||
|
||||
ResourceDatabase.Swap(oldHandle.AsResource(), newHandle.AsResource());
|
||||
ResourceDatabase.ReleaseResource(newHandle.AsResource()); // releases old fallback slot
|
||||
|
||||
SetStorage((oldHandle, Handle<GPUTexture>.Invalid)); // Old handle is now the new handle, and the old fallback slot is released. Use Invalid handle to clear second slot.
|
||||
|
||||
_rawData.Dispose();
|
||||
_rawData = default;
|
||||
}
|
||||
}
|
||||
|
||||
private Handle<GPUTexture> AllocateTextureHandle()
|
||||
{
|
||||
// This will create a new slot in the database, but not allocation any GPU resource.
|
||||
@@ -15,76 +99,6 @@ public partial class AssetManager
|
||||
return _resourceDatabase.CreateShared(_fallbackTexture.AsResource()).AsTexture();
|
||||
}
|
||||
|
||||
private static TextureFormat GetTextureFormat(uint depth, uint colorComponents)
|
||||
{
|
||||
return colorComponents switch
|
||||
{
|
||||
1 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8_UNorm,
|
||||
16 => TextureFormat.R16_UNorm,
|
||||
32 => TextureFormat.R32_UInt,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
2 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8G8_UNorm,
|
||||
16 => TextureFormat.R16G16_UNorm,
|
||||
32 => TextureFormat.R32G32_Float,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
3 or 4 => depth switch
|
||||
{
|
||||
8 => TextureFormat.R8G8B8A8_UNorm,
|
||||
16 => TextureFormat.R16G16B16A16_Float,
|
||||
32 => TextureFormat.R32G32B32A32_Float,
|
||||
_ => TextureFormat.Unknown,
|
||||
},
|
||||
_ => TextureFormat.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private unsafe Result UploadTexture(AssetEntry entry)
|
||||
{
|
||||
var pData = (byte*)entry.rawData.GetUnsafePtr();
|
||||
var reader = new BufferReader(pData, entry.rawData.Size);
|
||||
|
||||
var header = reader.Read<TextureContentHeader>();
|
||||
|
||||
var textureDesc = new TextureDesc
|
||||
{
|
||||
Width = header.width,
|
||||
Height = header.height,
|
||||
MipLevels = header.mipLevels,
|
||||
Slice = 1,
|
||||
Format = GetTextureFormat(header.depth, header.colorComponents),
|
||||
Dimension = (TextureDimension)header.dimension,
|
||||
Usage = TextureUsage.ShaderResource,
|
||||
};
|
||||
|
||||
var newHandle = RenderingUtility.CreateTexture(
|
||||
_resourceManager,
|
||||
_resourceDatabase,
|
||||
_resourceAllocator,
|
||||
_uploadedBatch.CommandBuffer,
|
||||
reader.Position,
|
||||
in textureDesc);
|
||||
|
||||
if (newHandle.IsInvalid)
|
||||
{
|
||||
return Result.Failure("Failed to create GPU texture.");
|
||||
}
|
||||
|
||||
// FIX: We can not Swap right now, we must wait on the GPU to finish the upload.
|
||||
var oldHandle = entry.GetStorage<Handle<GPUTexture>>();
|
||||
_resourceDatabase.Swap(oldHandle.AsResource(), newHandle.AsResource());
|
||||
// Release the new handle since it now contains the old handle's resource.
|
||||
// Because the old handle is shared, it will only release the slot in the database, not the actuall GPU resource, which is the fallback texture in this case.
|
||||
_resourceDatabase.ReleaseResource(newHandle.AsResource());
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
|
||||
public Handle<GPUTexture> ResolveTexture(Guid assetID)
|
||||
{
|
||||
if (assetID == Guid.Empty)
|
||||
@@ -93,7 +107,7 @@ public partial class AssetManager
|
||||
}
|
||||
|
||||
var entry = GetOrCreateEntry(assetID);
|
||||
Logger.DebugAssert(entry.assetType == AssetType.Texture);
|
||||
Logger.DebugAssert(entry.AssetType == AssetType.Texture);
|
||||
|
||||
return entry.GetStorage<Handle<GPUTexture>>();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using Ghost.Core;
|
||||
using Ghost.Core.Utilities;
|
||||
using Ghost.Engine.AssetLoader;
|
||||
using Ghost.Graphics.Core;
|
||||
using Ghost.Graphics.RHI;
|
||||
using Ghost.Graphics.Services;
|
||||
using Ghost.Graphics.Utilities;
|
||||
using Misaki.HighPerformance.Buffer;
|
||||
using Misaki.HighPerformance.Jobs;
|
||||
using Misaki.HighPerformance.LowLevel;
|
||||
using Misaki.HighPerformance.LowLevel.Buffer;
|
||||
using Misaki.HighPerformance.LowLevel.Collections;
|
||||
using Misaki.HighPerformance.LowLevel.Utilities;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
@@ -21,8 +20,9 @@ public enum AssetState : byte
|
||||
Scheduled = 1,
|
||||
Loading = 2,
|
||||
Loaded = 3,
|
||||
Ready = 4,
|
||||
Failed = 5,
|
||||
Uploading = 4,
|
||||
Ready = 5,
|
||||
Failed = 6,
|
||||
}
|
||||
|
||||
public enum AssetType : byte
|
||||
@@ -52,221 +52,52 @@ internal interface IContentProvider
|
||||
// TODO: Support DirectStorage.
|
||||
public partial class AssetManager : IDisposable
|
||||
{
|
||||
private unsafe class AssetEntry : IDisposable
|
||||
private unsafe partial class AssetEntry : IDisposable
|
||||
{
|
||||
private static readonly ObjectPool<AssetEntry> s_pool = new ObjectPool<AssetEntry>(() => new AssetEntry(), (entry) => entry.Reset());
|
||||
|
||||
public struct __storage
|
||||
{
|
||||
public fixed byte data[64];
|
||||
}
|
||||
|
||||
public Guid assetId;
|
||||
public __storage storage;
|
||||
public MemoryBlock rawData;
|
||||
private readonly AssetManager _assetManager;
|
||||
|
||||
public JobHandle loadJobHandle;
|
||||
public AssetType assetType;
|
||||
public int state;
|
||||
public int refCount;
|
||||
private Guid _assetId;
|
||||
private __storage _storage;
|
||||
private MemoryBlock _rawData;
|
||||
|
||||
public static AssetEntry Create()
|
||||
private JobHandle _loadJobHandle;
|
||||
private AssetType _assetType;
|
||||
private int _refCount;
|
||||
private int _state;
|
||||
|
||||
private ResourceManager ResourceManager => _assetManager._resourceManager;
|
||||
private IResourceDatabase ResourceDatabase => _assetManager._resourceDatabase;
|
||||
private IResourceAllocator ResourceAllocator => _assetManager._resourceAllocator;
|
||||
|
||||
public Guid AssetId => _assetId;
|
||||
public MemoryBlock RawData => _rawData;
|
||||
public JobHandle LoadJobHandle => _loadJobHandle;
|
||||
public AssetType AssetType => _assetType;
|
||||
public int RefCount => Volatile.Read(ref _refCount);
|
||||
|
||||
public AssetState State
|
||||
{
|
||||
return s_pool.Rent();
|
||||
get => (AssetState)Volatile.Read(ref _state);
|
||||
set => Volatile.Write(ref _state, (int)value);
|
||||
}
|
||||
|
||||
private AssetEntry()
|
||||
public AssetEntry(AssetManager manager, Guid assetId, AssetType assetType)
|
||||
{
|
||||
}
|
||||
_assetManager = manager;
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
assetId = Guid.Empty;
|
||||
assetType = AssetType.Unknown;
|
||||
storage = default;
|
||||
rawData = default;
|
||||
state = (int)AssetState.Unloaded;
|
||||
refCount = 0;
|
||||
loadJobHandle = default;
|
||||
}
|
||||
_assetId = assetId;
|
||||
_assetType = assetType;
|
||||
_refCount = 1;
|
||||
|
||||
public void SetStorage<T>(T asset)
|
||||
where T : unmanaged
|
||||
{
|
||||
Unsafe.WriteUnaligned(ref storage.data[0], asset);
|
||||
}
|
||||
|
||||
public T GetStorage<T>()
|
||||
where T : unmanaged
|
||||
{
|
||||
return Unsafe.ReadUnaligned<T>(ref storage.data[0]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
s_pool.Return(this);
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoadAssetJob : IJob
|
||||
{
|
||||
public Guid assetID;
|
||||
public AssetType assetType;
|
||||
|
||||
public void Execute(ref readonly JobExecutionContext ctx)
|
||||
{
|
||||
var assetManager = ctx.State as AssetManager;
|
||||
|
||||
Debug.Assert(assetManager is not null);
|
||||
Debug.Assert(assetManager._contentProvider.GetAssetType(assetID) == assetType);
|
||||
|
||||
if (!assetManager._entries.TryGetValue(assetID, out var entry))
|
||||
{
|
||||
Logger.Error($"Asset entry not found for {assetID}");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = assetManager.LoadRawData(entry);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
Volatile.Write(ref entry.state, (int)AssetState.Failed);
|
||||
Logger.Error($"Failed to load asset {assetID}: {result.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref entry.state, (int)AssetState.Loaded);
|
||||
|
||||
// Ensure the buffer inside the resource database does not move.
|
||||
assetManager._resourceDatabase.EnterParallelRead();
|
||||
|
||||
try
|
||||
{
|
||||
switch (assetType)
|
||||
{
|
||||
case AssetType.Texture:
|
||||
result = assetManager.UploadTexture(entry);
|
||||
break;
|
||||
case AssetType.Mesh:
|
||||
break;
|
||||
case AssetType.Material:
|
||||
break;
|
||||
case AssetType.Audio:
|
||||
break;
|
||||
case AssetType.Scene:
|
||||
break;
|
||||
case AssetType.Video:
|
||||
break;
|
||||
case AssetType.Json:
|
||||
break;
|
||||
case AssetType.Unknown:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
Logger.Error($"Failed to upload asset {assetID}: {result.Message}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
assetManager._resourceDatabase.ExitParallelRead();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly IContentProvider _contentProvider;
|
||||
|
||||
private readonly ResourceManager _resourceManager;
|
||||
private readonly IResourceAllocator _resourceAllocator;
|
||||
private readonly IResourceDatabase _resourceDatabase;
|
||||
private readonly ResourceUploadBatch _uploadedBatch; // Upload via copy queue.
|
||||
|
||||
private readonly JobScheduler _jobScheduler;
|
||||
private readonly ConcurrentDictionary<Guid, AssetEntry> _entries;
|
||||
private readonly ConcurrentQueue<Guid> _pendingUploads;
|
||||
|
||||
// TODO
|
||||
private Handle<GPUTexture> _fallbackTexture;
|
||||
private Handle<GPUTexture> _fallbackNormalMap;
|
||||
private Handle<Mesh> _fallbackMesh;
|
||||
private Handle<Material> _fallbackMaterial;
|
||||
|
||||
internal AssetManager(IContentProvider contentProvider, ResourceManager resourceManager, IResourceAllocator resourceAllocator, IResourceDatabase resourceDatabase, ResourceUploadBatch uploadBatch)
|
||||
{
|
||||
_contentProvider = contentProvider;
|
||||
_resourceManager = resourceManager;
|
||||
_resourceAllocator = resourceAllocator;
|
||||
_resourceDatabase = resourceDatabase;
|
||||
_uploadedBatch = uploadBatch;
|
||||
|
||||
// Ideally we should use a single JobScheduler across the entire engine, and schedule the streaming jobs to that scheduler as low priority background jobs.
|
||||
// But how can we get the reference to the AssetManager? Is force job types to be unmanaged a wrong decision? Because we don't have burst compiler at all.
|
||||
var threadCount = Environment.ProcessorCount < 8 ? 1 : 2;
|
||||
_jobScheduler = new JobScheduler(threadCount, ThreadPriority.BelowNormal, this);
|
||||
_entries = new ConcurrentDictionary<Guid, AssetEntry>();
|
||||
_pendingUploads = new ConcurrentQueue<Guid>();
|
||||
}
|
||||
|
||||
private JobHandle EnsureScheduled(Guid assetID)
|
||||
{
|
||||
if (_entries.TryGetValue(assetID, out var existing) && existing.state >= (int)AssetState.Scheduled)
|
||||
{
|
||||
return existing.loadJobHandle;
|
||||
}
|
||||
|
||||
// Resolve dependencies (in-memory manifest/catalog lookup — instant)
|
||||
var deps = _contentProvider.GetDependencies(assetID);
|
||||
|
||||
// Schedule all dependencies first (recursive, depth-first)
|
||||
JobHandle dependency = default;
|
||||
if (deps.Length > 0)
|
||||
{
|
||||
var depHandles = deps.Length <= 8
|
||||
? stackalloc JobHandle[deps.Length]
|
||||
: new JobHandle[deps.Length];
|
||||
|
||||
for (int i = 0; i < deps.Length; i++)
|
||||
{
|
||||
var depEntry = GetOrCreateEntry(deps[i]);
|
||||
depHandles[i] = depEntry.loadJobHandle;
|
||||
}
|
||||
|
||||
dependency = _jobScheduler.CombineDependencies(depHandles);
|
||||
}
|
||||
|
||||
if (_entries.TryGetValue(assetID, out var entry))
|
||||
{
|
||||
var job = new LoadAssetJob
|
||||
{
|
||||
assetID = assetID,
|
||||
assetType = entry.assetType,
|
||||
};
|
||||
|
||||
entry.loadJobHandle = _jobScheduler.Schedule(ref job, dependency);
|
||||
return entry.loadJobHandle;
|
||||
}
|
||||
|
||||
// This should not happen, because GetOrCreateEntry should have created the entry and scheduled the job.
|
||||
Debug.Fail($"Entry for {assetID} should have been created by GetOrCreateEntry");
|
||||
return JobHandle.Invalid;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private AssetEntry GetOrCreateEntry(Guid guid)
|
||||
{
|
||||
return _entries.GetOrAdd(guid, static (id, self) =>
|
||||
{
|
||||
var entry = AssetEntry.Create();
|
||||
entry.assetId = id;
|
||||
entry.assetType = self._contentProvider.GetAssetType(id);
|
||||
entry.state = (int)AssetState.Scheduled;
|
||||
|
||||
switch (entry.assetType)
|
||||
switch (assetType)
|
||||
{
|
||||
case AssetType.Texture:
|
||||
entry.SetStorage(self.AllocateTextureHandle());
|
||||
SetStorage(manager.AllocateTextureHandle());
|
||||
break;
|
||||
case AssetType.Mesh:
|
||||
break;
|
||||
@@ -284,142 +115,366 @@ public partial class AssetManager : IDisposable
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
entry.loadJobHandle = self.EnsureScheduled(entry.assetId);
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void SetStorage<T>(T asset)
|
||||
where T : unmanaged
|
||||
{
|
||||
Unsafe.WriteUnaligned(ref _storage.data[0], asset);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public T GetStorage<T>()
|
||||
where T : unmanaged
|
||||
{
|
||||
return Unsafe.ReadUnaligned<T>(ref _storage.data[0]);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void SetRawData([OwnershipTransfer] ref MemoryBlock data)
|
||||
{
|
||||
_rawData = data;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void SetLoadJobHandle(JobHandle handle)
|
||||
{
|
||||
_loadJobHandle = handle;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void AddRef()
|
||||
{
|
||||
Interlocked.Increment(ref _refCount);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public int Release()
|
||||
{
|
||||
var newRefCount = Interlocked.Decrement(ref _refCount);
|
||||
Debug.Assert(newRefCount >= 0, "Reference count should not be negative");
|
||||
|
||||
if (newRefCount == 0)
|
||||
{
|
||||
Dispose();
|
||||
}
|
||||
|
||||
return newRefCount;
|
||||
}
|
||||
|
||||
public void OnRecordUploadCommands(ICommandBuffer commandBuffer)
|
||||
{
|
||||
switch (_assetType)
|
||||
{
|
||||
case AssetType.Texture:
|
||||
RecordTextureUpload(commandBuffer);
|
||||
break;
|
||||
case AssetType.Mesh:
|
||||
break;
|
||||
case AssetType.Material:
|
||||
break;
|
||||
case AssetType.Audio:
|
||||
break;
|
||||
case AssetType.Scene:
|
||||
break;
|
||||
case AssetType.Video:
|
||||
break;
|
||||
case AssetType.Json:
|
||||
break;
|
||||
case AssetType.Unknown:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnUploadComplete()
|
||||
{
|
||||
switch (_assetType)
|
||||
{
|
||||
case AssetType.Texture:
|
||||
OnTextureUploadComplete();
|
||||
break;
|
||||
case AssetType.Mesh:
|
||||
break;
|
||||
case AssetType.Material:
|
||||
break;
|
||||
case AssetType.Audio:
|
||||
break;
|
||||
case AssetType.Scene:
|
||||
break;
|
||||
case AssetType.Video:
|
||||
break;
|
||||
case AssetType.Json:
|
||||
break;
|
||||
case AssetType.Unknown:
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _state, (int)AssetState.Ready);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var handle = GetStorage<Handle<GPUTexture>>();
|
||||
ResourceDatabase.ReleaseResource(handle.AsResource());
|
||||
|
||||
_assetManager.RemoveEntry(_assetId);
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoadAssetJob : IJob
|
||||
{
|
||||
public Guid assetID;
|
||||
public AssetType assetType;
|
||||
|
||||
private static Result LoadRawData(IContentProvider contentProvider, AssetEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = contentProvider.OpenRead(entry.AssetId).GetValueOrThrow();
|
||||
|
||||
var data = new MemoryBlock((nuint)stream.Length, MemoryUtility.AlignOf<IntPtr>(), AllocationHandle.Persistent);
|
||||
|
||||
// C# built-in collections use int for indexing, so we need to ensure that the buffer size does not exceed int.MaxValue
|
||||
var maxChunkSize = (int)Math.Min(0x7fffffffu, data.Size);
|
||||
var offset = 0u;
|
||||
|
||||
while (offset < data.Size)
|
||||
{
|
||||
using var mem = NativeMemoryManager<byte>.FromMemoryBlock(data, (int)offset, maxChunkSize);
|
||||
stream.ReadExactly(mem.Memory.Span);
|
||||
offset += (uint)mem.Memory.Length;
|
||||
}
|
||||
|
||||
entry.SetRawData(ref data);
|
||||
|
||||
return Result.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Failure(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Execute(ref readonly JobExecutionContext ctx)
|
||||
{
|
||||
var assetManager = ctx.State as AssetManager;
|
||||
|
||||
Debug.Assert(assetManager is not null);
|
||||
Debug.Assert(assetManager._contentProvider.GetAssetType(assetID) == assetType);
|
||||
|
||||
if (!assetManager._entries.TryGetValue(assetID, out var entry))
|
||||
{
|
||||
Logger.Error($"Asset entry not found for {assetID}");
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = AssetState.Loading;
|
||||
|
||||
var result = LoadRawData(assetManager._contentProvider, entry);
|
||||
if (result.IsFailure)
|
||||
{
|
||||
entry.State = AssetState.Failed;
|
||||
Logger.Error($"Failed to load asset {assetID}: {result.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = AssetState.Loaded;
|
||||
}
|
||||
}
|
||||
|
||||
private const int _MAX_UPLOADS_PER_FRAME = 8;
|
||||
|
||||
private readonly IContentProvider _contentProvider;
|
||||
|
||||
private readonly ResourceManager _resourceManager;
|
||||
private readonly IResourceAllocator _resourceAllocator;
|
||||
private readonly IResourceDatabase _resourceDatabase;
|
||||
private readonly AsyncCopyPipeline _copyPipeline; // Upload via copy queue.
|
||||
|
||||
private readonly JobScheduler _jobScheduler;
|
||||
private readonly ConcurrentDictionary<Guid, AssetEntry> _entries;
|
||||
private readonly ConcurrentQueue<AssetEntry> _pendingFinalize;
|
||||
|
||||
private ulong _pendingCopyFenceValue;
|
||||
|
||||
// TODO
|
||||
private Handle<GPUTexture> _fallbackTexture;
|
||||
private Handle<GPUTexture> _fallbackNormalMap;
|
||||
private Handle<Mesh> _fallbackMesh;
|
||||
private Handle<Material> _fallbackMaterial;
|
||||
|
||||
internal AssetManager(IContentProvider contentProvider, ResourceManager resourceManager, IResourceAllocator resourceAllocator, IResourceDatabase resourceDatabase, AsyncCopyPipeline uploadBatch)
|
||||
{
|
||||
_contentProvider = contentProvider;
|
||||
_resourceManager = resourceManager;
|
||||
_resourceAllocator = resourceAllocator;
|
||||
_resourceDatabase = resourceDatabase;
|
||||
_copyPipeline = uploadBatch;
|
||||
|
||||
var desc = new JobSchedulerDesc
|
||||
{
|
||||
ThreadCount = Environment.ProcessorCount < 8 ? 1 : 2,
|
||||
ThreadPriority = ThreadPriority.BelowNormal,
|
||||
State = this,
|
||||
};
|
||||
|
||||
_jobScheduler = new JobScheduler(in desc);
|
||||
_entries = new ConcurrentDictionary<Guid, AssetEntry>();
|
||||
_pendingFinalize = new ConcurrentQueue<AssetEntry>();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private bool RemoveEntry(Guid guid)
|
||||
{
|
||||
return _entries.TryRemove(guid, out _);
|
||||
}
|
||||
|
||||
private void EnsureScheduled(AssetEntry entry)
|
||||
{
|
||||
if ((int)entry.State >= (int)AssetState.Scheduled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve dependencies (in-memory manifest/catalog lookup — instant)
|
||||
var deps = _contentProvider.GetDependencies(entry.AssetId);
|
||||
|
||||
var dependency = JobHandle.Invalid;
|
||||
if (deps.Length > 0)
|
||||
{
|
||||
// Avoid stack overflow for deep dependency tree like a whole scene.
|
||||
|
||||
// Stack allocator here is fine, because it use virtual memory and has 32 mb capacity per thread.
|
||||
using var scope = AllocationManager.CreateStackScope();
|
||||
|
||||
using var list = new UnsafeList<Guid>(deps.Length * 2, scope.AllocationHandle);
|
||||
using var stack = new UnsafeStack<Guid>(deps.Length * 2, scope.AllocationHandle);
|
||||
using var visited = new UnsafeHashSet<Guid>(deps.Length * 2, scope.AllocationHandle);
|
||||
|
||||
for (var i = 0; i < deps.Length; i++)
|
||||
{
|
||||
stack.Push(deps[i]);
|
||||
}
|
||||
|
||||
while (stack.TryPop(out var guid))
|
||||
{
|
||||
if (visited.Contains(guid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.Add(guid);
|
||||
list.Add(guid);
|
||||
|
||||
var depss = _contentProvider.GetDependencies(guid);
|
||||
foreach (var d in depss)
|
||||
{
|
||||
if (!visited.Contains(d))
|
||||
{
|
||||
stack.Push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var depHandles = new UnsafeList<JobHandle>(list.Count, scope.AllocationHandle);
|
||||
|
||||
// Schedule all dependencies first (depth-first)
|
||||
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;
|
||||
Debug.Assert(handle.IsValid);
|
||||
|
||||
depHandles.Add(handle);
|
||||
}
|
||||
|
||||
dependency = _jobScheduler.CombineDependencies(depHandles);
|
||||
}
|
||||
|
||||
var job = new LoadAssetJob
|
||||
{
|
||||
assetID = entry.AssetId,
|
||||
assetType = entry.AssetType,
|
||||
};
|
||||
|
||||
entry.SetLoadJobHandle(_jobScheduler.Schedule(ref job, dependency));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private AssetEntry GetOrCreateEntry(Guid guid)
|
||||
{
|
||||
return _entries.GetOrAdd(guid, static (id, self) =>
|
||||
{
|
||||
var entry = new AssetEntry(self, id, self._contentProvider.GetAssetType(id))
|
||||
{
|
||||
State = AssetState.Scheduled
|
||||
};
|
||||
|
||||
self.EnsureScheduled(entry);
|
||||
return entry;
|
||||
}, this);
|
||||
}
|
||||
|
||||
private Result LoadRawData(AssetEntry entry)
|
||||
// NOTE: Render thread only.
|
||||
internal void ProcessPendingUploads()
|
||||
{
|
||||
try
|
||||
// 1. If there's a pending copy batch from last frame, check its fence
|
||||
if (_pendingCopyFenceValue > 0 && _copyPipeline.CurrentFenceValue() >= _pendingCopyFenceValue)
|
||||
{
|
||||
using var stream = _contentProvider.OpenRead(entry.assetId).GetValueOrThrow();
|
||||
|
||||
var data = new MemoryBlock((nuint)stream.Length, MemoryUtility.AlignOf<IntPtr>(), AllocationHandle.Persistent);
|
||||
|
||||
// C# built-in collections use int for indexing, so we need to ensure that the buffer size does not exceed int.MaxValue
|
||||
var maxChunkSize = (int)Math.Min(0x7fffffffu, data.Size);
|
||||
var offset = 0u;
|
||||
|
||||
while (offset < data.Size)
|
||||
while (_pendingFinalize.TryDequeue(out var item))
|
||||
{
|
||||
using var mem = NativeMemoryManager<byte>.FromMemoryBlock(data, (int)offset, maxChunkSize);
|
||||
stream.ReadExactly(mem.Memory.Span);
|
||||
offset += (uint)mem.Memory.Length;
|
||||
item.OnUploadComplete();
|
||||
}
|
||||
|
||||
entry.rawData = data;
|
||||
|
||||
return Result.Success();
|
||||
_pendingCopyFenceValue = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
if (_pendingCopyFenceValue > 0)
|
||||
{
|
||||
return Result.Failure(ex.Message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render thread only — single-threaded by design ──
|
||||
// TODO: Does this must be called on render thread? Can you just create a dedicated thred or a worker in thread pool for uploading?
|
||||
// Also, I think we may don't need this RenderContext at all, because the CommandBuffer is from the ResourceUploadBatch (via async upload), and the ResourceManager/Database/Allocator can be passed in the constructor.
|
||||
public void ProcessUploads(RenderContext ctx, int maxPerFrame = 4)
|
||||
{
|
||||
_uploadedBatch.Begin();
|
||||
// 2. Collect entries that are in state == Loaded (I/O done, not yet uploaded)
|
||||
// Cap per frame to avoid stalling (e.g., max 8 textures per frame)
|
||||
_copyPipeline.Begin();
|
||||
|
||||
for (var i = 0; i < maxPerFrame; i++)
|
||||
var cmdCopy = _copyPipeline.GetCommandBuffer();
|
||||
var uploadCount = 0;
|
||||
|
||||
foreach (var (guid, entry) in _entries)
|
||||
{
|
||||
if (!_pendingUploads.TryDequeue(out var guid))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_entries.TryGetValue(guid, out var entry) || entry.asset is null)
|
||||
if (entry.State != AssetState.Loaded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var error = Error.Success;
|
||||
switch (entry.assetType)
|
||||
if (uploadCount >= _MAX_UPLOADS_PER_FRAME)
|
||||
{
|
||||
case AssetType.Texture:
|
||||
var textureDesc = new TextureDesc
|
||||
{
|
||||
Width = textureAsset.Width,
|
||||
Height = textureAsset.Height,
|
||||
MipLevels = textureAsset.MipLevels,
|
||||
Slice = 1,
|
||||
Format = GetTextureFormat(textureAsset.Depth, textureAsset.ColorComponents),
|
||||
Dimension = GetTextureDimension(textureAsset.Dimension),
|
||||
Usage = TextureUsage.ShaderResource,
|
||||
};
|
||||
|
||||
// NOTE: We use Color128 here to avoid that c# span can't hold 16k x 16k x sizeof(float) x 4 textures, because the max span length is int.MaxValue.
|
||||
// Internal method will cast the data to void* so the type does not matter as long as the format and size are correct.
|
||||
var handle = RenderingUtility.CreateTexture(
|
||||
ctx.ResourceManager,
|
||||
ctx.ResourceDatabase,
|
||||
ctx.ResourceAllocator,
|
||||
_uploadedBatch.CommandBuffer,
|
||||
textureAsset.GeData<Color128>(),
|
||||
in textureDesc);
|
||||
|
||||
textureAsset.SetTextureHandle(handle);
|
||||
break;
|
||||
|
||||
default:
|
||||
error = Error.NotSupported;
|
||||
break;
|
||||
break;
|
||||
}
|
||||
|
||||
if (error.IsSuccess)
|
||||
// Record copy commands into cmdCopy
|
||||
entry.OnRecordUploadCommands(cmdCopy);
|
||||
entry.State = AssetState.Uploading;
|
||||
|
||||
_pendingFinalize.Enqueue(entry);
|
||||
uploadCount++;
|
||||
}
|
||||
|
||||
// 3. Submit the batch
|
||||
if (uploadCount > 0)
|
||||
{
|
||||
var result = _copyPipeline.End();
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Volatile.Write(ref entry.state, (int)AssetState.Ready);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingUploads.Enqueue(guid); // retry next frame
|
||||
_pendingCopyFenceValue = _copyPipeline.SignaledFenceValue();
|
||||
}
|
||||
}
|
||||
|
||||
_uploadedBatch.End();
|
||||
|
||||
// TODO: Do we need to wait?
|
||||
// await _uploadedBatch.WaitAsync(); // WaitIdle();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocking load. Returns when the asset reaches at least Loaded state.
|
||||
/// GPU upload still happens via ProcessUploads on the render thread.
|
||||
/// Use for loading screens or synchronous initialization.
|
||||
/// </summary>
|
||||
public async ValueTask<T?> LoadAsync<T>(AssetRef<T> assetRef, CancellationToken token = default)
|
||||
where T : Asset
|
||||
{
|
||||
if (!assetRef.IsValid)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = _entries.GetOrAdd(assetRef.guid, static (guid, self) =>
|
||||
{
|
||||
var e = new AssetEntryOld { assetId = guid, state = (int)AssetState.Loading };
|
||||
e.loadTask = Task.Run(() => self.ExecuteLoadAsync(e));
|
||||
return e;
|
||||
}, this);
|
||||
|
||||
if (Volatile.Read(ref entry.state) >= (int)AssetState.Loaded)
|
||||
{
|
||||
return entry.asset as T;
|
||||
}
|
||||
|
||||
var loadTask = entry.loadTask;
|
||||
if (loadTask is not null)
|
||||
{
|
||||
await loadTask.WaitAsync(token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _entries.TryGetValue(assetRef.guid, out var e) ? e.asset as T : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
Reference in New Issue
Block a user