Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-09 23:18:52 +02:00
commit 217a4bad69
329 changed files with 81439 additions and 8499 deletions

View 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

View file

@ -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&lt;WorldEntity&gt; 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&lt;WorldEntity&gt;</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

View file

@ -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)

View file

@ -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) ---

View file

@ -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 (0x00456fcd0x00457035): 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 0x00456fcd0x00457035).
/// 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);
}

View file

@ -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,

View file

@ -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);
}

View file

@ -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;

View file

@ -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.
}
}

View file

@ -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);
}
}
}
}

View file

@ -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
// ~12). 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.

View file

@ -1,3 +1,4 @@
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using System;

View file

@ -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

View file

@ -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);
}
}
}
}

View file

@ -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();
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter;