Opus dual-lens review of05970306+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>
477 lines
21 KiB
C#
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;
|
|
}
|
|
}
|