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