diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index 4077938..cff57a0 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -95,6 +95,22 @@ public sealed class ClipFrameAssembly /// is . Unused otherwise. public required Vector4 TerrainScissorNdcAabb { get; init; } + /// True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this + /// frame — the camera can see outdoors through a portal chain ( is + /// or ). False ⇒ a + /// sealed interior with no exit portal in view (). Drives the + /// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root + /// (the caller does not invoke there). + public required bool HasOutsideView { get; init; } + + /// NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway + /// opening's bounding box. Computed whenever is true, for BOTH the + /// Planes and Scissor terrain modes (unlike , which is valid + /// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail + /// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate + /// () when is false. + public required Vector4 OutsideViewNdcAabb { get; init; } + // ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) -------- /// Plane count the OutsideView reduced to (0 ⇒ scissor or empty). @@ -196,6 +212,17 @@ public static class ClipFrameAssembler scissorFallbacks++; } + // Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for + // BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional + // doorway Z-clear need it regardless of how the OutsideView reduced to a gate. + // TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView + // always tracks its Min/Max as polygons accumulate, so it is the single source here. + bool hasOutsideView = terrainMode != TerrainClipMode.Skip; + Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty) + ? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY, + pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY) + : Vector4.Zero; + return new ClipFrameAssembly { Frame = frame, @@ -204,6 +231,8 @@ public static class ClipFrameAssembler OutdoorVisible = outdoorVisible, TerrainMode = terrainMode, TerrainScissorNdcAabb = terrainScissor, + HasOutsideView = hasOutsideView, + OutsideViewNdcAabb = outsideViewNdcAabb, OutsidePlaneCount = ov.Count, PerCellPlaneCounts = perCellPlaneCounts, ScissorFallbacks = scissorFallbacks, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index c9d92ca..d59a9ac 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7272,32 +7272,20 @@ public sealed class GameWindow : IDisposable // call further below. // Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649. // Outdoor root (cameraInsideCell=false): always render sky. - // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — - // it draws full-screen here until Stage 4 clips it to the doorway via OutsideView. + // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped + // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. - // NOTE: interim regression until Stage 4 — sky draws full-screen in building interiors. - // This is expected per the EXECUTION POLICY; do NOT add a workaround gate. bool renderSky = !cameraInsideCell || rootSeenOutside; - if (renderSky) - { - _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf, environOverrideActive); - if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos, - AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); - } + // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape + // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to + // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky + // pre-scene" block after UploadShared. renderSky is the seen_outside policy gate; the draw + // additionally requires an exit portal in view when indoors (drawSkyThisFrame, below). - // K-fix1 (2026-04-26): suppress terrain + entity rendering - // while live mode is configured but the chase camera hasn't - // engaged yet — pairs with the streaming-Tick gate in - // OnUpdate so absolutely nothing of the world (Holtburg or - // otherwise) renders pre-login. The sky still draws above so - // the user sees a live, time-of-day-correct sky during the - // brief connection + character-list + EnterWorld handshake. - if (IsLiveModeWaitingForLogin) - { - goto SkipWorldGeometry; - } + // K-fix1 (2026-04-26): the pre-login world-suppression gate (goto SkipWorldGeometry) + // moved DOWN — below the sky pre-scene draw (Phase W Stage 4) — so the live sky still + // draws during the connection + EnterWorld handshake while the world geometry is skipped. + // See the gate just before the world-geometry clip bracket. // Phase U.4: build the SHARED per-frame clip data from the portal- // visibility result, ahead of both terrain and entity draws. @@ -7387,12 +7375,62 @@ public sealed class GameWindow : IDisposable _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _terrain?.SetClipUbo(_clipFrame.TerrainUbo); + // ── [Stage 4] sky pre-scene (LScape, drawn through the doorway) ───────────── + // Phase W Stage 4 (2026-06-02): the sky + (post-scene) weather are retail's LScape — + // "the outside seen through the exit portal." They draw clipped to the OutsideView via + // sky.vert's gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads + // (just uploaded by UploadShared above). Retail PView::DrawCells (pseudo_c:432709) draws + // LScape first when outside_view.view_count > 0; RenderNormalMode (92649) gates it on + // seen_outside. drawSkyThisFrame = the seen_outside policy (renderSky) AND somewhere to + // draw it: outdoors (clipAssembly == null → full-screen) OR indoors with an exit portal in + // view (HasOutsideView). An interior with no exit portal in the current view draws no sky + // (no full-screen bleed). skyDoorwayClip drives the doorway scissor for the particle + // passes (particle.vert has no gl_ClipDistance) and the conditional Z-clear below. + bool skyDoorwayClip = clipAssembly is not null && clipAssembly.HasOutsideView; + bool drawSkyThisFrame = renderSky && (clipAssembly is null || clipAssembly.HasOutsideView); + System.Numerics.Vector4 skyDoorwayNdc = clipAssembly?.OutsideViewNdcAabb ?? default; + if (drawSkyThisFrame) + { + // Sky MESH: enable the 8 clip planes around RenderSky so sky.vert clips it to the + // OutsideView (binding=2 UBO: count>0 indoor → confined to the doorway; count==0 + // outdoor → all distances +1 → full-screen, bit-identical to pre-Stage-4). Re-bind + // binding=2 (UBO namespace) defensively — SkyRenderer does not own it, and we must not + // inherit whatever was last bound (memory: render-self-contained-gl-state). + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Enable(EnableCap.ClipDistance0 + _cp); + _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, + _activeDayGroup, kf, environOverrideActive); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Disable(EnableCap.ClipDistance0 + _cp); + + // SkyPreScene particles (particle.vert, no gl_ClipDistance) → scissor to the doorway + // bbox indoors. Outdoors (skyDoorwayClip=false) draws full-screen. + if (_particleSystem is not null && _particleRenderer is not null) + { + bool sc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); + if (sc) _gl.Disable(EnableCap.ScissorTest); + } + } + + // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured + // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the + // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake; + // the world geometry below is skipped. (Phase W Stage 4: moved BELOW the sky draw — the sky + // now needs the assembled ClipFrame, which is harmless/no-clip pre-login.) + if (IsLiveModeWaitingForLogin) + goto SkipWorldGeometry; + // Phase U.3: enable the 8 hardware clip planes for the world-geometry // block ONLY. All gl_ClipDistance-writing draws (terrain, entities, and // U.4's EnvCellRenderer.Render) MUST be inside this enable/disable - // bracket; everything else (sky, particles, weather, debug, UI) renders - // with clip DISABLED. Sky already drew above (must not be clipped); - // particles/weather/debug/UI draw below the matching glDisable. Scoping + // bracket; everything else (particles, weather, debug, UI) renders with + // clip DISABLED. The sky/weather drew/draws above + below in their OWN + // local clip brackets (sky.vert now writes gl_ClipDistance); the + // particles/weather-particles/debug/UI draw with clip OFF. Scoping // the enable here (instead of a permanent init-time enable) avoids the // undefined behavior of leaving GL_CLIP_DISTANCE_i on for shaders that // never write gl_ClipDistance[i] — a driver is free to clip those away. @@ -7467,6 +7505,21 @@ public sealed class GameWindow : IDisposable animatedIds.Add(k); } + // ── [Stage 4] conditional doorway Z-clear ─────────────────────────────────── + // Retail PView::DrawCells @ pseudo_c:432731: after the landscape (sky + terrain) is drawn + // through the exit portal, RenderDevice->Clear(flag 4 = Z-BUFFER ONLY, NOT color) resets + // depth so the indoor walls / entities draw cleanly on top without z-fighting at the portal + // plane. Depth ONLY — never color — so there is NO blue clear-color hole: the sky / terrain + // color already written through the doorway stays, and the opaque cell shells overpaint the + // doorway-bbox corners. Scissored to the OutsideView AABB so only the doorway region's depth + // is cleared. Fires only for an indoor root with an exit portal in view (skyDoorwayClip). + if (skyDoorwayClip) + { + bool _zc = BeginDoorwayScissor(true, skyDoorwayNdc); + _gl.Clear(ClearBufferMask.DepthBufferBit); + if (_zc) _gl.Disable(EnableCap.ScissorTest); + } + // Phase U.4: render the indoor cell SHELLS (walls / floors / ceilings) // — previously DORMANT (EnvCellRenderer.Render was never called in the // live loop). Inside the clip bracket so each cell's instances are gated @@ -7515,16 +7568,33 @@ public sealed class GameWindow : IDisposable // instead of being painted over by them. This is the second // half of retail's LScape::draw split — GameSky::Draw(1) // fires after the DrawBlock loop. Same indoor gate as the - // sky pass: weather follows renderSky (seen_outside policy, - // Stage 3: suppressed in sealed dungeons, visible in building - // interiors through exit portals, always visible outdoors). - if (renderSky) + // sky pass: weather follows the same drawSkyThisFrame gate (seen_outside policy AND an + // exit portal in view when indoors), and — Phase W Stage 4 — draws inside its OWN local + // clip bracket so sky.vert clips the rain cylinder to the doorway indoors (full-screen + // outdoors). Suppressed in sealed dungeons / interiors with no exit portal in view. + if (drawSkyThisFrame) { + // Weather MESH (rain cylinder): re-bind binding=2 (the OutsideView UBO) defensively, + // enable the 8 clip planes around RenderWeather, disable after. count==0 outdoors ⇒ + // full-screen rain, unchanged. + _gl.BindBufferBase(BufferTargetARB.UniformBuffer, + ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); + for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) + _gl.Disable(EnableCap.ClipDistance0 + _cp); + + // SkyPostScene particles (particle.vert, no gl_ClipDistance) → scissor to the doorway + // bbox indoors, full-screen outdoors. if (_particleSystem is not null && _particleRenderer is not null) + { + bool sc = BeginDoorwayScissor(skyDoorwayClip, skyDoorwayNdc); _particleRenderer.Draw(_particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); + if (sc) _gl.Disable(EnableCap.ScissorTest); + } } // Debug: draw collision shapes as wireframe cylinders around the @@ -8847,6 +8917,31 @@ public sealed class GameWindow : IDisposable } } + // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in + // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then + // disables EnableCap.ScissorTest after its draw/clear). Mirrors the terrain Scissor-mode + // NDC→pixel conversion (one source for the box math). Used to confine the sky/weather particle + // passes (particle.vert has no gl_ClipDistance) and the conditional doorway depth-only Z-clear + // to the doorway opening. Returns false (no scissor) when not applied (outdoor / no window). + private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) + { + if (!apply || _window is null) return false; + var fb = _window.FramebufferSize; + // NDC [-1,1] → window pixels. Clamp so a doorway opening that extends past a screen edge + // still yields a valid box (same clamp the terrain Scissor path uses). + float nx0 = System.Math.Clamp(ndcAabb.X, -1f, 1f); + float ny0 = System.Math.Clamp(ndcAabb.Y, -1f, 1f); + float nx1 = System.Math.Clamp(ndcAabb.Z, -1f, 1f); + float ny1 = System.Math.Clamp(ndcAabb.W, -1f, 1f); + int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X); + int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y); + int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X); + int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y); + _gl!.Enable(EnableCap.ScissorTest); + _gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph)); + return true; + } + /// /// Derive the current sun (directional light, slot 0 of the UBO) /// from the interpolated , diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 0d6b4f1..035458b 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -65,6 +65,33 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; +// === Phase W Stage 4: sky/weather portal clip (the OutsideView region) ======== +// The sky + weather (rain cylinder) meshes are "the outside seen through a +// doorway" — retail draws them as part of LScape, clipped to the exit-portal +// region (PView::DrawCells @ 0x005a4840). acdream gates them with the SAME +// binding=2 TerrainClip UBO the terrain shader reads (ClipFrame.SetTerrainClip → +// the OutsideView convex planes). The planes are SCREEN-SPACE (NDC) half-spaces +// encoded as clip-space planes (nx, ny, 0, dw) with the test +// dot(plane, gl_Position) >= 0. After the perspective divide that reduces to +// nx*ndcX + ny*ndcY + dw >= 0 — INDEPENDENT of the projection matrix. So the same +// plane set clips the sky correctly even though the sky uses its OWN dome +// projection (uSkyProjection / uSkyView, translation-zeroed) rather than the +// camera view-proj. uTerrainClipCount == 0 (outdoor / no exit portal visible) +// ungates the sky entirely (the second loop sets all 8 distances to +1.0 ⇒ +// full-screen sky, bit-identical to pre-Stage-4). Host enables GL_CLIP_DISTANCE0..7 +// only around the sky/weather draws. +layout(std140, binding = 2) uniform TerrainClip { + int uTerrainClipCount; + vec4 uTerrainClipPlanes[8]; +}; + +// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal +// (mirrors terrain_modern.vert). Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; + out vec2 vTex; out vec3 vTint; out float vFogFactor; // 1 = no fog (close), 0 = full fog (far) @@ -113,4 +140,17 @@ void main() { float fogEnd = uFogParams.y; float span = max(fogEnd - fogStart, 1e-3); vFogFactor = clamp((fogEnd - dist) / span, 0.0, 1.0); + + // Phase W Stage 4: clip the sky/weather to the OutsideView (doorway) region. + // With uTerrainClipCount == 0 (outdoor / no exit portal in view) the first loop + // is skipped and the second sets all 8 distances to +1.0 ⇒ no clipping ⇒ + // full-screen sky. Indoors with an exit portal visible, the OutsideView planes + // confine the sky to the doorway opening — exactly, per-fragment, matching the + // terrain (no scissor approximation). plane.z is 0 (a screen-space slab), so the + // sky's depth / dome radius is irrelevant. gl_Position here is the sky's own + // dome-projected clip position; the NDC-plane test is projection-independent. + for (int i = 0; i < uTerrainClipCount; ++i) + gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position); + for (int i = uTerrainClipCount; i < 8; ++i) + gl_ClipDistance[i] = 1.0; }