Resolve field volume texture resources

This commit is contained in:
Meta Vens 2026-05-26 21:08:34 +01:00
commit 024b439006
10 changed files with 307 additions and 18 deletions

View file

@ -388,6 +388,32 @@ public readonly record struct AquariumFieldResourceDeclaration(
NativeHandleKind: string.IsNullOrWhiteSpace(sourceUri) ? "fensalir-surface-page" : "local-asset",
SourceUri: sourceUri);
public static AquariumFieldResourceDeclaration VolumeTexture(
string resourceKey,
int width,
int height,
int depth,
string format = "R16Float",
ulong version = 0,
long validFromNs = 0,
long validUntilNs = 0) =>
new(
ResourceKey: resourceKey,
Kind: AquariumFieldResourceKind.VolumeTexture,
Residency: AquariumFieldResourceResidency.GpuResident,
Access: AquariumFieldShaderAccess.ShaderResource,
Format: format,
Width: Math.Max(1, width),
Height: Math.Max(1, height),
DepthOrCount: Math.Max(1, depth),
StrideBytes: FormatStrideBytes(format),
ValidFromNs: validFromNs,
ValidUntilNs: validUntilNs,
Version: version,
NativeHandle: IntPtr.Zero,
NativeHandleKind: "fensalir-volume-texture",
SourceUri: "");
private static int FormatStrideBytes(string format) =>
format switch
{

View file

@ -42,6 +42,9 @@ public static class AquariumFieldScriptCompiler
case "surfacepage":
BindSurfacePage(args, resourceAliases, resources, lineIndex);
break;
case "volumetexture":
BindVolumeTexture(args, resourceAliases, resources, lineIndex);
break;
case "domain":
var domain = ParseFieldDomain(args, lineIndex);
if (domainKeys.Add(domain.DomainKey))
@ -128,6 +131,30 @@ public static class AquariumFieldScriptCompiler
}
}
private static void BindVolumeTexture(
IReadOnlyDictionary<string, string> args,
IDictionary<string, AquariumFieldResourceDeclaration> resourceAliases,
ICollection<AquariumFieldResourceDeclaration> resources,
int lineIndex)
{
var id = Required(args, "id", lineIndex);
var key = StringValue(args, "key", $"aquarium:resource:volume-texture:{id}");
var resource = AquariumFieldResourceDeclaration.VolumeTexture(
key,
Int(args, "width", 1, lineIndex),
Int(args, "height", 1, lineIndex),
Int(args, "depth", 1, lineIndex),
StringValue(args, "format", "R16Float"),
UInt64(args, "version", 0, lineIndex));
resourceAliases[id] = resource;
resourceAliases[key] = resource;
if (resources.All(existing => !string.Equals(existing.ResourceKey, resource.ResourceKey, StringComparison.Ordinal)))
{
resources.Add(resource);
}
}
public static AquariumBufferFieldFrame Compile(
string source,
IReadOnlyDictionary<string, AquariumTextureFieldBinding> textureBindings,

View file

@ -9,10 +9,11 @@ internal readonly record struct D3D12FieldResourceStats(
int StructuredBuffers,
int Texture2D,
int SurfacePages,
int VolumeTextures,
int Unsupported,
int StaleRemoved)
{
public static D3D12FieldResourceStats Empty { get; } = new(0, 0, 0, 0, 0, 0, 0);
public static D3D12FieldResourceStats Empty { get; } = new(0, 0, 0, 0, 0, 0, 0, 0);
}
internal sealed class D3D12FieldResourceRegistry : IDisposable
@ -21,6 +22,7 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
private readonly Dictionary<string, StructuredBufferSlot> structuredBuffers = new(StringComparer.Ordinal);
private readonly Dictionary<string, Texture2DSlot> textures = new(StringComparer.Ordinal);
private readonly Dictionary<string, VolumeTextureSlot> volumeTextures = new(StringComparer.Ordinal);
private readonly HashSet<string> liveKeys = new(StringComparer.Ordinal);
public D3D12FieldResourceStats Resolve(
@ -38,6 +40,7 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
var structuredBufferCount = 0;
var textureCount = 0;
var surfacePageCount = 0;
var volumeTextureCount = 0;
var unsupported = 0;
foreach (var declaration in declarations)
{
@ -93,17 +96,34 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
continue;
}
if (declaration.Kind == AquariumFieldResourceKind.VolumeTexture)
{
volumeTextureCount++;
if (ResolveVolumeTexture(device, declaration))
{
resolved++;
}
else
{
unsupported++;
}
continue;
}
unsupported++;
}
var staleRemoved = RemoveStaleStructuredBuffers(resourceRegistry, liveKeys);
staleRemoved += RemoveStaleTextures(liveKeys);
staleRemoved += RemoveStaleVolumeTextures(liveKeys);
return new D3D12FieldResourceStats(
Declared: declarations.Count,
Resolved: resolved,
StructuredBuffers: structuredBufferCount,
Texture2D: textureCount,
SurfacePages: surfacePageCount,
VolumeTextures: volumeTextureCount,
Unsupported: unsupported,
StaleRemoved: staleRemoved);
}
@ -120,8 +140,14 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
slot.Texture.Dispose();
}
foreach (var slot in volumeTextures.Values)
{
slot.Texture.Dispose();
}
structuredBuffers.Clear();
textures.Clear();
volumeTextures.Clear();
liveKeys.Clear();
}
@ -162,6 +188,18 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
return false;
}
public bool TryGetVolumeTexture(string resourceKey, out D3D12FieldTexture3D volumeTexture)
{
if (volumeTextures.TryGetValue(resourceKey, out var slot))
{
volumeTexture = slot.Texture;
return true;
}
volumeTexture = null!;
return false;
}
public bool TryResolveTexture2D(
ID3D12Device device,
ID3D12GraphicsCommandList commandList,
@ -386,6 +424,45 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
return true;
}
private bool ResolveVolumeTexture(
ID3D12Device device,
AquariumFieldResourceDeclaration declaration)
{
if (declaration.Residency != AquariumFieldResourceResidency.GpuResident ||
declaration.Access != AquariumFieldShaderAccess.ShaderResource)
{
return false;
}
if (volumeTextures.TryGetValue(declaration.ResourceKey, out var existing) &&
existing.Version == declaration.Version &&
existing.Width == declaration.Width &&
existing.Height == declaration.Height &&
existing.Depth == declaration.DepthOrCount)
{
return true;
}
if (existing is not null)
{
existing.Texture.Dispose();
volumeTextures.Remove(declaration.ResourceKey);
}
if (!D3D12FieldTexture3D.TryCreateEmpty(device, declaration, out var volumeTexture))
{
return false;
}
volumeTextures[declaration.ResourceKey] = new VolumeTextureSlot(
volumeTexture,
declaration.Width,
declaration.Height,
declaration.DepthOrCount,
declaration.Version);
return true;
}
private int RemoveStaleStructuredBuffers(D3D12ResourceRegistry resourceRegistry, HashSet<string> currentKeys)
{
var removed = 0;
@ -423,6 +500,24 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
return removed;
}
private int RemoveStaleVolumeTextures(HashSet<string> currentKeys)
{
var removed = 0;
foreach (var key in volumeTextures.Keys.ToArray())
{
if (currentKeys.Contains(key))
{
continue;
}
volumeTextures[key].Texture.Dispose();
volumeTextures.Remove(key);
removed++;
}
return removed;
}
private static string RegistryKey(string resourceKey) => RegistryPrefix + resourceKey;
private static D3D12StructuredBuffer? OpenSharedStructuredBuffer(
@ -468,4 +563,11 @@ internal sealed class D3D12FieldResourceRegistry : IDisposable
AquariumFieldResourceKind Kind,
string SourceUri,
ulong Version);
private sealed record VolumeTextureSlot(
D3D12FieldTexture3D Texture,
int Width,
int Height,
int Depth,
ulong Version);
}

View file

@ -88,7 +88,7 @@ internal sealed unsafe class D3D12FieldTexture2D : IDisposable
out D3D12FieldTexture2D texture)
{
texture = null!;
if (!TryFormat(declaration.Format, out var format))
if (!D3D12FieldTextureFormat.TryFormat(declaration.Format, out var format))
{
return false;
}
@ -258,17 +258,4 @@ internal sealed unsafe class D3D12FieldTexture2D : IDisposable
return (value + alignment - 1) & ~(alignment - 1);
}
private static bool TryFormat(string formatName, out Format format)
{
format = formatName switch
{
"R8Unorm" or "R8_UNorm" or "R8_UNORM" => Format.R8_UNorm,
"R16Float" or "R16_Float" or "R16_FLOAT" => Format.R16_Float,
"R32Float" or "R32_Float" or "R32_FLOAT" or "Float32" => Format.R32_Float,
"Rgba8Unorm" or "R8G8B8A8_UNorm" or "R8G8B8A8_UNORM" => Format.R8G8B8A8_UNorm,
"Rgba16Float" or "R16G16B16A16_Float" or "R16G16B16A16_FLOAT" => Format.R16G16B16A16_Float,
_ => Format.Unknown,
};
return format != Format.Unknown;
}
}

View file

@ -0,0 +1,101 @@
using Vortice.Direct3D12;
using Vortice.DXGI;
namespace Aquarium.Engine.Render;
internal sealed class D3D12FieldTexture3D : IDisposable
{
private D3D12FieldTexture3D(
ID3D12Resource resource,
int width,
int height,
int depth,
Format format,
string name)
{
Resource = resource;
Width = width;
Height = height;
Depth = depth;
Format = format;
Resource.Name = name;
}
public ID3D12Resource Resource { get; }
public int Width { get; }
public int Height { get; }
public int Depth { get; }
public Format Format { get; }
public ResourceStates State { get; private set; } = ResourceStates.PixelShaderResource;
public static bool TryCreateEmpty(
ID3D12Device device,
AquariumFieldResourceDeclaration declaration,
out D3D12FieldTexture3D texture)
{
texture = null!;
if (!D3D12FieldTextureFormat.TryFormat(declaration.Format, out var format))
{
return false;
}
var width = Math.Max(1, declaration.Width);
var height = Math.Max(1, declaration.Height);
var depth = Math.Max(1, declaration.DepthOrCount);
var resource = device.CreateCommittedResource(
HeapType.Default,
ResourceDescription.Texture3D(
format,
(uint)width,
(uint)height,
(ushort)Math.Min(depth, ushort.MaxValue),
1,
ResourceFlags.None),
ResourceStates.PixelShaderResource,
null);
texture = new D3D12FieldTexture3D(
resource,
width,
height,
depth,
format,
$"Aquarium D3D12 Field VolumeTexture {declaration.ResourceKey}");
return true;
}
public void CreateShaderResourceView(ID3D12Device device, D3D12DescriptorSlot descriptor)
{
device.CreateShaderResourceView(
Resource,
new ShaderResourceViewDescription
{
Format = Format,
ViewDimension = ShaderResourceViewDimension.Texture3D,
Shader4ComponentMapping = ShaderComponentMapping.Default,
Texture3D = new Texture3DShaderResourceView { MipLevels = 1 },
},
descriptor.Cpu);
}
public void Transition(ID3D12GraphicsCommandList commandList, ResourceStates nextState)
{
if (State == nextState)
{
return;
}
commandList.ResourceBarrier(ResourceBarrier.BarrierTransition(Resource, State, nextState));
State = nextState;
}
public void Dispose()
{
Resource.Dispose();
}
}

View file

@ -0,0 +1,20 @@
using Vortice.DXGI;
namespace Aquarium.Engine.Render;
internal static class D3D12FieldTextureFormat
{
public static bool TryFormat(string formatName, out Format format)
{
format = formatName switch
{
"R8Unorm" or "R8_UNorm" or "R8_UNORM" => Format.R8_UNorm,
"R16Float" or "R16_Float" or "R16_FLOAT" => Format.R16_Float,
"R32Float" or "R32_Float" or "R32_FLOAT" or "Float32" => Format.R32_Float,
"Rgba8Unorm" or "R8G8B8A8_UNorm" or "R8G8B8A8_UNORM" => Format.R8G8B8A8_UNorm,
"Rgba16Float" or "R16G16B16A16_Float" or "R16G16B16A16_FLOAT" => Format.R16G16B16A16_Float,
_ => Format.Unknown,
};
return format != Format.Unknown;
}
}

View file

@ -3031,6 +3031,7 @@ public sealed class D3D12Renderer : IAquariumRenderer
$"structured buffers {activeFieldResourceStats.StructuredBuffers:N0}; " +
$"texture2d {activeFieldResourceStats.Texture2D:N0}; " +
$"surface pages {activeFieldResourceStats.SurfacePages:N0}; " +
$"volume textures {activeFieldResourceStats.VolumeTextures:N0}; " +
$"unsupported resources {activeFieldResourceStats.Unsupported:N0}; " +
$"claims {activeFieldEvidenceFrame.Claims.Count:N0}; " +
$"candidates {activeFieldEvidenceFrame.Candidates.Count:N0}; " +

View file

@ -240,3 +240,4 @@
{"lesson":"Texture2D local assets are now first-class field resources. AquariumFieldResourceDeclaration carries SourceUri and has a LocalTexture2D factory; the field DSL accepts `texture2d id=... key=... path=...` and TubeField `ramp=...` resolves aliases into RampResourceKey. D3D12FieldResourceRegistry lazily uploads local Rgba8 assets into GPU-resident textures, TubeField rendering binds the ramp SRV plus linear clamp sampler, and unresolved ramps use a Fensalir-owned 0-1 fallback texture. This replaces raw ramp paths as live authority while keeping them as compatibility metadata. Fensalir build/tests/headless reload and Mimir build/field smokes passed.","timestamp":"2026-05-26T20:40:00+01:00","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","src/Aquarium.Engine.Contracts/Render/AquariumFieldScriptCompiler.cs","src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs","src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl","tests/Aquarium.Engine.Tests/FieldScriptCompilerTests.cs","state/map.yaml"],"source":"user requested Texture2D local asset resource declaration and automatic DSL binding","topic":"field-texture2d-resource-binding"}
{"lesson":"TubeField draw dimensions are now emitted as GPU-resident indirect draw packets. The expansion compute shader writes one D3D12 DrawIndexedArguments packet per resolved TubeField batch into a UAV argument buffer; render binds the matching source/ramp material state and calls ExecuteIndirect instead of CPU-manufacturing DrawIndexedInstanced counts. CPU still schedules one indirect call per material/resource batch because each batch has distinct root bindings, but draw count/start index authority now lives in the GPU expansion output. Fensalir build/tests/headless reload and Mimir build/field smokes passed.","timestamp":"2026-05-26T20:59:00+01:00","files":["src/Aquarium.Engine/Render/D3D12Renderer.cs","src/Aquarium.Engine/Render/Shaders/D3D12TubeField.hlsl","state/map.yaml"],"source":"continued migration plan after Texture2D resolver cut","topic":"d3d12-tubefield-indirect-draw-packets"}
{"lesson":"SurfacePage is now a resolvable field resource kind rather than just a planner enum. AquariumFieldResourceDeclaration has a SurfacePage factory, the field evidence DSL accepts `surfacepage id=... key=... width=... height=... format=...`, and D3D12FieldResourceRegistry allocates empty GPU-resident shader-readable page textures or lazily loads local source-backed pages through the same texture upload path. This does not finish terrain/heightfield lowering; it creates the resource authority that future Height/Sdf2D/Material packets can bind. Fensalir build/tests/headless reload and Mimir build/field smokes passed.","timestamp":"2026-05-26T21:05:00+01:00","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","src/Aquarium.Engine.Contracts/Render/AquariumFieldScriptCompiler.cs","src/Aquarium.Engine/Render/D3D12FieldTexture2D.cs","src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","tests/Aquarium.Engine.Tests/FieldScriptCompilerTests.cs","state/map.yaml"],"source":"continued field resource resolver migration","topic":"field-surface-page-resource-resolver"}
{"lesson":"VolumeTexture is now a resolvable field resource kind. AquariumFieldResourceDeclaration has a VolumeTexture factory, the field evidence DSL accepts `volumetexture id=... key=... width=... height=... depth=... format=...`, and D3D12FieldResourceRegistry allocates empty GPU-resident shader-readable Texture3D resources with format/dimension/version tracking. This creates resource authority for future density/extinction/SDF3D lowerings without pretending those render paths exist yet. Fensalir build/tests/headless reload and Mimir build/field smokes passed.","timestamp":"2026-05-26T21:11:00+01:00","files":["src/Aquarium.Engine.Contracts/Render/AquariumFieldEvidence.cs","src/Aquarium.Engine.Contracts/Render/AquariumFieldScriptCompiler.cs","src/Aquarium.Engine/Render/D3D12FieldTexture3D.cs","src/Aquarium.Engine/Render/D3D12FieldTextureFormat.cs","src/Aquarium.Engine/Render/D3D12FieldResourceRegistry.cs","src/Aquarium.Engine/Render/D3D12Renderer.cs","tests/Aquarium.Engine.Tests/FieldScriptCompilerTests.cs","state/map.yaml"],"source":"continued field resource resolver migration","topic":"field-volume-texture-resource-resolver"}

View file

@ -100,9 +100,12 @@ next_actions:
reporting. SurfacePage resources now have a first resolver: contracts and
DSL can declare GPU-resident 2D pages with width, height, format, version,
and optional local source, and the D3D12 registry allocates/imports them as
shader-readable page textures. Remaining cuts are mesh and volume resolvers,
plus render lowerings that consume surface pages as height/SDF/material
domains.
shader-readable page textures. VolumeTexture resources now have the same
first resolver shape: contracts and DSL can declare GPU-resident 3D
textures with dimensions, depth, format, and version, and the D3D12 registry
allocates them as shader-readable volume textures. Remaining resolver cut
is Mesh, plus render lowerings that consume surface pages and volumes as
height/SDF/material/density domains.
- Move any remaining client-only Epiphany state found here back to `E:\Projects\Aquarium-Engine`.
- Keep Mimir app/adapters out of this repo; Fensalir may expose generic sensor-fusion contracts only.
- Package contracts/host deliberately before replacing sibling source references in client repos.

View file

@ -147,4 +147,25 @@ public sealed class FieldScriptCompilerTests
Assert.Equal("R16Float", resource.Format);
Assert.Equal("fensalir-surface-page", resource.NativeHandleKind);
}
[Fact]
public void CompileEvidenceDeclaresVolumeTexturesAsGpuResources()
{
var frame = AquariumFieldScriptCompiler.CompileEvidence(
"""
volumetexture id=density key=aquarium:resource:volume:density width=32 height=32 depth=16 format=R16Float version=5
""",
new Dictionary<string, AquariumFieldResourceDeclaration>(StringComparer.Ordinal));
var resource = Assert.Single(frame.Resources);
Assert.Equal("aquarium:resource:volume:density", resource.ResourceKey);
Assert.Equal(AquariumFieldResourceKind.VolumeTexture, resource.Kind);
Assert.Equal(AquariumFieldResourceResidency.GpuResident, resource.Residency);
Assert.Equal(AquariumFieldShaderAccess.ShaderResource, resource.Access);
Assert.Equal(32, resource.Width);
Assert.Equal(32, resource.Height);
Assert.Equal(16, resource.DepthOrCount);
Assert.Equal("R16Float", resource.Format);
Assert.Equal("fensalir-volume-texture", resource.NativeHandleKind);
}
}