diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
index 9211cb68..34811e7e 100644
--- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
+++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs
@@ -286,9 +286,15 @@ public sealed class GlobalMeshBuffer : IDisposable
}
}
+ ///
+ /// #429: index batches arrive as (offset, count) segments of one shared
+ /// index array — the caller's retained CPU pick copy — instead of one
+ /// managed array per batch. The bytes staged per batch are unchanged.
+ ///
internal GlobalMeshAllocation UploadMesh(
VertexPositionNormalTexture[] vertices,
- IReadOnlyList indexBatches)
+ ushort[] indices,
+ ReadOnlySpan<(int Offset, int Count)> indexBatches)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_retirementLedger.RetryPendingPublications();
@@ -296,16 +302,21 @@ public sealed class GlobalMeshBuffer : IDisposable
if (_migration is not null)
throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress.");
ArgumentNullException.ThrowIfNull(vertices);
- ArgumentNullException.ThrowIfNull(indexBatches);
+ ArgumentNullException.ThrowIfNull(indices);
if (vertices.Length == 0)
throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices));
int totalIndices = 0;
- for (int i = 0; i < indexBatches.Count; i++)
+ for (int i = 0; i < indexBatches.Length; i++)
{
- ushort[] batch = indexBatches[i]
- ?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches));
- totalIndices = checked(totalIndices + batch.Length);
+ (int offset, int count) = indexBatches[i];
+ if (offset < 0 || count <= 0 || (long)offset + count > indices.Length)
+ {
+ throw new ArgumentException(
+ $"Index batch {i} ({offset}, {count}) is outside the shared index array ({indices.Length}).",
+ nameof(indexBatches));
+ }
+ totalIndices = checked(totalIndices + count);
}
if (totalIndices == 0)
throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches));
@@ -322,7 +333,7 @@ public sealed class GlobalMeshBuffer : IDisposable
throw;
}
- var firstIndices = new int[indexBatches.Count];
+ var firstIndices = new int[indexBatches.Length];
try
{
// IGpuBuffer.Upload stages through a neutral binding point of the
@@ -337,18 +348,15 @@ public sealed class GlobalMeshBuffer : IDisposable
IGpuBuffer indexStore = RequireStore(_indexBuffer);
int indexOffset = indexRange.Offset;
- for (int i = 0; i < indexBatches.Count; i++)
+ for (int i = 0; i < indexBatches.Length; i++)
{
- ushort[] batch = indexBatches[i];
+ (int offset, int count) = indexBatches[i];
firstIndices[i] = indexOffset;
- if (batch.Length > 0)
- {
- long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
- indexStore.Upload(
- indexOffsetBytes,
- MemoryMarshal.AsBytes(new ReadOnlySpan(batch)));
- indexOffset = checked(indexOffset + batch.Length);
- }
+ long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
+ indexStore.Upload(
+ indexOffsetBytes,
+ MemoryMarshal.AsBytes(new ReadOnlySpan(indices, offset, count)));
+ indexOffset = checked(indexOffset + count);
}
}
catch
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index 25a6bc42..f8030062 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -2045,13 +2045,47 @@ namespace AcDream.App.Rendering.Wb
{
if (meshData.Vertices.Length == 0) return null;
- var modernIndexBatches = meshData.TextureBatches.Values
- .SelectMany(batches => batches)
- .Where(batch => batch.Indices.Count != 0)
- .Select(batch => batch.Indices.ToArray())
- .ToArray();
+ // #429: allocation-exact conversion. The retained pick copies
+ // (CPUPositions/CPUIndices) are the only geometry-proportional
+ // allocations this method makes; the per-batch segments handed to
+ // the shared arena are (offset, count) views into the same
+ // retained CPUIndices array. The former LINQ chain materialized
+ // every batch twice (a per-batch ToArray plus an unsized
+ // SelectMany growth) — megabytes of transient garbage per mesh on
+ // the render thread.
+ int totalIndexCount = 0;
+ int nonEmptyBatchCount = 0;
+ foreach (List formatBatches in meshData.TextureBatches.Values)
+ {
+ foreach (TextureBatchData formatBatch in formatBatches)
+ {
+ if (formatBatch.Indices.Count == 0) continue;
+ totalIndexCount = checked(totalIndexCount + formatBatch.Indices.Count);
+ nonEmptyBatchCount++;
+ }
+ }
+
+ var cpuIndices = new ushort[totalIndexCount];
+ var indexSegments = new (int Offset, int Count)[nonEmptyBatchCount];
+ {
+ int fillOffset = 0;
+ int segmentIndex = 0;
+ foreach (List formatBatches in meshData.TextureBatches.Values)
+ {
+ foreach (TextureBatchData formatBatch in formatBatches)
+ {
+ int count = formatBatch.Indices.Count;
+ if (count == 0) continue;
+ CollectionsMarshal.AsSpan(formatBatch.Indices)
+ .CopyTo(cpuIndices.AsSpan(fillOffset, count));
+ indexSegments[segmentIndex++] = (fillOffset, count);
+ fillOffset = checked(fillOffset + count);
+ }
+ }
+ }
+
GlobalMeshAllocation? globalAllocation = null;
- var renderBatches = new List();
+ var renderBatches = new List(nonEmptyBatchCount);
var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>();
try
@@ -2068,8 +2102,13 @@ namespace AcDream.App.Rendering.Wb
// or vertex/index buffer to build here — Vulkan bakes vertex input
// into the pipeline, and the shared arena's stores are bound once
// per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh).
- if (GlobalBuffer is not null && modernIndexBatches.Length != 0)
- globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches);
+ if (GlobalBuffer is not null && nonEmptyBatchCount != 0)
+ {
+ globalAllocation = GlobalBuffer.UploadMesh(
+ meshData.Vertices,
+ cpuIndices,
+ indexSegments);
+ }
foreach (var (format, batches) in meshData.TextureBatches)
{
@@ -2093,8 +2132,25 @@ namespace AcDream.App.Rendering.Wb
// family. Choosing an earlier reclaimed slot first
// duplicates a layer already resident in a later array
// every time portal churn revisits that texture.
- atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key))
- ?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0);
+ for (int i = 0; i < atlasList.Count; i++)
+ {
+ if (atlasList[i].HasTexture(batch.Key))
+ {
+ atlasManager = atlasList[i];
+ break;
+ }
+ }
+ if (atlasManager is null)
+ {
+ for (int i = 0; i < atlasList.Count; i++)
+ {
+ if (atlasList[i].AvailableSlots > 0)
+ {
+ atlasManager = atlasList[i];
+ break;
+ }
+ }
+ }
if (atlasManager == null)
{
atlasManager = new TextureAtlasManager(
@@ -2180,21 +2236,34 @@ namespace AcDream.App.Rendering.Wb
}
}
+ // Every renderBatches entry is one non-empty texture batch, so
+ // their index counts sum to exactly totalIndexCount.
long geometryBytes = checked(
(long)meshData.Vertices.Length * VertexPositionNormalTexture.Size
- + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort)));
+ + (long)totalIndexCount * sizeof(ushort));
+ bool hasCutoutSubset = false;
+ for (int i = 0; i < renderBatches.Count; i++)
+ {
+ if (renderBatches[i].Translucency
+ == AcDream.Core.Meshing.TranslucencyKind.ClipMap)
+ {
+ hasCutoutSubset = true;
+ break;
+ }
+ }
+ var cpuPositions = new Vector3[meshData.Vertices.Length];
+ for (int i = 0; i < cpuPositions.Length; i++)
+ cpuPositions[i] = meshData.Vertices[i].Position;
var renderData = new ObjectRenderData
{
VertexCount = meshData.Vertices.Length,
Batches = renderBatches,
- HasCutoutSubset = renderBatches.Any(
- static batch => batch.Translucency
- == AcDream.Core.Meshing.TranslucencyKind.ClipMap),
+ HasCutoutSubset = hasCutoutSubset,
GlobalAllocation = globalAllocation,
ParticleEmitters = meshData.ParticleEmitters,
DIDDegrade = meshData.DIDDegrade,
- CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(),
- CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(),
+ CPUPositions = cpuPositions,
+ CPUIndices = cpuIndices,
CPUEdgeLines = meshData.EdgeLines,
MemorySize = geometryBytes,
NonArenaGpuBytes = CalculateNonArenaGeometryBytes(
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs
index ff9f00a6..48cdb2c5 100644
--- a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs
@@ -207,7 +207,10 @@ public sealed class MeshPipelineDeviceSeamTests
vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f);
ushort[] indices = [0, 1, 2];
- GlobalMeshAllocation allocation = arena.UploadMesh(vertices, [indices]);
+ GlobalMeshAllocation allocation = arena.UploadMesh(
+ vertices,
+ indices,
+ [(0, indices.Length)]);
Assert.Equal(3, allocation.Vertices.Length);
Assert.Equal(3, allocation.Indices.Length);
@@ -229,6 +232,75 @@ public sealed class MeshPipelineDeviceSeamTests
System.Runtime.InteropServices.MemoryMarshal.Cast(indexBytes).ToArray());
}
+ ///
+ /// #429 allocation gate (I1 style). Completing a prepared mesh on the
+ /// render thread must allocate near its retained pick-copy size
+ /// (CPUPositions + CPUIndices), not multiples of it. The regression this
+ /// pins: the upload conversion ran LINQ chains — a per-batch
+ /// Indices.ToArray() plus an unsized SelectMany().ToArray()
+ /// — that materialized every index three-plus times in transient garbage
+ /// per completed mesh, on the render thread, up to the per-frame upload
+ /// budget.
+ ///
+ [Fact]
+ public void AWarmedMeshCompletionAllocatesNearItsRetainedCopySize()
+ {
+ using var device = new RecordingGpuDevice();
+ using ObjectMeshManager manager = Build(device, modernPath: true);
+
+ // Warm: an identically shaped mesh grows the arena, the atlas family,
+ // and every pool the completion path touches.
+ Assert.NotNull(manager.UploadMeshData(
+ CreateLargeMeshData(0x0100AA01u, surfaceSeed: 0x08000000u)));
+
+ ObjectMeshData meshData =
+ CreateLargeMeshData(0x0100AA02u, surfaceSeed: 0x08001000u);
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ ObjectRenderData? uploaded = manager.UploadMeshData(meshData);
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.NotNull(uploaded);
+ long retained =
+ (long)uploaded!.CPUIndices.Length * sizeof(ushort)
+ + (long)uploaded.CPUPositions.Length * 3 * sizeof(float);
+ // Sanity: the fixture is actually index-heavy enough to discriminate.
+ Assert.True(retained >= 480_000, $"fixture retained only {retained} bytes");
+ // The LINQ regression allocates over 3x the index bytes and fails
+ // this bound by more than a megabyte.
+ long bound = retained + retained / 2 + 128 * 1024;
+ Assert.True(
+ allocated < bound,
+ $"A warmed mesh completion allocated {allocated} bytes "
+ + $"(retained copies {retained}, bound {bound}).");
+ }
+
+ private static ObjectMeshData CreateLargeMeshData(ulong id, uint surfaceSeed)
+ {
+ const int vertexCount = 1024;
+ const int batchCount = 4;
+ const int indicesPerBatch = 60_000;
+ var data = new ObjectMeshData
+ {
+ ObjectId = id,
+ Vertices = new VertexPositionNormalTexture[vertexCount],
+ };
+ var batches = new System.Collections.Generic.List(batchCount);
+ for (int b = 0; b < batchCount; b++)
+ {
+ var indices = new System.Collections.Generic.List(indicesPerBatch);
+ for (int i = 0; i < indicesPerBatch; i++)
+ indices.Add((ushort)((i + b) % vertexCount));
+ batches.Add(new TextureBatchData
+ {
+ Key = new TextureKey { SurfaceId = surfaceSeed + (uint)b },
+ TextureData = new byte[8 * 8 * 4],
+ Indices = indices,
+ });
+ }
+ data.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches;
+ return data;
+ }
+
///
/// The production Vulkan implementation of the seam, checked against the
/// same surface. Its two capability flags answer true because what they