fix #451: stabilize portal seam rendering
All checks were successful
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m55s
CI / release (push) Successful in 2m12s

This commit is contained in:
Erik 2026-08-27 14:30:21 +02:00
parent f6fe0f2a4f
commit 1d2f2f738f
29 changed files with 1650 additions and 239 deletions

View file

@ -39,6 +39,13 @@ public enum TerrainClipMode
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Identifies one cell inside one nested building look-in. The same EnvCell can
/// be reached by more than one building PView, so a cell id alone is not a
/// sufficient routing key.
/// </summary>
public readonly record struct LookInClipCell(int FrameIndex, uint CellId);
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
/// plus routing data consumed by the render orchestration.
@ -57,6 +64,12 @@ public sealed class ClipFrameAssembly
/// <summary>Full retail portal_view slices per visible cell.</summary>
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
/// <summary>First drawable slice slot per nested look-in cell.</summary>
public Dictionary<LookInClipCell, int> LookInCellToSlot { get; } = new();
/// <summary>All retail portal_view slices per nested look-in cell.</summary>
public Dictionary<LookInClipCell, ClipViewSlice[]> LookInCellToViewSlices { get; } = new();
/// <summary>Full retail outside_view slices.</summary>
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
@ -93,6 +106,8 @@ public sealed class ClipFrameAssembly
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values)
ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
@ -101,6 +116,8 @@ public sealed class ClipFrameAssembly
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
LookInCellToSlot.Clear();
LookInCellToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
SliceScratch.Clear();
@ -363,6 +380,70 @@ public static class ClipFrameAssembler
return assembly;
}
/// <summary>
/// Appends the cell views used by nested <c>DrawBuilding -&gt; DrawPortal</c>
/// PViews to the already assembled frame. Retail installs each nested
/// cell's own <c>portal_view</c> before drawing its shell and object list;
/// these slots must therefore be published with the main frame before the
/// first draw is recorded.
/// </summary>
public static void AppendLookInFrames(
ClipFrame frame,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(lookInFrames);
System.ArgumentNullException.ThrowIfNull(assembly);
if (!ReferenceEquals(frame, assembly.Frame))
throw new System.ArgumentException(
"The look-in slots must be appended to the assembly's clip frame.",
nameof(frame));
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame lookIn = lookInFrames[frameIndex];
foreach (uint cellId in lookIn.OrderedVisibleCells)
{
if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view))
continue;
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
foreach (ViewPolygon poly in view.Polygons)
{
ClipPlaneSet cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
assembly.ScissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
ClipViewSlice[] packed = assembly.CopySlices(slices);
var key = new LookInClipCell(frameIndex, cellId);
assembly.LookInCellToViewSlices.Add(key, packed);
assembly.LookInCellToSlot.Add(key, packed[0].Slot);
}
}
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);

View file

@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink
_host.DeltaSeconds,
_host.ViewportWidth,
_host.ViewportHeight,
IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell);
IsOutdoor: world.Roots.IsAtmosphericallyOutdoor);
_published = true;
}

View file

@ -431,8 +431,7 @@ internal sealed class AtmosphericPostProcessGraph :
var environment = new DirectionalShadowEnvironmentInput(
PackEnabled: true,
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
PlayerInsideCell: world.Roots.PlayerInsideCell
|| world.Roots.CameraInsideCell,
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
source,
foundation.Atmosphere,
ActiveDayGroupMultiplier: Math.Clamp(
@ -449,7 +448,7 @@ internal sealed class AtmosphericPostProcessGraph :
// or deactivation can never leave a stale exclusion set applied to
// the dispatcher (see FoliageWindExclusions's doc comment).
worldMeshes.FoliageWindExclusions = _foliageWindExclusions;
bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell;
bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor;
AtmosphericFrameBufferBinding shadowAtmosphericFrame =
BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor);
var input = new DirectionalSunShadowRenderInput(

View file

@ -210,8 +210,7 @@ internal class DeclaredFullscreenRenderPackGraph :
var environment = new DirectionalShadowEnvironmentInput(
PackEnabled: true,
PortalOrLoginCoverVisible: foundation.PortalViewportVisible,
PlayerInsideCell: world.Roots.PlayerInsideCell
|| world.Roots.CameraInsideCell,
PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell,
source,
foundation.Atmosphere,
ActiveDayGroupMultiplier: Math.Clamp(

View file

@ -71,10 +71,8 @@ public sealed unsafe partial class ParticleRenderer
private const uint QuadStrideBytes = 4 * sizeof(float);
/// <summary>Floats per mesh-particle instance: a <c>mat4</c> plus an RGBA colour.</summary>
internal const int MeshInstanceFloats = 20;
private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float);
private static readonly uint MeshInstanceStrideBytes =
(uint)sizeof(MeshParticleGpuInstance);
/// <summary>
/// The billboard layout: the shared unit quad at vertex rate, and one
@ -103,7 +101,8 @@ public sealed unsafe partial class ParticleRenderer
// Location 6 is `in uint aTextureIndex` — an INTEGER shader input,
// so R8G8B8A8's normalized cousin would be wrong in kind. It is one
// 32-bit unsigned value; Float1 would reinterpret its bits.
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1)));
new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1),
new GpuVertexAttribute(7, GpuVertexFormat.UInt1, 68, Binding: 1)));
/// <summary>
/// The mesh-particle layout: the shared world-mesh vertex at vertex rate,
@ -126,7 +125,8 @@ public sealed unsafe partial class ParticleRenderer
new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1),
new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1),
new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1),
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1)));
new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1),
new GpuVertexAttribute(8, GpuVertexFormat.UInt1, 80, Binding: 1)));
/// <summary>
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer
while (submission.Kind == ParticleSubmissionKind.Mesh
&& _meshDrawListScratch[submission.DrawIndex].Key == meshKey);
int neededFloats = _meshRunScratch.Count * MeshInstanceFloats;
if (_meshInstanceScratch.Length < neededFloats)
_meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats];
int neededInstances = _meshRunScratch.Count;
if (_meshInstanceScratch.Length < neededInstances)
_meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256];
for (int instance = 0; instance < _meshRunScratch.Count; instance++)
{
WriteMeshGpuInstance(
_meshInstanceScratch,
instance * MeshInstanceFloats,
ref _meshInstanceScratch[instance],
_meshRunScratch[instance]);
}
GpuRingAllocation instances = WriteVertexRing<float>(
GpuRingAllocation instances = WriteVertexRing<MeshParticleGpuInstance>(
frame,
_meshInstanceScratch.AsSpan(0, neededFloats));
_meshInstanceScratch.AsSpan(0, neededInstances));
DrawMeshBatchRhi(
encoder,
frame,
global,
batch,
viewProjection,
@ -384,7 +384,13 @@ public sealed unsafe partial class ParticleRenderer
GpuRingAllocation ring = WriteVertexRing<BillboardGpuInstance>(
frame,
_instanceScratch.AsSpan(0, instances.Count));
BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes);
BindBillboardPipeline(
encoder,
frame,
viewProjection,
additive,
ring.Buffer,
ring.OffsetBytes);
encoder.DrawIndexed(
(uint)QuadIndices.Length,
(uint)instances.Count,
@ -401,6 +407,7 @@ public sealed unsafe partial class ParticleRenderer
/// </summary>
private void BindBillboardPipeline(
IGpuPassEncoder encoder,
IGpuFrame frame,
Matrix4x4 viewProjection,
bool additive,
IGpuBuffer instanceBuffer,
@ -426,10 +433,15 @@ public sealed unsafe partial class ParticleRenderer
encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0);
encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes);
encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32);
WorldFrameSectionBinding.BindClipRegions(
encoder,
_scope!.Sections,
frame);
}
private void DrawMeshBatchRhi(
IGpuPassEncoder encoder,
IGpuFrame frame,
GlobalMeshBuffer global,
ObjectRenderBatch batch,
Matrix4x4 viewProjection,
@ -472,6 +484,10 @@ public sealed unsafe partial class ParticleRenderer
"The shared mesh arena has no index store."),
0,
GpuIndexType.UInt16);
WorldFrameSectionBinding.BindClipRegions(
encoder,
_scope!.Sections,
frame);
encoder.DrawIndexed(
(uint)batch.IndexCount,
instanceCount,
@ -520,9 +536,8 @@ public sealed unsafe partial class ParticleRenderer
Array.Resize(ref _preparedInstanceOffsets, count + 256);
if (_instanceScratch.Length < count)
Array.Resize(ref _instanceScratch, count + 256);
int neededMeshFloats = count * MeshInstanceFloats;
if (_meshInstanceScratch.Length < neededMeshFloats)
_meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats];
if (_meshInstanceScratch.Length < count)
_meshInstanceScratch = new MeshParticleGpuInstance[count + 256];
int billboardCount = 0;
int meshCount = 0;
@ -541,8 +556,7 @@ public sealed unsafe partial class ParticleRenderer
{
_preparedInstanceOffsets[i] = (uint)meshCount;
WriteMeshGpuInstance(
_meshInstanceScratch,
meshCount++ * MeshInstanceFloats,
ref _meshInstanceScratch[meshCount++],
deferred.Mesh.Instance);
}
}
@ -553,9 +567,9 @@ public sealed unsafe partial class ParticleRenderer
_instanceScratch.AsSpan(0, billboardCount)))
: default;
_preparedMeshInstances = meshCount > 0
? SectionOf(WriteVertexRing<float>(
? SectionOf(WriteVertexRing<MeshParticleGpuInstance>(
frame,
_meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats)))
_meshInstanceScratch.AsSpan(0, meshCount)))
: default;
_preparedAlphaCount = count;
}
@ -591,6 +605,7 @@ public sealed unsafe partial class ParticleRenderer
{
BindBillboardPipeline(
encoder,
RequireRhiFrame(),
viewProjection,
key.Additive,
billboards,
@ -632,6 +647,7 @@ public sealed unsafe partial class ParticleRenderer
{
DrawMeshBatchRhi(
encoder,
RequireRhiFrame(),
global,
batch,
meshViewProjection,

View file

@ -47,6 +47,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public readonly uint ColorArgb;
public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot;
public readonly float DistanceSq;
public readonly uint ClipSlot;
public ParticleInstance(
Vector3 position,
@ -54,7 +55,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 axisY,
uint colorArgb,
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot,
float distanceSq)
float distanceSq,
uint clipSlot)
{
Position = position;
AxisX = axisX;
@ -62,6 +64,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ColorArgb = colorArgb;
TextureSlot = textureSlot;
DistanceSq = distanceSq;
ClipSlot = clipSlot;
}
}
@ -80,6 +83,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public Vector4 AxisY;
public Vector4 Color;
public uint TextureIndex;
public uint ClipSlot;
}
/// <summary>Vertex-instance ABI shared with particle_mesh.vert.</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MeshParticleGpuInstance
{
public Matrix4x4 Model;
public Vector4 Color;
public uint ClipSlot;
}
private readonly struct MeshParticleInstance
@ -87,12 +100,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
public readonly Matrix4x4 Model;
public readonly uint ColorArgb;
public readonly float DistanceSq;
public readonly uint ClipSlot;
public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq)
public MeshParticleInstance(
Matrix4x4 model,
uint colorArgb,
float distanceSq,
uint clipSlot)
{
Model = model;
ColorArgb = colorArgb;
DistanceSq = distanceSq;
ClipSlot = clipSlot;
}
}
@ -124,7 +143,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0);
private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256];
private float[] _meshInstanceScratch = new float[256 * 20];
private MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
@ -203,7 +222,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraRight,
cameraUp,
emitterFilter,
scopedEmitters: null);
scopedEmitters: null,
clipSlot: 0);
FinishDraw(camera, renderPass);
}
@ -213,7 +233,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
ParticleRenderPass renderPass,
IReadOnlySet<uint> attachedOwnerIds,
bool includeUnattached = false,
IReadOnlySet<uint>? excludedAttachedOwnerIds = null)
IReadOnlySet<uint>? excludedAttachedOwnerIds = null,
uint clipSlot = 0)
{
if (camera is null)
return;
@ -233,7 +254,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraRight,
cameraUp,
emitterFilter: null,
_scopedEmitterScratch);
_scopedEmitterScratch,
clipSlot);
FinishDraw(camera, renderPass);
}
@ -332,7 +354,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 cameraRight,
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter,
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters)
IReadOnlyList<RuntimeParticleEmitter>? scopedEmitters,
uint clipSlot)
{
var draws = _drawListScratch;
draws.Clear();
@ -348,6 +371,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
cameraWorldPos,
cameraRight,
cameraUp,
clipSlot,
ref sequence);
}
return;
@ -356,7 +380,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass))
{
if (emitterFilter is null || emitterFilter(emitter))
AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence);
AppendEmitterDraws(
emitter,
cameraWorldPos,
cameraRight,
cameraUp,
clipSlot,
ref sequence);
}
}
@ -365,6 +395,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Vector3 cameraWorldPos,
Vector3 cameraRight,
Vector3 cameraUp,
uint clipSlot,
ref int sequence)
{
List<ParticleDraw> draws = _drawListScratch;
@ -385,7 +416,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence))
&& TryAppendMeshDraws(
em,
p,
gfxObjId,
cameraWorldPos,
clipSlot,
ref sequence))
{
continue;
}
@ -483,7 +520,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
axisY,
p.ColorArgb,
gfxInfo.TextureSlot,
distSq)));
distSq,
clipSlot)));
_submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard,
drawIndex,
@ -497,6 +535,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
Particle particle,
uint gfxObjId,
Vector3 cameraWorldPosition,
uint clipSlot,
ref int sequence)
{
if (_meshAdapter is null || !MeshParticlesAvailable)
@ -520,7 +559,11 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
model,
cameraWorldPosition);
float distanceSq = viewerDistance * viewerDistance;
var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq);
var instance = new MeshParticleInstance(
model,
particle.ColorArgb,
distanceSq,
clipSlot);
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
@ -581,35 +624,24 @@ public sealed unsafe partial class ParticleRenderer : IDisposable
TextureIndex = particle.TextureSlot.IsAssigned
? particle.TextureSlot.Index
: NoTextureSlot,
ClipSlot = particle.ClipSlot,
};
}
private static void WriteMeshGpuInstance(
float[] destination,
int offset,
ref MeshParticleGpuInstance destination,
MeshParticleInstance instance)
{
Matrix4x4 model = instance.Model;
destination[offset + 0] = model.M11;
destination[offset + 1] = model.M12;
destination[offset + 2] = model.M13;
destination[offset + 3] = model.M14;
destination[offset + 4] = model.M21;
destination[offset + 5] = model.M22;
destination[offset + 6] = model.M23;
destination[offset + 7] = model.M24;
destination[offset + 8] = model.M31;
destination[offset + 9] = model.M32;
destination[offset + 10] = model.M33;
destination[offset + 11] = model.M34;
destination[offset + 12] = model.M41;
destination[offset + 13] = model.M42;
destination[offset + 14] = model.M43;
destination[offset + 15] = model.M44;
destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f;
destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f;
destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f;
destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f;
destination = new MeshParticleGpuInstance
{
Model = instance.Model,
Color = new Vector4(
((instance.ColorArgb >> 16) & 0xFF) / 255f,
((instance.ColorArgb >> 8) & 0xFF) / 255f,
(instance.ColorArgb & 0xFF) / 255f,
((instance.ColorArgb >> 24) & 0xFF) / 255f),
ClipSlot = instance.ClipSlot,
};
}
private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch)

View file

@ -26,11 +26,21 @@ public sealed class PortalVisibilityFrame
private int _processedViewCountsUnderusedFrames;
private int _orderedVisibleCellsUnderusedFrames;
private int _todoUnderusedFrames;
private int _exteriorSeedPortalsUnderusedFrames;
internal PortalPolygonVertexStore PolygonVertices => _polygonVertices;
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
// Interior-root look-ins are constructed one building at a time. Keep
// the source identity on the retained frame so the landscape pass can
// pair retail's portal-only traversal with that building's own exterior
// shell instead of repainting every nearby shell after every look-in.
// Building ids are publication-local, so the landblock is part of the
// identity. An unstamped building uses its seed cell id as the key.
internal uint SourceBuildingKey { get; set; }
internal uint SourceBuildingLandblockId { get; set; }
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; private set; } = new();
@ -49,6 +59,15 @@ public sealed class PortalVisibilityFrame
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
/// <summary>
/// Exact outside-facing building portals that successfully seeded this
/// exterior construction, together with the clipped view region produced
/// by that portal. Retail's DrawBuilding pass punches these CBldPortal
/// apertures themselves; it does not infer them later from every exit on a
/// cell reached by the resulting flood.
/// </summary>
public List<ExteriorPortalSeed> ExteriorSeedPortals { get; } = new();
// Build scratch belongs to the frame so a caller that reuses a frame also reuses the large
// hash tables and the 128-entry convergence trace. This is especially important outdoors,
// where retail runs one small ConstructView flood per nearby building every frame.
@ -114,6 +133,7 @@ public sealed class PortalVisibilityFrame
int processedViewCount = ProcessedViewCountsScratch.Count;
int orderedVisibleCellCount = OrderedVisibleCells.Count;
int todoCount = TodoScratch.Count;
int exteriorSeedPortalCount = ExteriorSeedPortals.Count;
if (OutsideView.IsRetainable)
OutsideView.Reset();
@ -121,9 +141,14 @@ public sealed class PortalVisibilityFrame
OutsideView = new CellView();
ReturnCellViews(CellViews);
ReturnCellViews(CrossBuildingViews);
for (int index = 0; index < ExteriorSeedPortals.Count; index++)
ReturnCellView(ExteriorSeedPortals[index].View);
CellViews.Clear();
OrderedVisibleCells.Clear();
CrossBuildingViews.Clear();
ExteriorSeedPortals.Clear();
SourceBuildingKey = 0;
SourceBuildingLandblockId = 0;
QueuedScratch.Clear();
DrawListedScratch.Clear();
ProcessedViewCountsScratch.Clear();
@ -152,6 +177,10 @@ public sealed class PortalVisibilityFrame
orderedVisibleCellCount,
ref _orderedVisibleCellsUnderusedFrames);
TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames);
TrimIfCold(
ExteriorSeedPortals,
exteriorSeedPortalCount,
ref _exteriorSeedPortalsUnderusedFrames);
}
internal ViewPolygon CopyPolygon(ReadOnlySpan<Vector2> vertices)
@ -178,11 +207,14 @@ public sealed class PortalVisibilityFrame
private void ReturnCellViews(Dictionary<uint, CellView> views)
{
foreach (CellView view in views.Values)
{
view.Reset();
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
_cellViewPool.Push(view);
}
ReturnCellView(view);
}
private void ReturnCellView(CellView view)
{
view.Reset();
if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews)
_cellViewPool.Push(view);
}
private static void TrimIfCold<TKey, TValue>(
@ -243,6 +275,15 @@ public sealed class PortalVisibilityFrame
}
}
/// <summary>
/// One outside-facing portal accepted as an exterior building-view seed.
/// <paramref name="View"/> is the exact installed-view-clipped aperture.
/// </summary>
public readonly record struct ExteriorPortalSeed(
uint CellId,
int PortalIndex,
CellView View);
public static class PortalVisibilityBuilder
{
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
@ -710,10 +751,27 @@ public static class PortalVisibilityBuilder
// ever built from a knife-edge aperture.
if (i < cell.ClipPlanes.Count)
{
if (CameraOnInteriorSide(cell, i, cameraPos))
continue;
if (EyeInPlaneOfPortal(cell, i, cameraPos))
continue;
// Do NOT reuse the ordinary EnvCell traversal tolerance
// here. PortalSideEpsilon deliberately admits a 1 cm
// stale-root margin, but retail ConstructView(CBldPortal)
// classifies the seed with Sidedness's exact F_EPSILON
// (0.0002 m). At Sanctuary the 0104 and 0106 exterior
// planes coincide; the chase eye can sit ~4 mm outside the
// opposite cathedral half while its EnvCell root remains
// 0104. Applying the 1 cm margin calls that eye "inside"
// 0106 and drops the entire look-in flood, exposing the
// landscape/waterfall through half the cathedral.
if (CameraOnInteriorSide(
cell,
i,
cameraPos,
SeedInPlaneEpsilon))
{
continue;
}
}
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
@ -741,6 +799,17 @@ public static class PortalVisibilityBuilder
if (clippedRegion.Count == 0)
continue;
// Preserve the exact CBldPortal that produced this view. The
// renderer must punch only accepted seed apertures; iterating
// every OtherCellId==0xFFFF portal on the reached cell turns a
// single accepted opening into unrelated far-depth holes.
var seedPortalView = frame.RentCellView();
AddRegion(seedPortalView, clippedRegion);
frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed(
cell.CellId,
i,
seedPortalView));
var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId);
bool grew = AddRegion(seedView, clippedRegion);
@ -1068,13 +1137,17 @@ public static class PortalVisibilityBuilder
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
private static bool CameraOnInteriorSide(
LoadedCell cell,
int portalIndex,
Vector3 cameraPos,
float epsilon = PortalSideEpsilon)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon;
}
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject

View file

@ -134,6 +134,8 @@ internal sealed class RetailPViewPassExecutor :
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly RetailPViewParticleClassifications _particleClassifications = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
private readonly Dictionary<uint, int> _singleCellClipRouting = new(1);
private readonly Dictionary<uint, int> _noCellClipRouting = new(0);
/// <summary>
/// Borrowed until the next late landscape pass. The outdoor-root post-world
@ -207,6 +209,8 @@ internal sealed class RetailPViewPassExecutor :
{
List<Exception>? failures = null;
TryAbort(_frameGlState.RestoreFrameDefaults);
TryAbort(() => _envCells.SetClipRouting(null));
TryAbort(_entities.ClearClipRouting);
TryAbort(_particleClassifications.BeginFrame);
TryAbort(_noSceneParticleEntityIds.Clear);
if (failures is { Count: > 0 })
@ -230,6 +234,11 @@ internal sealed class RetailPViewPassExecutor :
ClipFrameAssembly reuseAssembly) =>
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
public void AppendLookInClipFrames(
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly) =>
ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly);
public void PrepareClipFrame(int terrainUploadCount) =>
_surface.PrepareClipFrame(terrainUploadCount);
@ -246,6 +255,24 @@ internal sealed class RetailPViewPassExecutor :
_entities.ClearClipRouting();
}
public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice)
{
_singleCellClipRouting.Clear();
_singleCellClipRouting.Add(cellId, slice.Slot);
_envCells.SetClipRouting(_singleCellClipRouting);
// Retail DrawMesh only viewcone-checks an object's sphere under the
// installed PortalList and then draws the mesh whole. Hard clipping the
// object here slices a stationary player when the chase camera crosses
// into the opposite cathedral cell while the player remains behind.
_entities.ClearClipRouting();
}
private void UseOutdoorPortalViewRouting(ClipViewSlice slice) =>
_entities.SetClipRouting(
_noCellClipRouting,
outdoorSlot: slice.Slot,
outdoorVisible: true);
public void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds) =>
@ -376,6 +403,7 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@ -418,8 +446,7 @@ internal sealed class RetailPViewPassExecutor :
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
if (!frame.RootCell.IsOutdoorNode
&& _particleClassifications.Outdoor.Count > 0
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
@ -427,7 +454,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor);
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
EnableClipDistances();
@ -456,6 +484,77 @@ internal sealed class RetailPViewPassExecutor :
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
public void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context)
{
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
_surface.BindTerrainClip();
DisableClipDistances();
_particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds);
if (_particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor,
clipSlot: (uint)context.Slice.Slot);
}
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
public void DrawLandscapeBuildingShellSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeBuildingShellSliceContext context)
{
UseOutdoorPortalViewRouting(context.Slice);
bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb);
_surface.BindTerrainClip();
DisableClipDistances();
if (context.EntityDraw is RenderFrameEntityDrawRequest request)
{
RenderFrameView drawView = request.View;
_entities.DrawPackedProductionRoute(
frame.Camera,
in drawView,
request.Route,
request.RouteIndex,
request.CellId,
request.TupleLandblockId);
}
else if (context.BuildingShells.Count > 0)
{
var buildingEntry = (
frame.PlayerLandblockId ?? 0u,
Vector3.Zero,
Vector3.Zero,
context.BuildingShells,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
frame.Camera,
new[] { buildingEntry },
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
visibleCellIds: null,
animatedEntityIds: frame.AnimatedEntityIds);
}
if (scissor)
_surface.EndScissor();
_entities.ClearClipRouting();
DisableClipDistances();
}
@ -468,10 +567,13 @@ internal sealed class RetailPViewPassExecutor :
public void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) =>
DrawPortalDepthWrite(context, frame, forceFarZ: true);
RetailPViewCellSliceContext context,
int portalIndex) =>
DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex);
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame)
public void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
ClipViewSlice slice)
{
if (_particles is null || _particleRenderer is null)
return;
@ -482,7 +584,8 @@ internal sealed class RetailPViewPassExecutor :
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true);
includeUnattached: true,
clipSlot: (uint)slice.Slot);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
@ -509,7 +612,8 @@ internal sealed class RetailPViewPassExecutor :
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible);
visible,
clipSlot: (uint)context.Slice.Slot);
DisableClipDistances();
}
@ -552,7 +656,8 @@ internal sealed class RetailPViewPassExecutor :
private void DrawPortalDepthWrite(
RetailPViewCellSliceContext context,
RetailPViewFrameInput frame,
bool forceFarZ)
bool forceFarZ,
int? onlyPortalIndex = null)
{
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
// Main interior roots stamp true depth (seal); outdoor and look-in
@ -566,6 +671,8 @@ internal sealed class RetailPViewPassExecutor :
Span<Vector3> world = stackalloc Vector3[32];
for (int index = 0; index < cell.Portals.Count; index++)
{
if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value)
continue;
if (cell.Portals[index].OtherCellId != 0xFFFF)
continue;
if (index >= cell.PortalPolygons.Count)

View file

@ -50,8 +50,10 @@ public sealed class RetailPViewRenderer
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
// #131/#132: landscape scene-particle owner survivors. With building
// look-ins, static owners use the pre-building alpha barrier and the late
// phase contains only outside-stage dynamics; otherwise the late phase
// carries both sets.
private readonly HashSet<uint> _lateParticleOwnerScratch = new();
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new();
@ -144,6 +146,7 @@ public sealed class RetailPViewRenderer
var clipAssembly = passes.AssembleClipFrame(
pvFrame,
_clipAssemblyScratch);
passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
passes.PrepareClipFrame(terrainUploadCount);
@ -413,6 +416,9 @@ public sealed class RetailPViewRenderer
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
LoadedCell sourceCell = group[0];
frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId;
frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u;
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
else
@ -454,65 +460,67 @@ public sealed class RetailPViewRenderer
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
// landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's
// nested portal_view before DrawEnvCell, while DrawMesh iterates that same
// PortalList for cell objects. Preserve that per-slice gate here; drawing a
// nested cell whole lets its floor, details, and emitters escape the authored
// aperture even when the outer depth choreography is otherwise correct.
private void DrawBuildingLookIns(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
int outsideSliceCount = clipAssembly.OutsideViewSlices.Length;
int lookInRouteIndex = 0;
for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains every
// alpha submission accumulated by the preceding building before
// punching the next building's portals. The first building uses
// the pre-look-in barrier in DrawLandscapeThroughOutsideView.
if (frameIndex > 0)
passes.FlushLandscapeAlpha();
// Pass 1: far-Z punch every aperture of this building.
foreach (uint cellId in frame.OrderedVisibleCells)
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
foreach (var poly in seed.View.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
cellId,
seed.CellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
NoParticleOwners));
NoParticleOwners),
seed.PortalIndex);
}
}
// Pass 2: shells + statics, far→near.
passes.UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and
// lighting is per-instance CellId-keyed) — was one heavy per-frame
// Render per cell. Per-cell entity/particle work stays in the loop.
_shellBatch.Clear();
foreach (uint cid in frame.OrderedVisibleCells)
_shellBatch.Add(cid);
if (_shellBatch.Count > 0)
passes.DrawOpaqueCellShells(_shellBatch);
// Pass 2: shells + objects, far→near, once per portal_view slice.
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
// Opaque shell batched above. Transparent stays per-cell (far→near)
// for correct compositing; skipped for opaque-only cells.
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? cellSlices)
|| cellSlices.Length == 0)
{
continue;
}
_cellStaticScratch.Clear();
if (partition is not null
@ -528,8 +536,7 @@ public sealed class RetailPViewRenderer
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// pc:432878+), i.e. right here in the landscape stage.
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
@ -541,48 +548,154 @@ public sealed class RetailPViewRenderer
_cellStaticScratch.Add(e);
}
if (frameEntityPasses is not null)
foreach (ClipViewSlice slice in cellSlices)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
i,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
int routeIndex = lookInRouteIndex++;
passes.UseCellPortalViewRouting(cellId, slice);
_oneCell.Clear();
_oneCell.Add(cellId);
passes.DrawOpaqueCellShells(_oneCell);
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
i,
cellId,
_cellStaticScratch);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
i,
cellId,
_cellStaticScratch,
_oneCell);
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch,
_oneCell);
// The nested DrawCells object pass includes emitters and
// retains the exact setup_view clip until alpha playback.
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, slice, _cellParticleOwnerScratch));
}
}
}
// The ordinary exterior building shell is clipped by the outer
// outside_view, not by the nested cell PortalList.
passes.UseIndoorMembershipOnlyRouting();
// Retail's ordinary shell pass immediately follows this same
// building's portal-only pass. Pair by the shell's authored
// anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby.
int sliceIndex = 0;
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{
int shellRouteIndex = LookInBuildingShellRouteIndex(
frameIndex,
outsideSliceCount,
sliceIndex);
_buildingShellScratch.Clear();
if (partition is not null)
{
foreach (WorldEntity entity in partition.OutdoorStatic)
{
if (!entity.IsBuildingShell
|| FindLookInFrameIndex(
entity.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) != frameIndex)
{
continue;
}
EntitySphere(entity, out Vector3 center, out float radius);
if (viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
center,
radius))
{
_buildingShellScratch.Add(entity);
}
}
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
_buildingShellScratch);
bool hasPackedShell = frameEntityPasses is not null
&& HasExactRoute(
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
if (hasPackedShell || _buildingShellScratch.Count > 0)
{
RenderFrameEntityDrawRequest? shellDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
ctx.PlayerLandblockId ?? 0);
passes.DrawLandscapeBuildingShellSlice(
ctx,
new RetailPViewLandscapeBuildingShellSliceContext(
slice,
_buildingShellScratch)
{
EntityDraw = shellDraw,
});
_lateParticleOwnerScratch.Clear();
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
}
else
{
ReplaceOwnerIds(
_lateParticleOwnerScratch,
_buildingShellScratch);
}
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
}
sliceIndex++;
}
}
}
@ -598,18 +711,13 @@ public sealed class RetailPViewRenderer
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
// #131/#132: retail drains the remaining landscape alpha after
// LScape::draw (DrawCells pc:432720), while each DrawBuilding is also
// an earlier alpha barrier before its portal traversal (pc:427954).
// Our dispatcher batches outdoor content, so the stage is split into:
// EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha
// barrier; building look-ins; then LATE outside-stage dynamics,
// remaining particles, and weather; followed by the outer flush.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -628,6 +736,14 @@ public sealed class RetailPViewRenderer
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
probeSliceIndex,
@ -663,15 +779,81 @@ public sealed class RetailPViewRenderer
});
}
// Retail DrawBuilding flushes every alpha submission accumulated before
// the building immediately before its portal-only traversal
// (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is
// essential at open-air seams: foliage and static emitters encountered
// before the building must not be flushed after the look-in cell floor
// and repaint it. Our outdoor statics are one retained batch rather than
// retail's BSP-by-building walk, so use one barrier before the first
// look-in; DrawBuildingLookIns adds the corresponding barrier between
// each later building pair. Submit the early static owners' particles
// into the same alpha queue first; their mesh alpha was already
// submitted by the EARLY entity route above.
bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns)
{
int barrierSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
// Ownerless outdoor emitters cannot ride an entity route. Retail
// draws their meshes once for every installed outside_view;
// retain that slot through deferred alpha playback.
passes.DrawUnattachedSceneParticles(ctx, slice);
_lateParticleOwnerScratch.Clear();
if (partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
barrierSliceIndex,
c,
r))
{
_lateParticleOwnerScratch.Add(e.Id);
}
}
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
barrierSliceIndex,
0);
}
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
slice,
_lateParticleOwnerScratch));
barrierSliceIndex++;
}
passes.FlushLandscapeAlpha();
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// drawn), strictly BEFORE the outer depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(
ctx,
passes,
clipAssembly,
partition,
viewcone,
frameEntityPasses,
in frameView);
@ -687,8 +869,8 @@ public sealed class RetailPViewRenderer
passes.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
if (partition is not null)
_lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins
if (!hasBuildingLookIns && partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
@ -712,12 +894,19 @@ public sealed class RetailPViewRenderer
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0);
if (hasBuildingLookIns)
{
_lateParticleOwnerScratch.Clear();
}
else
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0);
}
RenderFrameRouteOwnerSelector.Union(
_lateParticleOwnerScratch,
in frameView,
@ -753,13 +942,19 @@ public sealed class RetailPViewRenderer
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
passes.DrawUnattachedSceneParticles(ctx);
// to ride any of the id-filtered particle passes. Draw once per
// installed outside_view for BOTH root kinds, matching retail's
// landscape-stage placement and preserving the slot in each deferred
// draw. The former outdoor-root post-world tail ran after building
// cells and let exterior alpha repaint the cathedral transition.
// With no look-ins they drain at the end of the landscape stage; the
// look-in path submits them at its pre-building barrier so later opaque
// cell floors can cover them.
if (!hasBuildingLookIns)
{
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
passes.DrawUnattachedSceneParticles(ctx, slice);
}
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear.
@ -779,6 +974,58 @@ public sealed class RetailPViewRenderer
passes.UseIndoorMembershipOnlyRouting();
}
internal static int LookInBuildingShellRouteIndex(
int frameIndex,
int outsideSliceCount,
int sliceIndex) =>
checked((frameIndex * outsideSliceCount) + sliceIndex);
internal static int FindLookInFrameIndex(
uint buildingShellAnchorCellId,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
IRetailPViewCellSource cells)
{
if (buildingShellAnchorCellId == 0)
return -1;
LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId);
if (anchorCell is null)
return -1;
uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId;
uint landblockId = anchorCell.CellId & 0xFFFF0000u;
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = lookInFrames[frameIndex];
if (frame.SourceBuildingKey == buildingKey
&& frame.SourceBuildingLandblockId == landblockId)
{
return frameIndex;
}
}
return -1;
}
private static bool HasExactRoute(
in RenderFrameView view,
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId)
{
foreach (RenderFrameCandidateRange range in view.RouteRanges)
{
if (range.Route == route
&& range.RouteIndex == routeIndex
&& range.CellId == cellId
&& range.Count > 0)
{
return true;
}
}
return false;
}
private void DrawExitPortalMasks(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
@ -1180,6 +1427,7 @@ public sealed class RetailPViewRenderer
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _buildingShellScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
@ -1343,10 +1591,14 @@ public interface IRetailPViewPassExecutor
ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly);
void AppendLookInClipFrames(
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly);
void PrepareClipFrame(int terrainUploadCount);
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
void ClearClipRouting();
void UseIndoorMembershipOnlyRouting();
void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice);
void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds);
@ -1363,11 +1615,22 @@ public interface IRetailPViewPassExecutor
ClipViewSlice slice,
int sliceIndex);
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context);
void DrawLandscapeBuildingShellSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeBuildingShellSliceContext context);
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
void ClearInteriorDepth();
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawUnattachedSceneParticles(RetailPViewFrameInput frame);
void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context,
int portalIndex);
void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
ClipViewSlice slice);
void FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
@ -1690,9 +1953,27 @@ public readonly record struct RetailPViewLandscapeSliceContext(
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <summary>
/// Outdoor-static emitters submitted at retail's pre-building alpha barrier.
/// Mesh alpha for the same owners is already queued by the early landscape
/// entity route.
/// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext(
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>Retail DrawBuilding's ordinary exterior-shell pass, issued after
/// the same building's portal-only look-in traversal.</summary>
public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> BuildingShells)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
/// outside-stage dynamics to mesh-draw, plus the particle owners not already
/// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,

View file

@ -30,6 +30,7 @@ internal readonly record struct CurrentRenderProjectionFingerprint(
internal enum CurrentRenderPViewRoute : byte
{
LandscapeOutdoorStatic,
LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,

View file

@ -13,6 +13,7 @@ internal enum RenderFrameBlendClass : byte
internal enum RenderFrameCandidateRoute : byte
{
LandscapeOutdoorStatic,
LandscapeBuildingShell,
LandscapeOutsideDynamic,
LookInObject,
CellStatic,

View file

@ -992,6 +992,8 @@ internal sealed class RenderScenePViewFrameProductController :
{
CurrentRenderPViewRoute.LandscapeOutdoorStatic =>
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
CurrentRenderPViewRoute.LandscapeBuildingShell =>
RenderFrameCandidateRoute.LandscapeBuildingShell,
CurrentRenderPViewRoute.LandscapeOutsideDynamic =>
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
CurrentRenderPViewRoute.LookInObject =>
@ -1157,7 +1159,21 @@ internal sealed class RenderScenePViewFrameBuilder
LoadSceneIndices(input.Scene);
BuildOutdoorRoutes(writer, in input);
BuildLookInRoutes(writer, in input);
int lookInRouteIndex = 0;
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
{
BuildLookInRoutes(
writer,
in input,
frameIndex,
ref lookInRouteIndex);
BuildLookInBuildingShellRoutes(
writer,
in input,
frameIndex);
}
BuildOutsideDynamicRoutes(writer, in input);
BuildCellStaticRoute(writer, in input);
BuildDynamicLastRoute(writer, in input);
@ -1279,6 +1295,14 @@ internal sealed class RenderScenePViewFrameBuilder
for (int i = 0; i < _outdoorCount; i++)
{
RenderProjectionRecord record = _outdoor[i];
if (record.EntityPayload.IsBuildingShell
&& RetailPViewRenderer.FindLookInFrameIndex(
record.Source.BuildingShellAnchorCellId,
input.LookInFrames,
input.Cells) >= 0)
{
continue;
}
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
@ -1304,22 +1328,84 @@ internal sealed class RenderScenePViewFrameBuilder
}
}
private void BuildLookInBuildingShellRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input,
int frameIndex)
{
int sliceCount = input.ClipAssembly.OutsideViewSlices.Length;
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
{
int count = 0;
EnsureCapacity(ref _survivors, _outdoorCount);
for (int i = 0; i < _outdoorCount; i++)
{
RenderProjectionRecord record = _outdoor[i];
if (!record.EntityPayload.IsBuildingShell
|| RetailPViewRenderer.FindLookInFrameIndex(
record.Source.BuildingShellAnchorCellId,
input.LookInFrames,
input.Cells) != frameIndex)
{
continue;
}
Sphere(in record, out Vector3 center, out float radius);
if (!input.Viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
in center,
radius))
{
continue;
}
_survivors[count++] = record;
writer.AddOutdoor(in record);
AddProjection(
writer,
in record,
input.AnimatedEntityIds);
}
int routeIndex =
RetailPViewRenderer.LookInBuildingShellRouteIndex(
frameIndex,
sliceCount,
sliceIndex);
writer.AddRouteRange(
RenderFrameCandidateRoute.LandscapeBuildingShell,
routeIndex,
0,
_survivors.AsSpan(0, count));
}
}
private void BuildLookInRoutes(
RenderFrameWriter writer,
in RenderScenePViewBuildInput input)
in RenderScenePViewBuildInput input,
int frameIndex,
ref int routeIndex)
{
for (int frameIndex = 0;
frameIndex < input.LookInFrames.Count;
frameIndex++)
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
PortalVisibilityFrame frame = input.LookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
uint cellId = frame.OrderedVisibleCells[i];
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? slices)
|| slices.Length == 0)
{
uint cellId = frame.OrderedVisibleCells[i];
int count = LoadCell(
input.Scene,
cellId,
includeDynamics: true);
continue;
}
int count = LoadCell(
input.Scene,
cellId,
includeDynamics: true);
for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
{
int currentRouteIndex = routeIndex++;
if (count == 0)
continue;
@ -1330,7 +1416,7 @@ internal sealed class RenderScenePViewFrameBuilder
input.AnimatedEntityIds);
writer.AddRouteRange(
RenderFrameCandidateRoute.LookInObject,
i,
currentRouteIndex,
cellId,
_cell.AsSpan(0, count));
}

View file

@ -14,6 +14,23 @@ layout(location = 5) in vec4 aColor;
// (ACDREAM_TEXTURE_HANDLE, injected by
// tools/ShaderCompiler/VulkanGlslPreamble.cs).
layout(location = 6) in uint aTextureIndex;
layout(location = 7) in uint aClipSlot;
struct CellClip {
uint count;
uint _p0;
uint _p1;
uint _p2;
vec4 planes[8];
};
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
CellClip clipRegions[];
};
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
uniform mat4 uViewProjection;
@ -35,4 +52,9 @@ void main() {
// stage, which is the only form Vulkan can express.
vTextureIndex = aTextureIndex;
gl_Position = uViewProjection * vec4(world, 1.0);
CellClip clip = clipRegions[aClipSlot];
for (uint i = 0u; i < clip.count; ++i)
gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
for (uint i = clip.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
}

View file

@ -5,6 +5,23 @@ layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTexCoord;
layout(location = 3) in mat4 aModel;
layout(location = 7) in vec4 aColor;
layout(location = 8) in uint aClipSlot;
struct CellClip {
uint count;
uint _p0;
uint _p1;
uint _p2;
vec4 planes[8];
};
layout(std430, binding = 2) readonly buffer ClipRegionBuf {
CellClip clipRegions[];
};
out gl_PerVertex {
vec4 gl_Position;
float gl_ClipDistance[8];
};
uniform mat4 uViewProjection;
@ -15,4 +32,9 @@ void main() {
vTexCoord = aTexCoord;
vColor = aColor;
gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0);
CellClip clip = clipRegions[aClipSlot];
for (uint i = 0u; i < clip.count; ++i)
gl_ClipDistance[i] = dot(clip.planes[i], gl_Position);
for (uint i = clip.count; i < 8u; ++i)
gl_ClipDistance[i] = 1.0;
}

View file

@ -263,7 +263,7 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "921c32617708b3931a6304b4697ee96d728077d96f489d6c09eea4c5fe225b63",
"sourceSha256": "2bdd7114223164916a87e7d2e6df8a21fdcbd1cad082fafb3102b8a0fd4a2a8b",
"compiled": true
},
{
@ -279,7 +279,7 @@
"stages": [
{
"stage": "vert",
"sourceSha256": "19db8757c1a61a2fbec8e56ce89d56b6dd6d66a123cedcdae40915af07d58c0e",
"sourceSha256": "582e9be4bca8bd1807d5a2152b211d1d4dd1ed54a6efe4240f52d402e628be75",
"compiled": true
},
{

View file

@ -43,6 +43,21 @@ internal readonly record struct WorldRootFrame(
bool PlayerIndoorGate)
{
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
/// <summary>
/// The camera has an EnvCell root whose authored visibility does not reach
/// outdoors. SeenOutside cells are open-air presentation cells, not an
/// indoor environment gate merely because they have portal geometry.
/// </summary>
public bool CameraInsideEnclosedCell => CameraInsideCell && !RootSeenOutside;
/// <summary>Environment gate shared by directional shadows and other
/// outdoor-only render-pack effects.</summary>
public bool PlayerOrCameraInsideEnclosedCell =>
PlayerInsideCell || CameraInsideEnclosedCell;
public bool IsAtmosphericallyOutdoor =>
RenderSky && !CameraInsideEnclosedCell;
}
/// <summary>Borrowed building scratch, valid only until the next build.</summary>

View file

@ -221,17 +221,10 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor
return AppendSignature(currentSignature, "global");
}
if (clipRoot.IsOutdoorNode)
{
_particleRenderer.DrawForOwners(
camera.Camera,
camera.Position,
ParticleRenderPass.Scene,
outdoorOwnerIds,
includeUnattached: true);
return AppendSignature(currentSignature, "unattached");
}
// Every PView root, including the outdoor sentinel, now submits scene
// particles inside LScape::draw. Replaying them here is both a duplicate
// and too late: it occurs after nested building cells, allowing exterior
// waterfall/foliage alpha to repaint an indoor/outdoor transition.
return currentSignature;
}