using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Gpu; using AcDream.Core.Lighting; using DatReaderWriter.Enums; namespace AcDream.App.Rendering.Wb; /// /// Campaign V slice V6j: the dungeon-shell renderer's RHI submission arm. /// /// V4c's content, re-landed as a second arm rather than a replacement — /// see 's RHI file for /// why the fork exists and where it is confined. /// /// Two structural differences from V4c. It records into the pass /// VulkanWorldScenePhase opened rather than opening /// "envcell-shells" of its own, because the frame's one backbuffer pass /// resolves. And the global texture table is set 2 rather than a storage /// buffer: storage binding 9 is now #226's per-instance detail category. /// public sealed unsafe partial class EnvCellRenderer { private readonly IGpuDevice? _device; private readonly ICurrentGpuFrameSource? _frames; private readonly IWorldPassScope? _scope; private IGpuPipeline? _opaquePipeline; private IGpuPipeline? _alphaPipeline; private IGpuPipeline? _additivePipeline; private IGpuPipeline? _detailPipeline; private IGpuPipeline? _transparentDetailPipeline; private readonly TerrainAtlas.RetailDetailTextureBinding _environmentDetail; private readonly Func _buildingDetailEnabled; /// /// The RHI arm's constructor. It also completes Initialize's job: the /// five pipelines ARE this renderer's program, so there is no second step /// and no Shader to hand in. /// internal EnvCellRenderer( IGpuDevice device, ICurrentGpuFrameSource frames, IWorldPassScope scope, ObjectMeshManager meshManager, WbFrustum frustum, TerrainAtlas.RetailDetailTextureBinding environmentDetail = default, Func? buildingDetailEnabled = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); _scope = scope ?? throw new ArgumentNullException(nameof(scope)); _meshManager = meshManager ?? throw new ArgumentNullException(nameof(meshManager)); _frustum = frustum ?? throw new ArgumentNullException(nameof(frustum)); _environmentDetail = environmentDetail; _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; _opaquePipeline = CreateShellPipeline( device, "envcell-opaque", GpuBlendMode.None, depthWrite: true, scope.SampleCount); _alphaPipeline = CreateShellPipeline( device, "envcell-alpha", GpuBlendMode.StraightAlpha, depthWrite: false, scope.SampleCount); _additivePipeline = CreateShellPipeline( device, "envcell-additive", GpuBlendMode.Additive, depthWrite: false, scope.SampleCount); _detailPipeline = CreateShellPipeline( device, "envcell-retail-detail", GpuBlendMode.RetailDetail, depthWrite: true, scope.SampleCount, shaderName: "mesh_detail", depthCompare: RetailDetailTextureContract.DetailDepthCompare( transparent: false)); _transparentDetailPipeline = CreateShellPipeline( device, "envcell-retail-detail-alpha", GpuBlendMode.RetailDetail, depthWrite: false, scope.SampleCount, shaderName: "mesh_detail", depthCompare: RetailDetailTextureContract.DetailDepthCompare( transparent: true)); _initialized = true; } private static bool DisableDetailTextures() => false; /// /// One pipeline per blend state the shell pass uses. Everything else is /// shared: mesh_modern, the 32-byte world-mesh vertex, triangle lists, /// back-face culling with clockwise front faces. /// /// Depth compare is Less, not the contract's LessOrEqual /// default. The world frame runs under GL_LESS and this renderer never /// called glDepthFunc, so it inherited it; baking LessOrEqual /// would change which of two coplanar retail surfaces wins. /// private static IGpuPipeline CreateShellPipeline( IGpuDevice device, string name, GpuBlendMode blend, bool depthWrite, int sampleCount, string shaderName = "mesh_modern", GpuCompareOp depthCompare = GpuCompareOp.Less) => device.CreatePipeline(new GpuPipelineDescription { Name = name, Shaders = new GpuShaderSet(shaderName), VertexLayout = GpuVertexLayout.WorldMesh, Topology = GpuPrimitiveTopology.TriangleList, Blend = blend, Depth = new GpuDepthState(Test: true, Write: depthWrite, depthCompare), Cull = GpuCullMode.Back, FrontFace = GpuFrontFace.Clockwise, AlphaToCoverage = false, ColorWrite = true, SampleCount = sampleCount, }); /// /// Writes this pass's sections into the frame ring and records the same /// per-group multi-draw runs the GL arm issues, in the same order. /// private void SubmitRhi( List allInstances, WbRenderPass renderPass, int totalDraws, int uniqueInstanceCount) { IWorldPassScope scope = _scope!; IGpuPassEncoder encoder = scope.RequireEncoder(); IGpuFrame frame = _frames!.CurrentFrame ?? throw new InvalidOperationException( "EnvCellRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime)."); GlobalMeshBuffer mesh = _meshManager.GlobalBuffer ?? throw new InvalidOperationException("The shared mesh arena is not published."); if (_gpuInstanceTransforms.Length < uniqueInstanceCount) { Array.Resize( ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount)); } for (int i = 0; i < uniqueInstanceCount; i++) _gpuInstanceTransforms[i] = allInstances[i].Transform; // Phase U.4: per-instance clip slots, laid out parallel to the transforms // so instanceClipSlot[BaseInstance + gl_InstanceID] tracks Instances[]. if (_clipSlotData.Length < uniqueInstanceCount) _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)]; if (_cellIdToSlot is null || AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim) { Array.Clear(_clipSlotData, 0, uniqueInstanceCount); } else { for (int i = 0; i < uniqueInstanceCount; i++) { _clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) ? (uint)slot : 0u; } } // 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; if (_lightSetData.Length < uniqueInstanceCount * lightStride) { _lightSetData = new int[Math.Max( _lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; } for (int i = 0; i < uniqueInstanceCount; i++) { int[] cellSet = GetCellLightSet(allInstances[i].CellId); Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); } if (renderPass == WbRenderPass.Opaque && AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled) { EmitSeamDrawProbe(_renderDrawCalls, allInstances, _seamProbeFilter); } int lightCount = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); int globalLightUploadCount = lightCount > 0 ? lightCount : 1; var pushConstants = new GpuPushConstants { ViewProjection = _lastViewProjection, DrawIdOffset = 0, // A7 Fix D D-3/D-4: EnvCell bake — wrap points, no sun. LightingMode = 1, RenderPass = (int)renderPass, LightDebug = AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode, TextureIndexA = 0, TextureIndexB = 0, ParamA = 0f, ParamB = 0f, }; // Bind the pass's base pipeline first so the ring binds land on a live // program; the per-range switches below rebind the mesh with it. IGpuPipeline basePipeline = renderPass == WbRenderPass.Transparent ? _alphaPipeline! : _opaquePipeline!; BindPipelineWithMesh(encoder, basePipeline, mesh); encoder.SetPushConstants(in pushConstants); BindRingSection( encoder, frame, GpuBindingModel.StorageInstances, _gpuInstanceTransforms.AsSpan(0, uniqueInstanceCount)); BindRingSection( encoder, frame, GpuBindingModel.StorageBatches, _modernBatches.AsSpan(0, totalDraws)); 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( 0, globalLightUploadCount * GlobalLightPacker.FloatsPerLight)); BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceLightSets, _lightSetData.AsSpan(0, uniqueInstanceCount * lightStride)); BindEnvironmentDetailCategory(encoder, frame); // The frame-global sections, bound after this renderer's own binds // because those binds are what select the descriptor scope. AcDream.App.Rendering.WorldFrameSectionBinding.BindClipRegions( encoder, scope.Sections, frame); AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( encoder, scope.Sections, frame); GpuRingAllocation commands = frame.AllocateRing( totalDraws * sizeof(DrawElementsIndirectCommand), GpuRingUsage.Indirect); MemoryMarshal.AsBytes(_commands.AsSpan(0, totalDraws)).CopyTo(commands.Data); IGpuBuffer commandBuffer = commands.Buffer; uint commandBase = commands.OffsetBytes; bool detailEnabled = RetailDetailTextureContract.ShouldRender( _buildingDetailEnabled(), _environmentDetail); for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; int groupIndex = drawRange.GroupIndex; var cullMode = (CullMode)(groupIndex % 4); // Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock // uniformly, but the room surfaces need to be visible from inside. // Render cell polys double-sided, exactly as the GL arm does. if (cullMode == CullMode.Landblock) cullMode = CullMode.None; bool isAdditive = groupIndex >= 4; IGpuPipeline rangeBasePipeline = isAdditive ? _additivePipeline! : _alphaPipeline!; if (renderPass == WbRenderPass.Transparent) { // Blend state is the pipeline's; switching variants mid-pass has // to re-establish the mesh, which is vertex-array state. BindPipelineWithMesh( encoder, rangeBasePipeline, mesh); } // Must follow the pipeline bind: BindPipeline re-issues the // pipeline's own cull/front-face/depth-write defaults. SetCullMode(encoder, cullMode); pushConstants.RenderPass = isAdditive ? (int)renderPass | 0x100 : (int)renderPass; pushConstants.DrawIdOffset = drawRange.FirstCommand; encoder.SetPushConstants(in pushConstants); // Retail DrawMesh's two-pass fallback redraws each transparent // RenderMeshSubset immediately, before the next delayed-alpha // subset. Preserve that base/detail adjacency so another shell or // particle cannot be composited between the two contributions. if (renderPass == WbRenderPass.Transparent && detailEnabled) { int rangeEnd = drawRange.FirstCommand + drawRange.CommandCount; for (int command = drawRange.FirstCommand; command < rangeEnd; command++) { BindPipelineWithMesh(encoder, rangeBasePipeline, mesh); SetCullMode(encoder, cullMode); pushConstants.RenderPass = isAdditive ? (int)renderPass | 0x100 : (int)renderPass; pushConstants.DrawIdOffset = command; pushConstants.TextureIndexA = 0; pushConstants.ParamA = 0f; pushConstants.ParamB = 0f; encoder.SetPushConstants(in pushConstants); encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), 1, (uint)sizeof(DrawElementsIndirectCommand)); BindPipelineWithMesh(encoder, _transparentDetailPipeline!, mesh); SetCullMode(encoder, cullMode); pushConstants.DrawIdOffset = command; pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; pushConstants.ParamA = _environmentDetail.Tiling; pushConstants.ParamB = 0f; encoder.SetPushConstants(in pushConstants); encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(command * sizeof(DrawElementsIndirectCommand)), 1, (uint)sizeof(DrawElementsIndirectCommand)); } continue; } encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), (uint)drawRange.CommandCount, (uint)sizeof(DrawElementsIndirectCommand)); } // Retail DrawEnvCell category (2). Replay the already-filtered opaque // shell commands, including ClipMap built-mesh subsets. No distance // fade (VM1/VM2): retail's noFadeDetail gates get_alpha_for_z to the // immediate-polygon path only, which built meshes never reach; // attenuation is the sampler's linear mip chain converging to the // texture mean. The existing "Building Detail Textures" option gates // both this and buildings, matching LScape::ChangeRegion. if (renderPass == WbRenderPass.Opaque && detailEnabled) { BindPipelineWithMesh(encoder, _detailPipeline!, mesh); pushConstants.RenderPass = 0; pushConstants.TextureIndexA = _environmentDetail.TextureSlot.Index; pushConstants.ParamA = _environmentDetail.Tiling; pushConstants.ParamB = 0f; for (int drawRangeIndex = 0; drawRangeIndex < _mdiDrawRanges.Count; drawRangeIndex++) { MdiDrawRange drawRange = _mdiDrawRanges[drawRangeIndex]; var cullMode = (CullMode)(drawRange.GroupIndex % 4); if (cullMode == CullMode.Landblock) cullMode = CullMode.None; SetCullMode(encoder, cullMode); pushConstants.DrawIdOffset = drawRange.FirstCommand; encoder.SetPushConstants(in pushConstants); encoder.MultiDrawIndexedIndirect( commandBuffer, commandBase + (uint)(drawRange.FirstCommand * sizeof(DrawElementsIndirectCommand)), (uint)drawRange.CommandCount, (uint)sizeof(DrawElementsIndirectCommand)); } } } private void BindPipelineWithMesh( IGpuPassEncoder encoder, IGpuPipeline pipeline, GlobalMeshBuffer mesh) { encoder.BindPipeline(pipeline); encoder.BindVertexBuffer( 0, mesh.VertexStore ?? throw new InvalidOperationException( "The shared mesh arena has no vertex store."), 0); encoder.BindIndexBuffer( mesh.IndexStore ?? throw new InvalidOperationException( "The shared mesh arena has no index store."), 0, GpuIndexType.UInt16); } /// /// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI /// group; WB GameScene.cs:843 sets FrontFace(CW) globally. Both are /// dynamic state in core Vulkan 1.3, so they stay per-run calls. /// private static void SetCullMode(IGpuPassEncoder encoder, CullMode mode) { encoder.SetFrontFace(GpuFrontFace.Clockwise); switch (mode) { case CullMode.None: encoder.SetCullMode(GpuCullMode.None); break; case CullMode.Clockwise: encoder.SetCullMode(GpuCullMode.Front); break; case CullMode.CounterClockwise: case CullMode.Landblock: encoder.SetCullMode(GpuCullMode.Back); break; } } /// /// Reserves this frame's ring, copies into it, and binds the slice. A /// logically empty section still reserves one element so the bound range is /// never zero-length — the "bind at least one element so the shader never /// reads an unbound SSBO" rule the light buffers already stated. /// private static void BindRingSection( IGpuPassEncoder encoder, IGpuFrame frame, uint binding, ReadOnlySpan data) where T : unmanaged { int elementBytes = sizeof(T); int byteCount = Math.Max(data.Length * elementBytes, elementBytes); GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Storage); if (!data.IsEmpty) data.CopyTo(allocation.AsSpan()); encoder.BindStorageBuffer( binding, allocation.Buffer, allocation.OffsetBytes, (uint)byteCount); } /// /// Binds one category word for mesh_detail.vert's statically used /// binding 9. EnvCell draws select their renderer-wide category via /// uParamB=0, so the value is semantically unused, but Vulkan still /// requires the declared descriptor to be valid. /// internal static void BindEnvironmentDetailCategory( IGpuPassEncoder encoder, IGpuFrame frame) { Span category = stackalloc uint[1]; category[0] = 1u; BindRingSection( encoder, frame, GpuBindingModel.StorageInstanceDetailCategory, category); } private void DisposeRhiResources() { _opaquePipeline?.Dispose(); _opaquePipeline = null; _alphaPipeline?.Dispose(); _alphaPipeline = null; _additivePipeline?.Dispose(); _additivePipeline = null; _detailPipeline?.Dispose(); _detailPipeline = null; _transparentDetailPipeline?.Dispose(); _transparentDetailPipeline = null; } }