F1 (BLOCKING, punch-fan view alignment). ClipFrameAssembler.
ReassembleOutsideViewFromWalk now keeps exactly ONE outside-view slice per
walk view, index-aligned with RetailFrameWalk.DrawBuilding's own
ActiveViewIndex (retail building_view = portal_view_num @0x0059f3bf): a
collapsed view (ClipPlaneSet.IsNothingVisible — retail polyClipFinish under
a degenerate view leaves <3 vertices, 0x59BDBC-0x59BDBF) now appends its
own ClipViewSlice.NothingVisible=true slot instead of being skipped, which
used to shift every later view's slice down by one. DrawWalkPunchFan draws
NOTHING for a NothingVisible slice and throws ArgumentOutOfRangeException
when activeViewIndex is out of range (fail-loud; never draws unclipped).
New tests: WalkOutsideViewReassemblyTests.
FirstViewCollapses_SecondSurvives_SlicesStayIndexAligned and
PunchLeaf_UsesIndexAlignedSlice_DrawsNothingForCollapsed_ThrowsOutOfRange.
MUTATION M1 (revert the append-nothing-on-skip fix, restoring the old
skip): both new tests fail with
Assert.Equal() Failure: Values differ
Expected: 2
Actual: 1
MUTATION M2 (restore the old ": default" unclipped fallback in
DrawWalkPunchFan): the punch-leaf test fails with
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 1
(both restored; verified clean afterward).
F2 (major, exit-seal pin through the real producer + both overflow cases).
The exit-seal CPU/GPU equivalence pin moved from ClipFrameLayoutTests (which
drove ClipFrame.AppendSlot(ClipPlaneSet) via the zero-caller
ClipFrameAssembler.Assemble) to the new
tests/.../Walk/WalkFrameDriverClipSealTests.cs, which drives
WalkFrameDriver.BeginFrame + the real IWalkEventSink.OnInteriorFloodDrawTurn
hook — the SAME path RetailPViewPassExecutor.DrawExitPortalMask resolves
through via WalkFrameDriver.InteriorFloodViewClipPlanesAt. The new pin
compares WalkFrameDriver's captured planes EXACTLY (not just geometrically)
against ClipPlaneSet.From's independent CCW-normalized-perpendicular
computation, since AppendClipSlot's per-edge formula is bit-identical to
ClipPlaneSet's own. Two overflow cases added: a 9-vertex portal view exit-seals
to exactly the 4 conservative AABB planes containing every source vertex
(over-include, never under-include — AppendClipSlot's fallback for a view too
complex for the 8-plane budget); a 9-vertex OUTSIDE view (the punch-fan
sibling, WalkOutsideViewReassemblyTests.
NineVertexOutsideView_PunchSliceHasZeroPlanes_DrawsUnclipped_NotNothingVisible)
produces a zero-plane, NOT-NothingVisible slice — draws fully unclipped,
distinct from F1's "draw nothing" state.
MUTATION M1 (flip the winding selection, `ccw = area2 < 0f`):
Assert.Equal() Failure: Values are not within 4 decimal places
Expected: 0.9191 (rounded from 0.919144988)
Actual: 0.8944 (rounded from 0.89442724)
MUTATION M2 (drop the normalize from the per-edge plane formula):
Assert.Equal() Failure: Values are not within 4 decimal places
Expected: 0.9191 (rounded from 0.919144988)
Actual: 0.7 (rounded from 0.700000048)
MUTATION M3 (delete the >8-plane AABB overflow branch — the per-edge loop
then indexes a fixed 8-plane stackalloc with a 9-edge polygon):
System.IndexOutOfRangeException : Index was outside the bounds of the array.
at WalkFrameDriver.AppendClipSlot(...) line 1915
at WalkFrameDriver.CaptureViews(...) -> CaptureCellViews -> CaptureCellViewRoute
-> EmitFloodTurns -> IWalkEventSink.OnInteriorFloodDrawTurn
(all three restored; verified clean afterward).
F3 (minor/major). RetailPViewPassExecutor.cs's false bracket comment
(neither KEEP clip — exit seals nor punch fans — ever called Enable/
DisableClipDistances; the six DisableClipDistances() call sites all sat
around the UNCLIPPED particle/weather draws, whose bodies are no-ops on the
only backend) is rewritten to the truth; the six no-op calls and the
private DisableClipDistances() wrapper are deleted.
IWorldPassSurface.EnableClipDistances/DisableClipDistances stay on the
INTERFACE — WorldScenePassExecutor (the separate flat-world path) still
calls them.
F4a (EC transcript exactness). WalkFrameDriver.EmitFloodTurns now prints one
"EC" line per LIVE VIEW of the flood cell (retail's setup_view/DrawEnvCell
loop, PView::DrawCells @0x005a4ab1-0x005a4acc, fires once per live portal_view
slice, not once per cell) via the captured route's own SliceCount — read
directly off _lookInTurns[viewRouteIndex] rather than through
InteriorFloodViewSliceCountAt's flood-index indirection, because this same
loop also serves a building's LOOK-IN flood (WalkDrawStage.LookInStatic),
whose cells never populate InteriorFloodCells (the interior ROOT flood only)
— indexing through that accessor threw ArgumentOutOfRangeException on a real
look-in turn during verification, fixed before landing. The actual CellShell
DRAW event is unchanged (the frame stamp still dedupes it to one submission).
F4b (transcript exactness, trailing weather OC).
WalkTraceConformanceTests.Recorder now implements IWalkEventSink.
OnWeatherTurn (the interface's silent no-op default previously left the
replay side of Signature8 blind to the weather turn's own "OC" line), and
WalkTraceReplayContext.Signature8(WalkOracleFrame) no longer excludes the
trailing per-frame weather ObjectCellTurn — every ObjectCellTurn now reads
literally, on both sides. Implementing OnWeatherTurn alone left
Still_fixture_first_frame_reproduces_exactly(terrace-edge.walk) diverging
ONLY on the newly-un-excluded trailing OC (EXPECTED ...OC:f4180104|
OC:f418000b vs ACTUAL ...OC:f4180104 — a length-12 tail, nothing else
differs across a 16.6k-char signature) because
WalkTraceReplayContext.WeatherGateOpen had always defaulted to false (the
harness never needed to fire the walk's weather hook before). Per the
review round's own instruction not to weaken the pin, the harness itself is
fixed instead: WalkTraceReplayContext now implements ViewerCellId (= the
harness's own WalkFrame cameraCellId argument, matching production's
RetailPViewFrameInput.ViewerCellId invariant) and WeatherGateOpen
(retail's own SmartBox::is_player_outside gate, (cellId & 0xFFFF) < 0x100,
with no App-level render-toggle concept to AND against). MUTATION (delete
Recorder.OnWeatherTurn): Still_fixture_first_frame_reproduces_exactly
(terrace-edge.walk) fails with EXPECTED ending "...OC:f4180104|OC:f418000b"
vs ACTUAL ending "...OC:f4180104" (the trailing OC missing) — restored;
verified clean afterward.
Fixture-row status (InstalledDat lane, WalkTraceConformanceTests, 14 rows):
13 pass, 1 fails — Oh_doorway_still_first_frame_diff, the PRE-EXISTING
[Trait("Status","KnownFailure")] #458 row (a documented block-plane
precision boundary at token index 165, LC/SC content, upstream of any
EC/OC — unaffected by and unrelated to this round's OC change). Every OTHER
row, including every eight-kind (Signature8) row that now compares the
weather OC literally, passes exactly.
F5 (major, dead per-cell clip machinery). WbDrawDispatcher.SetClipRouting
(the only writer of the per-instance routing-active flag) had ZERO
production callers; EnvCellRenderer.SetClipRouting was called only with
null (WorldScenePassExecutor's BeginFrame/AbortFrame) — no path could ever
arm the per-cell clip-region table, on the walk path OR the flat path (the
S3 landing-hygiene AD-17 correction's "live for the flat path" clause is
itself corrected here: dormant in every path, deleted). Deleted:
WbDrawDispatcher's SetClipRouting/ClearClipRouting and their four backing
fields; EnvCellRenderer's SetClipRouting and its backing field (its
RenderModernMDIInternal write now unconditionally clears instanceClipSlot);
the ProbeClipRouteEnabled-gated per-frame [clip-route-disp] dispatch probe
and its three backing fields (the OTHER two ACDREAM_PROBE_CLIPROUTE
producers, [clip-route] and [clip-route-scis] in WorldRenderDiagnostics.cs,
are untouched — a different, still-live mechanism); ResolveEntitySlot,
IsIndoorCellId and the ClipSlotCull sentinel; the ACDREAM_CLIP_DEBUG probe
(RenderingDiagnostics.ClipDebugNoShellTrim) and its docs/launch-options.md
row, since the "clip shells" branch it toggled between is gone.
ResolveSlotForFrame is simplified to a parameterless
`(uint Slot, bool Culled) ResolveSlotForFrame() => (0u, false)` per the
review's own framing ("it becomes slot 0, never culled") rather than
deleted outright, since its callers (WbDrawDispatcher.cs and
WbDrawDispatcher.WalkClassify.cs) still want the same two-value shape.
tests/.../Wb/WbDrawDispatcherClipSlotTests.cs (12 tests, all exercising the
deleted routing arm via ResolveEntitySlot/the active ResolveSlotForFrame
branch) is replaced with one pin on the new parameterless behavior.
Shaders: mesh_modern.vert, mesh_atmospheric.vert, mesh_detail.vert,
particle.vert and particle_mesh.vert each lose their CellClip struct +
binding=2 ClipRegionBuf fetch, gl_ClipDistance write loop, and gl_PerVertex
redeclaration (portal_depth.vert's own, separate gl_PerVertex/gl_ClipDistance
pair — the KEEP mechanism, its planes handed through the TerrainClip UBO at
binding=2 in the UBO namespace — is untouched; verified it never read the
deleted SSBO). Recompiled via tools/compile-shaders.ps1 (glslc backend
detected; managed shaderc path ran); 24/24 pairs compiled. Re-pinned in
VulkanShaderManifestTests.RetailOracleSpirvSha256 (the only three of the
five edited shaders that carry a byte-exact retail-oracle pin):
mesh_modern.vert.spv = f9ed4ee7140ccd136130559dbea68545f733f6022f52b085b6df4bcb787223c6
particle.vert.spv = 95ce6ecf834930a92da5c5fe9aef513b38b5ba104704b98c1606af71fe17eaf3
particle_mesh.vert.spv = 043482b97c2ed036511692f89c75a0a6c298aba48cb519e5e3aff7fe7ba6371b
(mesh_atmospheric.vert.spv / mesh_detail.vert.spv changed too but carry no
retail-oracle pin, so no re-pin was needed for them.)
tests/.../ParticleBindlessInstanceTests.cs's
Assert.Contains("clipRegions[aClipSlot]", vertex) — a real breakage the
grep sweep caught — is deleted with a note; the surrounding aClipSlot
attribute-declaration assertion stays.
Vertex-layout residue kept THIS round, fed 0 by the CPU, unread by any
shader (S5's instance-buffer-layout revisit): instanceClipSlot[] (binding=3
SSBO) in mesh_modern.vert/mesh_atmospheric.vert/mesh_detail.vert; aClipSlot
(a per-vertex attribute) in particle.vert/particle_mesh.vert.
Grep sweep — "SetClipRouting|_clipRoutingActive|ClearClipRouting|
BeginScissor|NdcScissorRect|SetTerrainClip|BindTerrainClip|TerrainBytes|
ScissorNdcAabb|UseScissorFallback" over src/ and tests/: EMPTY (also swept
and rewrote several PRE-EXISTING prose mentions of BeginScissor/
ClearClipRouting in RetailPViewPassExecutor.cs, RetailPViewRenderer.cs,
WorldPassSurface.cs and RetailPViewPassExecutorTests.cs that predate this
commit's own work, purely to satisfy the literal sweep). "clipRegions|
instanceClipSlot|aClipSlot": every remaining match is the vertex-layout
residue named above, or a comment/assertion describing that same residue.
Deviations from a fully literal reading of F5 (recorded here since none of
them cross the mandatory automated gates, all of which pass):
- The C#-side ClipRegions SSBO publish/bind pipeline
(RhiWorldPassSurface.PrepareClipFrame's publish, WorldFrameSections.
ClipRegions, and ClipFrame's internal std430 byte-packing —
RegionBytes/RegionBytesForTest/CellClipStrideBytes) is NOT deleted this
round; it is left as documented dead residue (verified zero shader
readers) rather than rewritten. The mandatory grep sweep targets the
shader-source identifier `clipRegions` (lowercase), which is fully clean;
the C# publish machinery sits behind ~8 files (ClipFrame.cs,
WorldPassSurface.cs, WbDrawDispatcher.Rhi.cs, WorldPassScope.cs,
GpuBindingModel.cs, EnvCellRenderer.Rhi.cs, ParticleRenderer.Rhi.cs,
WbDrawDispatcher.OrderedStream.cs, plus the ClipFrameLayoutTests std430
pins) and CellClipPlanesOffset/MaxPlanes are the SAME constants
portal_depth.vert's KEEP mechanism uses for its own, unrelated UBO layout
— a full rewrite carried materially higher regression risk than this
round's mandatory scope justified. ClipFrame.cs and WorldPassSurface.cs
now both name this residue explicitly in their doc comments for a future
round to finish.
- SetClipRegionSsbo/_sharedClipRegionSsbo in WbDrawDispatcher.cs (a
pre-existing, already fully dead GL-era write-only field/method with zero
callers, unrelated to per-instance clip-slot routing) is left untouched —
outside F5's named scope and outside the mandatory grep sweep.
- WalkTraceReplayContext.ViewerCellId/WeatherGateOpen (F4b, detailed above)
is an addition beyond the literal §12 text, made to keep the InstalledDat
lane at exactly the four established known failures without weakening
the newly-un-excluded OC pin.
Gates: dotnet build tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c
Release and dotnet build src/AcDream.App/AcDream.App.csproj -c Release both
0 warnings/0 errors; dotnet build AcDream.slnx -c Release 0 warnings/0
errors; hermetic lane 6815/6815 passed; InstalledDat lane 243 passed / 4
failed / 1 skipped (exactly the four known failures: two #383 layout tests,
TowerAscentReplayTests, Oh_doorway_still_first_frame_diff #458);
VulkanShaderDescriptorContractTests/VulkanShaderManifestTests/
RenderPackSpirvValidatorTests/SkyVertexLayoutTests 34/34 passed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
298 lines
15 KiB
GLSL
298 lines
15 KiB
GLSL
#version 430 core
|
||
#extension GL_ARB_shader_draw_parameters : require
|
||
|
||
layout(location = 0) in vec3 aPosition;
|
||
layout(location = 1) in vec3 aNormal;
|
||
layout(location = 2) in vec2 aTexCoord;
|
||
|
||
struct InstanceData {
|
||
mat4 transform;
|
||
};
|
||
|
||
// Campaign V slice V2 (2026-07-27): textureHandle (uvec2, a 64-bit
|
||
// GL_ARB_bindless_texture handle) became textureIndex (uint) plus an explicit
|
||
// pad word. textureIndex is a slot into the global texture table (set 2,
|
||
// injected by tools/ShaderCompiler/VulkanGlslPreamble.cs — see
|
||
// ACDREAM_TEXTURE_HANDLE/ACDREAM_SAMPLE_ARRAY) which main() below forwards to
|
||
// the fragment stage. The pad word keeps textureLayer/flags at their original
|
||
// std430 offsets (8/12), so the struct is still 16 bytes and every existing
|
||
// CPU writer's layout is unchanged (GpuBindingModel.GpuBatchDataStrideBytes).
|
||
struct BatchData {
|
||
uint textureIndex; // slot into the global texture table
|
||
uint _pad; // keeps textureLayer/flags at offsets 8/12
|
||
uint textureLayer; // layer in the shared WB or pooled composite array
|
||
uint flags; // reserved — N.5 dispatcher owns all blend state
|
||
// (glBlendFunc per pass). If a future phase wants
|
||
// shader-side per-batch additive flag (Decision 2
|
||
// fallback), encode it here as bit 0.
|
||
};
|
||
|
||
layout(std430, binding = 0) readonly buffer InstanceBuffer {
|
||
InstanceData Instances[];
|
||
};
|
||
|
||
// binding=1 here is the SSBO namespace — distinct from the UBO namespace.
|
||
// SceneLighting UBO also uses binding=1 in the fragment shader; GL keeps
|
||
// GL_SHADER_STORAGE_BUFFER and GL_UNIFORM_BUFFER binding tables separate.
|
||
// Task 10 dispatcher binds:
|
||
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, instanceSsbo)
|
||
// glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, batchSsbo)
|
||
// Existing SceneLightingUboBinding handles the UBO side.
|
||
layout(std430, binding = 1) readonly buffer BatchBuffer {
|
||
BatchData Batches[];
|
||
};
|
||
|
||
// S3 review fix round 1 (F5): the per-cell screen-space gl_ClipDistance gate
|
||
// (Phase U.3's binding=2 CellClip region SSBO) is deleted — the CPU-side
|
||
// routing that could ever select a non-zero slot for an instance
|
||
// (WbDrawDispatcher's per-frame clip-routing arming call) had ZERO production callers, so every
|
||
// instance has always mapped to slot 0 (no-clip) in every shipped build; a
|
||
// shader-side clip test against a table that only ever holds the reserved
|
||
// no-clip slot clips nothing. binding=3 — PER-RENDERER per-instance slot
|
||
// index, parallel to the binding=0 instance buffer and indexed by the
|
||
// IDENTICAL per-instance index (gl_BaseInstanceARB + gl_InstanceID) — stays
|
||
// declared (the CPU side still writes it, always 0) but is no longer read
|
||
// here; the instance-buffer layout it occupies is S5's to revisit.
|
||
layout(std430, binding = 3) readonly buffer ClipSlotBuf {
|
||
uint instanceClipSlot[];
|
||
};
|
||
|
||
// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting =====
|
||
// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position
|
||
// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it
|
||
// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA
|
||
// UBO set (LightManager.Tick) made a wall brighten as the camera approached
|
||
// (its torches swapping into the global top-8). Two SSBOs replace that for
|
||
// point/spot lights (the SUN + ambient still come from the SceneLighting UBO):
|
||
//
|
||
// binding=4 — GLOBAL point/spot light array, uploaded once per frame from
|
||
// LightManager.PointSnapshot. The index of a light here is stable for the frame.
|
||
// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per
|
||
// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0
|
||
// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills
|
||
// it once per entity (the set is constant across the entity's parts/tuples).
|
||
struct GlobalLight {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std430, binding = 4) readonly buffer GlobalLightBuf {
|
||
GlobalLight gLights[];
|
||
};
|
||
layout(std430, binding = 5) readonly buffer InstanceLightSetBuf {
|
||
int instanceLightIdx[]; // 8 per instance; -1 = unused
|
||
};
|
||
|
||
// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0
|
||
// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the
|
||
// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun).
|
||
// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1
|
||
// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it.
|
||
layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
||
uint instanceIndoor[];
|
||
};
|
||
|
||
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
|
||
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
|
||
// while a TransparentPartHook translucency fade is in flight for the
|
||
// entity/part this instance belongs to (e.g. the "fading wall" secret-
|
||
// passage doors). Multiplied against the sampled texture alpha in
|
||
// mesh_modern.frag.
|
||
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
|
||
float instanceAlpha[];
|
||
};
|
||
|
||
// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
|
||
// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
|
||
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
|
||
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
|
||
// object-only binding.
|
||
layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
|
||
vec2 instanceSelectionLighting[];
|
||
};
|
||
|
||
uniform mat4 uViewProjection;
|
||
|
||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
|
||
// gl_DrawIDARB resets to 0 at the start of each glMultiDrawElementsIndirect
|
||
// call, so the transparent pass — which begins later in the indirect buffer
|
||
// — was fetching Batches[0..transparentCount) instead of its actual section
|
||
// at Batches[opaqueCount..end). The lifestone crystal (a transparent draw)
|
||
// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
|
||
// the camera moved and the opaque front-to-back sort reordered which group
|
||
// landed at BatchData[0], the lifestone's apparent texture flickered to
|
||
// whatever was first — frequently the player character's body parts.
|
||
//
|
||
// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
|
||
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
|
||
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
||
uniform int uDrawIDOffset;
|
||
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
|
||
// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
|
||
// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
|
||
// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
|
||
// 3 = handled in the frag (raw vLit visualization, texture ignored).
|
||
uniform int uLightDebug;
|
||
|
||
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
|
||
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
|
||
// above). IDENTICAL std140 layout to mesh_modern.frag.
|
||
//
|
||
// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so
|
||
// torch/point lights Gouraud-interpolate across each triangle the way retail's
|
||
// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for
|
||
// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls;
|
||
// per-vertex spreads it into a soft, broad gradient with no hard edge.
|
||
struct Light {
|
||
vec4 posAndKind;
|
||
vec4 dirAndRange;
|
||
vec4 colorAndIntensity;
|
||
vec4 coneAngleEtc;
|
||
};
|
||
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
|
||
Light uLights[8];
|
||
vec4 uCellAmbient;
|
||
vec4 uFogParams;
|
||
vec4 uFogColor;
|
||
vec4 uCameraAndTime;
|
||
};
|
||
|
||
// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light —
|
||
// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D =
|
||
// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal.
|
||
// Returns the RGB to ADD, already per-channel capped to the light's own colour.
|
||
vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
||
int kind = int(L.posAndKind.w);
|
||
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
|
||
float distsq = dot(toL, toL);
|
||
float d = sqrt(distsq);
|
||
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic)
|
||
if (d >= range || range <= 1e-4) return vec3(0.0);
|
||
float intensity = L.colorAndIntensity.w;
|
||
vec3 baseCol = L.colorAndIntensity.xyz;
|
||
|
||
// #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by
|
||
// coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation
|
||
// (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒
|
||
// att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads
|
||
// softly across the room (the portal tint, the viewer fill) instead of the static
|
||
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
|
||
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
|
||
if (L.coneAngleEtc.y > 0.5) {
|
||
if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
|
||
vec3 Ldir = toL / max(d, 1e-4);
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
if (ndl <= 0.0) return vec3(0.0);
|
||
if (kind == 2) { // dynamic spot: hard cos-cone gate
|
||
if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0);
|
||
}
|
||
return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d
|
||
}
|
||
|
||
// ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ──
|
||
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
|
||
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
|
||
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
|
||
// nothing (retail's hardware path). toL is un-normalised (length d).
|
||
float angular = (uLightingMode == 1)
|
||
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
|
||
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
|
||
if (angular <= 0.0) return vec3(0.0);
|
||
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
|
||
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
|
||
float norm = (distsq > 1.0) ? (distsq * d) : d;
|
||
float scale = (1.0 - d / range) * intensity * (angular / norm);
|
||
if (kind == 2) {
|
||
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
|
||
vec3 Ldir = toL / max(d, 1e-4);
|
||
float cos_edge = cos(L.coneAngleEtc.x * 0.5);
|
||
float cos_l = dot(-Ldir, L.dirAndRange.xyz);
|
||
if (cos_l <= cos_edge) scale = 0.0;
|
||
}
|
||
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
|
||
// a single light can't push a channel past its colour. Summed lit clamped in frag.
|
||
return min(scale * baseCol, baseCol);
|
||
}
|
||
|
||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||
vec3 lit = uCellAmbient.xyz;
|
||
if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
|
||
|
||
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
|
||
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
|
||
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
|
||
// #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an
|
||
// EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior
|
||
// stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects
|
||
// get no sun even in windowed buildings where the player's frame is not sun-killed.
|
||
if (uLightingMode == 0) {
|
||
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
|
||
int activeLights = int(uCellAmbient.w);
|
||
for (int i = 0; i < 8; ++i) {
|
||
if (i >= activeLights) break;
|
||
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
|
||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||
float ndl = max(0.0, dot(N, Ldir));
|
||
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
|
||
}
|
||
}
|
||
}
|
||
|
||
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
|
||
// SetStaticLightingVertexColors sums the static point lights from BLACK and
|
||
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
|
||
// few warm intensity-100 torches can't push the whole pixel to white the way
|
||
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
|
||
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
|
||
vec3 pointAcc = vec3(0.0);
|
||
int base = instanceIndex * 8;
|
||
for (int k = 0; k < 8; ++k) {
|
||
int gi = instanceLightIdx[base + k];
|
||
if (gi < 0) continue;
|
||
pointAcc += pointContribution(N, worldPos, gLights[gi]);
|
||
}
|
||
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
|
||
|
||
return lit; // frag still does the final min(lit, 1.0)
|
||
}
|
||
|
||
out vec3 vNormal;
|
||
out vec2 vTexCoord;
|
||
out vec3 vWorldPos;
|
||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||
// Campaign V slice V6e: was `flat uvec2 vTextureHandle` — a raw 64-bit
|
||
// GL_ARB_bindless_texture handle handed across the stage boundary. A varying
|
||
// cannot carry a Vulkan descriptor, so what travels is the table SLOT and the
|
||
// fragment stage does the lookup (see mesh_modern.frag). Under GL the value
|
||
// sampled is bit-for-bit the one the vertex stage used to forward; the SSBO
|
||
// read simply happens one stage later, and `flat` keeps it one scalar load per
|
||
// primitive rather than per fragment.
|
||
out flat uint vTextureIndex;
|
||
out flat uint vTextureLayer;
|
||
out flat float vOpacityMultiplier; // #188
|
||
out flat vec2 vSelectionLighting;
|
||
|
||
void main() {
|
||
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||
mat4 model = Instances[instanceIndex].transform;
|
||
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||
vSelectionLighting = (uLightingMode == 0)
|
||
? instanceSelectionLighting[instanceIndex]
|
||
: vec2(0.0, 1.0);
|
||
|
||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||
gl_Position = uViewProjection * worldPos;
|
||
|
||
vWorldPos = worldPos.xyz;
|
||
vNormal = normalize(mat3(model) * aNormal);
|
||
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
|
||
vTexCoord = aTexCoord;
|
||
|
||
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
|
||
// Campaign V slice V6e: forward the table SLOT untouched. V2 looked the
|
||
// handle up here and passed the handle; the lookup now lives at the sample
|
||
// site in mesh_modern.frag, which is the only form Vulkan can express.
|
||
vTextureIndex = b.textureIndex;
|
||
vTextureLayer = b.textureLayer;
|
||
}
|