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
|
|
@ -38,6 +38,7 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
||||
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
213
src/AcDream.App/Diagnostics/FrameProfiler.cs
Normal file
213
src/AcDream.App/Diagnostics/FrameProfiler.cs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using AcDream.Core.Rendering;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
/// <summary>Stage indices for per-frame CPU attribution.</summary>
|
||||
public enum FrameStage
|
||||
{
|
||||
/// <summary>Whole OnUpdate body (simulation + streaming apply).</summary>
|
||||
Update = 0,
|
||||
/// <summary>WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.</summary>
|
||||
Upload = 1,
|
||||
/// <summary>ImGui Render (dev overlay).</summary>
|
||||
ImGui = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
|
||||
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
|
||||
/// measures CPU frame time as the delta between consecutive boundaries
|
||||
/// (captures the FULL frame including present), brackets the frame in a
|
||||
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
|
||||
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
|
||||
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
|
||||
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
|
||||
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
|
||||
/// bool check per frame when off.
|
||||
///
|
||||
/// <para>Permanent apparatus — every MP-track gate reads it; do not strip.
|
||||
/// Whole-frame GPU timing self-disables under <c>ACDREAM_WB_DIAG=1</c>
|
||||
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).</para>
|
||||
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
|
||||
/// </summary>
|
||||
public sealed class FrameProfiler : IDisposable
|
||||
{
|
||||
private const int WindowCapacity = 2048; // ~12 s at 165 fps
|
||||
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
|
||||
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
|
||||
|
||||
private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity);
|
||||
private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity);
|
||||
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
|
||||
private readonly FrameStatsBuffer[] _stageUs;
|
||||
private readonly long[] _stageAccumTicks;
|
||||
private readonly bool _wbDiagActive =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
|
||||
|
||||
private GpuFrameTimer? _gpuTimer;
|
||||
private long _lastBoundaryTimestamp;
|
||||
private long _lastAllocBytes;
|
||||
private long _lastReportTicks;
|
||||
private int _gc0Base, _gc1Base, _gc2Base;
|
||||
private int _framesInWindow;
|
||||
private int _ownerThreadId;
|
||||
private bool _threadWarned;
|
||||
private bool _wbDiagNoticePrinted;
|
||||
private bool _wasEnabled;
|
||||
|
||||
public FrameProfiler()
|
||||
{
|
||||
_stageUs = new FrameStatsBuffer[StageCount];
|
||||
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
|
||||
_stageAccumTicks = new long[StageCount];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
|
||||
/// </summary>
|
||||
public void FrameBoundary(GL gl)
|
||||
{
|
||||
bool enabled = RenderingDiagnostics.FrameProfEnabled;
|
||||
if (!enabled)
|
||||
{
|
||||
if (_wasEnabled)
|
||||
{
|
||||
// Dispose (not just Stop) so a later re-enable rebuilds the
|
||||
// query ring fresh — a kept instance would poll slots left
|
||||
// pending from BEFORE the pause and report temporally stale
|
||||
// GPU samples. Safe here: this runs at the top of OnRender
|
||||
// with the GL context current.
|
||||
_gpuTimer?.Dispose();
|
||||
_gpuTimer = null;
|
||||
_wasEnabled = false;
|
||||
_lastBoundaryTimestamp = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId;
|
||||
else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId)
|
||||
{
|
||||
_threadWarned = true;
|
||||
Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable");
|
||||
}
|
||||
|
||||
long now = Stopwatch.GetTimestamp();
|
||||
long allocNow = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
if (!_wasEnabled)
|
||||
{
|
||||
// First enabled frame (startup or runtime toggle-on): establish
|
||||
// baselines, emit nothing. Clear any stage ticks a StageScope
|
||||
// disposed after toggle-off may have accumulated mid-pause —
|
||||
// EndStage still runs on scopes that were live when the flag
|
||||
// flipped, and that partial delta must not leak into the first
|
||||
// re-enabled frame.
|
||||
_wasEnabled = true;
|
||||
_lastReportTicks = DateTime.UtcNow.Ticks;
|
||||
Array.Clear(_stageAccumTicks);
|
||||
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
|
||||
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
|
||||
if (_wbDiagActive && !_wbDiagNoticePrinted)
|
||||
{
|
||||
_wbDiagNoticePrinted = true;
|
||||
Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
|
||||
_cpuUs.Push(cpuUs);
|
||||
_allocBytes.Push(allocNow - _lastAllocBytes);
|
||||
for (int i = 0; i < StageCount; i++)
|
||||
{
|
||||
_stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
|
||||
_stageAccumTicks[i] = 0;
|
||||
}
|
||||
_framesInWindow++;
|
||||
}
|
||||
|
||||
_lastBoundaryTimestamp = now;
|
||||
_lastAllocBytes = allocNow;
|
||||
|
||||
if (_gpuTimer?.FrameBoundary() is long gpuUs)
|
||||
_gpuUs.Push(gpuUs);
|
||||
|
||||
long nowTicks = DateTime.UtcNow.Ticks;
|
||||
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
|
||||
{
|
||||
int gc0 = GC.CollectionCount(0) - _gc0Base;
|
||||
int gc1 = GC.CollectionCount(1) - _gc1Base;
|
||||
int gc2 = GC.CollectionCount(2) - _gc2Base;
|
||||
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
|
||||
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
|
||||
_lastReportTicks = nowTicks;
|
||||
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
|
||||
_framesInWindow = 0;
|
||||
_cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset();
|
||||
for (int i = 0; i < StageCount; i++) _stageUs[i].Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
|
||||
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
|
||||
/// Zero-cost (default scope) when the profiler is off.
|
||||
/// </summary>
|
||||
public StageScope BeginStage(FrameStage stage)
|
||||
=> RenderingDiagnostics.FrameProfEnabled
|
||||
? new StageScope(this, stage, Stopwatch.GetTimestamp())
|
||||
: default;
|
||||
|
||||
internal void EndStage(FrameStage stage, long startTimestamp)
|
||||
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
|
||||
|
||||
/// <summary>Pure report formatter — unit-tested; invariant culture.</summary>
|
||||
public static string FormatReport(
|
||||
int frameCount,
|
||||
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
|
||||
FrameStatsBuffer alloc, int gc0, int gc1, int gc2,
|
||||
FrameStatsBuffer[] stages)
|
||||
{
|
||||
var ci = CultureInfo.InvariantCulture;
|
||||
var sb = new StringBuilder(256);
|
||||
sb.Append("[frame-prof] n=").Append(frameCount);
|
||||
sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}",
|
||||
cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0,
|
||||
cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0);
|
||||
if (gpuActive)
|
||||
sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}",
|
||||
gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0);
|
||||
else
|
||||
sb.Append(" | gpu=off(wbdiag)");
|
||||
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}",
|
||||
alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2);
|
||||
string[] names = { "upd", "upl", "imgui" };
|
||||
for (int i = 0; i < stages.Length && i < names.Length; i++)
|
||||
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}",
|
||||
names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public void Dispose() => _gpuTimer?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Disposable stage scope; default instance is a no-op.</summary>
|
||||
public readonly struct StageScope : IDisposable
|
||||
{
|
||||
private readonly FrameProfiler? _owner;
|
||||
private readonly FrameStage _stage;
|
||||
private readonly long _start;
|
||||
|
||||
internal StageScope(FrameProfiler owner, FrameStage stage, long start)
|
||||
{
|
||||
_owner = owner; _stage = stage; _start = start;
|
||||
}
|
||||
|
||||
public void Dispose() => _owner?.EndStage(_stage, _start);
|
||||
}
|
||||
69
src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
Normal file
69
src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
|
||||
/// (microseconds or bytes) with percentile/max over the current window.
|
||||
/// Pure and allocation-free after construction: <see cref="Percentile"/>
|
||||
/// sorts into a preallocated scratch array, so the 5-second report path
|
||||
/// allocates nothing. Not thread-safe — owned by the window loop thread.
|
||||
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
|
||||
/// </summary>
|
||||
public sealed class FrameStatsBuffer
|
||||
{
|
||||
private readonly long[] _samples;
|
||||
private readonly long[] _scratch;
|
||||
private int _cursor;
|
||||
private int _count;
|
||||
|
||||
public FrameStatsBuffer(int capacity)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_samples = new long[capacity];
|
||||
_scratch = new long[capacity];
|
||||
}
|
||||
|
||||
public int Count => _count;
|
||||
|
||||
public void Push(long value)
|
||||
{
|
||||
_samples[_cursor] = value;
|
||||
_cursor = (_cursor + 1) % _samples.Length;
|
||||
if (_count < _samples.Length) _count++;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
_cursor = 0;
|
||||
_count = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nearest-rank percentile over the current window: element at
|
||||
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
|
||||
/// </summary>
|
||||
public long Percentile(double q)
|
||||
{
|
||||
if (_count == 0) return 0;
|
||||
Array.Copy(_samples, _scratch, _count);
|
||||
Array.Sort(_scratch, 0, _count);
|
||||
int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
|
||||
if (rank < 1) rank = 1;
|
||||
if (rank > _count) rank = _count;
|
||||
return _scratch[rank - 1];
|
||||
}
|
||||
|
||||
public long Max()
|
||||
{
|
||||
if (_count == 0) return 0; // documented empty behavior, matches Percentile
|
||||
// Seed from the first live sample so all-negative windows (the alloc
|
||||
// channel can go negative if the boundary ever crosses threads) return
|
||||
// the true max instead of clamping to 0. Slots [0.._count) are always
|
||||
// the live window regardless of ring wraparound.
|
||||
long max = _samples[0];
|
||||
for (int i = 1; i < _count; i++)
|
||||
if (_samples[i] > max) max = _samples[i];
|
||||
return max;
|
||||
}
|
||||
}
|
||||
87
src/AcDream.App/Diagnostics/GpuFrameTimer.cs
Normal file
87
src/AcDream.App/Diagnostics/GpuFrameTimer.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
|
||||
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
|
||||
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
|
||||
/// idiom including the #125 lesson: a glGenQueries name is not a query
|
||||
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
|
||||
/// the Begun flags.
|
||||
///
|
||||
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
|
||||
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
|
||||
/// its passes with them under that flag. The caller (FrameProfiler)
|
||||
/// enforces the exclusion; this class just does the ring.</para>
|
||||
/// </summary>
|
||||
internal sealed class GpuFrameTimer : IDisposable
|
||||
{
|
||||
private const int RingDepth = 4;
|
||||
|
||||
private readonly GL _gl;
|
||||
private readonly uint[] _queries = new uint[RingDepth];
|
||||
private readonly bool[] _begun = new bool[RingDepth];
|
||||
private int _frameIndex;
|
||||
private bool _queryActive;
|
||||
|
||||
public GpuFrameTimer(GL gl)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
for (int i = 0; i < RingDepth; i++)
|
||||
_queries[i] = _gl.GenQuery();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call once per frame at the frame boundary. Ends the previous
|
||||
/// frame's query, polls the oldest slot non-blocking, begins this
|
||||
/// frame's query. Returns the completed GPU time in microseconds for
|
||||
/// a ~RingDepth-frames-old frame, or null when no result is ready.
|
||||
/// </summary>
|
||||
public long? FrameBoundary()
|
||||
{
|
||||
if (_queryActive)
|
||||
{
|
||||
_gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
_queryActive = false;
|
||||
}
|
||||
|
||||
long? completedUs = null;
|
||||
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
|
||||
if (_begun[readSlot])
|
||||
{
|
||||
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
|
||||
if (avail != 0)
|
||||
{
|
||||
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
|
||||
completedUs = (long)(ns / 1000UL);
|
||||
}
|
||||
// Not available ⇒ sample silently dropped (same policy as
|
||||
// WbDrawDispatcher) — percentiles tolerate missing samples.
|
||||
}
|
||||
|
||||
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
|
||||
_begun[readSlot] = true;
|
||||
_queryActive = true;
|
||||
_frameIndex++;
|
||||
return completedUs;
|
||||
}
|
||||
|
||||
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (_queryActive)
|
||||
{
|
||||
_gl.EndQuery(QueryTarget.TimeElapsed);
|
||||
_queryActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
for (int i = 0; i < RingDepth; i++)
|
||||
_gl.DeleteQuery(_queries[i]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
619
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal file
619
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
|
||||
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted
|
||||
/// verbatim from <c>GameWindow.TickAnimations</c> (Code Structure Rule 1: no
|
||||
/// new feature bodies in the >10k-line <c>GameWindow</c>). One instance is
|
||||
/// owned by <c>GameWindow</c> and called once per animated remote entity per
|
||||
/// frame, from inside the same guard the body used to live under
|
||||
/// (<c>ae.Sequencer != null && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 0</c>).
|
||||
///
|
||||
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
|
||||
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
|
||||
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
|
||||
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
|
||||
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
|
||||
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
|
||||
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
|
||||
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
|
||||
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
|
||||
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
|
||||
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
|
||||
///
|
||||
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
|
||||
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
|
||||
/// incl. the local player's own cylinder — kept on GameWindow) and
|
||||
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
|
||||
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
|
||||
/// (remote-physics-specific) moved here and is called back from the UP-branch
|
||||
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
|
||||
/// callers and moved here outright.</para>
|
||||
/// </summary>
|
||||
internal sealed class RemotePhysicsUpdater
|
||||
{
|
||||
// Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller).
|
||||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||||
|
||||
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
|
||||
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||
|
||||
internal RemotePhysicsUpdater(
|
||||
AcDream.Core.Physics.PhysicsEngine physicsEngine,
|
||||
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||
{
|
||||
_physicsEngine = physicsEngine;
|
||||
_getSetupCylinder = getSetupCylinder;
|
||||
_applyServerControlledVelocityCycle = applyServerControlledVelocityCycle;
|
||||
}
|
||||
|
||||
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
|
||||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||||
|
||||
/// <summary>
|
||||
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
|
||||
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
|
||||
/// body. <c>serverGuid</c> + the entity id derive from
|
||||
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
|
||||
/// are passed per-call (they change on streaming recentre — never snapshot
|
||||
/// them in the constructor).
|
||||
/// </summary>
|
||||
public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
|
||||
{
|
||||
uint serverGuid = ae.Entity.ServerGuid;
|
||||
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
|
||||
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
|
||||
// movement managers' UseTime. This is where this entity, as a
|
||||
// watched target, pushes its position to its voyeurs (any entity
|
||||
// moving-to it), and where its own target-info staleness times
|
||||
// out. Runs for every remote regardless of the grounded/airborne
|
||||
// branch below (which drive MoveToManager.UseTime via
|
||||
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
|
||||
rm.Host?.HandleTargetting();
|
||||
|
||||
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
|
||||
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
|
||||
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
|
||||
// problem" premise) is GONE — every remote now runs the SAME catch-up +
|
||||
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
|
||||
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
|
||||
// player/remote fork; the only surviving player/NPC split is the omega
|
||||
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
|
||||
//
|
||||
// Stop detection stays explicit on packet receipt (UpdateMotion
|
||||
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
|
||||
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
|
||||
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
|
||||
// The bare block scopes this update's locals (formerly the else body).
|
||||
{
|
||||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||||
|
||||
// Step 1: re-apply current motion commands → body.Velocity.
|
||||
// Forces OnWalkable + Contact so the gate in apply_current_movement
|
||||
// always succeeds (remotes are server-authoritative; we don't
|
||||
// simulate airborne physics for them).
|
||||
//
|
||||
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
|
||||
// Otherwise the force-OnWalkable + apply_current_movement
|
||||
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
|
||||
// and gravity never gets to integrate the arc. The airborne
|
||||
// body keeps the launch velocity from the VectorUpdate;
|
||||
// UpdatePhysicsInternal below applies gravity each tick;
|
||||
// the next UpdatePosition snaps to the new ground location
|
||||
// and re-grounds.
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
|
||||
// #184 (2026-07-07): a grounded remote carries NO translation
|
||||
// velocity. Its per-tick movement is the interp CATCH-UP toward
|
||||
// the MoveOrTeleport-queued server waypoint (computed at the
|
||||
// sticky-compose site below), which the KEPT ResolveWithTransition
|
||||
// sweep de-overlaps against neighbours — and the resolved position
|
||||
// is written back into the SHADOW (below) so the de-overlap
|
||||
// persists and neighbours collide against the resolved body, not
|
||||
// the raw server pos. This REPLACES the old synth-velocity model
|
||||
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
|
||||
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
|
||||
// leg — a remote translates by adjust_offset and the UP is a gentle
|
||||
// target. As of #184 Slice 2b this grounded model is the SINGLE
|
||||
// remote path (players + NPCs) — retail has no fork.
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
|
||||
// Stale server-velocity → stop the locomotion CYCLE (the legs).
|
||||
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
|
||||
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
|
||||
// so a scripted-path NPC that stops server-side drops out of its
|
||||
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
|
||||
// anim from ServerVelocity, independent of Body.Velocity.
|
||||
bool moveToArmed = rm.MoveTo is
|
||||
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
||||
bool stickyArmed =
|
||||
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
||||
&& !moveToArmed && !stickyArmed)
|
||||
{
|
||||
double velocityAge = nowSec - rm.LastServerPosTime;
|
||||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||||
{
|
||||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||||
rm.HasServerVelocity = false;
|
||||
_applyServerControlledVelocityCycle(
|
||||
serverGuid,
|
||||
ae,
|
||||
rm,
|
||||
System.Numerics.Vector3.Zero);
|
||||
}
|
||||
}
|
||||
|
||||
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
|
||||
// MovementManager::UseTime per tick, UpdateObjectInternal call
|
||||
// @0x00515998) — UseTime runs HandleMoveToPosition /
|
||||
// HandleTurnToHeading (steering + arrival + fail-distance),
|
||||
// dispatching its per-node locomotion (turn / RunForward) through
|
||||
// the sink (the LEGS). Position comes from the catch-up; legs from
|
||||
// this per-node dispatch + the funnel. The #170-deleted per-frame
|
||||
// apply_current_movement is NOT reintroduced.
|
||||
TickRemoteMoveTo(rm);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Airborne — keep Active flag (so UpdatePhysicsInternal
|
||||
// doesn't early-return) but DON'T set Contact / OnWalkable.
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||||
}
|
||||
|
||||
// Step 2: integrate rotation manually per tick. We can't
|
||||
// rely on PhysicsBody.update_object here — its MinQuantum
|
||||
// gate (1/30 s) causes it to SKIP integration when our
|
||||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||||
// rotation never advances. Measured snap per UP was ~129°
|
||||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||||
// between-tick rotation.
|
||||
//
|
||||
// Manual integration matches retail's FUN_005256b0
|
||||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||||
// ObservedOmega derived from server UP rotation deltas so
|
||||
// the rate exactly matches server physics — hard-snap on
|
||||
// next UP becomes invisible by construction.
|
||||
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
|
||||
// world-frame (pre-multiply, Concatenate) application inherited from the
|
||||
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
|
||||
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
|
||||
// the wire TurnCommand) must carry the turn or the body would not rotate
|
||||
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
|
||||
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
|
||||
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
|
||||
// is preserved. For an upright body + a yaw (world-Z) omega the two
|
||||
// multiplication orders commute, so this fork is faithful, not cosmetic.
|
||||
// calc_acceleration zeroes Body.Omega for grounded bodies before
|
||||
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
|
||||
// (a wire-set Body.Omega would otherwise double-integrate on top of the
|
||||
// manual rotation).
|
||||
rm.Body.Omega = System.Numerics.Vector3.Zero;
|
||||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
System.Numerics.Vector3 omegaToApply =
|
||||
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
|
||||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||||
{
|
||||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||||
}
|
||||
}
|
||||
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||||
{
|
||||
float omegaMag = rm.ObservedOmega.Length();
|
||||
var axis = rm.ObservedOmega / omegaMag;
|
||||
float angle = omegaMag * dt;
|
||||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||||
}
|
||||
|
||||
// Step 3: integrate physics — retail FUN_005111D0
|
||||
// UpdatePhysicsInternal. Pure Euler:
|
||||
// position += velocity × dt + 0.5 × accel × dt²
|
||||
//
|
||||
// Call UpdatePhysicsInternal DIRECTLY rather than via
|
||||
// PhysicsBody.update_object (FUN_00515020). update_object gates
|
||||
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
|
||||
// deltaTime < MinQuantum → early return AND LastUpdateTime is
|
||||
// NOT advanced. Net effect: position never integrates between
|
||||
// UpdatePositions and the only Body.Position changes come
|
||||
// from the UP hard-snap, producing a visible teleport-stride
|
||||
// on slopes (the "staircase" the user reported).
|
||||
//
|
||||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||||
// directly for the same reason. Remote motion mirrors that.
|
||||
// Omega is already integrated manually above, so we zero it
|
||||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||||
// double-integrating.
|
||||
var preIntegratePos = rm.Body.Position;
|
||||
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
|
||||
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
|
||||
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
|
||||
// sweep so collision resolves whichever movement won (preIntegratePos
|
||||
// captured first — the sweep covers it).
|
||||
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
|
||||
// the movement source is the adjust_offset walk toward the
|
||||
// MoveOrTeleport-queued server waypoint, exactly like Path A
|
||||
// (:10173). StickyManager::adjust_offset then OVERWRITES the
|
||||
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
|
||||
// dichotomy), so a stuck monster still steers via #171.
|
||||
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
|
||||
// from velocity + gravity, unchanged).
|
||||
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
|
||||
// adds no translation on top of the catch-up — no double-move.
|
||||
if (rm.Host is { } npcHost)
|
||||
{
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
|
||||
if (!rm.Airborne)
|
||||
{
|
||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVelNpc,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
{
|
||||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||||
rm.Body.Orientation, offsetNpc),
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||||
}
|
||||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||||
}
|
||||
else if (!rm.Airborne)
|
||||
{
|
||||
// No PositionManager host yet (pre-binding): apply the catch-up
|
||||
// directly, matching Path A's fallback (:10202).
|
||||
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
|
||||
?? System.Numerics.Vector3.Zero;
|
||||
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
|
||||
System.Numerics.Vector3? terrainNormalNpc =
|
||||
_physicsEngine.SampleTerrainNormal(
|
||||
rm.Body.Position.X, rm.Body.Position.Y);
|
||||
rm.Body.Position += rm.Position.ComputeOffset(
|
||||
dt: (double)dt,
|
||||
currentBodyPosition: rm.Body.Position,
|
||||
seqVel: seqVelNpc,
|
||||
ori: rm.Body.Orientation,
|
||||
interp: rm.Interp,
|
||||
maxSpeed: maxSpeedNpc,
|
||||
terrainNormal: terrainNormalNpc);
|
||||
}
|
||||
rm.Body.calc_acceleration();
|
||||
rm.Body.UpdatePhysicsInternal(dt);
|
||||
var postIntegratePos = rm.Body.Position;
|
||||
|
||||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||||
// Projects the sphere from preIntegratePos to postIntegratePos
|
||||
// through the BSP + terrain, resolving:
|
||||
// - terrain Z snap along the slope (fixes the "staircase" where
|
||||
// horizontal Euler motion up a slope sinks into rising ground
|
||||
// until the next UP pops it up)
|
||||
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
|
||||
// - object collisions via ShadowObjectRegistry
|
||||
// - step-up / step-down against walkable ledges
|
||||
// ResolveWithTransition is the same call PlayerMovementController
|
||||
// uses for the local player; remotes now get the full retail
|
||||
// treatment between UpdatePositions instead of pure kinematics.
|
||||
//
|
||||
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
|
||||
// a SpherePath without a starting cell). One-frame grace until
|
||||
// the first UP arrives; harmless because the entity is
|
||||
// server-freshly-spawned at a valid Z anyway.
|
||||
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
||||
{
|
||||
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
|
||||
// creatures de-overlap at their TRUE radii (a big monster
|
||||
// spreads wider, a small one tighter), not the hardcoded
|
||||
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
|
||||
// setup.Height) × ObjScale — the creature's own dat Setup
|
||||
// scaled by its wire ObjScale, the same source the local
|
||||
// player + moveto/sticky use, and consistent with the
|
||||
// spawn-time shadow registration's entScale. Retail seeds
|
||||
// the transition from the object's own Setup sphere list ×
|
||||
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
|
||||
// ObjScale from set_description 0x00514f40). This narrows
|
||||
// TS-46 (remotes no longer use human dims); the two-scalar
|
||||
// API is still a lossy stand-in for retail's full (≤2)
|
||||
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
|
||||
// those from the Setup too — an adjacent divergence left as-is).
|
||||
// Fallback to the human capsule for a shapeless / unresolvable
|
||||
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
|
||||
// degenerate the sweep.
|
||||
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
|
||||
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
|
||||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||||
preIntegratePos, postIntegratePos, rm.CellId,
|
||||
sphereRadius: deR,
|
||||
sphereHeight: deH,
|
||||
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
||||
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
||||
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
|
||||
// airborne remotes must NOT pre-seed the
|
||||
// ContactPlane, otherwise AdjustOffset's snap-to-plane
|
||||
// branch zeroes the +Z offset every step (same bug
|
||||
// we hit on the local jump).
|
||||
isOnGround: !rm.Airborne,
|
||||
body: rm.Body, // persist ContactPlane across frames for slope tracking
|
||||
// Retail default physics state includes EdgeSlide; remote DR
|
||||
// should exercise the same edge/cliff branch as local movement.
|
||||
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
|
||||
// CollisionExemption's PvP block fires exactly as it does for the
|
||||
// LOCAL player (PlayerMovementController :920) — two non-PK players
|
||||
// WALK THROUGH each other (retail sets IsPlayer on every object's
|
||||
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
|
||||
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
|
||||
// non-PK player pair). Without IsPlayer the mover would de-overlap
|
||||
// two players — MORE solid than retail (you can stand inside another
|
||||
// non-PK player in AC). Players still COLLIDE with monsters (target
|
||||
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
|
||||
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
|
||||
// pair walks through where retail collides — the SAME M1.5 gap the
|
||||
// local player carries (see TS-23; PlayerDescription PK status
|
||||
// unparsed).
|
||||
moverFlags: IsPlayerGuid(serverGuid)
|
||||
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
|
||||
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
// Fix #42 (2026-05-05): skip the moving remote's
|
||||
// own ShadowEntry. _animatedEntities is keyed by
|
||||
// entity.Id so kv.Key matches the EntityId the
|
||||
// ShadowObjectRegistry has for this remote.
|
||||
// Without this, the airborne sweep collides with
|
||||
// the remote's own cylinder and produces ~1m of
|
||||
// horizontal drift on the first jump frame
|
||||
// (validated by [SWEEP-OBJ] traces).
|
||||
movingEntityId: ae.Entity.Id);
|
||||
|
||||
rm.Body.Position = resolveResult.Position;
|
||||
if (resolveResult.CellId != 0)
|
||||
rm.CellId = resolveResult.CellId;
|
||||
|
||||
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
|
||||
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
|
||||
// re-registers a moved object's shadow every transition step
|
||||
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
|
||||
// 0x00515330) so its m_position — the RESOLVED position — is what
|
||||
// OTHER creatures collide against. acdream's shadow otherwise only
|
||||
// syncs to the RAW server pos on UpdatePosition, so neighbours would
|
||||
// de-overlap against each other's OVERLAPPING shadows and any spread
|
||||
// would be discarded on the next UP (never accumulating), AND the
|
||||
// player would collide with a shadow offset from where the monster
|
||||
// renders (the reverted attempt's "stuck on an invisible monster").
|
||||
// Syncing the shadow to the resolved body every tick makes the
|
||||
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
|
||||
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
|
||||
// body moved > ~1 cm since the last shadow registration. This is
|
||||
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
|
||||
// players too — every remote's shadow (player + NPC) is written ONLY
|
||||
// by this loop + the UP-branch tail, both to the resolved body, so a
|
||||
// net-stationary (de-overlapped, sweep-
|
||||
// blocked) creature keeps its correct shadow and never re-floods,
|
||||
// while a moving/de-overlapping crowd (which moves every tick) still
|
||||
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
|
||||
// to actually-moving remotes — the perf risk the review flagged for
|
||||
// a packed town. (In-place shadow-move + cell-relink-on-change is a
|
||||
// further optimization if profiling still shows churn.)
|
||||
if (System.Numerics.Vector3.DistanceSquared(
|
||||
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
|
||||
{
|
||||
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
|
||||
}
|
||||
|
||||
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
|
||||
// (pc:282699-282715) runs after EVERY SetPositionInternal —
|
||||
// remote objects included; a VectorUpdate-launched jump arc
|
||||
// is ordinary object physics in retail. acdream ported the
|
||||
// velocity reflection for the LOCAL player only (L.3a,
|
||||
// PlayerMovementController ~:940), so a remote jumping into
|
||||
// a dungeon ceiling had its POSITION pinned by the sweep
|
||||
// while its +Z velocity kept integrating — the char hovered
|
||||
// at the roof until gravity burned the arc off, landing
|
||||
// late (user report, 0x0007 dungeon). Mirror the local
|
||||
// site exactly:
|
||||
// v_new = v − (1 + elasticity)·dot(v, n)·n
|
||||
// with the AD-25 suppression (bounce only when airborne
|
||||
// before AND after — corridor slides and landings don't
|
||||
// reflect; the landing snap below keeps its
|
||||
// `Velocity.Z <= 0` gate intact). Inelastic movers
|
||||
// (missiles, later) zero out instead.
|
||||
if (resolveResult.CollisionNormalValid)
|
||||
{
|
||||
bool prevOnWalkable = rm.Body.OnWalkable;
|
||||
bool nowOnWalkable = resolveResult.IsOnGround;
|
||||
bool applyBounce = rm.Body.State.HasFlag(
|
||||
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
|
||||
? !(prevOnWalkable && nowOnWalkable)
|
||||
: (!prevOnWalkable && !nowOnWalkable);
|
||||
if (applyBounce)
|
||||
{
|
||||
if (rm.Body.State.HasFlag(
|
||||
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
|
||||
{
|
||||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
var vRem = rm.Body.Velocity;
|
||||
var nRem = resolveResult.CollisionNormal;
|
||||
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
|
||||
if (dotVN < 0f)
|
||||
{
|
||||
rm.Body.Velocity =
|
||||
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine(
|
||||
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// K-fix15 (2026-04-26): post-resolve landing
|
||||
// detection for airborne remotes. Mirrors
|
||||
// PlayerMovementController's local-player landing
|
||||
// path: when the resolver says we're on ground AND
|
||||
// velocity is no longer pointing up, transition
|
||||
// back to grounded — clear Airborne, restore
|
||||
// Contact + OnWalkable, remove Gravity, zero any
|
||||
// residual downward velocity, and trigger
|
||||
// HitGround so the sequencer can swap from
|
||||
// Falling → idle/locomotion. Without this, an
|
||||
// airborne remote falls through the floor (gravity
|
||||
// keeps building Velocity.Z negative until the
|
||||
// sphere-sweep clamps each frame, but Airborne
|
||||
// stays true forever).
|
||||
if (rm.Airborne
|
||||
&& resolveResult.IsOnGround
|
||||
&& rm.Body.Velocity.Z <= 0f)
|
||||
{
|
||||
rm.Airborne = false;
|
||||
// #184 (2026-07-07): clear the interp queue on landing (mirrors
|
||||
// the player-remote landing). Airborne UPs hard-snap and never
|
||||
// Enqueue, so any pre-jump waypoints are stale; without this the
|
||||
// first grounded catch-up after touchdown chases them backward.
|
||||
rm.Interp.Clear();
|
||||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||||
rm.Body.Velocity = new System.Numerics.Vector3(
|
||||
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
|
||||
// #161: HitGround MUST run with the Gravity state
|
||||
// bit still set — CMotionInterp::HitGround
|
||||
// (0x00528ac0) gates on state&0x400 (retail never
|
||||
// clears GRAVITY on landing; it's a persistent
|
||||
// object property). Clearing it first made this
|
||||
// re-apply a silent no-op, which is why the
|
||||
// falling pose never exited. The re-apply
|
||||
// dispatches the PRESERVED pre-fall forward
|
||||
// command through the funnel → the motion table
|
||||
// plays the Falling→X landing link. (The old
|
||||
// K-fix17 forced SetCycle is deleted: it read the
|
||||
// then-clobbered InterpretedState.ForwardCommand
|
||||
// — 0x40000015 — and re-set the very Falling
|
||||
// cycle it meant to clear.)
|
||||
// R4-V5 (closes the V4 wiring-contract gap the
|
||||
// adversarial review caught): retail order —
|
||||
// minterp first, then moveto (MovementManager::
|
||||
// HitGround 0x00524300, §2d — the R5-V5 facade
|
||||
// relay). Re-arms a moveto suspended by the
|
||||
// airborne UseTime contact gate; without it a
|
||||
// chasing NPC that lands stalls until ACE's
|
||||
// ~1 Hz re-emit.
|
||||
rm.Movement.HitGround();
|
||||
// DR bookkeeping only (partner of the jump-start
|
||||
// `State |= Gravity`): stops the per-tick gravity
|
||||
// integration for the grounded body.
|
||||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||||
if (rm.CellId != 0)
|
||||
ae.Entity.ParentCellId = rm.CellId;
|
||||
ae.Entity.Rotation = rm.Body.Orientation;
|
||||
}
|
||||
|
||||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||||
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
|
||||
// right after CPartArray::HandleMovement, UNCONDITIONAL for
|
||||
// every entity in both grounded and airborne branches): the
|
||||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||||
rm.Host?.PositionManager.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||||
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
|
||||
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
|
||||
/// </summary>
|
||||
private static void TickRemoteMoveTo(RemoteMotion rm)
|
||||
{
|
||||
rm.Movement.UseTime();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||||
/// stand-in for retail's <c>Frame::combine</c> in
|
||||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||||
/// out by the body orientation. An untouched (identity) rotation means "no
|
||||
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
|
||||
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
|
||||
/// DR tick is its only caller.
|
||||
/// </summary>
|
||||
private static void ApplyPositionManagerDelta(
|
||||
AcDream.Core.Physics.PhysicsBody body,
|
||||
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
|
||||
{
|
||||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||||
if (!delta.Orientation.IsIdentity)
|
||||
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||||
body.Orientation,
|
||||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||||
+ delta.GetHeading());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
|
||||
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
|
||||
/// de-overlap / collide against where the monster actually IS (== where it
|
||||
/// renders), not the raw overlapping server position. Retail re-registers a
|
||||
/// moved object's shadow every accepted transition step (SetPositionInternal
|
||||
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
|
||||
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
|
||||
/// rather than snapshotted, since it moves on recentre. Updates
|
||||
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
|
||||
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
|
||||
/// UP-branch tail.
|
||||
/// </summary>
|
||||
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
|
||||
{
|
||||
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
||||
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
||||
float shOffX = (shLbX - liveCenterX) * 192f;
|
||||
float shOffY = (shLbY - liveCenterY) * 192f;
|
||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||
entityId, rm.Body.Position, rm.Body.Orientation,
|
||||
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
||||
rm.LastShadowSyncPos = rm.Body.Position;
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -255,7 +255,43 @@ public sealed class GpuWorldState
|
|||
{
|
||||
if (entity.ServerGuid == 0) return;
|
||||
|
||||
// Remove from current landblock (find it by scanning)
|
||||
uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu;
|
||||
|
||||
// Fast path: already drawn in the correct loaded bucket → nothing to do
|
||||
// (avoids per-frame list churn for a settled, stationary entity).
|
||||
if (_loaded.TryGetValue(canonical, out var target))
|
||||
{
|
||||
foreach (var e in target.Entities)
|
||||
if (ReferenceEquals(e, entity)) return;
|
||||
}
|
||||
|
||||
// Remove the entity from wherever it currently lives — a loaded bucket
|
||||
// OR a pending bucket — then re-append to its current landblock.
|
||||
//
|
||||
// Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn /
|
||||
// run-out "invisible player" bug: a persistent (server-spawned) entity
|
||||
// that spawned into a not-yet-loaded landblock sits in _pendingByLandblock,
|
||||
// and the old code scanned ONLY _loaded — so it silently no-op'd and left
|
||||
// the player stranded, hidden, even after its landblock finished loading
|
||||
// (the AddLandblock pending-drain had already run empty before the churn
|
||||
// re-parked the player, and the player is excluded from the server-object
|
||||
// re-hydrate — so RelocateEntity was the ONLY path that could recover it,
|
||||
// and it couldn't reach a pending entity). Re-appending routes the entity
|
||||
// to _loaded (drawn) when its landblock is loaded, or back to pending to
|
||||
// await AddLandblock otherwise.
|
||||
RemoveEntityFromAllBuckets(entity);
|
||||
AppendLiveEntity(canonical, entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove <paramref name="entity"/> (by reference) from whichever
|
||||
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
|
||||
/// currently occupies. At most one bucket holds a given entity, so this
|
||||
/// stops after the first hit. Called by <see cref="RelocateEntity"/> before
|
||||
/// re-appending, so a stranded pending entity can be promoted.
|
||||
/// </summary>
|
||||
private void RemoveEntityFromAllBuckets(WorldEntity entity)
|
||||
{
|
||||
foreach (var kvp in _loaded)
|
||||
{
|
||||
var entities = kvp.Value.Entities;
|
||||
|
|
@ -263,23 +299,21 @@ public sealed class GpuWorldState
|
|||
{
|
||||
if (ReferenceEquals(entities[i], entity))
|
||||
{
|
||||
if (kvp.Key == newCanonicalLb) return; // already in the right place
|
||||
|
||||
// Remove from old
|
||||
var newList = new List<WorldEntity>(entities.Count - 1);
|
||||
for (int j = 0; j < entities.Count; j++)
|
||||
if (j != i) newList.Add(entities[j]);
|
||||
_loaded[kvp.Key] = new LoadedLandblock(
|
||||
kvp.Value.LandblockId,
|
||||
kvp.Value.Heightmap,
|
||||
newList);
|
||||
|
||||
// Add to new (via AppendLiveEntity which handles pending)
|
||||
AppendLiveEntity(newCanonicalLb, entity);
|
||||
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var kvp in _pendingByLandblock)
|
||||
{
|
||||
if (kvp.Value.Remove(entity))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveLandblock(uint landblockId)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue