perf(render) #429: allocation-exact streamed-mesh completion

UploadGfxObjMeshData built every completed mesh's index data three-plus
times over in LINQ transients (per-batch Indices.ToArray copies plus an
unsized SelectMany growth) on the render thread, up to the per-frame
upload budget. The conversion now fills one exact-size retained
CPUIndices array (the same one the B.4b pick path keeps) and hands the
shared arena (offset, count) segments of it; CPUPositions fills by a
direct pre-sized loop; the Sum/Any/FirstOrDefault transients are gone.
GlobalMeshBuffer.UploadMesh takes the segment form — the staged bytes
per batch are unchanged. Gate: a warmed completion must allocate near
its retained-copy size (MeshPipelineDeviceSeamTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 09:14:00 +02:00
parent 5b9d0260bb
commit 0330fcd0d1
3 changed files with 183 additions and 34 deletions

View file

@ -286,9 +286,15 @@ public sealed class GlobalMeshBuffer : IDisposable
}
}
/// <summary>
/// #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.
/// </summary>
internal GlobalMeshAllocation UploadMesh(
VertexPositionNormalTexture[] vertices,
IReadOnlyList<ushort[]> 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<ushort>(batch)));
indexOffset = checked(indexOffset + batch.Length);
}
long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort));
indexStore.Upload(
indexOffsetBytes,
MemoryMarshal.AsBytes(new ReadOnlySpan<ushort>(indices, offset, count)));
indexOffset = checked(indexOffset + count);
}
}
catch