Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196
# Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
217a4bad69
329 changed files with 81439 additions and 8499 deletions
149
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal file
149
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
|
||||
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
|
||||
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
|
||||
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
|
||||
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
|
||||
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
|
||||
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
|
||||
/// needs.
|
||||
///
|
||||
/// <para>Owns a <see cref="TargetManager"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
|
||||
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
|
||||
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
|
||||
/// target seams are repointed here, replacing the AP-79 poll adapter. The
|
||||
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
|
||||
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
|
||||
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
|
||||
///
|
||||
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
|
||||
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
|
||||
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
|
||||
/// behaviorally identical because the empty facade no-ops until its first
|
||||
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
|
||||
/// deliveries to the injected MoveToManager fan FIRST, then the
|
||||
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
|
||||
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
|
||||
/// @0x00512c1a).</para>
|
||||
/// </summary>
|
||||
public sealed class EntityPhysicsHost : IPhysicsObjHost
|
||||
{
|
||||
private readonly Func<Position> _getPosition;
|
||||
private readonly Func<Vector3> _getVelocity;
|
||||
private readonly Func<float> _getRadius;
|
||||
private readonly Func<bool> _inContact;
|
||||
private readonly Func<float?> _minterpMaxSpeed;
|
||||
private readonly Func<double> _curTime;
|
||||
private readonly Func<double> _physicsTimerTime;
|
||||
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
|
||||
private readonly Action<TargetInfo> _handleUpdateTarget;
|
||||
private readonly Action _interruptCurrentMovement;
|
||||
private readonly TargetManager _targetManager;
|
||||
|
||||
public EntityPhysicsHost(
|
||||
uint id,
|
||||
Func<Position> getPosition,
|
||||
Func<Vector3> getVelocity,
|
||||
Func<float> getRadius,
|
||||
Func<bool> inContact,
|
||||
Func<float?> minterpMaxSpeed,
|
||||
Func<double> curTime,
|
||||
Func<double> physicsTimerTime,
|
||||
Func<uint, IPhysicsObjHost?> getObjectA,
|
||||
Action<TargetInfo> handleUpdateTarget,
|
||||
Action interruptCurrentMovement)
|
||||
{
|
||||
Id = id;
|
||||
_getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
|
||||
_getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
|
||||
_getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
|
||||
_inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
|
||||
_minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
|
||||
_curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
|
||||
_physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
|
||||
_getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
|
||||
_handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
|
||||
_interruptCurrentMovement = interruptCurrentMovement
|
||||
?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
|
||||
_targetManager = new TargetManager(this);
|
||||
PositionManager = new PositionManager(this);
|
||||
}
|
||||
|
||||
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
|
||||
public uint Id { get; }
|
||||
public Position Position => _getPosition();
|
||||
public Vector3 Velocity => _getVelocity();
|
||||
public float Radius => _getRadius();
|
||||
public bool InContact => _inContact();
|
||||
public float? MinterpMaxSpeed => _minterpMaxSpeed();
|
||||
public double CurTime => _curTime();
|
||||
public double PhysicsTimerTime => _physicsTimerTime();
|
||||
|
||||
/// <summary>The owned voyeur manager (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>).</summary>
|
||||
public TargetManager TargetManager => _targetManager;
|
||||
|
||||
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
|
||||
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
|
||||
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
|
||||
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
|
||||
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
|
||||
public PositionManager PositionManager { get; }
|
||||
|
||||
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
|
||||
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
|
||||
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
|
||||
// MovementManager (the injected MoveToManager fan) first, then
|
||||
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
|
||||
_handleUpdateTarget(info);
|
||||
PositionManager.HandleUpdateTarget(info);
|
||||
}
|
||||
public void InterruptCurrentMovement() => _interruptCurrentMovement();
|
||||
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
|
||||
|
||||
public void ClearTarget() => _targetManager.ClearTarget();
|
||||
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum)
|
||||
=> _targetManager.AddVoyeur(watcherId, radius, quantum);
|
||||
public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
|
||||
|
||||
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
|
||||
|
||||
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
|
||||
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
|
||||
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
|
||||
/// managers' <c>UseTime</c>.</summary>
|
||||
public void HandleTargetting() => _targetManager.HandleTargetting();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
|
||||
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
|
||||
/// watcher of this entity that it left the world (they drop the
|
||||
/// stick/moveto). Called on despawn before the host is removed from the
|
||||
/// registry.</summary>
|
||||
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
|
||||
|
||||
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
|
||||
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
|
||||
/// (drop this entity's OWN subscription) then
|
||||
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
|
||||
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
|
||||
/// non-Ok teardown path). Called after a teleport placement.</summary>
|
||||
public void NotifyTeleported()
|
||||
{
|
||||
_targetManager.ClearTarget();
|
||||
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -35,8 +35,55 @@ public static class InteriorEntityPartition
|
|||
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
||||
public List<WorldEntity> OutdoorStatic { get; } = new();
|
||||
public List<WorldEntity> Dynamics { get; } = new();
|
||||
|
||||
// MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames
|
||||
// so pruning itself doesn't allocate.
|
||||
private readonly List<uint> _emptyCellScratch = new();
|
||||
|
||||
/// <summary>
|
||||
/// MP-Alloc (2026-07-05): clear every collection in place for reuse
|
||||
/// by <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>.
|
||||
/// The per-cell lists inside <see cref="ByCell"/> are cleared and
|
||||
/// KEPT (not removed) so a steady-state frame with the same visible
|
||||
/// cell set reuses the same List<WorldEntity> instances instead
|
||||
/// of reallocating one per cell every frame.
|
||||
/// </summary>
|
||||
internal void ClearForReuse()
|
||||
{
|
||||
foreach (var list in ByCell.Values)
|
||||
list.Clear();
|
||||
OutdoorStatic.Clear();
|
||||
Dynamics.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MP-Alloc: drop any cell bucket that ended this frame with zero
|
||||
/// entries (either newly emptied, or a leftover key from a previous
|
||||
/// frame's visible-cell set that this frame never touched). Keeps
|
||||
/// ByCell.Count / .Keys bit-identical to the old always-fresh-
|
||||
/// Dictionary behavior — callers that inspect key presence/count
|
||||
/// directly (not just TryGetValue) must see exactly the cells that
|
||||
/// actually received at least one static this frame.
|
||||
/// </summary>
|
||||
internal void PruneEmptyCellBuckets()
|
||||
{
|
||||
_emptyCellScratch.Clear();
|
||||
foreach (var (cellId, list) in ByCell)
|
||||
{
|
||||
if (list.Count == 0)
|
||||
_emptyCellScratch.Add(cellId);
|
||||
}
|
||||
foreach (var cellId in _emptyCellScratch)
|
||||
ByCell.Remove(cellId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allocating overload — always returns a brand-new <see cref="Result"/>.
|
||||
/// Kept for tests and any one-shot caller; the per-frame render path
|
||||
/// uses the <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>
|
||||
/// reuse overload instead (see <see cref="RetailPViewRenderer"/>).
|
||||
/// </summary>
|
||||
public static Result Partition(
|
||||
HashSet<uint> visibleCells,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
|
|
@ -44,6 +91,28 @@ public static class InteriorEntityPartition
|
|||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
||||
{
|
||||
var result = new Result();
|
||||
Partition(result, visibleCells, landblockEntries);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MP-Alloc (2026-07-05): reuse overload. Clears <paramref name="result"/>
|
||||
/// in place (see <see cref="Result.ClearForReuse"/>) and refills it,
|
||||
/// reusing each cell's existing <c>List<WorldEntity></c> when the
|
||||
/// cell key survives from the previous frame instead of allocating a new
|
||||
/// one — the per-cell dictionary entries persist across frames (cleared,
|
||||
/// never removed) since the visible-cell set is usually stable frame to
|
||||
/// frame. Identical partitioning output to the allocating overload; only
|
||||
/// the backing storage is reused.
|
||||
/// </summary>
|
||||
public static void Partition(
|
||||
Result result,
|
||||
HashSet<uint> visibleCells,
|
||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
||||
{
|
||||
result.ClearForReuse();
|
||||
foreach (var entry in landblockEntries)
|
||||
{
|
||||
foreach (var e in entry.Entities)
|
||||
|
|
@ -70,7 +139,8 @@ public static class InteriorEntityPartition
|
|||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
result.PruneEmptyCellBuckets();
|
||||
}
|
||||
|
||||
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
|
||||
|
|
|
|||
|
|
@ -48,6 +48,19 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
|
||||
private float[] _instanceScratch = new float[256 * 16];
|
||||
|
||||
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
|
||||
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
|
||||
// each previously `new`ing a List<ParticleDraw> (BuildDrawList) and a
|
||||
// List<ParticleInstance> (the per-batch `run` list) that became garbage
|
||||
// as soon as the call returned. All Draw() calls happen sequentially on
|
||||
// the render thread (verified: every call site in GameWindow.cs is a
|
||||
// plain synchronous invocation from the single-threaded OnRender chain,
|
||||
// none dispatched via Task.Run/Parallel) and each call fully drains its
|
||||
// lists before returning, so a single pair of reused fields is safe -
|
||||
// no call overlaps another's use of these buffers.
|
||||
private readonly List<ParticleDraw> _drawListScratch = new(64);
|
||||
private readonly List<ParticleInstance> _runScratch = new(64);
|
||||
|
||||
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
|
|
@ -133,6 +146,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
if (draws.Count == 0)
|
||||
return;
|
||||
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
|
||||
// draws IS _drawListScratch (see BuildDrawList) - sorting it in place
|
||||
// is fine, nothing else reads it between BuildDrawList's return and
|
||||
// this call.
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
|
||||
|
|
@ -144,7 +160,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
||||
var run = new List<ParticleInstance>(64);
|
||||
var run = _runScratch;
|
||||
for (int i = 0; i < draws.Count;)
|
||||
{
|
||||
var key = draws[i].Key;
|
||||
|
|
@ -178,7 +194,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
|
|||
Vector3 cameraUp,
|
||||
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
|
||||
{
|
||||
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
|
||||
var draws = _drawListScratch;
|
||||
draws.Clear();
|
||||
foreach (var (em, idx) in particles.EnumerateLive())
|
||||
{
|
||||
if (em.RenderPass != renderPass)
|
||||
|
|
|
|||
|
|
@ -180,10 +180,13 @@ public static class RenderBootstrap
|
|||
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
||||
var classificationCache = new Wb.EntityClassificationCache();
|
||||
|
||||
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
|
||||
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
|
||||
|
||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||
bindless, classificationCache);
|
||||
bindless, classificationCache, translucencyFades);
|
||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||
|
||||
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ namespace AcDream.App.Rendering;
|
|||
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
|
||||
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
|
||||
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
|
||||
/// exponential damping toward a target pose, 5-frame velocity-averaged
|
||||
/// slope-aligned heading frame, mouse-input low-pass filter.
|
||||
/// a STATEFUL sought position that converges from the current swept
|
||||
/// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
|
||||
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
|
||||
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
|
||||
/// filter. Pseudocode:
|
||||
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
|
||||
|
|
@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera
|
|||
private const float SnapEpsilon = 0.000199999995f * 2f;
|
||||
private const float RotCloseEpsilon = 0.000199999995f;
|
||||
|
||||
// ── Damped state ────────────────────────────────────────────────
|
||||
// ── Stateful camera state (retail SmartBox's two Positions) ─────
|
||||
//
|
||||
// _soughtEye = retail viewer_sought_position — the persisted sweep
|
||||
// TARGET, re-derived each frame from the current swept
|
||||
// viewer (NOT from itself).
|
||||
// _publishedEye = retail viewer — the swept, published eye; the base
|
||||
// of next frame's interpolation (SmartBox::
|
||||
// PlayerPhysicsUpdatedCallback passes &this->viewer
|
||||
// into UpdateCamera, 0x00452d75).
|
||||
// _dampedForward = the sought's look direction. Sweeps translate but
|
||||
// never rotate, so the viewer's rotation is always the
|
||||
// previous sought rotation — one field serves both.
|
||||
|
||||
private readonly Vector3[] _velocityRing = new Vector3[5];
|
||||
private int _velocityCount;
|
||||
private Vector3 _dampedEye;
|
||||
private Vector3 _soughtEye;
|
||||
private Vector3 _publishedEye;
|
||||
private Vector3 _dampedForward = new(1f, 0f, 0f);
|
||||
private bool _initialised;
|
||||
|
||||
|
|
@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera
|
|||
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
|
||||
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
|
||||
|
||||
// 5. Exponential damping (independent translation + rotation rates).
|
||||
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
|
||||
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
|
||||
// desired pose and assigns the result to viewer_sought_position
|
||||
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
|
||||
// target converges onto whatever the collision produced last frame
|
||||
// and re-extends gradually. The full-length ideal boom is never swept
|
||||
// directly. Pseudocode:
|
||||
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
|
||||
if (!_initialised)
|
||||
{
|
||||
_dampedEye = targetEye;
|
||||
_soughtEye = targetEye;
|
||||
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
|
||||
_dampedForward = targetForward;
|
||||
_initialised = true;
|
||||
}
|
||||
|
|
@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera
|
|||
{
|
||||
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
|
||||
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
|
||||
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
|
||||
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
|
||||
// VIEWER (0x00456fae), not the previous sought. The forward base is the
|
||||
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
|
||||
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
|
||||
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
|
||||
|
||||
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
|
||||
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
|
||||
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
|
||||
(_dampedEye, _dampedForward, _) =
|
||||
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
|
||||
// Retail UpdateCamera dead-band (0x00456fcd–0x00457035): once the step
|
||||
// off the viewer is sub-epsilon in translation AND rotation, the sought
|
||||
// parks EXACTLY ON the viewer — an exact fixed point instead of an
|
||||
// asymptote. Kills the at-rest drift AND the residual micro-jitter when
|
||||
// pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
|
||||
(_soughtEye, _dampedForward, _) =
|
||||
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
|
||||
}
|
||||
|
||||
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
|
||||
// (0x00453ce0) keeps TWO states: viewer_sought_position (the damped
|
||||
// desired eye) and viewer (the published eye = set_viewer(curr_pos)).
|
||||
// The collision produces the PUBLISHED eye each frame but must NOT
|
||||
// feed back into the damped state — writing the clamped result into
|
||||
// _dampedEye makes next frame's lerp start from the wall and fight
|
||||
// the clamp, which shows up as visible oscillation/vibration when the
|
||||
// eye is pressed against a wall. So collide into a separate local and
|
||||
// leave _dampedEye as the clean, uncollided sought position.
|
||||
Vector3 publishedEye = _dampedEye;
|
||||
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
|
||||
// (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
|
||||
// and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
|
||||
// — the sought is NOT reset on success). Pressed against a wall, the
|
||||
// sweep ray extends only one interpolation step past the contact, so a
|
||||
// knife-edge r±ε graze can move the eye by at most that step (sub-mm at
|
||||
// high fps) instead of re-solving the full-length boom with its 0.27 m
|
||||
// bistable contact pair — the #180 strobe fix.
|
||||
Vector3 publishedEye = _soughtEye;
|
||||
// The viewer cell defaults to the player cell (collision off / null probe); the sweep
|
||||
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
|
||||
// robust per-frame "which cell is the camera in?" answer.
|
||||
ViewerCellId = cellId;
|
||||
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
|
||||
{
|
||||
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
|
||||
var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
|
||||
publishedEye = swept.Eye;
|
||||
ViewerCellId = swept.ViewerCellId;
|
||||
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
|
||||
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
|
||||
// ViewerCellId == 0): the sought resets to the returned position and
|
||||
// re-extends from there.
|
||||
if (swept.ViewerCellId == 0)
|
||||
_soughtEye = swept.Eye;
|
||||
}
|
||||
// Retail viewer — the base of next frame's interpolation (step 5).
|
||||
_publishedEye = publishedEye;
|
||||
|
||||
// 6. Publish renderer surface (from the collided eye; rotation stays the
|
||||
// smoothly-damped look direction toward the pivot).
|
||||
|
|
@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
|
||||
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
|
||||
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
|
||||
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
|
||||
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
|
||||
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
|
||||
/// Both conditions are required (retail couples origin + rotation in the snap test),
|
||||
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd–0x00457035).
|
||||
/// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
|
||||
/// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
|
||||
/// is below <see cref="SnapEpsilon"/> AND the rotation step is below
|
||||
/// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
|
||||
/// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
|
||||
/// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
|
||||
/// Both conditions are required (retail couples origin + rotation in the test),
|
||||
/// so the boom keeps converging while the heading is still turning.
|
||||
/// </summary>
|
||||
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
|
||||
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
|
||||
Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
|
||||
{
|
||||
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
|
||||
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
|
||||
bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
|
||||
bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
|
||||
if (translationConverged && rotationConverged)
|
||||
return (dampedEye, dampedForward, true); // freeze: exact fixed point
|
||||
return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
|
||||
return (candidateEye, candidateForward, false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ public sealed class RetailPViewRenderer
|
|||
// (statics + outside-stage dynamics passing the slice cone).
|
||||
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
|
||||
|
||||
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
|
||||
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
|
||||
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
|
||||
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
|
||||
// place and reuses each cell's list across frames when the cell stays
|
||||
// visible.
|
||||
private readonly InteriorEntityPartition.Result _partitionResult = new();
|
||||
|
||||
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
|
||||
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
|
||||
// call. Every consumer (DrawEntityBucket, DrawExitPortalMasks,
|
||||
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
|
||||
// synchronously within the same frame it was built.
|
||||
private readonly HashSet<uint> _drawableCellsScratch = 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
|
||||
|
|
@ -110,7 +125,9 @@ public sealed class RetailPViewRenderer
|
|||
// so every visible cell's shell has a prepared batch and seals — killing the grey
|
||||
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
|
||||
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
|
||||
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
|
||||
_drawableCellsScratch.Clear();
|
||||
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
|
||||
var drawableCells = _drawableCellsScratch;
|
||||
UseIndoorMembershipOnlyRouting();
|
||||
|
||||
// #124: look-in cells need prepared shell batches + their statics routed
|
||||
|
|
@ -129,6 +146,11 @@ public sealed class RetailPViewRenderer
|
|||
prepareCells = _lookInPrepareScratch;
|
||||
}
|
||||
|
||||
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
|
||||
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
|
||||
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
|
||||
// is built once per frame in GameWindow, player-anchored.)
|
||||
|
||||
_envCells.PrepareRenderBatches(
|
||||
ctx.ViewProjection,
|
||||
ctx.CameraWorldPosition,
|
||||
|
|
@ -137,7 +159,8 @@ public sealed class RetailPViewRenderer
|
|||
centerLbY: ctx.RenderCenterLbY,
|
||||
renderRadius: ctx.RenderRadius);
|
||||
|
||||
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
|
||||
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
|
||||
var partition = _partitionResult;
|
||||
var result = new RetailPViewFrameResult
|
||||
{
|
||||
PortalFrame = pvFrame,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ in vec3 vWorldPos;
|
|||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||
in flat uvec2 vTextureHandle;
|
||||
in flat uint vTextureLayer;
|
||||
in flat float vOpacityMultiplier; // #188
|
||||
|
||||
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
|
||||
// 0 = opaque pass — discard fragments with alpha < 0.95
|
||||
|
|
@ -16,6 +17,7 @@ in flat uint vTextureLayer;
|
|||
// alpha < 0.05 (skip empty fragments — large
|
||||
// transparent overdraw cost otherwise)
|
||||
uniform int uRenderPass;
|
||||
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
|
||||
|
||||
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
|
||||
struct Light {
|
||||
|
|
@ -85,6 +87,14 @@ void main() {
|
|||
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
|
||||
vec3 lit = vLit;
|
||||
|
||||
// #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
|
||||
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
|
||||
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
|
||||
if (uLightDebug == 3) {
|
||||
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
|
||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
|
|
@ -93,5 +103,9 @@ void main() {
|
|||
|
||||
vec3 rgb = color.rgb * lit;
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
FragColor = vec4(rgb, color.a);
|
||||
// #188: multiply the FINAL alpha only — the discard thresholds above stay
|
||||
// keyed on the raw sampled color.a, so the last few frames of a fade
|
||||
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
|
||||
// than popping invisible early against the discard cutoff.
|
||||
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
|||
uint instanceIndoor[];
|
||||
};
|
||||
|
||||
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
|
||||
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
|
||||
// while a TransparentPartHook translucency fade is in flight for the
|
||||
// entity/part this instance belongs to (e.g. the "fading wall" secret-
|
||||
// passage doors). Multiplied against the sampled texture alpha in
|
||||
// mesh_modern.frag.
|
||||
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
|
||||
float instanceAlpha[];
|
||||
};
|
||||
|
||||
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
|
||||
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
|
||||
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
|
||||
|
|
@ -132,6 +142,11 @@ uniform mat4 uViewProjection;
|
|||
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
|
||||
uniform int uDrawIDOffset;
|
||||
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
|
||||
// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
|
||||
// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
|
||||
// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
|
||||
// 3 = handled in the frag (raw vLit visualization, texture ignored).
|
||||
uniform int uLightDebug;
|
||||
|
||||
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
|
||||
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
|
||||
|
|
@ -178,6 +193,7 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
|||
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
|
||||
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
|
||||
if (L.coneAngleEtc.y > 0.5) {
|
||||
if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
if (ndl <= 0.0) return vec3(0.0);
|
||||
|
|
@ -214,6 +230,7 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
|
|||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
|
||||
|
||||
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
|
||||
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
|
||||
|
|
@ -259,10 +276,12 @@ out vec3 vWorldPos;
|
|||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||
out flat uvec2 vTextureHandle;
|
||||
out flat uint vTextureLayer;
|
||||
out flat float vOpacityMultiplier; // #188
|
||||
|
||||
void main() {
|
||||
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||||
mat4 model = Instances[instanceIndex].transform;
|
||||
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||||
|
||||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||||
gl_Position = uViewProjection * worldPos;
|
||||
|
|
|
|||
|
|
@ -1,191 +0,0 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts acdream's <see cref="DatCollection"/> to WB's <see cref="IDatReaderWriter"/> interface.
|
||||
///
|
||||
/// O-D7 fallback path: taken because ObjectMeshManager has 26 _dats.X call sites (threshold is 20),
|
||||
/// making a full refactor to DatCollection larger than spec permits in a single task.
|
||||
/// This adapter lets ObjectMeshManager stay byte-identical to the WB original while
|
||||
/// routing all DAT I/O through our single DatCollection. The adapter is dropped in T7
|
||||
/// when the WorldBuilder project reference is removed entirely.
|
||||
/// </summary>
|
||||
internal sealed class DatCollectionAdapter : IDatReaderWriter
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
private readonly DatDatabaseWrapper _portal;
|
||||
private readonly DatDatabaseWrapper _cell;
|
||||
private readonly DatDatabaseWrapper _highRes;
|
||||
private readonly DatDatabaseWrapper _language;
|
||||
private readonly ReadOnlyDictionary<uint, IDatDatabase> _cellRegions;
|
||||
|
||||
public DatCollectionAdapter(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
_dats = dats;
|
||||
_portal = new DatDatabaseWrapper(dats.Portal);
|
||||
_cell = new DatDatabaseWrapper(dats.Cell);
|
||||
_highRes = new DatDatabaseWrapper(dats.HighRes);
|
||||
_language = new DatDatabaseWrapper(dats.Local);
|
||||
|
||||
// DatCollection has a single Cell, not multiple cell regions.
|
||||
// Expose it as region 0 to satisfy callers that iterate CellRegions.
|
||||
var regions = new Dictionary<uint, IDatDatabase> { [0u] = _cell };
|
||||
_cellRegions = new ReadOnlyDictionary<uint, IDatDatabase>(regions);
|
||||
}
|
||||
|
||||
/// <summary>Source directory of the underlying DatCollection.</summary>
|
||||
public string SourceDirectory => _dats.Options.DatDirectory ?? string.Empty;
|
||||
|
||||
public IDatDatabase Portal => _portal;
|
||||
public ReadOnlyDictionary<uint, IDatDatabase> CellRegions => _cellRegions;
|
||||
public IDatDatabase HighRes => _highRes;
|
||||
public IDatDatabase Language => _language;
|
||||
|
||||
// RegionFileMap is used by some WB internals but not by ObjectMeshManager.
|
||||
public ReadOnlyDictionary<uint, uint> RegionFileMap =>
|
||||
new ReadOnlyDictionary<uint, uint>(new Dictionary<uint, uint>());
|
||||
|
||||
// Iteration properties — not used by ObjectMeshManager, so delegate to 0.
|
||||
public int PortalIteration => 0;
|
||||
public int CellIteration => 0;
|
||||
public int HighResIteration => 0;
|
||||
public int LanguageIteration => 0;
|
||||
|
||||
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
// Route to cell db (the only region we expose)
|
||||
return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a DAT id to all databases that contain it, along with the DBObjType.
|
||||
/// Mirrors DefaultDatReaderWriter.ResolveId — checks each underlying DatDatabase
|
||||
/// via DatDatabase.TypeFromId (which reads the type range tables).
|
||||
/// </summary>
|
||||
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id)
|
||||
{
|
||||
var results = new List<IDatReaderWriter.IdResolution>();
|
||||
|
||||
void CheckDb(DatDatabaseWrapper wrapper)
|
||||
{
|
||||
var rawDb = wrapper.RawDatabase;
|
||||
if (rawDb.Tree.TryGetFile(id, out _))
|
||||
{
|
||||
var type = rawDb.TypeFromId(id);
|
||||
if (type != DBObjType.Unknown)
|
||||
results.Add(new IDatReaderWriter.IdResolution(wrapper, type));
|
||||
}
|
||||
}
|
||||
|
||||
// Match DefaultDatReaderWriter ordering: HighRes → Portal → Language → Cell
|
||||
CheckDb(_highRes);
|
||||
CheckDb(_portal);
|
||||
CheckDb(_language);
|
||||
CheckDb(_cell);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException("DatCollectionAdapter is read-only.");
|
||||
|
||||
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException("DatCollectionAdapter is read-only.");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// The underlying DatCollection is owned by the caller — do not dispose it here.
|
||||
// Individual wrapper objects hold no unmanaged resources.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="DatDatabase"/> as <see cref="IDatDatabase"/>.
|
||||
/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase but lives in our namespace
|
||||
/// so the WorldBuilder project reference can be dropped in T7.
|
||||
/// </summary>
|
||||
internal sealed class DatDatabaseWrapper : IDatDatabase
|
||||
{
|
||||
private readonly DatDatabase _db;
|
||||
private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public DatDatabaseWrapper(DatDatabase db)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(db);
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>Exposes the raw DatDatabase for ResolveId's Tree.TryGetFile + TypeFromId calls.</summary>
|
||||
internal DatDatabase RawDatabase => _db;
|
||||
|
||||
public DatDatabase Db => _db;
|
||||
public int Iteration => _db.Iteration?.CurrentIteration ?? 0;
|
||||
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
|
||||
_db.GetAllIdsOfType<T>();
|
||||
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
if (_cache.TryGetValue((typeof(T), fileId), out var cached))
|
||||
{
|
||||
value = (T)cached;
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_db.TryGet<T>(fileId, out value))
|
||||
{
|
||||
_cache.TryAdd((typeof(T), fileId), value);
|
||||
return true;
|
||||
}
|
||||
|
||||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||||
// a miss for an id whose BTree entry EXISTS is always an anomaly —
|
||||
// either Unpack returned false or the lookup flickered transiently.
|
||||
// Legit not-found probes (e.g. Portal→HighRes fallback) stay silent.
|
||||
if (_db.Tree.TryGetFile(fileId, out _))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[dat-miss] {typeof(T).Name} 0x{fileId:X8} entry EXISTS but TryGet failed " +
|
||||
$"(thread={Environment.CurrentManagedThreadId})");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _db.TryGetFileBytes(fileId, out value);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
|
||||
throw new NotSupportedException("DatDatabaseWrapper is read-only.");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// The underlying DatDatabase is owned by DatCollection — do not dispose here.
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb {
|
||||
public static class EdgeLineBuilder {
|
||||
public static List<Vector3> BuildEdgeLines(CellStruct cellStruct) {
|
||||
var edgeMap = new Dictionary<EdgeKey, List<Edge>>();
|
||||
|
||||
foreach (var kvp in cellStruct.Polygons) {
|
||||
var polyIdx = kvp.Key;
|
||||
var vertexIds = kvp.Value.VertexIds;
|
||||
|
||||
var v0 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[0]].Origin;
|
||||
|
||||
// AC polys can either be triangles or triangle fans
|
||||
for (var i = 1; i < vertexIds.Count - 1; i++) {
|
||||
var v1 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[i]].Origin;
|
||||
var v2 = cellStruct.VertexArray.Vertices[(ushort)vertexIds[i + 1]].Origin;
|
||||
|
||||
AddEdge(edgeMap, polyIdx, v0, v1);
|
||||
AddEdge(edgeMap, polyIdx, v1, v2);
|
||||
AddEdge(edgeMap, polyIdx, v2, v0);
|
||||
}
|
||||
}
|
||||
|
||||
var output = new List<Vector3>();
|
||||
var processedEdges = new HashSet<EdgeKey>();
|
||||
|
||||
foreach (var kvp in edgeMap) {
|
||||
var edgeKey = kvp.Key;
|
||||
var edgeList = kvp.Value;
|
||||
|
||||
if (processedEdges.Contains(edgeKey)) continue;
|
||||
|
||||
processedEdges.Add(edgeKey);
|
||||
|
||||
if (edgeList.Count == 2) {
|
||||
var poly1 = cellStruct.Polygons[edgeList[0].PolyIdx];
|
||||
var poly2 = cellStruct.Polygons[edgeList[1].PolyIdx];
|
||||
|
||||
if (HaveSameTexture(poly1, poly2) && IsCoplanar(poly1, poly2, cellStruct))
|
||||
continue;
|
||||
}
|
||||
|
||||
output.Add(edgeList[0].P0);
|
||||
output.Add(edgeList[0].P1);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void AddEdge(Dictionary<EdgeKey, List<Edge>> edgeMap, ushort polyIdx, Vector3 p0, Vector3 p1) {
|
||||
var key = new EdgeKey(p0, p1);
|
||||
var edge = new Edge(polyIdx, p0, p1);
|
||||
|
||||
if (!edgeMap.ContainsKey(key))
|
||||
edgeMap[key] = new List<Edge>();
|
||||
|
||||
edgeMap[key].Add(edge);
|
||||
}
|
||||
|
||||
private static bool HaveSameTexture(Polygon a, Polygon b) {
|
||||
return a.PosSurface == b.PosSurface;
|
||||
}
|
||||
|
||||
private static Vector3 CalculateNormal(Polygon poly, CellStruct cellStruct) {
|
||||
var vertexIds = poly.VertexIds;
|
||||
var verts = cellStruct.VertexArray.Vertices;
|
||||
|
||||
var v0 = verts[(ushort)vertexIds[0]];
|
||||
var v1 = verts[(ushort)vertexIds[1]];
|
||||
var v2 = verts[(ushort)vertexIds[2]];
|
||||
|
||||
var edge1 = v1.Origin - v0.Origin;
|
||||
var edge2 = v2.Origin - v0.Origin;
|
||||
return Vector3.Normalize(Vector3.Cross(edge1, edge2));
|
||||
}
|
||||
|
||||
private static bool IsCoplanar(Polygon a, Polygon b, CellStruct cellStruct) {
|
||||
var normA = CalculateNormal(a, cellStruct);
|
||||
var normB = CalculateNormal(b, cellStruct);
|
||||
|
||||
var dp = Vector3.Dot(normA, normB);
|
||||
|
||||
// If dot product is 1 or -1, normals are parallel (coplanar)
|
||||
// Allow for both same and opposite facing normals
|
||||
const float tolerance = 0.01f;
|
||||
return Math.Abs(Math.Abs(dp) - 1) < tolerance;
|
||||
}
|
||||
|
||||
private class Edge {
|
||||
public ushort PolyIdx { get; }
|
||||
public Vector3 P0 { get; }
|
||||
public Vector3 P1 { get; }
|
||||
|
||||
public Edge(ushort polyIdx, Vector3 p0, Vector3 p1) {
|
||||
PolyIdx = polyIdx;
|
||||
P0 = p0;
|
||||
P1 = p1;
|
||||
}
|
||||
}
|
||||
|
||||
private class EdgeKey : IEquatable<EdgeKey> {
|
||||
private readonly Vector3 _p0;
|
||||
private readonly Vector3 _p1;
|
||||
|
||||
public EdgeKey(Vector3 p0, Vector3 p1) {
|
||||
if (CompareVector3(p0, p1) > 0) {
|
||||
_p0 = p1;
|
||||
_p1 = p0;
|
||||
}
|
||||
else {
|
||||
_p0 = p0;
|
||||
_p1 = p1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Equals(EdgeKey? e) {
|
||||
if (e == null) return false;
|
||||
return _p0 == e._p0 && _p1 == e._p1;
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return HashCode.Combine(_p0, _p1);
|
||||
}
|
||||
|
||||
private static int CompareVector3(Vector3 a, Vector3 b) {
|
||||
if (a.X != b.X) return a.X.CompareTo(b.X);
|
||||
if (a.Y != b.Y) return a.Y.CompareTo(b.Y);
|
||||
return a.Z.CompareTo(b.Z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -878,6 +878,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_shader.SetInt("uRenderPass", (int)renderPass);
|
||||
_shader.SetInt("uFilterByCell", 0);
|
||||
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
|
||||
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
|
||||
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
|
||||
|
||||
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
|
||||
// moving"): upload uViewProjection HERE rather than inheriting it from
|
||||
|
|
@ -890,7 +892,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
|
||||
|
||||
var allInstances = new List<InstanceData>();
|
||||
var drawCalls = new List<(ObjectRenderData renderData, int count, int offset)>();
|
||||
var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>();
|
||||
|
||||
if (filter == null)
|
||||
{
|
||||
|
|
@ -902,7 +904,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||||
if (renderData != null && !renderData.IsSetup)
|
||||
{
|
||||
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
|
||||
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
|
||||
allInstances.AddRange(transforms);
|
||||
}
|
||||
}
|
||||
|
|
@ -950,12 +952,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
var renderData = _meshManager.TryGetRenderData(gfxObjId);
|
||||
if (renderData != null && !renderData.IsSetup)
|
||||
{
|
||||
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
|
||||
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
|
||||
allInstances.AddRange(transforms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #176 seam-draw probe: stash this call's filter so the opaque-pass
|
||||
// emitter inside RenderModernMDIInternal can report flood membership
|
||||
// per target cell (null on the unfiltered/outdoor path).
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||
_seamProbeFilter = filter;
|
||||
|
||||
// WB EnvCellRenderManager.cs:470-483:
|
||||
if (allInstances.Count > 0)
|
||||
{
|
||||
|
|
@ -1085,7 +1093,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
{
|
||||
Vector3 center = (b.Min + b.Max) * 0.5f;
|
||||
float radius = (b.Max - b.Min).Length() * 0.5f;
|
||||
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
|
||||
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
|
||||
// dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
|
||||
// let the portal set flip as the flood shifted → floor-lighting flap.
|
||||
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
|
||||
}
|
||||
_cellLightSetCache[cellId] = set;
|
||||
return set;
|
||||
|
|
@ -1100,7 +1111,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
|
||||
private void RenderModernMDIInternal(
|
||||
AcDream.App.Rendering.Shader shader,
|
||||
List<(ObjectRenderData renderData, int count, int offset)> drawCalls,
|
||||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||||
List<InstanceData> allInstances,
|
||||
WbRenderPass renderPass)
|
||||
{
|
||||
|
|
@ -1297,7 +1308,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
|
||||
if (_clipSlotData.Length < uniqueInstanceCount)
|
||||
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
|
||||
if (_cellIdToSlot is null)
|
||||
// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell
|
||||
// instance to slot 0 (no-clip) — retail draws cell shells WHOLE.
|
||||
if (_cellIdToSlot is null
|
||||
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
|
||||
{
|
||||
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
|
||||
}
|
||||
|
|
@ -1327,6 +1341,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
|
||||
}
|
||||
|
||||
// #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light
|
||||
// sets read through the just-cleared cache against THIS frame's
|
||||
// _pointSnapshot — the exact data the SSBO upload below carries.
|
||||
if (renderPass == WbRenderPass.Opaque
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
|
||||
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
|
||||
|
||||
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
|
||||
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||||
int glUploadCount = lightCount > 0 ? lightCount : 1;
|
||||
|
|
@ -1416,6 +1437,104 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus.
|
||||
// The in-engine replacement for the RenderDoc pixel-history the pipeline
|
||||
// can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern
|
||||
// startup gate throws). Per opaque pass: for each target cell — flood
|
||||
// membership, every shell instance (count + translation, F3 z shows the
|
||||
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
|
||||
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
|
||||
// intensity; raw indices shuffle when the pool rebuilds). Plus the
|
||||
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
|
||||
// ~1–2). Change-deduped block with a 2 s heartbeat: a purple identity
|
||||
// flipping with flood membership = the snapshot-scope mechanism; two
|
||||
// coincident instances = the z-fight. See RenderingDiagnostics.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private HashSet<uint>? _seamProbeFilter;
|
||||
private string? _seamSig;
|
||||
private long _seamLastEmitMs;
|
||||
|
||||
private void EmitSeamDrawProbe(
|
||||
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
|
||||
List<InstanceData> allInstances,
|
||||
HashSet<uint>? filter)
|
||||
{
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var snap = _pointSnapshot;
|
||||
var sb = new System.Text.StringBuilder(640);
|
||||
|
||||
var sorted = new List<uint>(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells);
|
||||
sorted.Sort();
|
||||
foreach (uint cell in sorted)
|
||||
{
|
||||
sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8"));
|
||||
sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N'));
|
||||
|
||||
int totalInst = 0;
|
||||
foreach (var dc in drawCalls)
|
||||
{
|
||||
int n = 0;
|
||||
InstanceData first = default;
|
||||
for (int i = dc.offset; i < dc.offset + dc.count; i++)
|
||||
{
|
||||
if (allInstances[i].CellId != cell) continue;
|
||||
if (n == 0) first = allInstances[i];
|
||||
n++;
|
||||
}
|
||||
if (n == 0) continue;
|
||||
totalInst += n;
|
||||
var t = first.Transform.Translation;
|
||||
sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})",
|
||||
dc.gfxObjId, n, t.X, t.Y, t.Z);
|
||||
}
|
||||
if (totalInst == 0) sb.Append(" inst=0");
|
||||
|
||||
// The 8-light set this cell's instances carry (fresh: the per-pass
|
||||
// cache was cleared at the top of RenderModernMDIInternal).
|
||||
int[] set = GetCellLightSet(cell);
|
||||
sb.Append(" L=[");
|
||||
bool any = false;
|
||||
for (int k = 0; k < set.Length; k++)
|
||||
{
|
||||
int idx = set[k];
|
||||
if (idx < 0) continue;
|
||||
if (any) sb.Append(',');
|
||||
if (snap is not null && idx < snap.Count)
|
||||
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||
else
|
||||
sb.Append('?').Append(idx);
|
||||
any = true;
|
||||
}
|
||||
sb.Append(']');
|
||||
}
|
||||
|
||||
sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=[");
|
||||
if (snap is not null)
|
||||
{
|
||||
bool anyHot = false;
|
||||
for (int i = 0; i < snap.Count; i++)
|
||||
{
|
||||
var ls = snap[i];
|
||||
if (ls.Intensity < 50f) continue;
|
||||
if (anyHot) sb.Append(',');
|
||||
sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})",
|
||||
ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z);
|
||||
anyHot = true;
|
||||
}
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
string sig = sb.ToString();
|
||||
long now = System.Environment.TickCount64;
|
||||
bool changed = sig != _seamSig;
|
||||
if (!changed && (now - _seamLastEmitMs) < 2000) return;
|
||||
_seamSig = sig;
|
||||
_seamLastEmitMs = now;
|
||||
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetCullMode
|
||||
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.Content;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
using Silk.NET.OpenGL;
|
||||
using System;
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
using DatReaderWriter;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Lib.IO;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
// Phase O-T7: verbatim copy of WorldBuilder.Shared.Services.IDatReaderWriter +
|
||||
// IDatDatabase into the AcDream.App.Rendering.Wb namespace so the
|
||||
// WorldBuilder.Shared project reference can be dropped.
|
||||
// The only consumer of IDatReaderWriter in acdream is DatCollectionAdapter +
|
||||
// ObjectMeshManager, both already in this namespace.
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for the dat reader/writer
|
||||
/// </summary>
|
||||
public interface IDatReaderWriter : IDisposable {
|
||||
/// <summary>
|
||||
/// Gets the source directory of the DAT files.
|
||||
/// </summary>
|
||||
string SourceDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the raw bytes of a file from a specific region database.
|
||||
/// </summary>
|
||||
bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead);
|
||||
|
||||
/// <summary>
|
||||
/// The portal database
|
||||
/// </summary>
|
||||
IDatDatabase Portal { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The cell region databases. Each key is a cell region ID
|
||||
/// </summary>
|
||||
ReadOnlyDictionary<uint, IDatDatabase> CellRegions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The high res database
|
||||
/// </summary>
|
||||
IDatDatabase HighRes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The language database
|
||||
/// </summary>
|
||||
IDatDatabase Language { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A mapping of region ids to region dat file entry ids. key: region id, value: region dat file entry
|
||||
/// </summary>
|
||||
ReadOnlyDictionary<uint, uint> RegionFileMap { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current portal iteration.
|
||||
/// </summary>
|
||||
int PortalIteration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current cell iteration (from the first cell region).
|
||||
/// </summary>
|
||||
int CellIteration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current high res iteration.
|
||||
/// </summary>
|
||||
int HighResIteration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current language iteration.
|
||||
/// </summary>
|
||||
int LanguageIteration { get; }
|
||||
|
||||
// Write-path methods — preserved from WB's interface for verbatim
|
||||
// compatibility but not exercised by ObjectMeshManager in acdream.
|
||||
/// <summary>Attempts to save a database object to the appropriate DAT.</summary>
|
||||
bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj;
|
||||
|
||||
/// <summary>Attempts to save a database object to the appropriate DAT for a specific region.</summary>
|
||||
bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution of a data ID to a database and type
|
||||
/// </summary>
|
||||
public record IdResolution(IDatDatabase Database, DBObjType Type);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a data ID to all possible databases and types.
|
||||
/// </summary>
|
||||
public IEnumerable<IdResolution> ResolveId(uint id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a dat database, providing methods to retrieve files and objects.
|
||||
/// </summary>
|
||||
public interface IDatDatabase : IDisposable {
|
||||
DatDatabase Db { get; }
|
||||
|
||||
/// <summary>Retrieves the current iteration of the database.</summary>
|
||||
int Iteration { get; }
|
||||
|
||||
/// <summary>Retrieves all file IDs of a specific type.</summary>
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj;
|
||||
|
||||
/// <summary>Attempts to retrieve a database object by its file ID.</summary>
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj;
|
||||
|
||||
/// <summary>Attempts to retrieve the raw bytes of a file by its ID.</summary>
|
||||
bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value);
|
||||
|
||||
/// <summary>Attempts to retrieve the raw bytes of a file by its ID into a provided buffer.</summary>
|
||||
bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead);
|
||||
|
||||
/// <summary>Attempts to save a database object.</summary>
|
||||
bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
using AcDream.Content;
|
||||
using Chorizite.Core.Render;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
using DatReaderWriter.Enums;
|
||||
|
|
@ -94,27 +95,5 @@ namespace AcDream.App.Rendering.Wb {
|
|||
_refCounts.Clear();
|
||||
_freeSlots.Clear();
|
||||
}
|
||||
|
||||
public struct TextureKey : IEquatable<TextureKey> {
|
||||
public uint SurfaceId;
|
||||
public uint PaletteId;
|
||||
public StipplingType Stippling;
|
||||
public bool IsSolid;
|
||||
|
||||
public bool Equals(TextureKey other) {
|
||||
return SurfaceId == other.SurfaceId &&
|
||||
PaletteId == other.PaletteId &&
|
||||
Stippling == other.Stippling &&
|
||||
IsSolid == other.IsSolid;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) {
|
||||
return obj is TextureKey other && Equals(other);
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// miss-populate / hit-fast-path through the loop.
|
||||
private readonly EntityClassificationCache _cache;
|
||||
|
||||
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
|
||||
// secret-passage walls). ClassifyBatches reads this per part to compute
|
||||
// the instance's opacity multiplier; never mutated here.
|
||||
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
|
||||
|
||||
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
|
||||
// entity through the slow path. Read once in ctor.
|
||||
private readonly bool _tier1CacheDisabled =
|
||||
|
|
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
|
||||
private uint _instIndoorSsbo;
|
||||
private uint[] _indoorData = new uint[256];
|
||||
|
||||
// #188: per-instance opacity multiplier (binding=7), one float per
|
||||
// instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own
|
||||
// material/texture alpha, untouched); < 1.0 multiplies the shader's
|
||||
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
|
||||
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
|
||||
private uint _instAlphaSsbo;
|
||||
private float[] _alphaData = new float[256];
|
||||
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
||||
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
||||
private IReadOnlyList<LightSource>? _pointSnapshot;
|
||||
|
|
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
WbMeshAdapter meshAdapter,
|
||||
EntitySpawnAdapter entitySpawnAdapter,
|
||||
BindlessSupport bindless,
|
||||
EntityClassificationCache classificationCache)
|
||||
EntityClassificationCache classificationCache,
|
||||
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(gl);
|
||||
ArgumentNullException.ThrowIfNull(shader);
|
||||
|
|
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||||
ArgumentNullException.ThrowIfNull(classificationCache);
|
||||
ArgumentNullException.ThrowIfNull(translucencyFades);
|
||||
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
|
|
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_meshAdapter = meshAdapter;
|
||||
_entitySpawnAdapter = entitySpawnAdapter;
|
||||
_cache = classificationCache;
|
||||
_translucencyFades = translucencyFades;
|
||||
|
||||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||||
_instanceSsbo = _gl.GenBuffer();
|
||||
|
|
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
||||
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
||||
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
|
||||
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -910,6 +927,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
|
||||
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
|
||||
_shader.SetInt("uLightingMode", 0);
|
||||
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
|
||||
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
|
||||
|
||||
// #128 self-heal: fresh re-request dedup per Draw pass.
|
||||
_missRequested.Clear();
|
||||
|
|
@ -1111,6 +1130,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
|
||||
// so a routed-out entity still reports its state.
|
||||
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
|
||||
|
||||
// #176 seam-draw probe: any entity parented to a target cell reports
|
||||
// its position + light set (a floor-coincident static/plate would be
|
||||
// the z-fight's second draw; the player entity is the positive
|
||||
// control). Before the culled-continue, like the dump above.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
|
||||
&& entity.ParentCellId is { } seamPc
|
||||
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
|
||||
MaybeEmitSeamEnt(entity);
|
||||
}
|
||||
prevTupleEntityId = entity.Id;
|
||||
|
||||
|
|
@ -1279,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
bool drewAny = false;
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
{
|
||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
||||
// #188: setupPartIndex is the SAME index space
|
||||
// TransparentPartHook.PartIndex addresses — retail's CPartArray
|
||||
// numbers parts by their ordinal position in the Setup's own
|
||||
// part list (SetupPartTransforms.Compute is the other verified
|
||||
// consumer of this exact indexing: one Matrix4x4 per
|
||||
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
|
||||
// MeshRef is acdream's own decomposition of top-level
|
||||
// attachments (weapon/shield/etc), a different concept.
|
||||
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
|
||||
{
|
||||
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
|
||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||
if (partData is null)
|
||||
{
|
||||
|
|
@ -1321,15 +1358,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
|
||||
var restPose = partTransform * meshRef.PartTransform;
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
|
||||
|
||||
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
||||
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
|
||||
// sets draw_state|=1 and skips the whole part outright — not a
|
||||
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
|
||||
// t=1 commits the bitwise-exact value so this check is safe.
|
||||
float opacityMultiplier = 1.0f;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
|
||||
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
|
||||
}
|
||||
|
||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
|
||||
drewAny = true;
|
||||
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
|
||||
// retail's CPartArray for such an object is a single-entry array,
|
||||
// so TransparentPartHook.PartIndex for it is always 0.
|
||||
float opacityMultiplier = 1.0f;
|
||||
bool fullyInvisible = false;
|
||||
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
|
||||
{
|
||||
if (translucencyValue >= 1.0f) fullyInvisible = true;
|
||||
else opacityMultiplier = 1f - translucencyValue;
|
||||
}
|
||||
|
||||
if (!fullyInvisible)
|
||||
{
|
||||
var model = meshRef.PartTransform * entityWorld;
|
||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Track THIS entity for the next iteration's flush check. Only
|
||||
|
|
@ -1416,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_indoorData.Length < totalInstances)
|
||||
_indoorData = new uint[totalInstances + 256];
|
||||
|
||||
// #188: per-instance opacity buffer, one float per instance, parallel to
|
||||
// _clipSlotData / _instanceData. Grown on demand like the others.
|
||||
if (_alphaData.Length < totalInstances)
|
||||
_alphaData = new float[totalInstances + 256];
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
|
||||
|
|
@ -1451,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
|
||||
_indoorData[cursor] = grp.IndoorFlags[i];
|
||||
// #188: Opacities[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
|
||||
_alphaData[cursor] = grp.Opacities[i];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
|
|
@ -1542,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
fixed (uint* dp = _indoorData)
|
||||
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
|
||||
|
||||
// #188: per-instance opacity buffer (binding=7), one float per instance,
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
|
||||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||||
fixed (float* ap = _alphaData)
|
||||
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
|
||||
|
||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||||
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||||
|
|
@ -2045,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
// #188: cache-hit entities are always non-animated (the Tier-1 cache
|
||||
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
|
||||
// state for entities whose animation hooks fired — so a cached
|
||||
// instance can never be mid-fade. Always unmodified opacity.
|
||||
grp.Opacities.Add(1.0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2075,6 +2158,44 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
|
||||
// [seam-ent] line per target-cell entity, re-emitted on state change: world
|
||||
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
|
||||
// and the SelectForObject light set resolved to identities (owner-cell
|
||||
// low16 + intensity). Sig dict is bounded by the handful of entities that
|
||||
// ever live in the target cells.
|
||||
private readonly Dictionary<ulong, string> _seamEntSigs = new();
|
||||
|
||||
private void MaybeEmitSeamEnt(WorldEntity entity)
|
||||
{
|
||||
var ci = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var snap = _pointSnapshot;
|
||||
var sb = new System.Text.StringBuilder(200);
|
||||
sb.AppendFormat(ci,
|
||||
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
|
||||
entity.ServerGuid, entity.ParentCellId ?? 0u,
|
||||
entity.Position.X, entity.Position.Y, entity.Position.Z,
|
||||
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
|
||||
bool any = false;
|
||||
for (int k = 0; k < _currentEntityLightSet.Length; k++)
|
||||
{
|
||||
int idx = _currentEntityLightSet[k];
|
||||
if (idx < 0) continue;
|
||||
if (any) sb.Append(',');
|
||||
if (snap is not null && idx < snap.Count)
|
||||
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
|
||||
else
|
||||
sb.Append('?').Append(idx);
|
||||
any = true;
|
||||
}
|
||||
sb.Append(']');
|
||||
|
||||
string sig = sb.ToString();
|
||||
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
|
||||
_seamEntSigs[entity.Id] = sig;
|
||||
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
|
||||
}
|
||||
|
||||
private void ComputeEntityLightSet(WorldEntity entity)
|
||||
{
|
||||
// #142: set the indoor flag first so it's available even when the early-return
|
||||
|
|
@ -2132,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ulong palHash,
|
||||
AcSurfaceMetadataTable metaTable,
|
||||
Matrix4x4 restPose,
|
||||
float opacityMultiplier = 1.0f,
|
||||
List<CachedBatch>? collector = null)
|
||||
{
|
||||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||
|
|
@ -2150,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
: TranslucencyKind.Opaque;
|
||||
}
|
||||
|
||||
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
|
||||
// must route through the alpha-blend pass so mesh_modern.frag's
|
||||
// (blend-enabled) shader actually composites the reduced alpha —
|
||||
// the no-blend opaque pass would ignore it.
|
||||
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
|
||||
translucency = TranslucencyKind.AlphaBlend;
|
||||
|
||||
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
||||
if (texHandle == 0) continue;
|
||||
|
||||
|
|
@ -2179,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
grp.Matrices.Add(model);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
|
||||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||||
}
|
||||
}
|
||||
|
|
@ -2261,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
||||
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
|
||||
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
|
||||
if (_gpuQueriesInitialized)
|
||||
{
|
||||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||||
|
|
@ -2460,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// for outdoor objects (gets the sun). Written into _indoorData at the same
|
||||
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
|
||||
public readonly List<uint> IndoorFlags = new();
|
||||
|
||||
// #188: per-instance opacity multiplier, parallel to Matrices.
|
||||
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
|
||||
// fade is in flight for the instance whose matrix is Matrices[i]. At
|
||||
// layout time the dispatcher writes Opacities[i] into _alphaData at
|
||||
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
|
||||
public readonly List<float> Opacities = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using DatReaderWriter;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue