merge: bring main into claude/hopeful-maxwell-214a12 (LayoutDesc importer branch)
main was 65 commits ahead of this branch's fork point. Only conflict was the divergence register: both sides appended an 'AP-32' row. Resolved by keeping main's AP-32..AP-36 (cell-shell lift, look-in cells, alpha deferral, dungeon streaming, point lights) and renumbering the importer's row to AP-37; AP header count -> 37. GameWindow.cs auto-merged cleanly. Verified: AcDream.App builds 0/0; AcDream.App.Tests 354 passed / 1 skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5ac9d8c19c
53 changed files with 6691 additions and 439 deletions
|
|
@ -1015,21 +1015,36 @@ public sealed class GameWindow : IDisposable
|
|||
// integrates gravity against an empty world and free-falls
|
||||
// the player into the void (retail loads cells synchronously;
|
||||
// this is the async-streaming equivalent of that invariant).
|
||||
isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)
|
||||
&& _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
|
||||
// #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
|
||||
// additionally waits for the claimed cell's hydration so the
|
||||
// entry snap's AdjustPosition validation can act (retail loads
|
||||
// the cell synchronously before SetPosition; this is the
|
||||
// async-streaming equivalent). Claims that can never hydrate
|
||||
// (id outside the landblock's NumCells range per the dat)
|
||||
// don't hold the gate — the Resolve-head safety net demotes
|
||||
// them loudly.
|
||||
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
|| sp.Position is not { } spawnClaim
|
||||
|| spawnClaim.LandblockId == 0
|
||||
|| _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
|
||||
|| IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
|
||||
isSpawnGroundReady: () =>
|
||||
{
|
||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
|
||||
|
||||
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
|
||||
// INDOOR claim (sealed dungeon / building interior) the ground the
|
||||
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
|
||||
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
|
||||
// A dungeon's cells sit in their landblock at an arbitrary (often
|
||||
// negative) offset, so the spawn's WORLD position can fall in a
|
||||
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
|
||||
// deliberately does not load; requiring terrain there hangs login
|
||||
// forever (cellReady true, SampleTerrainZ null). Retail loads the
|
||||
// cell synchronously and places the player on the cell floor —
|
||||
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
|
||||
// (Before #135 this only passed by accident: the 25×25 window
|
||||
// happened to stream the neighbour terrain.)
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
&& sp.Position is { } spawnClaim
|
||||
&& spawnClaim.LandblockId != 0
|
||||
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
|
||||
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
|
||||
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
|
||||
|
||||
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
|
||||
// an outdoor position: hold until the terrain under the spawn streams
|
||||
// (the original #106 gate — entering against an empty world free-falls
|
||||
// the player into the void).
|
||||
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
|
||||
},
|
||||
enterPlayerMode: EnterPlayerModeFromAutoEntry);
|
||||
}
|
||||
|
||||
|
|
@ -2086,6 +2101,7 @@ public sealed class GameWindow : IDisposable
|
|||
state: _worldState,
|
||||
nearRadius: _nearRadius,
|
||||
farRadius: _farRadius,
|
||||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||
removeTerrain: id =>
|
||||
{
|
||||
// Phase G.2: release any LightSources attached to entities
|
||||
|
|
@ -2608,6 +2624,57 @@ public sealed class GameWindow : IDisposable
|
|||
// landblock; each neighbor landblock is offset by 192 units per step.
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
|
||||
// G.3 (#133): recenter streaming onto the player's spawn landblock at
|
||||
// login. The streaming center (_liveCenterX/_liveCenterY) is pinned to
|
||||
// the startup default (Holtburg, 0xA9B4) and is otherwise only moved by
|
||||
// the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A
|
||||
// character saved INSIDE a far dungeon spawns with that dungeon's
|
||||
// landblock id, but the center never followed it, so the dungeon (tens
|
||||
// of km away in world space) never streamed and the #107 auto-entry
|
||||
// gate's SampleTerrainZ(pe.Position) waited forever — the player hung
|
||||
// frozen at login. Mirror the teleport-arrival recenter HERE, for the
|
||||
// PLAYER's spawn only, BEFORE the world-space translation below: when
|
||||
// the spawn landblock differs from the current center, move the center
|
||||
// onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the
|
||||
// new center frame (identical to the teleport path's
|
||||
// `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`),
|
||||
// and the next StreamingController.Tick observes the new center and
|
||||
// streams the spawn landblock.
|
||||
//
|
||||
// No-op for a normal Holtburg login: the saved spawn landblock equals
|
||||
// the default center, so the guard is false and origin/worldPos are
|
||||
// byte-identical to the pre-fix path. Gated on the player guid so NPC /
|
||||
// object spawns never move the center. Idempotent + thrash-free: a
|
||||
// re-sent CreateObject for the same spawn landblock leaves the center
|
||||
// already-equal, so the guard is false on every repeat.
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& (lbX != _liveCenterX || lbY != _liveCenterY))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
|
||||
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
}
|
||||
|
||||
// #135: the instant we know the player spawned into a SEALED dungeon,
|
||||
// pre-collapse streaming to that single landblock — BEFORE the first
|
||||
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
|
||||
// player isn't placed yet (physics CurrCell is null), so the per-frame
|
||||
// insideDungeon gate stays false for the entire hydration window and
|
||||
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
|
||||
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
|
||||
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
|
||||
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
|
||||
// (reentrant); the controller call is render-thread-safe (Channel writes).
|
||||
if (spawn.Guid == _playerServerGuid
|
||||
&& _streamingController is not null
|
||||
&& IsSealedDungeonCell(p.LandblockId))
|
||||
{
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
|
|
@ -4621,10 +4688,18 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
// Phase A.1: track the most recently updated entity's landblock so the
|
||||
// streaming controller can follow the player. TODO: filter by our own
|
||||
// character guid once we reliably know it from CharacterList.
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||||
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
|
||||
// must NOT move the streaming observer: during a dungeon-login hold (player not
|
||||
// yet placed, so _playerController is null and the PortalSpace observer branch
|
||||
// can't apply) that would drift the observer off the pre-collapsed dungeon
|
||||
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
|
||||
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
|
||||
// (~line 1984) before world entry, so it is valid by the time updates arrive.
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
|
|
@ -5046,7 +5121,7 @@ public sealed class GameWindow : IDisposable
|
|||
entity.Rotation = rmState.Body.Orientation;
|
||||
}
|
||||
|
||||
// Phase B.3: portal-space arrival detection.
|
||||
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
||||
// Only runs for our own player character while in PortalSpace.
|
||||
if (_playerController is not null
|
||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||||
|
|
@ -5060,79 +5135,127 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
||||
|
||||
// #107 (2026-06-10): ANY player position update while in PortalSpace
|
||||
// IS the teleport arrival. Retail/holtburger exit portal space on the
|
||||
// next position event unconditionally (holtburger messages.rs
|
||||
// PlayerTeleport handler: log + LoginComplete; the destination applies
|
||||
// through the normal position flow — no distance test). The old
|
||||
// `differentLandblock || farAway(>100m)` arrival gate was an
|
||||
// invention: ACE's same-landblock short-hop position corrections
|
||||
// (e.g. right after an indoor login) matched neither condition, so
|
||||
// PortalSpace never exited and movement input stayed frozen for the
|
||||
// whole session (the #107 "input ignored" wedge shape —
|
||||
// flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||
|
||||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||||
// Recenter the streaming controller on the new landblock NOW (kick
|
||||
// off the dungeon load). After recentering, the destination is
|
||||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
|
||||
System.Numerics.Vector3 newWorldPos;
|
||||
if (differentLandblock)
|
||||
{
|
||||
// 1. Recenter the streaming controller on the new landblock.
|
||||
_liveCenterX = lbX;
|
||||
_liveCenterY = lbY;
|
||||
|
||||
// Recompute worldPos with new center (it becomes local-to-center).
|
||||
// After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
|
||||
// relative to the new origin — which maps to world-space (0,0,0) + local offset.
|
||||
// The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
|
||||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||||
// (after recentering, origin is (0,0,0) since lb == center)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Same landblock: worldPos is already in the current center frame.
|
||||
newWorldPos = worldPos;
|
||||
}
|
||||
|
||||
// 2. Resolve through physics for the correct ground Z.
|
||||
uint newCellId = p.LandblockId;
|
||||
var resolved = _physicsEngine.Resolve(
|
||||
newWorldPos, newCellId,
|
||||
System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
|
||||
var snappedPos = new System.Numerics.Vector3(
|
||||
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||
|
||||
// 3. Snap player entity + controller.
|
||||
entity.SetPosition(snappedPos);
|
||||
entity.ParentCellId = resolved.CellId;
|
||||
entity.Rotation = rot;
|
||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||
|
||||
// 4. Recenter chase camera on the new position.
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
// 5. Return to InWorld.
|
||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||
|
||||
// 5. Send LoginComplete to tell the server the client finished loading.
|
||||
// Per holtburger's PlayerTeleport handler (client/messages.rs:434-440),
|
||||
// retail clients call send_login_complete() after each portal transition.
|
||||
// ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path
|
||||
// doesn't also send one. We send directly here instead.
|
||||
_liveSession?.SendGameAction(
|
||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
// #135: pre-collapse on teleport into a sealed dungeon too — same
|
||||
// race as login. The destination isn't placed until it hydrates, so
|
||||
// without this NormalTick loads the full neighbor window during the
|
||||
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
|
||||
// observer pinned to _liveCenterX/Y while held, so the stale frozen
|
||||
// player position can't drift the observer off the dungeon and re-expand.
|
||||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||||
}
|
||||
else
|
||||
{
|
||||
newWorldPos = worldPos;
|
||||
}
|
||||
|
||||
// G.3a: do NOT snap here. The destination dungeon landblock has not
|
||||
// streamed in yet; an immediate Resolve falls back to the resident
|
||||
// (old) landblocks and lands the player in ocean (#133). HOLD the snap
|
||||
// in portal space — TeleportArrivalController.Tick (per frame) places
|
||||
// the player via PlaceTeleportArrival once the destination cell
|
||||
// hydrates (TeleportArrivalReadiness == Ready), or force-places on an
|
||||
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
|
||||
EnsureTeleportArrivalController();
|
||||
_pendingTeleportRot = rot;
|
||||
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
|
||||
}
|
||||
}
|
||||
|
||||
// G.3a (#133): holds a teleport arrival in portal space until the destination
|
||||
// dungeon landblock/cell has hydrated, then places the player via the unchanged
|
||||
// validated-claim Resolve path. Lazily constructed on the first teleport (all
|
||||
// runtime deps are wired by then).
|
||||
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
|
||||
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
|
||||
|
||||
private void EnsureTeleportArrivalController()
|
||||
{
|
||||
if (_teleportArrival is not null) return;
|
||||
_teleportArrival = new AcDream.App.World.TeleportArrivalController(
|
||||
readiness: TeleportArrivalReadiness,
|
||||
place: PlaceTeleportArrival);
|
||||
}
|
||||
|
||||
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
|
||||
// against the teleport's (destPos, destCell): an impossible indoor claim short-
|
||||
// circuits to immediate placement; otherwise hold until terrain is sampled and,
|
||||
// for an indoor cell, the cell struct has hydrated.
|
||||
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
|
||||
System.Numerics.Vector3 destPos, uint destCell)
|
||||
{
|
||||
if (IsSpawnClaimUnhydratable(destCell))
|
||||
return AcDream.App.World.ArrivalReadiness.Impossible;
|
||||
|
||||
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
|
||||
// EnvCell FLOOR, not the terrain heightmap. A dungeon's negative-offset cells can
|
||||
// place destPos in a NEIGHBOUR terrain landblock the #135 collapse doesn't load,
|
||||
// so SampleTerrainZ would stay null forever (the cell IS ready). Retail places on
|
||||
// the cell floor. Outdoor: the terrain heightmap is the ground.
|
||||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||||
if (indoor)
|
||||
return _physicsEngine.IsSpawnCellReady(destCell)
|
||||
? AcDream.App.World.ArrivalReadiness.Ready
|
||||
: AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
|
||||
if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null)
|
||||
return AcDream.App.World.ArrivalReadiness.NotReady;
|
||||
return AcDream.App.World.ArrivalReadiness.Ready;
|
||||
}
|
||||
|
||||
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
|
||||
// once the destination is ready (or force-run on impossible/timeout, logged loud).
|
||||
private void PlaceTeleportArrival(
|
||||
System.Numerics.Vector3 destPos, uint destCell, bool forced)
|
||||
{
|
||||
var resolved = _physicsEngine.Resolve(
|
||||
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
|
||||
var snappedPos = new System.Numerics.Vector3(
|
||||
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
|
||||
|
||||
if (forced)
|
||||
Console.WriteLine(
|
||||
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
|
||||
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
|
||||
|
||||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
|
||||
{
|
||||
pe.SetPosition(snappedPos);
|
||||
pe.ParentCellId = resolved.CellId;
|
||||
pe.Rotation = _pendingTeleportRot;
|
||||
}
|
||||
_playerController.SetPosition(snappedPos, resolved.CellId);
|
||||
|
||||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||||
playerVelocity: System.Numerics.Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||||
dt: 1f / 60f);
|
||||
|
||||
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
|
||||
Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
|
||||
|
||||
// Tell the server the client finished loading the new landblock (holtburger
|
||||
// client/messages.rs:434 — re-send LoginComplete after each portal transition).
|
||||
_liveSession?.SendGameAction(
|
||||
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
|
||||
/// Freeze movement input by setting the player controller to PortalSpace.
|
||||
|
|
@ -5144,6 +5267,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
if (_playerController is not null)
|
||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||
EnsureTeleportArrivalController();
|
||||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||||
}
|
||||
|
||||
|
|
@ -5266,6 +5390,11 @@ public sealed class GameWindow : IDisposable
|
|||
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
|
||||
=> entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id;
|
||||
|
||||
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
|
||||
private string? _lastOutStagePtSig;
|
||||
private readonly HashSet<uint> _outStageUnmatchedScratch = new();
|
||||
private readonly HashSet<uint> _outStageMatchedScratch = new();
|
||||
|
||||
private static System.Numerics.Vector3 SkyPesAnchor(
|
||||
AcDream.Core.World.SkyObjectData obj,
|
||||
System.Numerics.Vector3 cameraWorldPos)
|
||||
|
|
@ -5765,22 +5894,56 @@ public sealed class GameWindow : IDisposable
|
|||
// Static objects inside the cell continue to flow through the dispatcher
|
||||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||||
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
|
||||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||||
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
|
||||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
|
||||
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
|
||||
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
|
||||
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
|
||||
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
|
||||
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
|
||||
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
|
||||
// un-cached connector 0x014D — so the side test culled the on-screen connector
|
||||
// portal and the grey clear showed through). Retail keeps the whole landblock
|
||||
// cell array resident for the sweep; a portals-only connector has an empty
|
||||
// collision BSP but its portals drive the transit. CacheCellStruct reads the
|
||||
// cellStruct directly, not the render sub-meshes.
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
|
||||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||||
if (cellSubMeshes.Count > 0)
|
||||
{
|
||||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||||
|
||||
// Keep the small render lift out of physics; retail BSP
|
||||
// contact planes use the EnvCell origin verbatim.
|
||||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(0f, 0f, 0.02f);
|
||||
var cellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||||
var physicsCellTransform =
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||||
|
||||
// Phase A8: register the cell with EnvCellRenderer for rendering.
|
||||
// staticObjects is empty — cell stabs continue as separate WorldEntity
|
||||
// records via the dispatcher (see lines below for the unchanged stab path).
|
||||
|
|
@ -5793,25 +5956,6 @@ public sealed class GameWindow : IDisposable
|
|||
cellWorldPosition: cellOrigin,
|
||||
cellRotation: envCell.Position.Orientation,
|
||||
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
|
||||
|
||||
// Step 4: build LoadedCell for portal visibility — with the
|
||||
// PHYSICS (unlifted) transform. The +0.02 m render lift above
|
||||
// is a DRAW concern (shell z-fighting vs terrain); feeding it
|
||||
// into the visibility graph shifted every HORIZONTAL portal
|
||||
// plane 2 cm up, putting an eye standing on a deck/landing
|
||||
// 10–20 mm BELOW the lifted plane — outside the side test's
|
||||
// ±10 mm in-plane window — so the cell behind the portal was
|
||||
// side-culled: the tower-top staircase vanish + roof flap
|
||||
// (#119-residual; captured live at eye z=126.803 vs the
|
||||
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
|
||||
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
|
||||
// doorways were immune (the lift slides their planes along
|
||||
// themselves), which is why this hit exactly stairs, decks,
|
||||
// and cellar mouths.
|
||||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||||
|
||||
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
|
||||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5828,6 +5972,17 @@ public sealed class GameWindow : IDisposable
|
|||
.DumpEntitySourceIds.Contains(stab.Id);
|
||||
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
|
||||
|
||||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||||
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u
|
||||
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
|
||||
continue;
|
||||
|
||||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||||
|
|
@ -5861,6 +6016,12 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
// #136: skip an editor-only marker PART (retail hides it at runtime
|
||||
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
|
||||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||||
// meshRefs and the whole stab drops below.
|
||||
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
|
||||
continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||||
if (gfx is null)
|
||||
{
|
||||
|
|
@ -6949,7 +7110,27 @@ public sealed class GameWindow : IDisposable
|
|||
int observerCx = _liveCenterX;
|
||||
int observerCy = _liveCenterY;
|
||||
|
||||
if (_playerMode && _playerController is not null)
|
||||
if (_playerMode && _playerController is not null
|
||||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||||
{
|
||||
// Teleport hold (#135): the local player position is frozen at the
|
||||
// PRE-teleport spot, expressed in the OLD center frame, but
|
||||
// _liveCenterX/_liveCenterY were already recentered onto the
|
||||
// destination landblock (OnLivePositionUpdated). Follow the
|
||||
// destination directly — the stale position-derived offset
|
||||
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
|
||||
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
||||
// neighbor window the pre-collapse just suppressed. Correct for an
|
||||
// outdoor teleport too: pre-load the destination during the hold.
|
||||
//
|
||||
// NOTE: these assignments equal the observerCx/Cy defaults initialized
|
||||
// above — the LOAD-BEARING effect of this branch is INHIBITING the
|
||||
// position-derived offset in the else-if below while the player position
|
||||
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
|
||||
observerCx = _liveCenterX;
|
||||
observerCy = _liveCenterY;
|
||||
}
|
||||
else if (_playerMode && _playerController is not null)
|
||||
{
|
||||
// Player mode: follow the physics-resolved player position.
|
||||
// The player walks via the local physics engine; the server
|
||||
|
|
@ -6961,12 +7142,28 @@ public sealed class GameWindow : IDisposable
|
|||
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
}
|
||||
else if (_liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
|
||||
&& _lastLivePlayerLandblockId is { } lid)
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
|
||||
{
|
||||
// Live mode (fly camera): follow the server's last-known player position.
|
||||
observerCx = (int)((lid >> 24) & 0xFFu);
|
||||
observerCy = (int)((lid >> 16) & 0xFFu);
|
||||
// Live, not yet in player mode: the login auto-entry hold, or a live
|
||||
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it
|
||||
// hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is
|
||||
// the spawn/teleport recenter (the dungeon landblock at a dungeon login).
|
||||
//
|
||||
// #135 regression fix (2026-06-14): this MUST NOT fall through to the
|
||||
// fly-camera projection below. During a dungeon-login hold the streaming is
|
||||
// pre-collapsed onto the spawn landblock; a camera-derived observer far from
|
||||
// it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
|
||||
// the player is never placed and login hangs with no dungeon. Previously
|
||||
// _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC
|
||||
// kept this branch on the dungeon; once it was filtered to the player guid
|
||||
// (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
|
||||
// The fly camera is the OFFLINE observer only.
|
||||
if (_lastLivePlayerLandblockId is { } lid)
|
||||
{
|
||||
observerCx = (int)((lid >> 24) & 0xFFu);
|
||||
observerCy = (int)((lid >> 16) & 0xFFu);
|
||||
}
|
||||
// else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -6980,7 +7177,37 @@ public sealed class GameWindow : IDisposable
|
|||
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||
}
|
||||
|
||||
_streamingController.Tick(observerCx, observerCy);
|
||||
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
|
||||
// (indoor cell that doesn't see outside — the same predicate that kills
|
||||
// the sun/sky, playerInsideCell below), collapse streaming to the single
|
||||
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
|
||||
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
|
||||
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
|
||||
// and keep their surrounding terrain.
|
||||
// True only for a sealed indoor cell. Read the physics CurrCell's own
|
||||
// SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
|
||||
// than the render registry: the registry lookup only succeeds AFTER the
|
||||
// landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
|
||||
// delayed the collapse and let the full 25×25 neighbor window churn in
|
||||
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
||||
// moment the player is placed, so the collapse now engages at the snap.
|
||||
bool insideDungeon = false;
|
||||
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell pcEnv
|
||||
&& !pcEnv.SeenOutside)
|
||||
{
|
||||
insideDungeon = true;
|
||||
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
||||
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
||||
// at arbitrary world coords (the "ocean" placement) with negative local
|
||||
// offsets, so floor(pp.Y/192) lands one landblock off — which collapses
|
||||
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
||||
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
||||
// authoritative landblock.
|
||||
uint cellLb = pcEnv.Id >> 16;
|
||||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||||
observerCy = (int)(cellLb & 0xFFu);
|
||||
}
|
||||
_streamingController.Tick(observerCx, observerCy, insideDungeon);
|
||||
|
||||
// Re-inject persistent entities rescued from unloaded landblocks
|
||||
// into the current center landblock (the one the observer is in).
|
||||
|
|
@ -7000,6 +7227,12 @@ public sealed class GameWindow : IDisposable
|
|||
// Step 2: routed through the controller; functionally identical.
|
||||
_liveSessionController?.Tick();
|
||||
|
||||
// G.3a (#133): advance any held teleport arrival. Runs AFTER streaming
|
||||
// (which applies the destination landblock) and the live-session drain
|
||||
// (which may have just called BeginArrival), so a destination that
|
||||
// hydrated this frame is placed the same frame.
|
||||
_teleportArrival?.Tick();
|
||||
|
||||
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||
// re-fire while their chord is held. K.1b adds the subscribers
|
||||
// that actually consume the events.
|
||||
|
|
@ -7138,10 +7371,24 @@ public sealed class GameWindow : IDisposable
|
|||
// so it doesn't get frustum-culled when the player walks away from
|
||||
// the spawn landblock. Without this, the entity stays in the spawn
|
||||
// landblock's entity list and disappears when that landblock is culled.
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
uint currentLb;
|
||||
if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
|
||||
{
|
||||
// Indoor cell (dungeon/building EnvCell): the entity's landblock is
|
||||
// the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
|
||||
// world coords with negative local-Y, so floor(pp.Y/192) lands one
|
||||
// landblock off (the Bug-A class) — relocating the player into the
|
||||
// landblock the dungeon collapse unloaded, making the avatar
|
||||
// invisible. The cell id is authoritative.
|
||||
currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
|
||||
}
|
||||
else
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
_worldState.RelocateEntity(pe, currentLb);
|
||||
}
|
||||
|
||||
|
|
@ -7717,6 +7964,25 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
_sceneLightingUbo?.Upload(ubo);
|
||||
|
||||
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
|
||||
// rate-limited [light] line — insideCell / ambient / sun /
|
||||
// registered-point-lights / active-slot-count / player cell — so
|
||||
// the dungeon-dim question is self-verifiable from launch.log
|
||||
// without a screenshot. RegisteredCount is point/spot lights only
|
||||
// (the sun lives in LightManager.Sun, never in the _all list);
|
||||
// ubo.CellAmbient.W is the shader active-slot count, which counts
|
||||
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
|
||||
insideCell: playerInsideCell,
|
||||
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
|
||||
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
|
||||
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
|
||||
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
|
||||
registeredLights: Lighting.RegisteredCount,
|
||||
activeLights: (int)ubo.CellAmbient.W,
|
||||
playerCellId: playerRoot?.CellId ?? 0u,
|
||||
lights: Lighting);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
|
|
@ -7796,9 +8062,9 @@ public sealed class GameWindow : IDisposable
|
|||
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
|
||||
// OutsideView terrain integration that consumes this is the next (cutover) step.
|
||||
_outdoorNode = null;
|
||||
if (viewerRoot is null && viewerCellId != 0u)
|
||||
_outdoorNodeBuildingCells.Clear();
|
||||
if (viewerRoot is not null || viewerCellId != 0u)
|
||||
{
|
||||
_outdoorNodeBuildingCells.Clear();
|
||||
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
|
||||
// interior exactly when its shell DRAWS and an aperture survives
|
||||
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
|
||||
|
|
@ -7813,6 +8079,12 @@ public sealed class GameWindow : IDisposable
|
|||
// Per-building iteration is also the FPS fix the 2026-06-07
|
||||
// Chebyshev hack approximated: dozens of AABB tests instead of an
|
||||
// O(all loaded cells) portal sweep.
|
||||
// #124: the gather now runs for INTERIOR roots too — retail's
|
||||
// look-in executes inside LScape::draw for ANY root with a
|
||||
// non-empty outside view (DrawCells pc:432719). The renderer
|
||||
// routes interior-root look-ins to its landscape-stage sub-pass
|
||||
// (DrawBuildingLookIns); the root's own building self-excludes
|
||||
// via the seed eye-side test.
|
||||
foreach (var registry in _buildingRegistries.Values)
|
||||
{
|
||||
foreach (var b in registry.All())
|
||||
|
|
@ -7827,10 +8099,11 @@ public sealed class GameWindow : IDisposable
|
|||
_outdoorNodeBuildingCells.Add(bc);
|
||||
}
|
||||
}
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (viewerRoot is null)
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
|
||||
}
|
||||
|
||||
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
|
||||
|
|
@ -7956,10 +8229,10 @@ public sealed class GameWindow : IDisposable
|
|||
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
|
||||
{
|
||||
RootCell = clipRoot,
|
||||
// R-A2: outdoor root floods each nearby building per-building (not via the root). The
|
||||
// gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it
|
||||
// is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots.
|
||||
NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null,
|
||||
// R-A2: outdoor root floods each nearby building per-building (not via the root).
|
||||
// #124: interior roots get the gather too — the renderer routes them to the
|
||||
// landscape-stage look-in sub-pass instead of the merge.
|
||||
NearbyBuildingCells = _outdoorNodeBuildingCells,
|
||||
ViewerEyePos = viewerEyePos,
|
||||
ViewProjection = envCellViewProj,
|
||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||
|
|
@ -7985,6 +8258,22 @@ public sealed class GameWindow : IDisposable
|
|||
renderWeather: playerSeenOutside,
|
||||
kf,
|
||||
environOverrideActive),
|
||||
// #131/#132: the late phase — dynamics meshes + scene
|
||||
// particles + weather AFTER the look-ins (FlushAlphaList
|
||||
// deferral).
|
||||
DrawLandscapeSliceLate = lateCtx =>
|
||||
DrawRetailPViewLandscapeSliceLate(
|
||||
lateCtx,
|
||||
camera,
|
||||
frustum,
|
||||
camPos,
|
||||
playerLb,
|
||||
animatedIds,
|
||||
renderSky,
|
||||
renderWeather: playerSeenOutside,
|
||||
kf,
|
||||
environOverrideActive,
|
||||
isOutdoorRoot: clipRoot.IsOutdoorNode),
|
||||
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
|
||||
// INTERIOR roots: one FULL depth clear between the outside stage and
|
||||
// the interior stage, then SEALS re-stamp every outside-leading
|
||||
|
|
@ -8005,6 +8294,26 @@ public sealed class GameWindow : IDisposable
|
|||
DrawExitPortalMasks = sliceCtx =>
|
||||
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||
forceFarZ: clipRoot.IsOutdoorNode),
|
||||
// #124: look-in apertures are ALWAYS the punch (retail
|
||||
// maxZ1), independent of the root-keyed selector above.
|
||||
DrawLookInPortalPunch = sliceCtx =>
|
||||
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||
forceFarZ: true),
|
||||
// #131: unattached emitters under an interior root — the
|
||||
// landscape-stage pass (the outdoor T3 pass below is gated
|
||||
// IsOutdoorNode, so the two never both run).
|
||||
DrawUnattachedSceneParticles = () =>
|
||||
{
|
||||
if (_particleSystem is null || _particleRenderer is null)
|
||||
return;
|
||||
DisableClipDistances();
|
||||
_particleRenderer.Draw(
|
||||
_particleSystem,
|
||||
camera,
|
||||
camPos,
|
||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||||
emitter => emitter.AttachedObjectId == 0);
|
||||
},
|
||||
DrawCellParticles = sliceCtx =>
|
||||
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
|
||||
DrawDynamicsParticles = survivors =>
|
||||
|
|
@ -8125,20 +8434,26 @@ public sealed class GameWindow : IDisposable
|
|||
&& _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.
|
||||
// AttachedObjectId == 0) under the OUTDOOR root. The outdoor
|
||||
// root's outside view is full-screen (cone pass-all); depth
|
||||
// test composites them against the world.
|
||||
// #132 outdoor sibling: ATTACHED outdoor-static scene emitters
|
||||
// (lantern/candle flames) moved here too — drawn in the
|
||||
// landscape slice they were overpainted by merged building
|
||||
// interiors (drawn later) whenever a punched aperture sat
|
||||
// behind them. Post-frame, depth is complete and the flames
|
||||
// composite correctly. The owner-id set is the late slice's
|
||||
// (full-screen cone outdoors). Cell-pass and dynamics-pass
|
||||
// emitters keep their own passes (no double-draw: their owners
|
||||
// are never in the outdoor-static id set).
|
||||
sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached";
|
||||
_particleRenderer.Draw(
|
||||
_particleSystem,
|
||||
camera,
|
||||
camPos,
|
||||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||||
emitter => emitter.AttachedObjectId == 0);
|
||||
emitter => emitter.AttachedObjectId == 0
|
||||
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||||
}
|
||||
|
||||
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
|
||||
|
|
@ -9800,12 +10115,113 @@ public sealed class GameWindow : IDisposable
|
|||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
|
||||
_outdoorSceneParticleEntityIds.Clear();
|
||||
foreach (var entity in sliceCtx.OutdoorEntities)
|
||||
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
|
||||
// #131/#132: scene particles + weather MOVED to the LATE phase
|
||||
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
|
||||
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
|
||||
// pc:432722); drawn here they were overpainted by far-building
|
||||
// interiors wherever a look-in aperture sat behind them.
|
||||
|
||||
if (scissor)
|
||||
_gl!.Disable(EnableCap.ScissorTest);
|
||||
|
||||
DisableClipDistances();
|
||||
if (_outdoorSceneParticleEntityIds.Count > 0
|
||||
}
|
||||
|
||||
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
|
||||
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
|
||||
// dynamics' meshes (a translucent portal swirl blends over a far interior
|
||||
// instead of being overpainted by it — translucents write no depth to
|
||||
// protect themselves) + ALL attached scene particles (statics' flames
|
||||
// included — the #132 candle) + weather. Retail equivalent: alpha draws
|
||||
// collected during LScape::draw flush ONCE after it
|
||||
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
|
||||
private void DrawRetailPViewLandscapeSliceLate(
|
||||
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
|
||||
ICamera camera,
|
||||
FrustumPlanes? frustum,
|
||||
System.Numerics.Vector3 camPos,
|
||||
uint? playerLb,
|
||||
HashSet<uint>? animatedIds,
|
||||
bool renderSky,
|
||||
bool renderWeather,
|
||||
AcDream.Core.World.SkyKeyframe kf,
|
||||
bool environOverrideActive,
|
||||
bool isOutdoorRoot)
|
||||
{
|
||||
var slice = lateCtx.Slice;
|
||||
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
|
||||
|
||||
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||||
ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo);
|
||||
|
||||
// Outside-stage dynamics' meshes — viewcone pre-filtered by the
|
||||
// renderer, never hard-clipped (T3).
|
||||
DisableClipDistances();
|
||||
if (lateCtx.Dynamics.Count > 0)
|
||||
{
|
||||
var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
|
||||
lateCtx.Dynamics,
|
||||
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
|
||||
_wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum,
|
||||
neverCullLandblockId: playerLb,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: animatedIds);
|
||||
}
|
||||
|
||||
_outdoorSceneParticleEntityIds.Clear();
|
||||
foreach (var entity in lateCtx.ParticleOwners)
|
||||
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
|
||||
|
||||
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
|
||||
// live emitters the filter would actually match, plus the distinct
|
||||
// UNMATCHED attached owner ids (the portal-identification handle —
|
||||
// an emitter whose owner never lands in the set draws nowhere
|
||||
// indoors). Print-on-change.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
|
||||
&& _particleSystem is not null)
|
||||
{
|
||||
int matched = 0, attached = 0, unattached = 0;
|
||||
_outStageUnmatchedScratch.Clear();
|
||||
_outStageMatchedScratch.Clear();
|
||||
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
|
||||
{
|
||||
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
|
||||
attached++;
|
||||
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId))
|
||||
{
|
||||
matched++;
|
||||
if (_outStageMatchedScratch.Count < 48)
|
||||
_outStageMatchedScratch.Add(emitter.AttachedObjectId);
|
||||
}
|
||||
else if (_outStageUnmatchedScratch.Count < 12)
|
||||
_outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
|
||||
}
|
||||
var unm = new System.Text.StringBuilder(96);
|
||||
foreach (uint id in _outStageUnmatchedScratch)
|
||||
unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
|
||||
var mat = new System.Text.StringBuilder(192);
|
||||
foreach (uint id in _outStageMatchedScratch)
|
||||
mat.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
|
||||
string ptSig = System.FormattableString.Invariant(
|
||||
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]");
|
||||
if (ptSig != _lastOutStagePtSig)
|
||||
{
|
||||
_lastOutStagePtSig = ptSig;
|
||||
Console.WriteLine("[outstage-pt] " + ptSig);
|
||||
}
|
||||
}
|
||||
|
||||
// #132 outdoor sibling: under an OUTDOOR root the merged building
|
||||
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
|
||||
// here is overpainted whenever a punched aperture sits behind it
|
||||
// (user-confirmed at the outdoor candle). Outdoor roots therefore
|
||||
// SKIP the slice Scene pass and draw attached scene particles in the
|
||||
// post-frame pass alongside the T3 unattached pass (the id set built
|
||||
// above carries over — the outdoor root has a single full-screen
|
||||
// slice). Interior roots draw here: the look-ins already ran and the
|
||||
// post-clear seal discipline owns the rest of the frame.
|
||||
if (!isOutdoorRoot
|
||||
&& _outdoorSceneParticleEntityIds.Count > 0
|
||||
&& _particleSystem is not null
|
||||
&& _particleRenderer is not null)
|
||||
{
|
||||
|
|
@ -9881,9 +10297,16 @@ public sealed class GameWindow : IDisposable
|
|||
if (localVerts.Length < 3)
|
||||
continue;
|
||||
|
||||
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
|
||||
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
|
||||
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
|
||||
// space or the stamp sits 2 cm below the drawn hole (#130 family).
|
||||
int n = System.Math.Min(localVerts.Length, world.Length);
|
||||
for (int v = 0; v < n; v++)
|
||||
{
|
||||
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
|
||||
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
|
||||
}
|
||||
|
||||
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
|
||||
}
|
||||
|
|
@ -10136,26 +10559,18 @@ 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).
|
||||
// disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
|
||||
// (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
|
||||
// (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
|
||||
// bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
|
||||
// off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
|
||||
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);
|
||||
var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
|
||||
_gl!.Enable(EnableCap.ScissorTest);
|
||||
_gl.Scissor(px, py, (uint)System.Math.Max(1, pw), (uint)System.Math.Max(1, ph));
|
||||
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -10523,6 +10938,7 @@ public sealed class GameWindow : IDisposable
|
|||
state: _worldState,
|
||||
nearRadius: _nearRadius,
|
||||
farRadius: _farRadius,
|
||||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||||
removeTerrain: id =>
|
||||
{
|
||||
if (_lightingSink is not null &&
|
||||
|
|
@ -11771,6 +12187,35 @@ public sealed class GameWindow : IDisposable
|
|||
return unhydratable;
|
||||
}
|
||||
|
||||
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
|
||||
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
|
||||
// a real dungeon (collapse streaming to its single landblock) from a building
|
||||
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
|
||||
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
|
||||
// the hydration path (BuildLoadedCell, ~line 5999) and as the physics
|
||||
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
|
||||
// decision matches the eventual gate decision exactly. Returns false when the dat
|
||||
// lacks the cell (out-of-range index / missing record) so we never collapse on a
|
||||
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
|
||||
// already hold it (the login spawn handler does).
|
||||
private bool IsSealedDungeonCell(uint cellId)
|
||||
{
|
||||
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
|
||||
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
|
||||
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> on
|
||||
// 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
|
||||
// an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
|
||||
// position never carries a shell id, but exclude them so the read is sound.
|
||||
uint low = cellId & 0xFFFFu;
|
||||
if (low < 0x0100u || low >= 0xFFFEu) return false;
|
||||
if (_dats is null) return false;
|
||||
DatReaderWriter.DBObjs.EnvCell? envCell;
|
||||
lock (_datLock)
|
||||
envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId);
|
||||
return envCell is not null
|
||||
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
|
||||
}
|
||||
|
||||
private void EnterPlayerModeFromAutoEntry()
|
||||
{
|
||||
_playerMode = true;
|
||||
|
|
|
|||
45
src/AcDream.App/Rendering/NdcScissorRect.cs
Normal file
45
src/AcDream.App/Rendering/NdcScissorRect.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// NdcScissorRect.cs
|
||||
//
|
||||
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
|
||||
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
|
||||
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
|
||||
// every fragment the per-fragment plane clip would keep. Under-inclusion is
|
||||
// the bug class — the #130 doorway top-edge background strip was this box
|
||||
// computed as Floor(origin) + Ceiling(size), whose far edge
|
||||
// floor(min)+ceil(max−min) lands up to one pixel SHORT of the true max edge
|
||||
// at unlucky fractional alignments, scissoring away the aperture's top/right
|
||||
// pixel row for the whole slice (sky, terrain, statics, weather) while the
|
||||
// seal still stamps it — a strip of clear color no later pass can fill.
|
||||
//
|
||||
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
|
||||
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
|
||||
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
|
||||
// i ≥ X0−0.5 ⇒ i ≥ floor(X0) and i ≤ X1−0.5 ⇒ i < ceil(X1). So
|
||||
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
|
||||
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
|
||||
// plane clip repaints or kills the surplus).
|
||||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
public static class NdcScissorRect
|
||||
{
|
||||
/// <summary>Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
|
||||
/// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to
|
||||
/// the screen so a region extending past an edge still yields a valid box.
|
||||
/// Width/height are at least 1.</summary>
|
||||
public static (int X, int Y, int Width, int Height) ToPixels(
|
||||
Vector4 ndcAabb, int fbWidth, int fbHeight)
|
||||
{
|
||||
float nx0 = Math.Clamp(ndcAabb.X, -1f, 1f);
|
||||
float ny0 = Math.Clamp(ndcAabb.Y, -1f, 1f);
|
||||
float nx1 = Math.Clamp(ndcAabb.Z, -1f, 1f);
|
||||
float ny1 = Math.Clamp(ndcAabb.W, -1f, 1f);
|
||||
int px0 = (int)MathF.Floor((nx0 * 0.5f + 0.5f) * fbWidth);
|
||||
int py0 = (int)MathF.Floor((ny0 * 0.5f + 0.5f) * fbHeight);
|
||||
int px1 = (int)MathF.Ceiling((nx1 * 0.5f + 0.5f) * fbWidth);
|
||||
int py1 = (int)MathF.Ceiling((ny1 * 0.5f + 0.5f) * fbHeight);
|
||||
return (px0, py0, Math.Max(1, px1 - px0), Math.Max(1, py1 - py0));
|
||||
}
|
||||
}
|
||||
|
|
@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
|
|||
uniform int uPlaneCount;
|
||||
uniform vec4 uPlanes[8];
|
||||
uniform int uForceFarZ;
|
||||
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
|
||||
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
|
||||
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
|
||||
out float gl_ClipDistance[8];
|
||||
void main()
|
||||
{
|
||||
|
|
@ -62,7 +63,14 @@ void main()
|
|||
if (uForceFarZ == 1)
|
||||
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
|
||||
else if (uDepthBias > 0.0)
|
||||
clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan)
|
||||
{
|
||||
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
|
||||
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
|
||||
// constant-NDC form alone reached METERS at distance (door-shaped
|
||||
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
|
||||
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
|
||||
clipPos.z -= biasNdc * clipPos.w;
|
||||
}
|
||||
gl_Position = clipPos;
|
||||
}";
|
||||
|
||||
|
|
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
private readonly int _locPlanes;
|
||||
private readonly int _locForceFarZ;
|
||||
private readonly int _locDepthBias;
|
||||
private readonly int _locDepthBiasEyeCapN;
|
||||
|
||||
private const int MaxFanVerts = 32;
|
||||
private readonly float[] _scratch = new float[MaxFanVerts * 3];
|
||||
|
|
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
|
||||
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
|
||||
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
|
||||
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
|
||||
|
||||
_vao = _gl.GenVertexArray();
|
||||
_vbo = _gl.GenBuffer();
|
||||
|
|
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
/// stencil below). The bias keeps the #108 case covered — terrain
|
||||
/// hugging the door plane (centimeters in front of the aperture) must
|
||||
/// still be punched; a hill or another house meters nearer must not.
|
||||
/// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1.
|
||||
/// </summary>
|
||||
private const float PunchMarkDepthBias = 0.0005f;
|
||||
|
||||
/// <summary>
|
||||
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
|
||||
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
|
||||
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
|
||||
/// ~190 m at a landblock away: every hill/house in front of a distant
|
||||
/// aperture passed the mark and got far-Z punched — door-shaped leaks
|
||||
/// through occluders. Fix: cap the bias's EYE-SPACE span at
|
||||
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
|
||||
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
|
||||
/// bit-identical to the T5-validated close-range behavior (#108 grass
|
||||
/// coverage untouched); beyond it the punch can never reach an occluder
|
||||
/// more than the cap in front of the aperture plane.
|
||||
/// </summary>
|
||||
public const float PunchMarkBiasEyeCapMeters = 0.5f;
|
||||
|
||||
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
|
||||
/// d4b5c71). The cap conversion below assumes the production camera near
|
||||
/// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored.</summary>
|
||||
public const float CameraNearPlaneMeters = 0.1f;
|
||||
|
||||
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
|
||||
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
|
||||
/// <paramref name="eyeDepthMeters"/>.</summary>
|
||||
public static float MarkBiasNdc(float eyeDepthMeters) =>
|
||||
MathF.Min(PunchMarkDepthBias,
|
||||
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
|
||||
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
|
||||
|
||||
/// <summary>
|
||||
/// Draw one portal polygon as an invisible depth write, clipped to the
|
||||
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
|
||||
|
|
@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
|
|||
_gl.DepthMask(false);
|
||||
_gl.Uniform1(_locForceFarZ, 0);
|
||||
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
|
||||
_gl.Uniform1(_locDepthBiasEyeCapN,
|
||||
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
|
||||
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
|
||||
|
||||
// ── PUNCH pass B: far-Z write on marked pixels only;
|
||||
|
|
|
|||
|
|
@ -97,16 +97,31 @@ public static class PortalVisibilityBuilder
|
|||
Console.WriteLine($"[pv-ERROR] chain tail(24):{tail}");
|
||||
}
|
||||
|
||||
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
|
||||
/// terrain; applied in GameWindow's cell registration). The visibility
|
||||
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
|
||||
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
|
||||
/// Draw-space consumers of portal polygons (the OutsideView color gate
|
||||
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
|
||||
/// they meet the drawn shell's aperture edge — the unlifted gate left a
|
||||
/// 2 cm background strip under the drawn lintel (#130).</summary>
|
||||
public const float ShellDrawLiftZ = 0.02f;
|
||||
|
||||
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
||||
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
|
||||
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
|
||||
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
|
||||
/// <param name="drawLiftZ">World +Z applied ONLY to the exit-portal projection feeding
|
||||
/// <see cref="PortalVisibilityFrame.OutsideView"/> (a draw-space region; see
|
||||
/// <see cref="ShellDrawLiftZ"/>). Flood admission, side tests, and CellViews are unaffected.
|
||||
/// Production passes <see cref="ShellDrawLiftZ"/>; tests replaying visibility semantics pass 0.</param>
|
||||
public static PortalVisibilityFrame Build(
|
||||
LoadedCell cameraCell,
|
||||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
Func<uint, bool>? buildingMembership = null)
|
||||
Func<uint, bool>? buildingMembership = null,
|
||||
float drawLiftZ = 0f)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
if (cameraCell == null) return frame;
|
||||
|
|
@ -318,8 +333,22 @@ public static class PortalVisibilityBuilder
|
|||
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
||||
}
|
||||
// Exit portal -> outdoors visible through this (clipped) opening.
|
||||
AddRegion(frame.OutsideView, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts}");
|
||||
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
|
||||
// shell that rasterizes this aperture draws +drawLiftZ above
|
||||
// the physics transform — project the region in the SAME
|
||||
// lifted space or terrain stops a lift-height short of the
|
||||
// drawn lintel (#130 strip). Flood semantics keep the
|
||||
// unlifted clippedRegion path above.
|
||||
var outsideRegion = drawLiftZ == 0f
|
||||
? clippedRegion
|
||||
: ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform * Matrix4x4.CreateTranslation(0f, 0f, drawLiftZ),
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
out _);
|
||||
AddRegion(frame.OutsideView, outsideRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={outsideRegion.Count} clipVerts={clipVerts}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -451,12 +480,18 @@ public static class PortalVisibilityBuilder
|
|||
/// camera cell. It keeps the same retail distance-priority traversal and
|
||||
/// neighbour reciprocal clipping once inside the building.
|
||||
/// </summary>
|
||||
/// <param name="seedRegion">Optional NDC region the seed apertures clip against —
|
||||
/// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
|
||||
/// 0x005a4320): full screen when the viewer is outdoors, the accumulated
|
||||
/// outside (doorway) view when a building is looked into from an interior
|
||||
/// root (#124). Null = full screen (the outdoor-root behavior).</param>
|
||||
public static PortalVisibilityFrame BuildFromExterior(
|
||||
IEnumerable<LoadedCell> candidateCells,
|
||||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity)
|
||||
float maxSeedDistance = float.PositiveInfinity,
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var todo = new CellTodoList();
|
||||
|
|
@ -503,7 +538,7 @@ public static class PortalVisibilityBuilder
|
|||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
FullScreenRegion,
|
||||
seedRegion ?? FullScreenRegion,
|
||||
out _);
|
||||
|
||||
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
|
||||
|
|
@ -633,8 +668,9 @@ public static class PortalVisibilityBuilder
|
|||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity)
|
||||
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance);
|
||||
float maxSeedDistance = float.PositiveInfinity,
|
||||
IReadOnlyList<ViewPolygon>? seedRegion = null)
|
||||
=> BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance, seedRegion);
|
||||
|
||||
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
|
||||
private static readonly Vector2[] FullScreenQuad =
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ public sealed class RetailPViewRenderer
|
|||
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
|
||||
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
|
||||
|
||||
// #124: per-building look-in frames under an INTERIOR root, drawn as a
|
||||
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
|
||||
// main frame (see DrawInside). Rebuilt each interior-root frame.
|
||||
private readonly List<PortalVisibilityFrame> _lookInFrames = 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).
|
||||
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
|
||||
|
||||
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
|
||||
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
|
||||
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
|
||||
|
|
@ -54,7 +64,9 @@ public sealed class RetailPViewRenderer
|
|||
ctx.RootCell,
|
||||
ctx.ViewerEyePos,
|
||||
ctx.CellLookup,
|
||||
ctx.ViewProjection);
|
||||
ctx.ViewProjection,
|
||||
buildingMembership: null,
|
||||
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
|
||||
|
||||
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
|
||||
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
|
||||
|
|
@ -65,6 +77,26 @@ public sealed class RetailPViewRenderer
|
|||
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
|
||||
MergeNearbyBuildingFloods(ctx, pvFrame);
|
||||
|
||||
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
|
||||
// the landscape stage for ANY root — LScape::draw is the FIRST call of
|
||||
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
|
||||
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
|
||||
// building seen through our doorway floods clipped to the INSTALLED
|
||||
// outside view (GetClip vs current view, ConstructView(CBldPortal)
|
||||
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
|
||||
// (inside the landscape stage), NEVER merged into the main frame — a
|
||||
// merged cell would draw post-clear and z-fail against the root's seal
|
||||
// (its geometry is beyond the door plane). The eye-side seed test
|
||||
// self-excludes the root's own building (the eye is on its interior
|
||||
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
|
||||
// (no depth clear under outdoor roots — the merged form is equivalent
|
||||
// there).
|
||||
_lookInFrames.Clear();
|
||||
if (!ctx.RootCell.IsOutdoorNode
|
||||
&& ctx.NearbyBuildingCells is not null
|
||||
&& pvFrame.OutsideView.Polygons.Count > 0)
|
||||
BuildInteriorRootLookIns(ctx, pvFrame);
|
||||
|
||||
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
|
||||
|
|
@ -76,15 +108,31 @@ public sealed class RetailPViewRenderer
|
|||
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// #124: look-in cells need prepared shell batches + their statics routed
|
||||
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
|
||||
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
|
||||
// contains them). drawableCells itself stays the MAIN flood: it feeds the
|
||||
// seals, the outside-stage predicate, and the frame result.
|
||||
var prepareCells = drawableCells;
|
||||
if (_lookInFrames.Count > 0)
|
||||
{
|
||||
_lookInPrepareScratch.Clear();
|
||||
_lookInPrepareScratch.UnionWith(drawableCells);
|
||||
foreach (var f in _lookInFrames)
|
||||
foreach (uint c in f.OrderedVisibleCells)
|
||||
_lookInPrepareScratch.Add(c);
|
||||
prepareCells = _lookInPrepareScratch;
|
||||
}
|
||||
|
||||
_envCells.PrepareRenderBatches(
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
filter: drawableCells,
|
||||
filter: prepareCells,
|
||||
centerLbX: ctx.RenderCenterLbX,
|
||||
centerLbY: ctx.RenderCenterLbY,
|
||||
renderRadius: ctx.RenderRadius);
|
||||
|
||||
var partition = InteriorEntityPartition.Partition(drawableCells, ctx.LandblockEntries);
|
||||
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
|
||||
var result = new RetailPViewFrameResult
|
||||
{
|
||||
PortalFrame = pvFrame,
|
||||
|
|
@ -213,6 +261,133 @@ public sealed class RetailPViewRenderer
|
|||
}
|
||||
}
|
||||
|
||||
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
|
||||
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
|
||||
// the accumulated doorway region — so a far building floods only within the
|
||||
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
|
||||
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
|
||||
// building self-excludes via the seed eye-side test.
|
||||
private void BuildInteriorRootLookIns(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
|
||||
{
|
||||
foreach (var group in _buildingGroups.Values)
|
||||
group.Clear();
|
||||
|
||||
foreach (var cell in ctx.NearbyBuildingCells!)
|
||||
{
|
||||
uint groupKey = cell.BuildingId ?? cell.CellId;
|
||||
if (!_buildingGroups.TryGetValue(groupKey, out var group))
|
||||
{
|
||||
group = new List<LoadedCell>();
|
||||
_buildingGroups[groupKey] = group;
|
||||
}
|
||||
group.Add(cell);
|
||||
}
|
||||
|
||||
foreach (var group in _buildingGroups.Values)
|
||||
{
|
||||
if (group.Count == 0)
|
||||
continue;
|
||||
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
|
||||
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
|
||||
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
|
||||
if (frame.OrderedVisibleCells.Count > 0)
|
||||
_lookInFrames.Add(frame);
|
||||
}
|
||||
}
|
||||
|
||||
// #124: draw the interior-root look-ins INSIDE the landscape stage —
|
||||
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
|
||||
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
|
||||
// pc:432719, before the depth clear + seals). Per building: punch ALL
|
||||
// apertures first (retail finishes build_draw_portals_only pass 1 — the
|
||||
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
|
||||
// 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).
|
||||
private void DrawBuildingLookIns(
|
||||
RetailPViewDrawContext ctx,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
InteriorEntityPartition.Result partition)
|
||||
{
|
||||
if (_lookInFrames.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var frame in _lookInFrames)
|
||||
{
|
||||
// Pass 1: far-Z punch every aperture of this building.
|
||||
if (ctx.DrawLookInPortalPunch is not null)
|
||||
{
|
||||
foreach (uint cellId in frame.OrderedVisibleCells)
|
||||
{
|
||||
if (!frame.CellViews.TryGetValue(cellId, out var view))
|
||||
continue;
|
||||
foreach (var poly in view.Polygons)
|
||||
{
|
||||
var single = new CellView();
|
||||
single.Add(poly);
|
||||
var cps = ClipPlaneSet.From(single);
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
var planes = new Vector4[cps.Count];
|
||||
for (int p = 0; p < cps.Count; p++)
|
||||
planes[p] = cps.Planes[p];
|
||||
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
|
||||
cellId,
|
||||
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
|
||||
Array.Empty<WorldEntity>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: shells + statics, far→near.
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = frame.OrderedVisibleCells[i];
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||
|
||||
_cellStaticScratch.Clear();
|
||||
if (partition.ByCell.TryGetValue(cellId, out var bucket))
|
||||
_cellStaticScratch.AddRange(bucket);
|
||||
|
||||
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
|
||||
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
|
||||
// under an interior root — DrawDynamicsLast viewcone-culls
|
||||
// them (the main cone has no entries for look-in cells), and
|
||||
// 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).
|
||||
// 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
|
||||
// (which only sees dynamics-last cone survivors).
|
||||
foreach (var e in partition.Dynamics)
|
||||
if (e.ParentCellId == cellId)
|
||||
_cellStaticScratch.Add(e);
|
||||
|
||||
if (_cellStaticScratch.Count > 0)
|
||||
{
|
||||
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
|
||||
|
||||
// The cell-particles pass for look-in cells — retail's
|
||||
// nested DrawCells draws objects WITH their emitters.
|
||||
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
||||
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(
|
||||
cellId, slice, _cellStaticScratch));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLandscapeThroughOutsideView(
|
||||
RetailPViewDrawContext ctx,
|
||||
ClipFrameAssembly clipAssembly,
|
||||
|
|
@ -222,6 +397,18 @@ 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.
|
||||
int probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
|
|
@ -243,21 +430,74 @@ public sealed class RetailPViewRenderer
|
|||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||
_outdoorStaticScratch.Add(e);
|
||||
}
|
||||
// #118: outside-stage dynamics ride the landscape pass like retail's
|
||||
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
|
||||
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
|
||||
// the aperture instead of z-killing them. Same per-slice cone test as
|
||||
// the statics above. Empty under outdoor roots (see DrawInside).
|
||||
probeSliceIndex++;
|
||||
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
|
||||
}
|
||||
|
||||
// #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
|
||||
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
|
||||
DrawBuildingLookIns(ctx, clipAssembly, partition);
|
||||
|
||||
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
|
||||
// pre-clear so the seal protects their aperture pixels; AFTER the
|
||||
// look-ins so a translucent portal mesh blends over a far interior
|
||||
// instead of being overpainted) + the scene-particle owners (statics +
|
||||
// dynamics cone survivors — flames ride here for the same reason).
|
||||
probeSliceIndex = 0;
|
||||
foreach (var slice in clipAssembly.OutsideViewSlices)
|
||||
{
|
||||
_clipFrame.SetTerrainClip(slice.Planes);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
_entities.ClearClipRouting();
|
||||
|
||||
_outdoorStaticScratch.Clear(); // late: dynamics survivors
|
||||
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
|
||||
foreach (var e in partition.OutdoorStatic)
|
||||
{
|
||||
EntitySphere(e, out var c, out float r);
|
||||
bool ownerPass = viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r);
|
||||
if (ownerPass)
|
||||
_lateParticleOwnerScratch.Add(e);
|
||||
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
|
||||
// double as an ENTITY-id watchlist here — one line per watched
|
||||
// outdoor-static owner per CHANGE of its cone verdict.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id)
|
||||
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass))
|
||||
{
|
||||
_outStageOwnerVerdicts[e.Id] = ownerPass;
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}"));
|
||||
}
|
||||
}
|
||||
foreach (var e in _outsideStageDynamics)
|
||||
{
|
||||
EntitySphere(e, out var c, out float r);
|
||||
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
|
||||
{
|
||||
_outdoorStaticScratch.Add(e);
|
||||
_lateParticleOwnerScratch.Add(e);
|
||||
}
|
||||
}
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
|
||||
EmitOutStageProbe(probeSliceIndex, viewcone);
|
||||
probeSliceIndex++;
|
||||
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
|
||||
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
|
||||
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
|
||||
}
|
||||
|
||||
// #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)
|
||||
ctx.DrawUnattachedSceneParticles?.Invoke();
|
||||
|
||||
// T1: retail clears the FULL depth buffer ONCE between the outside
|
||||
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
|
||||
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
|
||||
|
|
@ -271,6 +511,33 @@ public sealed class RetailPViewRenderer
|
|||
UseIndoorMembershipOnlyRouting();
|
||||
}
|
||||
|
||||
// #131 [outstage] probe state (2026-06-12, throwaway): print-on-change —
|
||||
// which outdoor dynamics were routed to the outside stage and which
|
||||
// survived the slice viewcone. Strip with the probe when #131 closes.
|
||||
private string? _lastOutStageSig;
|
||||
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = new();
|
||||
|
||||
private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(192);
|
||||
sb.Append("slice=").Append(sliceIndex)
|
||||
.Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" [");
|
||||
for (int i = 0; i < _outsideStageDynamics.Count; i++)
|
||||
{
|
||||
var e = _outsideStageDynamics[i];
|
||||
EntitySphere(e, out var c, out float r);
|
||||
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
|
||||
if (i > 0) sb.Append(' ');
|
||||
sb.Append(System.FormattableString.Invariant(
|
||||
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
|
||||
}
|
||||
sb.Append(']');
|
||||
string sig = sb.ToString();
|
||||
if (sig == _lastOutStageSig) return;
|
||||
_lastOutStageSig = sig;
|
||||
Console.WriteLine("[outstage] " + sig);
|
||||
}
|
||||
|
||||
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
|
||||
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
|
||||
private string? _lastClipRouteSig;
|
||||
|
|
@ -665,6 +932,12 @@ public interface IRetailPViewCellDrawCallbacks
|
|||
{
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
|
||||
|
||||
/// <summary>#124: far-Z punch one look-in aperture (a clipped view polygon
|
||||
/// of a looked-into building cell) — always the PUNCH variant regardless
|
||||
/// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
|
||||
/// governs the MAIN frame's exit-portal masks).</summary>
|
||||
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
|
||||
}
|
||||
|
||||
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
|
||||
|
|
@ -704,6 +977,11 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
|||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
|
||||
public required Action<uint> SetTerrainClipUbo { get; init; }
|
||||
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
|
||||
|
||||
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
|
||||
/// look-ins — outside-stage dynamics' meshes + all scene particles +
|
||||
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).</summary>
|
||||
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; }
|
||||
/// <summary>T1: one full-buffer depth clear between the outside stage and the
|
||||
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
|
||||
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
|
||||
|
|
@ -711,6 +989,13 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
|||
public Action? ClearDepthForInterior { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
|
||||
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
|
||||
|
||||
/// <summary>#131: Scene-pass draw of UNATTACHED emitters
|
||||
/// (AttachedObjectId == 0) for interior-root frames — invoked once at the
|
||||
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
|
||||
/// GameWindow's dedicated post-frame pass instead.</summary>
|
||||
public Action? DrawUnattachedSceneParticles { get; init; }
|
||||
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
|
||||
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
|
||||
}
|
||||
|
|
@ -727,6 +1012,14 @@ public readonly record struct RetailPViewLandscapeSliceContext(
|
|||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> OutdoorEntities);
|
||||
|
||||
/// <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>
|
||||
public readonly record struct RetailPViewLandscapeLateSliceContext(
|
||||
ClipViewSlice Slice,
|
||||
IReadOnlyList<WorldEntity> Dynamics,
|
||||
IReadOnlyList<WorldEntity> ParticleOwners);
|
||||
|
||||
public readonly record struct RetailPViewCellSliceContext(
|
||||
uint CellId,
|
||||
ClipViewSlice Slice,
|
||||
|
|
|
|||
|
|
@ -46,10 +46,12 @@ layout(std140, binding = 1) uniform SceneLighting {
|
|||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
|
||||
// attenuation inside Range; hard edge at Range; spotlights use a
|
||||
// binary cos-cone test. This is deliberate — the retail "bubble of
|
||||
// light" look relies on crisp boundaries.
|
||||
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the
|
||||
// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly
|
||||
// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside
|
||||
// Range / crisp boundaries" note was a misread; it is the literal cause of
|
||||
// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3
|
||||
// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test.
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int activeLights = int(uCellAmbient.w);
|
||||
|
|
@ -73,7 +75,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
|||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0; // retail: no attenuation inside Range
|
||||
// calc_point_light (1 - dist/falloff_eff) linear ramp; Range already
|
||||
// carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff.
|
||||
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone test.
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
|||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0;
|
||||
// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0,
|
||||
// line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a
|
||||
// LINEAR fade to exactly 0 at the edge. That is what makes a torch a
|
||||
// smooth glow that blends into the ambient instead of a flat disc with
|
||||
// a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7).
|
||||
// falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded
|
||||
// into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp
|
||||
// denominator is just Range and fades to 0 exactly at the cutoff.
|
||||
float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0);
|
||||
if (kind == 2) {
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
|
|
|
|||
|
|
@ -283,6 +283,27 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
|
|||
// when wired, else the no-clip fallback (count 0 = ungated terrain).
|
||||
BindClipUboBinding2();
|
||||
|
||||
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
|
||||
// (0x006b7040) draws each land triangle ONLY when the camera is on the
|
||||
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
|
||||
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
|
||||
// evaluates the same per-triangle eye-side predicate at rasterization.
|
||||
// LandblockMesh emits every triangle CCW in world XY seen from above
|
||||
// (LandblockMeshTests winding pin), which the unified camera chain
|
||||
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
|
||||
// winding from above / CW from below (TerrainCullOrientationTests) —
|
||||
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
|
||||
// underside. WB drew the whole world with culling DISABLED
|
||||
// frame-globally (WB GameScene.cs:841 — an editor camera goes
|
||||
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
|
||||
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
|
||||
// sheet through the exit-door aperture — the #108 grass window.
|
||||
// Self-contained state per feedback_render_self_contained_gl_state;
|
||||
// the frame-global CW + cull-off baseline is restored after the draw.
|
||||
_gl.Enable(EnableCap.CullFace);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.Ccw);
|
||||
|
||||
_gl.BindVertexArray(_globalVao);
|
||||
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
|
||||
_gl.MultiDrawElementsIndirect(
|
||||
|
|
@ -292,6 +313,9 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
|
|||
(uint)sizeof(DrawElementsIndirectCommand));
|
||||
_gl.BindVertexArray(0);
|
||||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -62,6 +62,24 @@ namespace AcDream.App.Rendering.Wb {
|
|||
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
|
||||
public List<MeshBatchData> Batches { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// #125 (2026-06-12): GL upload-retry counter. A failed
|
||||
/// <see cref="ObjectMeshManager.UploadMeshData"/> (returns null from its
|
||||
/// catch) used to be dropped permanently — the staged item was consumed,
|
||||
/// no render data was produced, and the prepared data lingered in the CPU
|
||||
/// cache where <c>PrepareMeshDataAsync</c>'s cache-hit short-circuit
|
||||
/// returned it without ever re-staging it for upload (session-sticky
|
||||
/// invisible mesh, one [wb-error] line). The drain loop now re-stages a
|
||||
/// failed upload for the NEXT frame up to <see cref="ObjectMeshManager.
|
||||
/// MaxUploadRetries"/> times. The counter lives on the mesh-data object so
|
||||
/// it resets to 0 naturally whenever the id is re-prepared (fresh object),
|
||||
/// and bounds a deterministic GL failure to a few loud lines instead of a
|
||||
/// silent permanent drop OR an unbounded per-frame retry storm. Retail
|
||||
/// loads content synchronously and has no such failure mode — this
|
||||
/// converges our async pipeline toward that guarantee.
|
||||
/// </summary>
|
||||
public int UploadAttempts;
|
||||
|
||||
/// <summary>For EnvCell: the geometry of the cell itself.</summary>
|
||||
public ObjectMeshData? EnvCellGeometry { get; set; }
|
||||
|
||||
|
|
@ -216,6 +234,32 @@ namespace AcDream.App.Rendering.Wb {
|
|||
private readonly ConcurrentQueue<ObjectMeshData> _stagedMeshData = new();
|
||||
public ConcurrentQueue<ObjectMeshData> StagedMeshData => _stagedMeshData;
|
||||
|
||||
/// <summary>#125: how many times a failed GL upload is re-staged before
|
||||
/// giving up loudly. Small — a transient GL error clears on the next
|
||||
/// frame; anything that fails this many times is a genuine defect to
|
||||
/// surface, not retry forever. See <see cref="ObjectMeshData.UploadAttempts"/>.</summary>
|
||||
public const int MaxUploadRetries = 3;
|
||||
|
||||
/// <summary>
|
||||
/// #125: drain one staged upload, returning whether it should be
|
||||
/// re-staged for a later frame. The caller (the per-frame Tick drain)
|
||||
/// collects the re-stages and re-enqueues them AFTER the drain loop —
|
||||
/// never inside it — so a deterministic failure can't spin the queue in
|
||||
/// a single frame. Increments the mesh-data's own attempt counter (resets
|
||||
/// on re-prepare) and gives up loudly past <see cref="MaxUploadRetries"/>.
|
||||
/// </summary>
|
||||
public bool UploadOrRequeue(ObjectMeshData meshData) {
|
||||
if (UploadMeshData(meshData) is not null)
|
||||
return false; // success (incl. legitimate 0-vertex → empty render data)
|
||||
if (HasRenderData(meshData.ObjectId))
|
||||
return false; // raced to present by another path
|
||||
meshData.UploadAttempts++;
|
||||
if (meshData.UploadAttempts < MaxUploadRetries)
|
||||
return true; // re-stage for next frame
|
||||
Console.WriteLine($"[up-retry] 0x{meshData.ObjectId:X10} upload failed {meshData.UploadAttempts}x — giving up (was the #125 silent sticky drop; a GL error is being surfaced, not hidden)");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cache for decoded textures to avoid redundant BCn decoding
|
||||
private readonly ConcurrentQueue<uint> _decodedTextureLru = new();
|
||||
private readonly ConcurrentDictionary<uint, byte[]> _decodedTextureCache = new();
|
||||
|
|
|
|||
|
|
@ -244,10 +244,21 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
|
|||
if (_disposed) return;
|
||||
|
||||
_graphicsDevice!.ProcessGLQueue();
|
||||
// #125: drain staged uploads; a FAILED upload (UploadMeshData returned
|
||||
// null from its catch) is re-staged for a LATER frame, not dropped. The
|
||||
// re-stages are collected and re-enqueued AFTER the loop — re-enqueuing
|
||||
// inside the while would let a deterministic failure spin the queue in a
|
||||
// single frame. UploadOrRequeue bounds the retries (MaxUploadRetries) so
|
||||
// a genuine defect surfaces loudly instead of the old silent sticky drop.
|
||||
List<ObjectMeshData>? requeue = null;
|
||||
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
|
||||
{
|
||||
_meshManager.UploadMeshData(meshData);
|
||||
if (_meshManager.UploadOrRequeue(meshData))
|
||||
(requeue ??= new()).Add(meshData);
|
||||
}
|
||||
if (requeue is not null)
|
||||
foreach (var m in requeue)
|
||||
_meshManager.StagedMeshData.Enqueue(m);
|
||||
|
||||
bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled;
|
||||
var pendingBefore = texProbe
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ public abstract record LandblockStreamJob(uint LandblockId)
|
|||
{
|
||||
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
|
||||
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
|
||||
|
||||
/// <summary>
|
||||
/// Control job: drop every queued (not-yet-started) Load from the worker's
|
||||
/// priority queues, keeping Unloads. Posted by
|
||||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
|
||||
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
|
||||
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
|
||||
/// convention; readers pattern-match on the type.
|
||||
/// </summary>
|
||||
public sealed record ClearLoads() : LandblockStreamJob(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -141,6 +141,22 @@ public sealed class LandblockStreamer : IDisposable
|
|||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel every queued-but-not-started Load. Posts a
|
||||
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
|
||||
/// honours at read time, dropping all pending Loads from both priority
|
||||
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
|
||||
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
|
||||
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
|
||||
/// complete; the StreamingController's collapsed-sweep unloads those few.
|
||||
/// </summary>
|
||||
public void ClearPendingLoads()
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain up to <paramref name="maxBatchSize"/> completed results.
|
||||
/// Non-blocking. Call from the render thread once per OnUpdate.
|
||||
|
|
@ -180,7 +196,18 @@ public sealed class LandblockStreamer : IDisposable
|
|||
}
|
||||
|
||||
while (_inbox.Reader.TryRead(out var job))
|
||||
{
|
||||
if (job is LandblockStreamJob.ClearLoads)
|
||||
{
|
||||
// Dungeon-entry cancellation: drop every queued Load,
|
||||
// keep Unloads. Handled at read time so it supersedes
|
||||
// Loads sitting in the priority queues ahead of it.
|
||||
DropLoadJobs(highPriority);
|
||||
DropLoadJobs(lowPriority);
|
||||
continue;
|
||||
}
|
||||
EnqueuePrioritized(job, highPriority, lowPriority);
|
||||
}
|
||||
|
||||
if (highPriority.Count == 0 && lowPriority.Count == 0)
|
||||
continue;
|
||||
|
|
@ -233,6 +260,22 @@ public sealed class LandblockStreamer : IDisposable
|
|||
lowPriority.Enqueue(job);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop every <see cref="LandblockStreamJob.Load"/> from a priority queue,
|
||||
/// preserving Unloads (and any other control jobs). Rotates the queue once
|
||||
/// in place. Used by the <see cref="LandblockStreamJob.ClearLoads"/> path.
|
||||
/// </summary>
|
||||
private static void DropLoadJobs(Queue<LandblockStreamJob> queue)
|
||||
{
|
||||
int count = queue.Count;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var job = queue.Dequeue();
|
||||
if (job is not LandblockStreamJob.Load)
|
||||
queue.Enqueue(job);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveLowPriorityJobsForLandblock(
|
||||
Queue<LandblockStreamJob> queue,
|
||||
uint landblockId,
|
||||
|
|
|
|||
|
|
@ -22,9 +22,24 @@ public sealed class StreamingController
|
|||
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
|
||||
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
|
||||
private readonly Action<uint>? _removeTerrain;
|
||||
private readonly Action? _clearPendingLoads;
|
||||
private readonly GpuWorldState _state;
|
||||
private StreamingRegion? _region;
|
||||
|
||||
// True while streaming is collapsed to the single dungeon landblock the
|
||||
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
|
||||
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
|
||||
// are never visible, so we stop loading the 25×25 window entirely.
|
||||
private bool _collapsed;
|
||||
|
||||
// The dungeon landblock id we collapsed onto. Once collapsed we key the
|
||||
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
|
||||
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
|
||||
// expand on that flicker thrashes collapse↔expand (reload storms + a light
|
||||
// leak). We only expand when the observer actually moves to a different
|
||||
// landblock (teleport/portal out).
|
||||
private uint _collapsedCenter;
|
||||
|
||||
/// <summary>
|
||||
/// Near-tier radius (LBs from observer that load full detail: terrain +
|
||||
/// scenery + entities). Set at construction; readable thereafter.
|
||||
|
|
@ -71,13 +86,15 @@ public sealed class StreamingController
|
|||
GpuWorldState state,
|
||||
int nearRadius,
|
||||
int farRadius,
|
||||
Action<uint>? removeTerrain = null)
|
||||
Action<uint>? removeTerrain = null,
|
||||
Action? clearPendingLoads = null)
|
||||
{
|
||||
_enqueueLoad = enqueueLoad;
|
||||
_enqueueUnload = enqueueUnload;
|
||||
_drainCompletions = drainCompletions;
|
||||
_applyTerrain = applyTerrain;
|
||||
_removeTerrain = removeTerrain;
|
||||
_clearPendingLoads = clearPendingLoads;
|
||||
_state = state;
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
|
|
@ -97,7 +114,76 @@ public sealed class StreamingController
|
|||
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public void Tick(int observerCx, int observerCy)
|
||||
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
|
||||
{
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy);
|
||||
|
||||
if (_collapsed)
|
||||
{
|
||||
// Hysteresis. Cases:
|
||||
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
|
||||
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
|
||||
// → re-collapse onto it.
|
||||
// - CurrCell flickered null but the player hasn't gone anywhere: the
|
||||
// observer landblock reverts to the position-derived value, which for a
|
||||
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
|
||||
// local Y). Hold — never expand on an adjacent flicker.
|
||||
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
|
||||
// from the ocean-grid dungeon block) → expand.
|
||||
if (insideDungeon && centerId != _collapsedCenter)
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
|
||||
ExitDungeonExpand(observerCx, observerCy);
|
||||
else
|
||||
SweepCollapsed();
|
||||
}
|
||||
else if (insideDungeon)
|
||||
{
|
||||
EnterDungeonCollapse(observerCx, observerCy, centerId);
|
||||
}
|
||||
else
|
||||
{
|
||||
NormalTick(observerCx, observerCy);
|
||||
}
|
||||
|
||||
DrainAndApply();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
|
||||
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
|
||||
/// from the login / teleport spawn path the instant the streaming center is
|
||||
/// recentered onto a SEALED dungeon landblock.
|
||||
///
|
||||
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
|
||||
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
|
||||
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
|
||||
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
|
||||
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
|
||||
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
|
||||
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
|
||||
///
|
||||
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
|
||||
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
|
||||
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
|
||||
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
|
||||
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
|
||||
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
|
||||
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
|
||||
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
|
||||
/// same as <see cref="Tick"/>.</para>
|
||||
/// </summary>
|
||||
public void PreCollapseToDungeon(int cx, int cy)
|
||||
{
|
||||
uint centerId = StreamingRegion.EncodeLandblockId(cx, cy);
|
||||
if (_collapsed && _collapsedCenter == centerId) return;
|
||||
EnterDungeonCollapse(cx, cy, centerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor / building-interior streaming — the original two-tier model.
|
||||
/// </summary>
|
||||
private void NormalTick(int observerCx, int observerCy)
|
||||
{
|
||||
if (_region is null)
|
||||
{
|
||||
|
|
@ -116,9 +202,88 @@ public sealed class StreamingController
|
|||
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
|
||||
foreach (var id in diff.ToUnload) _enqueueUnload(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Drain up to N completions per frame so a big diff doesn't spike
|
||||
// GPU upload time. Remaining completions wait for the next frame.
|
||||
/// <summary>
|
||||
/// Dungeon-entry edge: cancel the in-flight window load, unload every
|
||||
/// resident neighbor, and pin streaming to the player's single dungeon
|
||||
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
|
||||
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
|
||||
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
|
||||
/// their thousands of emitters (#133 FPS). Unloading them also tears down
|
||||
/// their lights, shrinking the static-light set toward retail's ≤40.
|
||||
/// </summary>
|
||||
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
|
||||
{
|
||||
_collapsed = true;
|
||||
_collapsedCenter = centerId;
|
||||
_clearPendingLoads?.Invoke();
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (id != centerId) _enqueueUnload(id);
|
||||
|
||||
// Pin a radius-0 region so RecenterTo never re-expands while inside,
|
||||
// and so the post-exit rebuild starts from a clean, consistent state.
|
||||
_region = new StreamingRegion(cx, cy, 0, 0);
|
||||
_region.MarkResidentFromBootstrap();
|
||||
|
||||
// The dungeon landblock itself must be (or become) loaded. If a prior
|
||||
// ClearPendingLoads cancelled its queued load, re-enqueue it.
|
||||
if (!_state.IsLoaded(centerId))
|
||||
_enqueueLoad(centerId, LandblockStreamJobKind.LoadNear);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While collapsed, unload any landblock that finished loading after the
|
||||
/// collapse edge — a Load the worker had already dequeued before the
|
||||
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
|
||||
/// effect. At steady state only the dungeon landblock is resident, so this
|
||||
/// is a no-op.
|
||||
/// </summary>
|
||||
private void SweepCollapsed()
|
||||
{
|
||||
// Always preserve the true dungeon landblock (_collapsedCenter), never the
|
||||
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (id != _collapsedCenter) _enqueueUnload(id);
|
||||
}
|
||||
|
||||
/// <summary>Chebyshev distance in landblock cells between two landblock ids.</summary>
|
||||
private static int ChebyshevLandblocks(uint a, uint b)
|
||||
{
|
||||
int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu);
|
||||
int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu);
|
||||
return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full
|
||||
/// two-tier window at the new center and unload anything resident from the
|
||||
/// collapsed state that falls outside it.
|
||||
/// </summary>
|
||||
private void ExitDungeonExpand(int observerCx, int observerCy)
|
||||
{
|
||||
_collapsed = false;
|
||||
var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
|
||||
|
||||
foreach (var id in _state.LoadedLandblockIds)
|
||||
if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id);
|
||||
|
||||
var boot = rebuilt.ComputeFirstTickDiff();
|
||||
foreach (var id in boot.ToLoadNear)
|
||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
|
||||
foreach (var id in boot.ToLoadFar)
|
||||
if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
|
||||
rebuilt.MarkResidentFromBootstrap();
|
||||
_region = rebuilt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drain up to N completions per frame so a big diff doesn't spike GPU
|
||||
/// upload time. Remaining completions wait for the next frame.
|
||||
/// </summary>
|
||||
private void DrainAndApply()
|
||||
{
|
||||
var drained = _drainCompletions(MaxCompletionsPerFrame);
|
||||
foreach (var result in drained)
|
||||
{
|
||||
|
|
|
|||
105
src/AcDream.App/World/TeleportArrivalController.cs
Normal file
105
src/AcDream.App/World/TeleportArrivalController.cs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
|
||||
public enum ArrivalReadiness
|
||||
{
|
||||
/// <summary>Destination not yet hydrated; keep holding.</summary>
|
||||
NotReady,
|
||||
|
||||
/// <summary>Destination terrain + cell are ready; place now.</summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>The claim can never hydrate (e.g. an indoor cell id outside the dat's
|
||||
/// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
|
||||
/// demote rather than hold forever.</summary>
|
||||
Impossible,
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle of a single teleport arrival.</summary>
|
||||
public enum TeleportArrivalPhase { Idle, Holding }
|
||||
|
||||
/// <summary>
|
||||
/// G.3a (#133) — holds a teleport arrival in portal space until the destination
|
||||
/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
|
||||
/// unconditional snap in <c>GameWindow.OnLivePositionUpdated</c> that resolved the
|
||||
/// arrival against the resident (old) landblocks before the destination hydrated
|
||||
/// and landed the player in ocean.
|
||||
///
|
||||
/// <para>The controller is pure: readiness and placement are injected delegates,
|
||||
/// so it carries no GL / dat / network dependency and is fully unit-testable. The
|
||||
/// player stays input-frozen while this is Holding because the GameWindow keeps
|
||||
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
|
||||
/// InWorld.</para>
|
||||
///
|
||||
/// <para>The timeout is a coarse frame count (not wall-clock) so the controller
|
||||
/// needs no external clock; it is a loud safety net for a never-hydrating
|
||||
/// destination, not a precise deadline.</para>
|
||||
/// </summary>
|
||||
public sealed class TeleportArrivalController
|
||||
{
|
||||
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
|
||||
public const int DefaultMaxHoldFrames = 600;
|
||||
|
||||
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
|
||||
private readonly Action<Vector3, uint, bool> _place; // (destPos, destCell, forced)
|
||||
private readonly int _maxHoldFrames;
|
||||
|
||||
private Vector3 _destPos;
|
||||
private uint _destCell;
|
||||
private int _heldFrames;
|
||||
|
||||
public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
|
||||
|
||||
public TeleportArrivalController(
|
||||
Func<Vector3, uint, ArrivalReadiness> readiness,
|
||||
Action<Vector3, uint, bool> place,
|
||||
int maxHoldFrames = DefaultMaxHoldFrames)
|
||||
{
|
||||
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
|
||||
_place = place ?? throw new ArgumentNullException(nameof(place));
|
||||
_maxHoldFrames = maxHoldFrames;
|
||||
}
|
||||
|
||||
/// <summary>Begin holding for a teleport arrival. Called from OnLivePositionUpdated
|
||||
/// AFTER the streaming origin has been recentered on the destination landblock.
|
||||
/// Re-calling with a fresh server position resets the hold (server-authoritative).</summary>
|
||||
public void BeginArrival(Vector3 destPos, uint destCell)
|
||||
{
|
||||
_destPos = destPos;
|
||||
_destCell = destCell;
|
||||
_heldFrames = 0;
|
||||
Phase = TeleportArrivalPhase.Holding;
|
||||
}
|
||||
|
||||
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
|
||||
/// No-op when Idle.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (Phase != TeleportArrivalPhase.Holding) return;
|
||||
_heldFrames++;
|
||||
|
||||
ArrivalReadiness verdict = _readiness(_destPos, _destCell);
|
||||
if (verdict == ArrivalReadiness.Ready)
|
||||
{
|
||||
Place(forced: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
|
||||
{
|
||||
Place(forced: true);
|
||||
}
|
||||
// else NotReady -> keep holding
|
||||
}
|
||||
|
||||
private void Place(bool forced)
|
||||
{
|
||||
// Flip to Idle BEFORE invoking the placement delegate so the machine
|
||||
// reflects "done holding" even if the delegate were to re-enter Tick.
|
||||
Phase = TeleportArrivalPhase.Idle;
|
||||
_place(_destPos, _destCell, forced);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue