feat(render): Campaign V slice V4c - move the world draw path onto the RHI
The two renderers that draw everything in the world - WbDrawDispatcher for
entities and EnvCellRenderer for dungeon shells - now record through
IGpuPassEncoder instead of calling GL directly. They share mesh_modern and its
binding layout, which is why they had to move together.
What moved. Every per-frame upload became an IGpuFrame.AllocateRing slice:
instance transforms, batch metadata, clip slots, global lights, per-instance
light sets, indoor flags, opacity, selection lighting, and the indirect command
array. That retires both renderers' DynamicBufferSet pools outright. Those pools
existed so a second Draw within one frame could not overwrite an earlier draw's
still-pending data; the frame ring gives that structurally, because every
allocation within a frame is distinct memory that lives until the frame retires.
DynamicBufferSetCount now reports 0 for both, which is the truth rather than a
silent change - they own no such pool any more.
The imperative Enable/Disable/BlendFunc/DepthMask brackets around the two
multi-draw passes became pipeline variants: five for the dispatcher (opaque,
opaque+alpha-to-coverage, and the three retail blends) and three for the cell
shells. Cull mode and front face stay dynamic per MDI run, exactly where
ApplyCullMode and SetCullMode set them, because core Vulkan 1.3 makes those
dynamic and blend and alpha-to-coverage not. ApplyRetailBlend is gone: its three
cases are now three pipelines, including the inverse-alpha one that
GpuBlendMode.InverseAlpha was added for. uViewProjection, uDrawIDOffset,
uLightingMode, uRenderPass and uLightDebug became fields of the shared
GpuPushConstants block. Issue #52's per-pass batch offset is unchanged - the
draw index still resets per indirect call, and Vulkan's gl_DrawID resets
identically.
Depth compare is baked as GL_LESS, not the contract's LessOrEqual default. The
world frame runs under GL_LESS (RenderFrameGlStateController.RestoreFrameDefaults)
and neither renderer ever called glDepthFunc, so both inherited it; baking
LessOrEqual would have changed which of two coplanar retail surfaces wins.
Two uniform writes were dropped rather than ported, and both are no-ops today:
uFilterByCell and uHighlightColor are declared in neither mesh_modern stage, so
they resolved to location -1. Saying so here rather than letting them vanish.
GPU timing moved to IGpuPassEncoder.BeginTimerScope. The [WB-DIAG] median/p95
window is still fed and still measures opaque + transparent time for the
dispatch, but the sample now comes from IGpuTimerPool.TryResolve - the most
recent retired result - instead of a hand-rolled 3-deep query ring read at N-3.
A sample can therefore repeat when the GPU has not finished a newer query,
where the old code dropped it. The pool also owns the #125 "never read a query
that was never begun" guard now. Diagnostic-only, and flagged rather than left
to be discovered.
Three things deliberately did NOT move, per the campaign doc's section 5.3.
The interim GlBindlessHandleTable stays; both renderers still intern raw
bindless handles and now bind that table through the encoder as an ordinary
IGpuBuffer at binding 9. Retiring it is slice V4t, because the handles are
produced by the texture caches and carried through GroupKey and CachedBatch.
ClipFrame's region buffer (binding 2) and the SceneLighting UBO stay globally
bound by raw GL, because terrain and the viewport/portal renderers read the same
bindings and are raw GL until V4d/V4g. EnvCellRenderer's glMemoryBarrier stays a
raw call: it has no RHI verb, and it guards incoherent shader writes that
acdream does not make, so it was already a no-op against client-side uploads.
RetailAlphaQueue, the GroupKey bucketing, the front-to-back and translucent sort
orders, and every other piece of CPU fidelity logic are untouched. The deferred
alpha payload is still prepared exactly once per sorted alpha scope: a ring
allocation cannot outlive its frame as a ref struct, but its buffer, offset and
size can be stored, so DrawPreparedAlphaBatch binds the same bytes many times
without recopying them.
Two supporting changes outside the two renderers, both flagged.
GlGpuDevice.BeginPass no longer binds framebuffer 0 for a null colour target; it
leaves the binding alone and only binds an explicitly named target. A null target
means "whatever the spine bound", which is what GpuPassDescription's own remarks
describe when they say clears and framebuffer management stay with the spine
until V4h. Forcing 0 would have been fatal here and invisible to this gate:
PrivateEntityViewportRenderer binds its offscreen FBO and then calls
WbDrawDispatcher.Draw, as does PortalTunnelPresentation, so the paperdoll and
creature-appraisal viewports would have rendered to the backbuffer and left their
textures empty - and the offline gate does not cover those viewports. This is the
same class of fix as the ambient-capability save/restore in GlGpuPassEncoder.
GlGpuDevice.CreatePipeline now splices the slice-V2 shared preamble
(Shaders/common.glsl) into every pipeline, reusing Shader.InjectPreamble - widened
from private to internal - so a pipeline-compiled program and a Shader-compiled
one are built from byte-identical sources. mesh_modern requires it: the preamble
declares the binding-9 table and defines ACDREAM_TEXTURE_HANDLE, without which
the world shaders do not compile. Shaders that reference none of it gain an
unused SSBO declaration and two macros; every shader in the tree is #version 430
core, so that is always legal.
Both renderers keep their trailing raw-GL disable block after the pass closes.
The encoder's Dispose restores the capability state that was ambient on ENTRY,
which is not the state these renderers used to leave behind - terrain, sky and
particles are still raw GL and still inherit what the previous renderer left, so
the exit state is reasserted explicitly. It goes at V4h with the last raw-GL
renderer.
A defect caught in review and fixed before the gate: each IGpuPipeline owns its
own vertex array, and vertex attribute pointers plus the index binding are
vertex-array state, so switching blend variants mid-pass silently dropped the
mesh source while the storage bindings survived. Every pipeline switch now goes
through one helper that re-binds the arena.
Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed /
3 skipped, stable over four consecutive runs, against a 3,843 baseline plus the
InverseAlpha contract test. Offline pixel gate against 111e7236: 20 differing
pixels of 563,200 compared (fraction 3.55e-05), against a same-commit control
captured immediately afterwards of 17 - indistinguishable from capture noise and
28x under the 0.001 threshold. The gate run's client log has zero exceptions and
an empty stderr.
Coverage gap, stated rather than assumed: the offline gate's scene is a fixed
outdoor view, so it exercises WbDrawDispatcher heavily and EnvCellRenderer not at
all. Dungeon interiors, the paperdoll and appraisal viewports, and portal transit
need a user visual check before this slice is considered proven.
No divergence-register row: this slice changes no retail-facing behaviour.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
111e72362f
commit
f353fb53f8
8 changed files with 1211 additions and 1168 deletions
|
|
@ -680,7 +680,8 @@ internal sealed class LivePresentationCompositionPhase
|
|||
"WB draw dispatcher",
|
||||
() => new WbDrawDispatcher(
|
||||
d.Gl,
|
||||
foundation.MeshShader,
|
||||
host.GpuDevice,
|
||||
host.GpuFrameLifetime,
|
||||
foundation.TextureCache,
|
||||
foundation.MeshAdapter,
|
||||
entitySpawnAdapter,
|
||||
|
|
@ -855,10 +856,12 @@ internal sealed class LivePresentationCompositionPhase
|
|||
"environment-cell renderer",
|
||||
() => new EnvCellRenderer(
|
||||
d.Gl,
|
||||
host.GpuDevice,
|
||||
host.GpuFrameLifetime,
|
||||
foundation.MeshAdapter.MeshManager!,
|
||||
envCellFrustum),
|
||||
static value => value.Dispose());
|
||||
envCellLease.Resource.Initialize(foundation.MeshShader);
|
||||
envCellLease.Resource.Initialize();
|
||||
Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated);
|
||||
|
||||
var landblockRenderPublisher = new LandblockRenderPublisher(
|
||||
|
|
|
|||
|
|
@ -190,6 +190,30 @@ internal sealed class GlGpuDevice : IGpuDevice
|
|||
string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag");
|
||||
string vertexSource = File.ReadAllText(vertexPath);
|
||||
string fragmentSource = File.ReadAllText(fragmentPath);
|
||||
|
||||
// Campaign V slice V4c: splice the slice-V2 shared preamble
|
||||
// (Shaders/common.glsl) into every pipeline, using Shader's own
|
||||
// InjectPreamble so a pipeline-compiled program and a
|
||||
// Shader-compiled one are built from byte-identical sources.
|
||||
//
|
||||
// mesh_modern REQUIRES it — the preamble declares the binding-9
|
||||
// texture table and defines ACDREAM_TEXTURE_HANDLE / ACDREAM_UBO_SET,
|
||||
// so without it the world shaders do not compile at all. Applying it
|
||||
// unconditionally rather than per-pipeline keeps one rule: every
|
||||
// shader this backend compiles sees the same preamble, which is also
|
||||
// what the Vulkan backend gets for free once the sources are compiled
|
||||
// to .spv. For shaders that reference none of it (ui_text,
|
||||
// debug_line) the added text is an unused SSBO declaration and two
|
||||
// macro definitions — every shader in the tree is #version 430 core,
|
||||
// so the std430 declaration is always legal.
|
||||
string commonPath = Path.Combine(_shadersDirectory, "common.glsl");
|
||||
if (File.Exists(commonPath))
|
||||
{
|
||||
string commonSource = File.ReadAllText(commonPath);
|
||||
vertexSource = Shader.InjectPreamble(vertexSource, commonSource);
|
||||
fragmentSource = Shader.InjectPreamble(fragmentSource, commonSource);
|
||||
}
|
||||
|
||||
return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource);
|
||||
}
|
||||
|
||||
|
|
@ -297,14 +321,31 @@ internal sealed class GlGpuDevice : IGpuDevice
|
|||
"only ever renders single-sampled targets.");
|
||||
}
|
||||
|
||||
uint framebuffer = 0;
|
||||
// Campaign V slice V4c: a null Color.Target means "whatever the frame
|
||||
// spine has bound", NOT "framebuffer 0" — so this deliberately does
|
||||
// not rebind. GpuPassDescription's own remarks say the transitional
|
||||
// path keeps "clears and framebuffer management" with the spine, and
|
||||
// forcing 0 here breaks that: PrivateEntityViewportRenderer binds its
|
||||
// offscreen FBO and then calls WbDrawDispatcher.Draw (see that file's
|
||||
// RenderToTexture), as does PortalTunnelPresentation. Once the
|
||||
// dispatcher records through an encoder, binding 0 on BeginPass would
|
||||
// redirect the paperdoll and creature-appraisal viewports to the
|
||||
// backbuffer and leave their textures empty — and the offline pixel
|
||||
// gate does not cover those viewports, so it would have shipped
|
||||
// silently. This is the same class of fix as the ambient-capability
|
||||
// save/restore in GlGpuPassEncoder (plan §7.1 rule 1): while raw-GL
|
||||
// renderers still own framebuffers, the backend preserves what they
|
||||
// bound rather than asserting its own. Removed at V4h, when the spine
|
||||
// declares real passes and a target is always explicit.
|
||||
//
|
||||
// An explicit Target still binds, because then the caller HAS named
|
||||
// the attachment.
|
||||
if (description.Color.Target is { } target)
|
||||
{
|
||||
if (target is not GlGpuRenderTarget glTarget)
|
||||
throw new ArgumentException("The GL backend can only render into a GL render target.");
|
||||
framebuffer = glTarget.GlFramebufferName;
|
||||
_gl.BindFramebuffer(GLEnum.Framebuffer, glTarget.GlFramebufferName);
|
||||
}
|
||||
_gl.BindFramebuffer(GLEnum.Framebuffer, framebuffer);
|
||||
|
||||
bool clearsColor = description.Color.Load == GpuLoadOp.Clear;
|
||||
bool clearsDepth = description.Depth is { Load: GpuLoadOp.Clear };
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ public static class RenderBootstrap
|
|||
|
||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
gl, gpuDevice, gpuFrameLifetime, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
bindless, classificationCache, translucencyFades);
|
||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,16 @@ public sealed class Shader : IDisposable
|
|||
/// preamble cannot simply be prepended — it has to land after that block,
|
||||
/// before the first real declaration.
|
||||
/// </summary>
|
||||
private static string InjectPreamble(string source, string preamble)
|
||||
/// <remarks>
|
||||
/// Campaign V slice V4c widened this from <c>private</c> to
|
||||
/// <c>internal</c> so <c>GlGpuDevice.CreatePipeline</c> splices the
|
||||
/// preamble with the SAME code rather than a second copy of the rule.
|
||||
/// mesh_modern needs the preamble (it calls ACDREAM_TEXTURE_HANDLE and
|
||||
/// ACDREAM_UBO_SET), and a pipeline-compiled copy that differed from the
|
||||
/// <see cref="Shader"/>-compiled one by even a line would be a silent
|
||||
/// divergence between two programs that must stay identical.
|
||||
/// </remarks>
|
||||
internal static string InjectPreamble(string source, string preamble)
|
||||
{
|
||||
int insertAt = 0;
|
||||
int lineStart = 0;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -171,6 +171,23 @@ public sealed class GlobalMeshBuffer : IDisposable
|
|||
|
||||
/// <summary>The index store's raw GL name. See <see cref="VBO"/>.</summary>
|
||||
public uint IBO => _indexBuffer is null ? 0u : RequireGlBuffer(_indexBuffer).GlName;
|
||||
|
||||
/// <summary>
|
||||
/// The vertex store as the arena actually owns it. Campaign V slice V4c's
|
||||
/// draw paths bind this through <c>IGpuPassEncoder.BindVertexBuffer</c>;
|
||||
/// its layout is already byte-identical to
|
||||
/// <c>GpuVertexLayout.WorldMesh</c> (32-byte stride, locations 0/1/2 at
|
||||
/// offsets 0/12/24 — see <see cref="ConfigureVertexAttributes"/>), so the
|
||||
/// pipeline's own VAO describes the same vertices this arena packs.
|
||||
///
|
||||
/// <see cref="VBO"/>/<see cref="IBO"/> survive alongside these because
|
||||
/// <c>ParticleRenderer</c> and <c>ObjectMeshManager</c> still bind the raw
|
||||
/// names; that bridge retires with them, not with this slice.
|
||||
/// </summary>
|
||||
internal IGpuBuffer? VertexStore => _vertexBuffer;
|
||||
|
||||
/// <summary>The index store as the arena owns it. See <see cref="VertexStore"/>.</summary>
|
||||
internal IGpuBuffer? IndexStore => _indexBuffer;
|
||||
internal long UploadCount { get; private set; }
|
||||
internal long UploadedBytes { get; private set; }
|
||||
internal long CapacityBytes =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -110,14 +110,14 @@ public class EnvCellRendererTests
|
|||
{
|
||||
// GL and meshManager are null — only valid for pure-data tests (no
|
||||
// Initialize() is called, so no GL calls are made).
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
Assert.True(r.NeedsPrepare);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewRenderer_NotDisposed()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
Assert.False(r.IsDisposed);
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ public class EnvCellRendererTests
|
|||
[Fact]
|
||||
public void RemoveLandblock_NonExistent_DoesNotThrow()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
// Should silently no-op.
|
||||
r.RemoveLandblock(0xA9B40000u);
|
||||
Assert.True(r.NeedsPrepare);
|
||||
|
|
@ -218,7 +218,7 @@ public class EnvCellRendererTests
|
|||
// Reflection-based test that drives the private GetPooledList +
|
||||
// _poolIndex/_listPool fields. If a future refactor removes the
|
||||
// Clear() call, this test fails.
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
|
||||
var type = typeof(EnvCellRenderer);
|
||||
var getPooledListMethod = type.GetMethod("GetPooledList",
|
||||
|
|
@ -249,7 +249,7 @@ public class EnvCellRendererTests
|
|||
{
|
||||
// Sanity check for the fresh-list branch. _poolIndex past _listPool.Count
|
||||
// should produce a brand-new empty list and grow the pool.
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
|
||||
var type = typeof(EnvCellRenderer);
|
||||
var getPooledListMethod = type.GetMethod("GetPooledList",
|
||||
|
|
@ -338,7 +338,7 @@ public class EnvCellRendererTests
|
|||
[Fact]
|
||||
public void NewRenderer_SnapshotGenerationStartsAtZero()
|
||||
{
|
||||
var r = new EnvCellRenderer(gl: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
var r = new EnvCellRenderer(gl: null!, device: null!, frameSource: null!, meshManager: null!, frustum: new WbFrustum());
|
||||
Assert.Equal(0, r.SnapshotGeneration);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue