acdream/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs
Erik ae6513126e fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes
Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four
items, all landed:

1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the
   texture-stage pipeline, so the detail contribution must be fogged, not
   just the base. mesh_modern.frag already fogs the base colour
   (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it;
   mesh_detail.frag previously emitted raw detail.rgb, understating fog by
   f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring
   mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting
   UBO and applyFog function (copied verbatim, same binding/std140/math) and
   fogs detail.rgb before emitting it. This collapses algebraically to
   retail's fog-after-combine order:
     (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f)
   RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog,
   fogFactor); RetailDetailTextureContractTests pins the identity across 200
   random samples within 1e-6.

2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply
   the 10-50 m positive-view-depth fade" — a stale claim from before VM1
   removed the fade. Replaced with the mip-chain attenuation statement that
   mesh_detail.vert's header comment already carries.

3. Added the test the VM1 contract required but never had: TerrainAtlas
   .TryCreateDetailTexture uploads a full mip chain (MipLevelCount ==
   RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and
   registers with the repeat/linear world sampler, not single-level or
   clamped. Drives the private method directly (reflection) against a
   synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory
   IDatReaderWriter fake, so the lane stays hermetic (no installed DAT).

4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha
   (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a
   delayed-alpha subset composites with — is not modelled; acdream instead
   draws a second pass weighted by detail.a*diffuseAlpha. Identical for
   opaque subsets, a bounded difference on translucent building/EnvCell
   subsets already covered by the existing AP-34 shared-alpha-queue
   divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99)
   citation to name its exact branch (burnedInStaticLights < 0 &&
   *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex,
   but the opaque->1 / fading->opacity mapping still holds either way.

Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test
is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the
bind-before-first-draw invariant on both passes.

Regenerated mesh_detail's committed SPIR-V and the shader manifest
(tools/compile-shaders.ps1); no other shader pair changed.

Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including
the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:39:58 +02:00

477 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;
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);
}
/// <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>
/// 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;
}
}