feat: implement asynchronous asset management system with texture streaming support

This commit is contained in:
2026-04-20 01:09:59 +09:00
parent 4f5556ee1b
commit ed00f205b0
64 changed files with 1385 additions and 1157 deletions

View File

@@ -3,5 +3,7 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Ghost.Editor")]
[assembly: InternalsVisibleTo("Ghost.Editor.Core")]
[assembly: InternalsVisibleTo("Ghost.UnitTest")]
[assembly: EngineAssembly]

View File

@@ -1,42 +0,0 @@
using Ghost.Core;
using Ghost.Core.Utilities;
using Misaki.HighPerformance.LowLevel.Buffer;
using Misaki.HighPerformance.LowLevel.Utilities;
using System.Runtime.InteropServices;
namespace Ghost.Engine.AssetLoader;
internal sealed class TextureLoader : IRuntimeAssetLoader
{
public static readonly AssetType AssetType = AssetType.Texture;
public async ValueTask<Result<Asset>> LoadAsync(Stream cookedData, Guid id, CancellationToken token)
{
var header = new TextureContentHeader();
cookedData.ReadExactly(MemoryMarshal.AsBytes(new Span<TextureContentHeader>(ref header)));
var alignment = header.depth switch
{
8 => MemoryUtility.AlignOf<byte>(),
16 => MemoryUtility.AlignOf<ushort>(),
32 => MemoryUtility.AlignOf<float>(),
_ => MemoryUtility.AlignOf<float>()
};
var data = new MemoryBlock((nuint)(cookedData.Length - cookedData.Position), alignment, 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 maxBufferSize = (int)Math.Min(0x7effffffu, header.width * header.height * header.depth / 8u * header.colorComponents);
var offset = 0u;
while (offset < data.Size)
{
using var memoryManager = NativeMemoryManager<byte>.FromMemoryBlock(data, (int)offset, maxBufferSize);
await cookedData.ReadExactlyAsync(memoryManager.Memory, token);
offset += (uint)memoryManager.Memory.Length;
}
return new TextureAsset(ref data, header, id);
}
}

View File

@@ -1,104 +1,146 @@
using Ghost.Core;
using Ghost.Core.Utilities;
using Ghost.Engine.AssetLoader;
using Ghost.Graphics;
using Ghost.Graphics.RHI;
using Ghost.Graphics.Utilities;
using Misaki.HighPerformance.LowLevel.Buffer;
using TerraFX.Interop.Windows;
namespace Ghost.Engine;
public partial class AssetManager
internal partial class AssetEntry
{
private partial class AssetEntry
private unsafe class TextureData
{
private static TextureFormat GetTextureFormat(uint depth, uint colorComponents)
public TextureDesc desc;
public TextureContentHeader header;
public byte* pData;
public nuint dataSize;
}
private static void RegisterTextureCallback()
{
s_onCreation[(int)AssetType.Texture] = static (e) =>
{
return colorComponents switch
// 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();
e.SetStorage(handle);
};
s_onParseRawData[(int)AssetType.Texture] = static (e) => e.ParseTextureData();
s_onRecordUpload[(int)AssetType.Texture] = static (e, ctx) => e.RecordTextureUpload(ctx);
s_onUploadComplete[(int)AssetType.Texture] = static (e, ctx) => e.OnTextureUploadComplete(ctx);
s_onReleaseResource[(int)AssetType.Texture] = static (e) =>
{
var handle = e.GetStorage<Handle<GPUTexture>>();
e._resourceDatabase.ReleaseResource(handle.AsResource());
};
}
private static TextureFormat GetTextureFormat(uint depth, uint colorComponents)
{
return colorComponents switch
{
1 => depth 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,
},
8 => TextureFormat.R8_UNorm,
16 => TextureFormat.R16_UNorm,
32 => TextureFormat.R32_UInt,
_ => 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
},
2 => depth switch
{
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)
8 => TextureFormat.R8G8_UNorm,
16 => TextureFormat.R16G16_UNorm,
32 => TextureFormat.R32G32_Float,
_ => TextureFormat.Unknown,
},
3 or 4 => depth switch
{
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;
}
8 => TextureFormat.R8G8B8A8_UNorm,
16 => TextureFormat.R16G16B16A16_Float,
32 => TextureFormat.R32G32B32A32_Float,
_ => TextureFormat.Unknown,
},
_ => TextureFormat.Unknown,
};
}
private Handle<GPUTexture> AllocateTextureHandle()
private unsafe Result ParseTextureData()
{
// 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.
return _resourceDatabase.CreateShared(_fallbackTexture.AsResource()).AsTexture();
var pData = (byte*)_rawData.GetUnsafePtr();
Logger.DebugAssert(pData != null);
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,
};
// Will the gc be fine here?
var textureData = new TextureData
{
desc = textureDesc,
header = header,
pData = reader.CurrentAddress,
dataSize = reader.RemainingBytes,
};
_parsedObject = textureData;
return Result.Success();
}
private unsafe Result RecordTextureUpload(ResourceStreamingContext context)
{
var textureData = _parsedObject as TextureData;
Logger.DebugAssert(textureData != null);
var newHandle = RenderingUtility.CreateTexture(
context.ResourceManager,
context.ResourceDatabase,
context.ResourceAllocator,
context.CopyPipeline.GetCommandBuffer(),
textureData.pData,
textureData.dataSize,
in textureData.desc);
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(ResourceStreamingContext context)
{
var (oldHandle, newHandle) = GetStorage<(Handle<GPUTexture>, Handle<GPUTexture>)>();
var actualHandle = context.ResourceDatabase.Replace(oldHandle.AsResource(), newHandle.AsResource());
context.GraphicsCommandBuffer.Barrier(BarrierDesc.Texture(oldHandle.AsResource(), BarrierSync.AllShading, BarrierAccess.ShaderResource, BarrierLayout.ShaderResource));
SetStorage((actualHandle, Handle<GPUTexture>.Invalid));
_rawData.Dispose();
_parsedObject = null;
}
}
internal partial class AssetManager
{
public Handle<GPUTexture> ResolveTexture(Guid assetID)
{
if (assetID == Guid.Empty)
@@ -111,4 +153,19 @@ public partial class AssetManager
return entry.GetStorage<Handle<GPUTexture>>();
}
public int ReleaseTexture(Guid assetID)
{
if (assetID == Guid.Empty)
{
return 0;
}
if (!_entries.TryGetValue(assetID, out var entry) || entry.AssetType != AssetType.Texture)
{
return 0;
}
return entry.Release();
}
}

View File

@@ -1,16 +1,17 @@
using Ghost.Core;
using Ghost.Core.Utilities;
using Ghost.Graphics;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Ghost.Graphics.Services;
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;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Ghost.Engine;
@@ -25,20 +26,7 @@ public enum AssetState : byte
Failed = 6,
}
public enum AssetType : byte
{
Texture = 0,
Mesh = 1,
Material = 2,
Audio = 3,
Scene = 4,
Video = 5,
Json = 6,
Unknown = 255,
}
internal interface IContentProvider
public interface IContentProvider
{
bool HasAsset(Guid guid);
@@ -49,258 +37,249 @@ internal interface IContentProvider
AssetType GetAssetType(Guid guid);
}
// TODO: Support DirectStorage.
public partial class AssetManager : IDisposable
internal partial class AssetEntry
{
private unsafe partial class AssetEntry : IDisposable
private static readonly Action<AssetEntry>[] s_onCreation = new Action<AssetEntry>[(int)AssetType.Unknown + 1];
private static readonly Func<AssetEntry, Result>[] s_onParseRawData = new Func<AssetEntry, Result>[(int)AssetType.Unknown + 1];
private static readonly Action<AssetEntry, ResourceStreamingContext>[] s_onRecordUpload = new Action<AssetEntry, ResourceStreamingContext>[(int)AssetType.Unknown + 1];
private static readonly Action<AssetEntry, ResourceStreamingContext>[] s_onUploadComplete = new Action<AssetEntry, ResourceStreamingContext>[(int)AssetType.Unknown + 1];
private static readonly Action<AssetEntry>[] s_onReleaseResource = new Action<AssetEntry>[(int)AssetType.Unknown + 1];
static AssetEntry()
{
public struct __storage
{
public fixed byte data[64];
}
RegisterTextureCallback();
}
}
private readonly AssetManager _assetManager;
private Guid _assetId;
private __storage _storage;
private MemoryBlock _rawData;
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
{
get => (AssetState)Volatile.Read(ref _state);
set => Volatile.Write(ref _state, (int)value);
}
public AssetEntry(AssetManager manager, Guid assetId, AssetType assetType)
{
_assetManager = manager;
_assetId = assetId;
_assetType = assetType;
_refCount = 1;
switch (assetType)
{
case AssetType.Texture:
SetStorage(manager.AllocateTextureHandle());
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;
}
}
[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);
}
internal unsafe partial class AssetEntry
{
private struct Storage
{
public fixed byte data[64];
}
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 AssetManager _assetManager;
private readonly IResourceDatabase _resourceDatabase;
private readonly AsyncCopyPipeline _copyPipeline; // Upload via copy queue.
private readonly Guid _assetId;
private readonly AssetType _assetType;
private readonly Guid[] _dependencies;
private Storage _storage;
private MemoryBlock _rawData;
private object? _parsedObject;
private JobHandle _loadJobHandle;
private int _refCount;
private int _state;
private bool _pendingReimport;
public Guid AssetId => _assetId;
public MemoryBlock RawData => _rawData;
public JobHandle LoadJobHandle => _loadJobHandle;
public AssetType AssetType => _assetType;
public ReadOnlySpan<Guid> Dependencies => _dependencies;
public int RefCount => Volatile.Read(ref _refCount);
public ref int StateValue => ref _state;
public AssetState State
{
get => (AssetState)Volatile.Read(ref _state);
set => Volatile.Write(ref _state, (int)value);
}
public AssetEntry(AssetManager manager, IResourceDatabase resourceDatabase, Guid assetId, AssetType assetType, Guid[] dependencies)
{
_assetManager = manager;
_resourceDatabase = resourceDatabase;
_assetId = assetId;
_assetType = assetType;
_dependencies = dependencies;
s_onCreation[(int)_assetType]?.Invoke(this);
}
[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 SetPendingReimport()
{
Volatile.Write(ref _pendingReimport, true);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AddRef()
{
Interlocked.Increment(ref _refCount);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int Release()
{
Logger.DebugAssert(State == AssetState.Ready);
var newRefCount = Interlocked.Decrement(ref _refCount);
Logger.DebugAssert(newRefCount >= 0, "Reference count should not be negative");
if (newRefCount == 0)
{
_assetManager.RemoveEntry(_assetId);
OnReleaseResource();
foreach (var dep in _dependencies)
{
if (_assetManager.TryGetEntry(dep, out var entry))
{
entry.Release();
}
}
}
return newRefCount;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Result OnParseRawData()
{
return s_onParseRawData[(int)_assetType]?.Invoke(this) ?? Result.Failure("Unsupported asset type.");
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnRecordUploadCommands(ResourceStreamingContext context)
{
s_onRecordUpload[(int)_assetType]?.Invoke(this, context);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnUploadComplete(ResourceStreamingContext context)
{
s_onUploadComplete[(int)_assetType]?.Invoke(this, context);
Volatile.Write(ref _state, (int)AssetState.Ready);
if (Interlocked.CompareExchange(ref _pendingReimport, false, true))
{
_assetManager.InvalidateAsset(_assetId); // re-queue
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnReleaseResource()
{
s_onReleaseResource[(int)_assetType]?.Invoke(this);
}
}
internal struct LoadAssetJob : IJob
{
public Guid assetID;
public AssetType assetType;
public GCHandle assetManagerHandle;
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 readonly void Execute(ref readonly JobExecutionContext ctx)
{
var assetManager = assetManagerHandle.Target as AssetManager;
Logger.DebugAssert(assetManager is not null);
if (!assetManager.TryGetEntry(assetID, out var entry))
{
Logger.Error($"Asset entry not found for {assetID}");
return;
}
Logger.DebugAssert(entry.AssetType == assetType);
Logger.DebugAssert(entry.State == AssetState.Scheduled);
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;
}
result = entry.OnParseRawData();
if (result.IsFailure)
{
entry.State = AssetState.Failed;
Logger.Error($"Failed to parse asset {assetID}: {result.Message}");
return;
}
entry.State = AssetState.Loaded;
assetManager.StreamingProcessor.EnqueueForUpload(entry);
}
}
// TODO: Support DirectStorage.
internal partial class AssetManager : IDisposable
{
private readonly IResourceDatabase _resourceDatabase;
private readonly IContentProvider _contentProvider;
private readonly ResourceStreamingProcessor _streamingProcessor;
private readonly JobScheduler _jobScheduler;
private readonly ConcurrentDictionary<Guid, AssetEntry> _entries;
private readonly ConcurrentQueue<AssetEntry> _pendingFinalize;
private ulong _pendingCopyFenceValue;
private readonly ConcurrentDictionary<Guid, AssetEntry> _entries;
private GCHandle _selfHandle;
// TODO
private Handle<GPUTexture> _fallbackTexture;
@@ -308,57 +287,56 @@ public partial class AssetManager : IDisposable
private Handle<Mesh> _fallbackMesh;
private Handle<Material> _fallbackMaterial;
internal AssetManager(IContentProvider contentProvider, ResourceManager resourceManager, IResourceAllocator resourceAllocator, IResourceDatabase resourceDatabase, AsyncCopyPipeline uploadBatch)
public IContentProvider ContentProvider => _contentProvider;
public ResourceStreamingProcessor StreamingProcessor => _streamingProcessor;
public Handle<GPUTexture> FallbackTexture => _fallbackTexture;
internal AssetManager(IResourceDatabase resourceDatabase, IContentProvider contentProvider, ResourceStreamingProcessor streamingProcessor, JobScheduler jobScheduler)
{
_contentProvider = contentProvider;
_resourceManager = resourceManager;
_resourceAllocator = resourceAllocator;
_resourceDatabase = resourceDatabase;
_copyPipeline = uploadBatch;
_contentProvider = contentProvider;
_streamingProcessor = streamingProcessor;
_jobScheduler = jobScheduler;
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>();
_selfHandle = GCHandle.Alloc(this, GCHandleType.Normal);
}
internal bool TryGetEntry(Guid guid, [NotNullWhen(true)] out AssetEntry? entry)
{
return _entries.TryGetValue(guid, out entry);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool RemoveEntry(Guid guid)
internal bool RemoveEntry(Guid guid)
{
return _entries.TryRemove(guid, out _);
return _entries.TryRemove(guid, out var entry);
}
private void EnsureScheduled(AssetEntry entry)
{
if ((int)entry.State >= (int)AssetState.Scheduled)
if (Interlocked.CompareExchange(ref entry.StateValue, (int)AssetState.Scheduled, (int)AssetState.Unloaded) != (int)AssetState.Unloaded)
{
return;
}
// Resolve dependencies (in-memory manifest/catalog lookup — instant)
var deps = _contentProvider.GetDependencies(entry.AssetId);
// TODO: Can this be jobified? If the dependency tree is not deep, it should be fine to do it in main thread, otherwise we might need to schedule a job to do it.
var dependency = JobHandle.Invalid;
if (deps.Length > 0)
if (entry.Dependencies.Length > 0)
{
// Avoid stack overflow for deep dependency tree like a whole scene.
// Avoid stack overflow for deep dependency tree like a 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);
using var list = new UnsafeList<Guid>(entry.Dependencies.Length * 2, scope.AllocationHandle);
using var stack = new UnsafeStack<Guid>(entry.Dependencies.Length * 2, scope.AllocationHandle);
using var visited = new UnsafeHashSet<Guid>(entry.Dependencies.Length * 2, scope.AllocationHandle);
for (var i = 0; i < deps.Length; i++)
for (var i = 0; i < entry.Dependencies.Length; i++)
{
stack.Push(deps[i]);
stack.Push(entry.Dependencies[i]);
}
while (stack.TryPop(out var guid))
@@ -388,7 +366,7 @@ public partial class AssetManager : IDisposable
{
// 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);
Logger.DebugAssert(handle.IsValid);
depHandles.Add(handle);
}
@@ -400,85 +378,59 @@ public partial class AssetManager : IDisposable
{
assetID = entry.AssetId,
assetType = entry.AssetType,
assetManagerHandle = _selfHandle,
};
entry.SetLoadJobHandle(_jobScheduler.Schedule(ref job, dependency));
entry.SetLoadJobHandle(_jobScheduler.Schedule(ref job, dependency, JobPriority.Low)); // Use low priority to avoid blocking main thread critical tasks like rendering and physics.
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private AssetEntry GetOrCreateEntry(Guid guid)
{
return _entries.GetOrAdd(guid, static (id, self) =>
var entry = _entries.GetOrAdd(guid, static (id, self) =>
{
var entry = new AssetEntry(self, id, self._contentProvider.GetAssetType(id))
{
State = AssetState.Scheduled
};
var type = self._contentProvider.GetAssetType(id);
var deps = self._contentProvider.GetDependencies(id);
var entry = new AssetEntry(self, self._resourceDatabase, id, type, deps);
self.EnsureScheduled(entry);
return entry;
}, this);
entry.AddRef();
return entry;
}
// NOTE: Render thread only.
internal void ProcessPendingUploads()
public void InvalidateAsset(Guid guid)
{
// 1. If there's a pending copy batch from last frame, check its fence
if (_pendingCopyFenceValue > 0 && _copyPipeline.CurrentFenceValue() >= _pendingCopyFenceValue)
{
while (_pendingFinalize.TryDequeue(out var item))
{
item.OnUploadComplete();
}
_pendingCopyFenceValue = 0;
}
if (_pendingCopyFenceValue > 0)
if (!_entries.TryGetValue(guid, out var entry))
{
return;
}
// 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();
var cmdCopy = _copyPipeline.GetCommandBuffer();
var uploadCount = 0;
foreach (var (guid, entry) in _entries)
if (entry.State is AssetState.Loading or AssetState.Loaded or AssetState.Uploading)
{
if (entry.State != AssetState.Loaded)
{
continue;
}
if (uploadCount >= _MAX_UPLOADS_PER_FRAME)
{
break;
}
// Record copy commands into cmdCopy
entry.OnRecordUploadCommands(cmdCopy);
entry.State = AssetState.Uploading;
_pendingFinalize.Enqueue(entry);
uploadCount++;
entry.SetPendingReimport();
return;
}
// 3. Submit the batch
if (uploadCount > 0)
{
var result = _copyPipeline.End();
if (result.IsSuccess)
{
_pendingCopyFenceValue = _copyPipeline.SignaledFenceValue();
}
}
// Entry is in Ready state — the old texture is valid and will remain visible.
// Go directly to Scheduled → Loading → Loaded → Uploading → Ready again.
// The swap cycle in RecordTextureUpload/OnTextureUploadComplete handles the
// v1 → v2 transition exactly like the fallback → v1 transition.
entry.State = AssetState.Scheduled;
EnsureScheduled(entry);
}
public void Dispose()
{
throw new NotImplementedException();
foreach (var entry in _entries.Values)
{
entry.OnReleaseResource();
}
_entries.Clear();
_selfHandle.Free();
}
}

View File

@@ -6,29 +6,42 @@ namespace Ghost.Engine;
public sealed partial class EngineCore : IDisposable
{
private readonly IContentProvider _contentProvider;
private readonly JobScheduler _jobScheduler;
private readonly ResourceStreamingProcessor _streamingProcessor;
private readonly RenderSystem _renderSystem;
private readonly AssetManager _assetManager;
public JobScheduler JobScheduler => _jobScheduler;
public RenderSystem RenderSystem => _renderSystem;
public EngineCore()
public EngineCore(IContentProvider contentProvider)
{
_jobScheduler = new JobScheduler(Environment.ProcessorCount - 2); // We -2 here, one for main thread, one for render thread
_contentProvider = contentProvider;
var renderingConfig = new RenderSystemDesc
var desc = new JobSchedulerDesc
{
ThreadCount = Environment.ProcessorCount - 2, // We -2 here, one for main thread, one for render thread
ThreadPriority = ThreadPriority.Normal,
};
_jobScheduler = new JobScheduler(in desc);
_streamingProcessor = new ResourceStreamingProcessor();
var renderingDesc = new RenderSystemDesc
{
FrameBufferCount = 2,
GraphicsAPI = GraphicsAPI.Direct3D12,
InitialRenderPipelineSettings = new GhostRenderPipelineSettings(),
ResourceStreamingProcessor = _streamingProcessor,
ShaderCacheDirectory = "ShaderCache",
};
_renderSystem = new RenderSystem(renderingConfig);
_renderSystem = new RenderSystem(renderingDesc);
_assetManager = new AssetManager(_renderSystem.GraphicsEngine.ResourceDatabase, _contentProvider, _streamingProcessor, _jobScheduler);
}
public void Dispose()
{
_assetManager.Dispose();
_renderSystem.Dispose();
_jobScheduler.Dispose();
}

View File

@@ -55,7 +55,6 @@ internal unsafe class GPUScene : IDisposable
Dispose();
}
// NOTE: This is not thread safe.
public void ResizeIfNeeded(ICommandBuffer cmd)
{
if (_requiredResize == 0)
@@ -107,6 +106,8 @@ internal unsafe class GPUScene : IDisposable
// Return the last index. We will swap the last instance data with the removed index on gpu to keep the buffer compact.
var last = Interlocked.Decrement(ref _instanceCount);
Logger.DebugAssert(last >= 0);
return last;
}

View File

@@ -1,5 +1,6 @@
using Ghost.Core;
using Ghost.Core.Graphics;
using Ghost.Core.Utilities;
using Ghost.Graphics.Core;
using Ghost.Graphics.RHI;
using Ghost.Graphics.Services;
@@ -13,28 +14,47 @@ namespace Ghost.Engine.RenderPipeline;
public partial struct UpdateGPUSceneShaderProperty
{
public uint gpuSceneBuffer;
public uint addBuffer;
public uint addCount;
public uint updateBuffer;
public uint updateCount;
public uint removeBuffer;
public uint removeCount;
}
internal partial class GhostRenderPipeline
{
private static unsafe Handle<GPUBuffer> CreateAddInstanceBuffer(GhostRenderPayload ghostPayload, ResourceManager resourceManager, IResourceDatabase resourceDatabase, out int count)
private struct UpdateInstanceData
{
public float4x4 localToWorld;
public uint instanceID;
public uint meshBuffer;
public uint materialPalette;
public uint renderingLayerMask;
public uint shadowCastingMode;
}
private struct RemoveInstanceData
{
public uint instanceID;
public uint swapWithInstanceID;
}
private static unsafe Handle<GPUBuffer> CreateUpdateInstanceBuffer(GhostRenderPayload ghostPayload, ResourceManager resourceManager, IResourceDatabase resourceDatabase, out int count)
{
// TODO: This should also include update requests like transform update, material update, etc.
var totalUpdateCount = ghostPayload.AddRequest.Count; // + ghostPayload.UpdateRequest.Count;
if (!ghostPayload.AddRequest.IsEmpty)
{
var addDesc = new BufferDesc
{
Size = (nuint)ghostPayload.AddRequest.Count * MemoryUtility.SizeOf<AddInstanceData>(),
Stride = (uint)MemoryUtility.SizeOf<AddInstanceData>(),
Size = (nuint)ghostPayload.AddRequest.Count * MemoryUtility.SizeOf<UpdateInstanceData>(),
Stride = (uint)MemoryUtility.SizeOf<UpdateInstanceData>(),
Usage = BufferUsage.Structured | BufferUsage.ShaderResource,
HeapType = HeapType.Upload
};
var addBuffer = resourceManager.CreateTransientBuffer(in addDesc, "Add Instance Buffer");
var pAddData = (AddInstanceData*)resourceDatabase.MapResource(addBuffer.AsResource(), 0, null);
var pAddData = (UpdateInstanceData*)resourceDatabase.MapResource(addBuffer.AsResource(), 0, null);
var i = 0;
while (ghostPayload.AddRequest.TryDequeue(out var addRequest))
@@ -46,7 +66,7 @@ internal partial class GhostRenderPipeline
continue;
}
pAddData[i] = new AddInstanceData
pAddData[i] = new UpdateInstanceData
{
localToWorld = addRequest.localToWorld,
instanceID = addRequest.instanceId,
@@ -106,14 +126,16 @@ internal partial class GhostRenderPipeline
return default;
}
public void UpdateGPUScene(RenderContext ctx, GhostRenderPayload payload)
private void UpdateGPUScene(RenderContext ctx, GhostRenderPayload payload)
{
var addBuffer = CreateAddInstanceBuffer(payload, ctx.ResourceManager, ctx.ResourceDatabase, out var addCount);
_gpuScene.ResizeIfNeeded(ctx.CommandBuffer);
var updateBuffer = CreateUpdateInstanceBuffer(payload, ctx.ResourceManager, ctx.ResourceDatabase, out var updateCount);
var removeBuffer = CreateRemoveInstanceBuffer(payload, ctx.ResourceManager, ctx.ResourceDatabase, out var removeCount);
if (addCount <= 0 && removeCount <= 0)
if (updateCount <= 0 && removeCount <= 0)
{
Logger.DebugAssert(addBuffer.IsInvalid && removeBuffer.IsInvalid, "Buffers should be invalid when there are no updates.");
Logger.DebugAssert(updateBuffer.IsInvalid && removeBuffer.IsInvalid, "Buffers should be invalid when there are no updates.");
return; // No updates needed
}
@@ -126,8 +148,8 @@ internal partial class GhostRenderPipeline
var property = new UpdateGPUSceneShaderProperty
{
gpuSceneBuffer = ctx.ResourceDatabase.GetBindlessIndex(_gpuScene.SceneBuffer.AsResource(), BindlessAccess.UnorderedAccess),
addBuffer = ctx.ResourceDatabase.GetBindlessIndex(addBuffer.AsResource()),
addCount = (uint)addCount,
updateBuffer = ctx.ResourceDatabase.GetBindlessIndex(updateBuffer.AsResource()),
updateCount = (uint)updateCount,
removeBuffer = ctx.ResourceDatabase.GetBindlessIndex(removeBuffer.AsResource()),
removeCount = (uint)removeCount
};

View File

@@ -2,28 +2,11 @@ using Ghost.Core;
using Ghost.Graphics;
using Ghost.Graphics.Core;
using Ghost.Graphics.RenderGraphModule;
using Misaki.HighPerformance.Mathematics;
namespace Ghost.Engine.RenderPipeline;
internal partial class GhostRenderPipeline : IRenderPipeline
{
private struct AddInstanceData
{
public float4x4 localToWorld;
public uint instanceID;
public uint meshBuffer;
public uint materialPalette;
public uint renderingLayerMask;
public uint shadowCastingMode;
}
private struct RemoveInstanceData
{
public uint instanceID;
public uint swapWithInstanceID;
}
private readonly RenderSystem _renderSystem;
private readonly RenderGraph _renderGraph;
@@ -39,7 +22,7 @@ internal partial class GhostRenderPipeline : IRenderPipeline
_gpuScene = new GPUScene(renderSystem.GraphicsEngine.ResourceAllocator, renderSystem.GraphicsEngine.ResourceDatabase, 102_400u); // 102.4k objects should be enough for now
}
public void Render(RenderContext ctx, int frameIndex, IRenderPayload payload)
public void Render(RenderContext ctx, int frameIndex, IRenderPayload payload, ReadOnlySpan<byte> resourceUpdateCommands)
{
var ghostPayload = (GhostRenderPayload)payload;

View File

@@ -1,3 +1,4 @@
using Ghost.Core;
using Ghost.Engine.Components;
using Ghost.Graphics;
using Ghost.Graphics.Core;
@@ -26,16 +27,17 @@ internal sealed class GhostRenderPayload : IRenderPayload
private UnsafeList<RenderRequest> _renderRequests;
// TODO: Consider using a more efficient data structure for these queues, such as a lock-free ring buffer or a custom concurrent queue implementation.
private readonly ConcurrentQueue<AddInstanceRequest> _addRequest;
private readonly ConcurrentQueue<RemoveInstanceRequest> _removeRequest;
private uint _instanceCountBefore;
private uint _instanceCount;
public ReadOnlySpan<RenderRequest> RenderRequests => _renderRequests;
public ConcurrentQueue<AddInstanceRequest> AddRequest => _addRequest;
public ConcurrentQueue<RemoveInstanceRequest> RemoveRequest => _removeRequest;
public uint InstanceCountBefore => _instanceCountBefore;
public uint InstanceCount => _instanceCount;
public GhostRenderPayload(GhostRenderPipeline renderPipeline)
@@ -70,10 +72,16 @@ internal sealed class GhostRenderPayload : IRenderPayload
}
}
public void BeginRecord()
{
_instanceCountBefore = _renderPipeline.GPUScene.InstanceCount;
}
public void EndRecord()
{
// We capture the count here to prevent that main thread continues to add more requests for next frame while the render thread is still processing current frame's requests.
_instanceCount = _renderPipeline.GPUScene.InstanceCount;
Logger.DebugAssert(_instanceCount == _instanceCountBefore + (uint)_addRequest.Count - (uint)_removeRequest.Count);
}
public void Reset()

View File

@@ -0,0 +1,81 @@
using Ghost.Core;
using Ghost.Graphics;
using SharpCompress.Common;
using System.Collections.Concurrent;
namespace Ghost.Engine;
internal class ResourceStreamingProcessor : IResourceStreamingProcessor
{
private const int _MAX_UPLOADS_PER_FRAME = 8;
private readonly ConcurrentQueue<AssetEntry> _pendingUpload;
private readonly ConcurrentQueue<AssetEntry> _pendingFinalize;
private ulong _pendingCopyFenceValue;
public ResourceStreamingProcessor()
{
_pendingUpload = new ConcurrentQueue<AssetEntry>();
_pendingFinalize = new ConcurrentQueue<AssetEntry>();
_pendingCopyFenceValue = 0;
}
public void EnqueueForUpload(AssetEntry entry)
{
_pendingUpload.Enqueue(entry);
}
public void ProcessPendingUploads(ResourceStreamingContext context)
{
// 1. If there's a pending copy batch from last frame, check its fence
if (_pendingCopyFenceValue > 0 && context.CopyPipeline.CurrentFenceValue() >= _pendingCopyFenceValue)
{
while (_pendingFinalize.TryDequeue(out var item))
{
item.OnUploadComplete(context);
}
_pendingCopyFenceValue = 0;
}
if (_pendingCopyFenceValue > 0)
{
return;
}
// 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)
if (_pendingUpload.IsEmpty)
{
return;
}
context.CopyPipeline.Begin();
var uploadCount = 0;
while (uploadCount < _MAX_UPLOADS_PER_FRAME && _pendingUpload.TryDequeue(out var entry))
{
if (entry.State != AssetState.Loaded)
{
Logger.Warning($"Asset {entry.AssetId} is in state {entry.State}, expected Loaded. Skipping upload.");
continue;
}
// Record copy commands into cmdCopy
entry.OnRecordUploadCommands(context);
entry.State = AssetState.Uploading;
_pendingFinalize.Enqueue(entry);
uploadCount++;
}
var result = context.CopyPipeline.End();
// 3. Submit the batch
if (uploadCount > 0 && result.IsSuccess)
{
_pendingCopyFenceValue = context.CopyPipeline.SignaledFenceValue();
}
}
}