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>
EnvCellRenderer.Rhi's SubmitRhi bound StorageInstances/StorageBatches/
StorageClipSlots/StorageGlobalLights/StorageInstanceLightSets every frame
but never GpuBindingModel.StorageInstanceAlpha (binding 7) — the SSBO
mesh_modern.vert reads as instanceAlpha[instanceIndex] (vOpacityMultiplier,
#188) and, as of Campaign VM VM1 (05970306), mesh_detail.vert now reads the
same way (vDetailOpacity). Without a bind of its own, both the interior
shell pass and the interior detail replay read whatever section
WbDrawDispatcher's own SubmitRhi last bound in the same pass — an unrelated
object's opacity array, indexed by these EnvCell instance ids.
This predates VM1 (6c79d35c has the same omission on the mesh_modern side);
VM1 must not widen a latent defect by adding a second unconditional reader
of the same unbound slot.
Fix, root cause, no guard: EnvCellRenderer now owns _instanceAlphaData, a
grow-only float[] parallel to _gpuInstanceTransforms (same pattern as
_clipSlotData/_lightSetData), filled with the constant 1.0f every frame —
EnvCell shells have no #188 TransparentPartHook translucency fade (that
mechanism fades object PARTS, never cells) — and bound at
GpuBindingModel.StorageInstanceAlpha alongside the renderer's other
per-frame ring sections, before any draw in the pass.
Test: EnvCellRendererTests.SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass
drives SubmitRhi directly (reflection, mirroring the file's existing
private-method test pattern) with N seeded cell instances and one real
draw command, then asserts against RecordingGpuDevice that
StorageInstanceAlpha is bound with exactly N floats all equal to 1.0f, and
that the bind precedes the pass's first MultiDrawIndexedIndirect call.
Verified failing (StorageInstanceAlpha was never bound) with the fix
temporarily reverted, then passing restored.
Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors);
dotnet test on AcDream.App.Tests (Release, hermetic lanes) green,
5960/5960 (5959 baseline + 1 new test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against 08ffe141: 3.20e-05, 18 differing pixels of 563,200,
inside the documented 9-31 band. GL connected -Runs 3: 3/3 RENDERED on the
desktop witness and 3/3 on the client capture. One offline Vulkan run with
VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation
errors, zero warnings, a captured world frame that still draws terrain,
blending, roads, water, statics, scenery, sky and the complete retained UI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.
What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.
The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.
Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.
That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.
Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.
Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.
Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.
The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.
Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.
What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.
Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 3/3
RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
validation errors, zero warnings, a captured world frame, and a graceful close.
Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>