diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index aa6af66b..5a9cb8bd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7646,6 +7646,7 @@ public sealed class GameWindow : IDisposable playerLb, animatedIds, renderSky, + renderWeather: playerSeenOutside, kf, environOverrideActive), // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). @@ -7877,6 +7878,25 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.Scene); } } + else if (clipRoot is { IsOutdoorNode: true } + && _particleSystem is not null && _particleRenderer is not null) + { + // T3 (BR-5): unattached emitters (campfires, ground effects — + // AttachedObjectId == 0) under the OUTDOOR root. The unified + // path's attached emitters draw via the landscape slice + the + // per-cell callbacks; unattached ones had NO pass on + // outdoor-node frames (the unattached-particles-dropped- + // outdoors divergence, adjusted-confirmed). The outdoor root's + // outside view is full-screen (cone pass-all); depth test + // composites them against the world. + sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; + _particleRenderer.Draw( + _particleSystem, + camera, + camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene, + emitter => emitter.AttachedObjectId == 0); + } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // Outdoor LScape post-scene weather. Indoor weather through an exit portal is @@ -9480,6 +9500,7 @@ public sealed class GameWindow : IDisposable uint? playerLb, HashSet? animatedIds, bool renderSky, + bool renderWeather, AcDream.Core.World.SkyKeyframe kf, bool environOverrideActive) { @@ -9510,6 +9531,11 @@ public sealed class GameWindow : IDisposable _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; MaybeFlushTerrainDiag(); + // T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes + // are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the + // sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities + // is the per-slice survivor set). + DisableClipDistances(); if (sliceCtx.OutdoorEntities.Count > 0) { var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, @@ -9539,8 +9565,13 @@ public sealed class GameWindow : IDisposable && _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); } + // T3 (BR-5): weather gates on the PLAYER being outside, not the viewer + // root — retail draws weather only when is_player_outside (the rain + // cylinder rides the player; an indoor player gets NO rain even while + // looking out a doorway). Closes the rain-through-doorways divergence + // (weather-gate-player-vs-viewer, adjusted-confirmed). EnableClipDistances(); - if (renderSky) + if (renderSky && renderWeather) { _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); @@ -9620,8 +9651,12 @@ public sealed class GameWindow : IDisposable if (_visibleSceneParticleEntityIds.Count == 0) return; + // T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles + // like meshes (viewcone on the owner; depth does the pixels). The + // CellEntities set is already the cone-surviving owner list, so the + // id-predicate below IS the cone gate; the punch/seal depth discipline + // composites the pixels. DisableClipDistances(); - bool scissor = BeginDoorwayScissor(true, sliceCtx.Slice.NdcAabb); _particleRenderer.Draw( _particleSystem, camera, @@ -9629,8 +9664,6 @@ public sealed class GameWindow : IDisposable AcDream.Core.Vfx.ParticleRenderPass.Scene, emitter => emitter.AttachedObjectId != 0 && _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)); - if (scissor) - _gl!.Disable(EnableCap.ScissorTest); DisableClipDistances(); } diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index e2faaf6a..2b297da0 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -104,12 +104,17 @@ public sealed class RetailPViewRenderer // from the punch/seal depth writes + the z-buffer, and the dynamics- // last order is what makes the punch safe (the first BR-2 attempt // punched after dynamics and erased the player, reverted 88be519). - DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition); + // T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view, + // never clipped (Ghidra 0x0054c250). Built once per frame from the + // assembled slices + this frame's view-projection. + var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection); + + DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone); UseIndoorMembershipOnlyRouting(); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(pvFrame); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); - DrawDynamicsLast(ctx, partition); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); + DrawDynamicsLast(ctx, partition, viewcone); return result; } @@ -228,9 +233,10 @@ public sealed class RetailPViewRenderer // drawn here: they belong exclusively to the frame's single last // entity pass (the outdoor root's DrawDynamicsLast), which prevents // double-draws of entities inside looked-into buildings. + var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection); DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells); DrawEnvCellShells(pvFrame); - DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition); + DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone); RestoreNoClip(ctx.SetTerrainClipUbo); return result; @@ -239,7 +245,8 @@ public sealed class RetailPViewRenderer private void DrawLandscapeThroughOutsideView( RetailPViewDrawContext ctx, ClipFrameAssembly clipAssembly, - InteriorEntityPartition.Result partition) + InteriorEntityPartition.Result partition, + ViewconeCuller viewcone) { if (clipAssembly.OutsideViewSlices.Length == 0) return; @@ -249,11 +256,24 @@ public sealed class RetailPViewRenderer { _clipFrame.SetTerrainClip(slice.Planes); UploadClipFrame(ctx.SetTerrainClipUbo); - _entities.SetClipRouting(clipAssembly.CellIdToSlot, slice.Slot, outdoorVisible: true); + // T3 (BR-5): entities are never hard-clipped — retail viewcone- + // CHECKS each mesh's sphere against the view (Ghidra 0x0054c250) + // and draws it whole. The old per-slice entity clip routing + // (gl_ClipDistance via SetClipRouting) is replaced by the sphere + // pre-filter below; terrain/sky keep their per-slice plane clip. + _entities.ClearClipRouting(); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled) EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex); + + _outdoorStaticScratch.Clear(); + foreach (var e in partition.OutdoorStatic) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r)) + _outdoorStaticScratch.Add(e); + } probeSliceIndex++; - ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, partition.OutdoorStatic)); + ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch)); } // T1: retail clears the FULL depth buffer ONCE between the outside @@ -402,15 +422,38 @@ public sealed class RetailPViewRenderer // (retail draws objects per cell AFTER cells and viewcone-culls them — // PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is // T3). Drawing dynamics last is what makes the aperture punch safe. + // T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its + // cell's views; outdoor/unresolved vs the outside views (pass-all under + // the outdoor root's full-screen outside view). A dynamic in a NON-flooded + // room culls HERE — retail never reaches an object whose cell is not in + // the draw list; the partition keeps routing it so the CULL (not the + // visibility set) drops it, exactly retail's shape. private void DrawDynamicsLast( IRetailPViewCellDrawContext ctx, - InteriorEntityPartition.Result partition) + InteriorEntityPartition.Result partition, + ViewconeCuller viewcone) { if (partition.Dynamics.Count == 0) return; + _dynamicsScratch.Clear(); + foreach (var e in partition.Dynamics) + { + EntitySphere(e, out var c, out float r); + bool indoor = e.ParentCellId is uint cell + && (cell & 0xFFFFu) >= 0x0100u && (cell & 0xFFFFu) != 0xFFFFu; + bool visible = indoor + ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) + : viewcone.SphereVisibleOutside(c, r); + if (visible) + _dynamicsScratch.Add(e); + } + + if (_dynamicsScratch.Count == 0) + return; + UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, partition.Dynamics, visibleCellIds: null); + DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null); } private void DrawCellObjectLists( @@ -418,13 +461,17 @@ public sealed class RetailPViewRenderer PortalVisibilityFrame pvFrame, ClipFrameAssembly clipAssembly, HashSet drawableCells, - InteriorEntityPartition.Result partition) + InteriorEntityPartition.Result partition, + ViewconeCuller viewcone) { // T1: per-cell STATIC object lists only (dat-baked 0x40 statics) — // dynamics moved to DrawDynamicsLast. Far→near with the cells, after // the shells (retail DrawCells epilogue: PortalList = cell's views → - // DrawObjCell, Ghidra 0x005a4840). Unclipped; per-view sphere culling - // (viewconeCheck) is T3. + // DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is + // tested against ITS CELL's views (retail viewconeCheck) — the + // statics-through-walls fix: a static whose sphere is outside every + // view of its cell no longer paints through the wall (the cottage + // phantom staircase's draw path). for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; @@ -434,22 +481,51 @@ public sealed class RetailPViewRenderer if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) continue; - _oneCell.Clear(); - _oneCell.Add(cellId); + _cellStaticScratch.Clear(); + foreach (var e in bucket) + { + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInCell(cellId, c, r)) + _cellStaticScratch.Add(e); + } - // BR-2 phantom-site probe: static buckets draw unclipped + - // un-viewcone'd until T3 — log the per-cell exposure. + // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) - EmitPhantomObjsProbe(cellId, bucket.Count); + EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count); - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, bucket, _oneCell); + if (_cellStaticScratch.Count > 0) + { + _oneCell.Clear(); + _oneCell.Add(cellId); + UseIndoorMembershipOnlyRouting(); + DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + } + // T3 (BR-5): particles gate through the SAME viewcone as their + // owners — the callback receives the cone-surviving entity set, so + // an emitter attached to a culled static no longer draws through + // the wall (the candle-flames-through-walls fix). Consumed + // synchronously within this iteration (scratch list reuse). foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) - ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, bucket)); + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch)); } } + // T3 scratch lists (render thread only; cleared per use). + private readonly List _outdoorStaticScratch = new(); + private readonly List _cellStaticScratch = new(); + private readonly List _dynamicsScratch = new(); + + // Conservative bounding sphere from the entity's cached AABB — the same + // bounds source the dispatcher's frustum cull uses. + private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius) + { + if (e.AabbDirty) + e.RefreshAabb(); + center = (e.AabbMin + e.AabbMax) * 0.5f; + radius = (e.AabbMax - e.AabbMin).Length() * 0.5f; + } + // BR-2 phantom-site probe state: print-on-change per cell so the log stays // diffable while the condition persists. Throwaway apparatus — strip when // the #113 phantom residual closes. (The [phantom-shell] half died with diff --git a/src/AcDream.App/Rendering/ViewconeCuller.cs b/src/AcDream.App/Rendering/ViewconeCuller.cs new file mode 100644 index 00000000..e60e864e --- /dev/null +++ b/src/AcDream.App/Rendering/ViewconeCuller.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// T3 (BR-5): the port of retail's Render::viewconeCheck (Ghidra +/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal +/// view by a bounding-sphere test against the view's edge planes, never +/// clipped. Retail stores each view vertex with its 3D eye-edge plane +/// (view_vertex { Vec2D pt; Plane plane }, acclient.h:32483) and tests +/// the object's drawing sphere against the installed view's plane set; +/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310, +/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840). +/// +/// Our views are clip-space half-planes (≤8 per slice, +/// output: (nx,ny,0,d) satisfied when +/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space — +/// the view_vertex.plane analog, a plane through the EYE and the view edge — +/// is one matrix fold: with row-vector convention (System.Numerics), +/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P) +/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the +/// sphere when L.xyz·c + L.w ≥ −r·|L.xyz| (not entirely outside). +/// +/// A sphere is visible through a SLICE when it is not entirely outside +/// any of the slice's planes (convex region); visible for a CELL when any of +/// the cell's slices passes. A slice with zero planes is pass-all (the +/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in +/// retail an object whose cell is not in the draw list is simply never +/// reached. +/// +public sealed class ViewconeCuller +{ + private readonly Dictionary _cellPlanes = new(); + private Vector4[][] _outsidePlanes = Array.Empty(); + + /// True when the outside view is a full-screen pass-all (the + /// synthetic outdoor root) — every outside-test passes. + public bool OutsideIsFullScreen { get; private set; } + + public static ViewconeCuller Build(ClipFrameAssembly assembly, in Matrix4x4 viewProjection) + { + ArgumentNullException.ThrowIfNull(assembly); + var culler = new ViewconeCuller(); + + foreach (var (cellId, slices) in assembly.CellIdToViewSlices) + { + var lifted = new Vector4[slices.Length][]; + for (int s = 0; s < slices.Length; s++) + lifted[s] = LiftPlanes(slices[s].Planes, viewProjection); + culler._cellPlanes[cellId] = lifted; + } + + var outside = assembly.OutsideViewSlices; + var outsideLifted = new Vector4[outside.Length][]; + bool fullScreen = false; + for (int s = 0; s < outside.Length; s++) + { + outsideLifted[s] = LiftPlanes(outside[s].Planes, viewProjection); + if (outside[s].Planes.Length == 0) + fullScreen = true; + } + culler._outsidePlanes = outsideLifted; + culler.OutsideIsFullScreen = fullScreen; + return culler; + } + + private static Vector4[] LiftPlanes(Vector4[] clipPlanes, in Matrix4x4 m) + { + if (clipPlanes.Length == 0) + return Array.Empty(); + var world = new Vector4[clipPlanes.Length]; + for (int i = 0; i < clipPlanes.Length; i++) + { + var p = clipPlanes[i]; + world[i] = new Vector4( + m.M11 * p.X + m.M12 * p.Y + m.M13 * p.Z + m.M14 * p.W, + m.M21 * p.X + m.M22 * p.Y + m.M23 * p.Z + m.M24 * p.W, + m.M31 * p.X + m.M32 * p.Y + m.M33 * p.Z + m.M34 * p.W, + m.M41 * p.X + m.M42 * p.Y + m.M43 * p.Z + m.M44 * p.W); + } + return world; + } + + private static bool SphereInsidePlanes(Vector4[] planes, in Vector3 center, float radius) + { + for (int i = 0; i < planes.Length; i++) + { + var l = planes[i]; + float nLen = MathF.Sqrt(l.X * l.X + l.Y * l.Y + l.Z * l.Z); + if (nLen < 1e-12f) + continue; // degenerate plane — no constraint + float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W; + if (dist < -radius * nLen) + return false; // entirely outside this edge plane + } + return true; + } + + /// Sphere-vs-the-cell's-views: visible when any slice passes. + /// A cell with no views culls (not in the draw list ⇒ never reached in + /// retail). A zero-plane slice is pass-all. + public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius) + { + if (!_cellPlanes.TryGetValue(cellId, out var slices)) + return false; + for (int s = 0; s < slices.Length; s++) + if (SphereInsidePlanes(slices[s], center, radius)) + return true; + return false; + } + + /// Sphere-vs-the-outside-views (objects in outdoor space seen + /// from an interior root through doorways; pass-all under the outdoor + /// root's full-screen outside view). + public bool SphereVisibleOutside(in Vector3 center, float radius) + { + if (OutsideIsFullScreen) + return true; + for (int s = 0; s < _outsidePlanes.Length; s++) + if (SphereInsidePlanes(_outsidePlanes[s], center, radius)) + return true; + return false; + } + + /// Sphere vs ONE outside slice (the landscape pass draws per + /// slice; its statics pre-filter tests against exactly that slice). + public bool SphereVisibleInOutsideSlice(int sliceIndex, in Vector3 center, float radius) + { + if ((uint)sliceIndex >= (uint)_outsidePlanes.Length) + return false; + return SphereInsidePlanes(_outsidePlanes[sliceIndex], center, radius); + } +}