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;
}
}