diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index cb43449b..f61a27a6 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -163,6 +163,18 @@ public sealed unsafe partial class EnvCellRenderer } } + // Campaign VM VM1 follow-up: per-instance opacity multiplier, laid out + // parallel to the transforms exactly like _clipSlotData above. EnvCell + // shells have no #188 translucency-fade concept, so every element is + // the constant no-op 1.0f — grown but not reallocated per frame, same + // as the other per-instance scratch arrays here. + if (_instanceAlphaData.Length < uniqueInstanceCount) + { + _instanceAlphaData = new float[ + Math.Max(_instanceAlphaData.Length * 2, uniqueInstanceCount)]; + } + Array.Fill(_instanceAlphaData, 1f, 0, uniqueInstanceCount); + // A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each // shell instance belongs to. int lightStride = LightManager.MaxLightsPerObject; @@ -218,6 +230,9 @@ public sealed unsafe partial class EnvCellRenderer BindRingSection( encoder, frame, GpuBindingModel.StorageClipSlots, _clipSlotData.AsSpan(0, uniqueInstanceCount)); + BindRingSection( + encoder, frame, GpuBindingModel.StorageInstanceAlpha, + _instanceAlphaData.AsSpan(0, uniqueInstanceCount)); BindRingSection( encoder, frame, GpuBindingModel.StorageGlobalLights, _globalLightData.AsSpan( diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 426768f4..a83b7895 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -82,6 +82,18 @@ public sealed partial class EnvCellRenderer : // slot 0 ⇒ no-clip. private uint[] _clipSlotData = Array.Empty(); + // Campaign VM VM1 follow-up: per-instance opacity multiplier, parallel to + // _gpuInstanceTransforms, feeding mesh_modern.vert/mesh_detail.vert's + // InstanceAlphaBuf (binding 7, GpuBindingModel.StorageInstanceAlpha). + // Before this field the shell and interior-detail passes never bound that + // storage buffer at all, so both shaders read whatever section + // WbDrawDispatcher's own SubmitRhi last bound in the same pass — an + // unrelated object's opacity array, indexed by these cell instance ids. + // EnvCell shells never carry a #188 TransparentPartHook translucency fade + // (that mechanism fades object PARTS, never cells), so every element is + // the constant no-op 1.0f. + private float[] _instanceAlphaData = Array.Empty(); + // A7 Fix D (D-2): this renderer owns its lighting (self-contained state, // like uViewProjection) instead of reading whatever WbDrawDispatcher last // bound. Global point-light snapshot (same data/indices as the dispatcher, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index d2ea6e24..e6ae4cbc 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Numerics; +using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using AcDream.App.Rendering; @@ -98,6 +99,125 @@ public class EnvCellRendererTests device.RingBytes.Slice((int)storageBind.OffsetBytes, sizeof(uint)))); } + /// + /// Campaign VM VM1 follow-up. EnvCellRenderer.Rhi's SubmitRhi bound + /// StorageInstances/StorageBatches/StorageClipSlots/StorageGlobalLights/ + /// StorageInstanceLightSets every frame but never + /// GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO both + /// mesh_modern.vert (interior shells) and mesh_detail.vert (interior + /// detail replay) read as instanceAlpha[instanceIndex]. Predates VM1 + /// (6c79d35c has the same omission); with mesh_detail.vert now reading + /// that binding too, an unfixed gap here would have made the interior + /// detail overlay read whatever section a DIFFERENT renderer (or a + /// stale ring slot) last left in binding 7, indexed by these cells' + /// instance ids. This pins that SubmitRhi binds its own constant-1.0f + /// section, sized to the live instance count, before it records any + /// draw in the pass — one bind serves every subsequent draw in that + /// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's + /// replay alike. + /// + [Fact] + public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass() + { + const int instanceCount = 5; + + using var device = new RecordingGpuDevice(); + using var meshManager = CreateMeshManager(device); + var frameLifetime = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var renderer = new EnvCellRenderer( + device, + frameLifetime, + scope, + meshManager, + new WbFrustum()); + + frameLifetime.BeginFrame(); + IGpuFrame frame = frameLifetime.CurrentFrame!; + using IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "envcell-submit-alpha-binding", + Vector4.Zero, + sampleCount: 1)); + using IDisposable publication = scope.Publish(pass); + + // Seed one real draw command, exactly what + // RenderModernMDIInternal would have built from a live landblock, + // so the test can assert an actual ordering against a real draw + // rather than a vacuous "no draw happened" pass. + Type rendererType = typeof(EnvCellRenderer); + FieldInfo commandsField = rendererType.GetField( + "_commands", BindingFlags.NonPublic | BindingFlags.Instance)!; + commandsField.SetValue(renderer, new[] + { + new DrawElementsIndirectCommand + { + Count = 3, + InstanceCount = (uint)instanceCount, + FirstIndex = 0, + BaseVertex = 0, + BaseInstance = 0, + }, + }); + FieldInfo batchesField = rendererType.GetField( + "_modernBatches", BindingFlags.NonPublic | BindingFlags.Instance)!; + batchesField.SetValue(renderer, new ModernBatchData[] { default }); + FieldInfo rangesField = rendererType.GetField( + "_mdiDrawRanges", BindingFlags.NonPublic | BindingFlags.Instance)!; + var ranges = (List)rangesField.GetValue(renderer)!; + ranges.Clear(); + EnvCellRenderer.AppendMdiDrawRange(ranges, groupIndex: 0, firstCommand: 0, commandCount: 1); + + var allInstances = new List(); + for (int i = 0; i < instanceCount; i++) + { + allInstances.Add(new InstanceData + { + Transform = Matrix4x4.Identity, + CellId = 0x8C040100u + (uint)i, + }); + } + + device.Clear(); + + MethodInfo submitRhi = rendererType.GetMethod( + "SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!; + submitRhi.Invoke( + renderer, + new object[] { allInstances, WbRenderPass.Opaque, 1, instanceCount }); + + IReadOnlyList calls = device.Calls; + int alphaBindIndex = -1; + int firstDrawIndex = -1; + for (int i = 0; i < calls.Count; i++) + { + if (alphaBindIndex < 0 + && calls[i] is GpuRecordedStorageBind bind + && bind.Binding == GpuBindingModel.StorageInstanceAlpha) + { + alphaBindIndex = i; + } + + if (firstDrawIndex < 0 && calls[i] is GpuRecordedMultiDrawIndirect) + firstDrawIndex = i; + } + + Assert.True(alphaBindIndex >= 0, "StorageInstanceAlpha was never bound."); + Assert.True(firstDrawIndex >= 0, "The seeded draw command was never recorded."); + Assert.True( + alphaBindIndex < firstDrawIndex, + "StorageInstanceAlpha must be bound before the pass's draw call, " + + "not left to whatever a prior renderer's bind left in slot 7."); + + var alphaBind = (GpuRecordedStorageBind)calls[alphaBindIndex]; + Assert.Equal((uint)(instanceCount * sizeof(float)), alphaBind.SizeBytes); + ReadOnlySpan alphaValues = MemoryMarshal.Cast( + device.RingBytes.Slice((int)alphaBind.OffsetBytes, (int)alphaBind.SizeBytes)); + Assert.Equal(instanceCount, alphaValues.Length); + foreach (float value in alphaValues) + Assert.Equal(1.0f, value); + } + [Fact] public void RetailDetailPipelinesPreserveOpaqueAndTransparentDepthWriteContracts() {