acdream/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
Erik f7aa8e0eb7
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s
fix: complete retail parity stability pass
2026-08-28 20:01:39 +02:00

492 lines
21 KiB
C#

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.Core.Lighting;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Campaign V slice V6j: the dungeon-shell renderer's RHI submission arm.
///
/// <para>V4c's content, re-landed as a second arm rather than a replacement —
/// see <see cref="AcDream.App.Rendering.TerrainModernRenderer"/>'s RHI file for
/// why the fork exists and where it is confined.</para>
///
/// <para>Two structural differences from V4c. It records into the pass
/// <c>VulkanWorldScenePhase</c> opened rather than opening
/// <c>"envcell-shells"</c> 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.</para>
/// </summary>
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<bool> _buildingDetailEnabled;
/// <summary>
/// The RHI arm's constructor. It also completes <c>Initialize</c>'s job: the
/// five pipelines ARE this renderer's program, so there is no second step
/// and no <c>Shader</c> to hand in.
/// </summary>
internal EnvCellRenderer(
IGpuDevice device,
ICurrentGpuFrameSource frames,
IWorldPassScope scope,
ObjectMeshManager meshManager,
WbFrustum frustum,
TerrainAtlas.RetailDetailTextureBinding environmentDetail = default,
Func<bool>? 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;
/// <summary>
/// One pipeline per blend state the shell pass uses. Everything else is
/// shared: <c>mesh_modern</c>, the 32-byte world-mesh vertex, triangle lists,
/// back-face culling with clockwise front faces.
///
/// <para>Depth compare is <c>Less</c>, not the contract's <c>LessOrEqual</c>
/// default. The world frame runs under <c>GL_LESS</c> and this renderer never
/// called <c>glDepthFunc</c>, so it inherited it; baking <c>LessOrEqual</c>
/// would change which of two coplanar retail surfaces wins.</para>
/// </summary>
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,
});
/// <summary>
/// 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.
/// </summary>
private void SubmitRhi(
List<InstanceData> 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<Matrix4x4>(
encoder, frame, GpuBindingModel.StorageInstances,
_gpuInstanceTransforms.AsSpan(0, uniqueInstanceCount));
BindRingSection<ModernBatchData>(
encoder, frame, GpuBindingModel.StorageBatches,
_modernBatches.AsSpan(0, totalDraws));
BindRingSection<uint>(
encoder, frame, GpuBindingModel.StorageClipSlots,
_clipSlotData.AsSpan(0, uniqueInstanceCount));
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageInstanceAlpha,
_instanceAlphaData.AsSpan(0, uniqueInstanceCount));
BindRingSection<float>(
encoder, frame, GpuBindingModel.StorageGlobalLights,
_globalLightData.AsSpan(
0,
globalLightUploadCount * GlobalLightPacker.FloatsPerLight));
BindRingSection<int>(
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;
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(groupIndex % 4));
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];
CullMode cullMode = ResolveRetailCellShellCullMode(
(CullMode)(drawRange.GroupIndex % 4));
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);
}
/// <summary>
/// WB <c>BaseObjectRenderManager.cs:850-866</c> applies CullMode per MDI
/// group; WB <c>GameScene.cs:843</c> sets FrontFace(CW) globally. Both are
/// dynamic state in core Vulkan 1.3, so they stay per-run calls.
/// </summary>
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;
}
}
/// <summary>
/// Resolves a CellStruct polygon's DAT <c>sides_type</c> to the render
/// state used by retail's constructed EnvCell mesh. The similarly named
/// <see cref="CullMode"/> values on <c>Polygon.SidesType</c> are not GPU
/// cull states: 0 emits the positive face, 1 emits that face twice with
/// reversed indices, and 2 emits the positive and negative surface.
/// <c>D3DPolyRender::ConstructMesh @ 0x0059DFA0</c> performs that geometry
/// expansion, then every subset is drawn with <c>D3DCULL_CW</c> through
/// <c>RenderMeshSubset @ 0x0059CA10</c>. <see cref="MeshExtractor"/>
/// already performs the identical expansion, so every shell batch must
/// cull clockwise here. Returning <see cref="CullMode.None"/> for DAT 0
/// was #178's Phase-A8 double-sided stopgap.
/// </summary>
internal static CullMode ResolveRetailCellShellCullMode(CullMode sidesType)
{
_ = sidesType;
return CullMode.Clockwise;
}
/// <summary>
/// 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.
/// </summary>
private static void BindRingSection<T>(
IGpuPassEncoder encoder,
IGpuFrame frame,
uint binding,
ReadOnlySpan<T> 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<T>());
encoder.BindStorageBuffer(
binding,
allocation.Buffer,
allocation.OffsetBytes,
(uint)byteCount);
}
/// <summary>
/// Binds one category word for <c>mesh_detail.vert</c>'s statically used
/// binding 9. EnvCell draws select their renderer-wide category via
/// <c>uParamB=0</c>, so the value is semantically unused, but Vulkan still
/// requires the declared descriptor to be valid.
/// </summary>
internal static void BindEnvironmentDetailCategory(
IGpuPassEncoder encoder,
IGpuFrame frame)
{
Span<uint> category = stackalloc uint[1];
category[0] = 1u;
BindRingSection<uint>(
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;
}
}