feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice

TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 18:22:08 +02:00
parent ec414d60cd
commit ceec3bc440
334 changed files with 3660 additions and 3840 deletions

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
@ -19,33 +19,33 @@ namespace AcDream.App.Audio;
/// Wiring:
/// <list type="bullet">
/// <item><description>
/// <see cref="SoundHook"/> direct play of <c>SoundHook.Id</c> (a
/// <see cref="SoundHook"/> → direct play of <c>SoundHook.Id</c> (a
/// Wave dat id) at the entity's world position. Used for custom /
/// per-animation audio like weapon swoosh or spell chant.
/// </description></item>
/// <item><description>
/// <see cref="SoundTableHook"/> look up the entity's SoundTable +
/// <see cref="SoundTableHook"/> → look up the entity's SoundTable +
/// the hook's <c>SoundType</c>, roll one
/// <see cref="DatReaderWriter.Types.SoundEntry"/> via
/// <see cref="SoundCookbook"/>, play its wave. Retail's "footstep
/// that varies slightly" mechanism also how attack / damage sounds
/// that varies slightly" mechanism — also how attack / damage sounds
/// pick a creature-specific variant.
/// </description></item>
/// <item><description>
/// <see cref="SoundTweakedHook"/> same as SoundHook but with
/// <see cref="SoundTweakedHook"/> → same as SoundHook but with
/// pitch / volume overrides baked into the hook.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Entity SoundTable id is resolved via an <see cref="IEntitySoundTable"/>
/// Entity → SoundTable id is resolved via an <see cref="IEntitySoundTable"/>
/// callback passed in at construction; the renderer's per-entity state
/// bag knows the PhysicsObj's <c>SoundTableId</c> (retail:
/// <c>PhysicsObj.soundtable_id</c>).
/// </para>
/// </summary>
public sealed class AudioHookSink : IAnimationHookSink
internal sealed class AudioHookSink : IAnimationHookSink
{
private readonly OpenAlAudioEngine _engine;
private readonly DatSoundCache _cache;
@ -81,7 +81,7 @@ public sealed class AudioHookSink : IAnimationHookSink
case SoundTweakedHook stw:
// SoundTweakedHook is a direct wave play with volume +
// priority overrides baked into the hook itself (NOT a
// SoundTable lookup that's SoundTableHook). Retail uses
// SoundTable lookup — that's SoundTableHook). Retail uses
// this for the rare "explicit wave + explicit volume" case.
Play(entityId, entityWorldPosition,
waveId: (uint)stw.SoundId,
@ -90,7 +90,7 @@ public sealed class AudioHookSink : IAnimationHookSink
pitch: 1f);
break;
// All the visual-only hooks (Scale, Luminous, Diffuse, )
// All the visual-only hooks (Scale, Luminous, Diffuse, …)
// are for other sinks to handle.
}
}
@ -138,7 +138,7 @@ public sealed class AudioHookSink : IAnimationHookSink
/// entity. Retail stores this on <c>PhysicsObj.soundtable_id</c>; our
/// renderer keeps per-entity state that includes it.
/// </summary>
public interface IEntitySoundTable
internal interface IEntitySoundTable
{
/// <summary>
/// Return the SoundTable dat id (0x20xxxxxx) for <paramref name="entityId"/>,
@ -151,7 +151,7 @@ public interface IEntitySoundTable
/// Simple dictionary-backed <see cref="IEntitySoundTable"/>; the renderer
/// assigns entries as it hydrates entities.
/// </summary>
public sealed class DictionaryEntitySoundTable : IEntitySoundTable
internal sealed class DictionaryEntitySoundTable : IEntitySoundTable
{
private readonly Dictionary<uint, uint> _table = new();

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Audio;
@ -7,8 +7,8 @@ using Silk.NET.OpenAL;
namespace AcDream.App.Audio;
/// <summary>
/// OpenAL-backed audio engine (Phase E.2) faithful to retail's
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
/// OpenAL-backed audio engine (Phase E.2) — faithful to retail's
/// 16-voice pool and inverse-square falloff behaviour (r05 §5.3).
///
/// <para>
/// Architecture:
@ -16,7 +16,7 @@ namespace AcDream.App.Audio;
/// <item><description>
/// Single <see cref="ALContext"/> + <see cref="AL"/> bound to the
/// system default device. Cross-platform (WASAPI / WinMM /
/// PulseAudio / CoreAudio whichever OpenAL-Soft picks).
/// PulseAudio / CoreAudio — whichever OpenAL-Soft picks).
/// </description></item>
/// <item><description>
/// Fixed 16-source pool for 3D positional sounds. When all 16 are
@ -27,13 +27,13 @@ namespace AcDream.App.Audio;
/// </description></item>
/// <item><description>
/// Separate UI source pool (4 sources) for flat 2D UI clicks /
/// wooshes not subject to the 3D eviction game.
/// wooshes — not subject to the 3D eviction game.
/// </description></item>
/// <item><description>
/// PCM buffer cache keyed by Wave dat id so the same footstep isn't
/// re-uploaded to the GL-equivalent AL buffers on every hit. Bounded
/// by a byte budget (<see cref="DefaultBufferByteBudget"/>) enforced
/// with LRU eviction see <see cref="EvictBuffersOverBudget"/>. A
/// with LRU eviction — see <see cref="EvictBuffersOverBudget"/>. A
/// buffer still attached to a live source is never evicted (AL
/// rejects deleting a bound buffer); eviction re-queries live AL
/// source state rather than tracking a second copy of it.
@ -60,15 +60,15 @@ internal interface IWorldAudioQuiescence
void ResumeWorldAudio();
}
public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
internal sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescence
{
// ── Backends ─────────────────────────────────────────────────────────────
// ── Backends ─────────────────────────────────────────────────────────────
private AL? _al;
private OpenAlResourceLifetime? _resources;
private bool _available;
private bool _disposed;
// ── Pools ────────────────────────────────────────────────────────────────
// ── Pools ────────────────────────────────────────────────────────────────
private const int PoolSize3D = 16; // retail 16-slot voice pool
private const int PoolSizeUi = 4;
@ -88,7 +88,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
private readonly uint[] _poolUi = new uint[PoolSizeUi];
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
// ── Buffer cache (Wave dat id → AL buffer) ───────────────────────────────
// Budget rationale: decoded PCM waves run ~100-500 KB each (same sizing
// as DatSoundCache's payload LRU, which this cache re-uploads from). 48
// MiB gives comfortable headroom for the working set of a play session
@ -99,11 +99,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
private readonly Dictionary<uint, uint> _bufferByWaveId = new();
private readonly AlBufferBudgetTracker _bufferBudget = new(DefaultBufferByteBudget);
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
// ── Ambient handles (StartAmbient/StopAmbient) ───────────────────────────
private readonly Dictionary<int, uint> _ambientSources = new();
private int _nextAmbientHandle = 1;
// ── Public volume knobs ──────────────────────────────────────────────────
// ── Public volume knobs ──────────────────────────────────────────────────
public float MasterVolume { get; set; } = 1f;
public float SfxVolume { get; set; } = 1f;
public float MusicVolume { get; set; } = 0.7f;
@ -219,7 +219,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
_al = null;
}
// ── IAudioEngine ─────────────────────────────────────────────────────────
// ── IAudioEngine ─────────────────────────────────────────────────────────
public void SetListener(
float posX, float posY, float posZ,
@ -241,7 +241,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
}
/// <summary>
/// Not exposed on IAudioEngine but used by the hook sink play a raw
/// Not exposed on IAudioEngine but used by the hook sink — play a raw
/// WaveData blob at a 3D position with full priority/volume controls.
/// Returns true on success, false if the buffer was rejected.
/// </summary>
@ -278,7 +278,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
if (_pool3D[idx].PlayingGain < effectiveGain) { slotIdx = idx; break; }
}
}
if (slotIdx < 0) return false; // no slot quieter than us drop
if (slotIdx < 0) return false; // no slot quieter than us — drop
var slot = _pool3D[slotIdx];
_al.SourceStop(slot.SourceId);
@ -355,7 +355,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
return true;
}
// IAudioEngine implementations the enum-based overloads are less
// IAudioEngine implementations — the enum-based overloads are less
// useful than the raw-Wave overloads above, since the hook sink already
// has access to decoded WaveData. Left as no-ops for now; R5 defines
// SoundId as a sparse subset of retail enums.
@ -366,7 +366,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
public int StartAmbient(SoundId id, float x, float y, float z)
{
// Looping ambient needs a decoded wave + WaveId. The hook sink
// Looping ambient — needs a decoded wave + WaveId. The hook sink
// doesn't route ambient; a separate landblock-attached ambient
// system (outside R5) will drive this. For now: reserve a handle.
int handle = _nextAmbientHandle++;
@ -383,17 +383,17 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
}
}
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void PlayMusic(string resourceName, bool loop) { /* R5 §6 MIDI — not ported */ }
public void StopMusic() { /* ditto */ }
// ── Private helpers ──────────────────────────────────────────────────────
// ── Private helpers ──────────────────────────────────────────────────────
private uint EnsureBuffer(uint waveId, WaveData wave)
{
if (!_available || _al is null) return 0;
if (_bufferByWaveId.TryGetValue(waveId, out var existing))
{
// Buffer id 0 is the "unsupported format" negative marker no
// Buffer id 0 is the "unsupported format" negative marker — no
// payload, not tracked by the budget, nothing to touch.
if (existing != 0)
_bufferBudget.Touch(waveId);
@ -425,15 +425,15 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
/// <summary>
/// Evict least-recently-used AL buffers until the resident-byte budget
/// is satisfied again. A buffer still bound to a live source (3D pool,
/// UI pool, or an ambient source) is protected <c>alDeleteBuffers</c>
/// fails on a buffer that's still attached to a source so eviction
/// UI pool, or an ambient source) is protected — <c>alDeleteBuffers</c>
/// fails on a buffer that's still attached to a source — so eviction
/// never targets one; nor does it target <paramref name="protectedBufferId"/>,
/// the buffer <see cref="EnsureBuffer"/> just created for this call and
/// hasn't attached to a source yet. If every resident buffer is
/// protected the budget is temporarily exceeded rather than looping
/// forever; the bounded pool sizes (16 + 4 + ambient) cap how large
/// that overage can get. Evicted waves replay through
/// <see cref="EnsureBuffer"/> again on next use re-upload from
/// <see cref="EnsureBuffer"/> again on next use — re-upload from
/// <see cref="DatSoundCache"/>, identical to a first play.
/// </summary>
private void EvictBuffersOverBudget(uint protectedBufferId)

View file

@ -263,8 +263,6 @@ internal sealed class FrameRootCompositionPhase
live.DrawDispatcher,
live.EnvCellRenderer,
live.PortalDepthMask,
foundation.TextRenderer,
interaction.RetainedUi?.Host.TextRenderer,
live.ClipFrame,
foundation.Terrain,
foundation.SceneLighting),
@ -363,6 +361,7 @@ internal sealed class FrameRootCompositionPhase
d.CellVisibility),
d.WorldSceneDebugState,
foundation.DebugLines,
host.GpuFrameLifetime,
d.PhysicsEngine,
d.PlayerMode,
d.PlayerController,
@ -509,7 +508,7 @@ internal sealed class FrameRootCompositionPhase
: (IRenderFramePostDiagnosticsPhase?)lifecycleAutomation
?? NullRenderFramePostDiagnosticsPhase.Instance;
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
host.GpuFrameLifetime,
new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl),
framePreparation,
worldSceneRenderer,

View file

@ -12,6 +12,7 @@ internal interface IGameWindowHostInputCameraPublication
{
void PublishGpuFrameFlights(GpuFrameFlightController value);
void PublishGpuDevice(IGpuDevice value);
void PublishGpuFrameLifetime(GpuDeviceFrameLifetime value);
void PublishKeyboardSource(SilkKeyboardSource value);
void PublishMouseSource(SilkMouseSource value);
void PublishMouseLookCursor(IMouseLookCursor value);
@ -23,6 +24,7 @@ internal interface IGameWindowHostInputCameraPublication
internal sealed record HostInputCameraResult(
GpuFrameFlightController GpuFrameFlights,
IGpuDevice GpuDevice,
GpuDeviceFrameLifetime GpuFrameLifetime,
WorldRenderDiagnostics WorldRenderDiagnostics,
SilkKeyboardSource? KeyboardSource,
SilkMouseSource? MouseSource,
@ -235,9 +237,10 @@ internal sealed class HostInputCameraCompositionPhase :
// exist — it owns its own BindlessSupport detection (see
// GlGpuDevice's class comment), so unlike the legacy WB render path it
// has no dependency on WorldRenderCompositionPhase running first.
// Nothing consumes this device yet (Campaign V slice V1); it is
// Campaign V slice V4a is the first real consumer (TextRenderer,
// BitmapFont, DebugLineRenderer, TextureCache's UI upload path); it is
// proven against the real driver here and torn down with the render
// stack so later slices (starting at V4a) have somewhere to plug in.
// stack.
IGpuDevice gpuDevice = scope.Acquire(
"GPU device (RHI)",
() => _factory.CreateGpuDevice(gl, gpuFrames),
@ -245,6 +248,14 @@ internal sealed class HostInputCameraCompositionPhase :
_publication.PublishGpuDevice);
Fault(HostInputCameraCompositionPoint.GpuDevicePublished);
// V4a's structural addition: the shared IGpuFrame lifecycle that
// ported renderers allocate rings and open passes against. Owns no
// disposable resource of its own — it wraps gpuDevice, whose scope
// above already tears it down — so it is published as a plain value,
// not a second acquisition.
var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
_publication.PublishGpuFrameLifetime(gpuFrameLifetime);
WorldRenderDiagnostics diagnostics =
_factory.CreateWorldRenderDiagnostics(
gl,
@ -345,6 +356,7 @@ internal sealed class HostInputCameraCompositionPhase :
return new HostInputCameraResult(
gpuFrames,
gpuDevice,
gpuFrameLifetime,
diagnostics,
keyboard,
mouse,

View file

@ -33,9 +33,10 @@ namespace AcDream.App.Composition;
internal sealed record InteractionRetainedUiDependencies(
RuntimeOptions Options,
GL Gl,
IGpuDevice GpuDevice,
Func<IGpuFrame> CurrentGpuFrame,
IView Window,
IInputContext Input,
string ShadersDirectory,
IDatReaderWriter Dats,
object DatLock,
TextureCache TextureCache,
@ -389,8 +390,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Character.LocalPlayer);
UiHost host = lease.AcquireHost(
() => new UiHost(
d.Gl,
d.ShadersDirectory,
d.GpuDevice,
d.CurrentGpuFrame,
d.DebugFont,
d.HostQuiescence));
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
@ -477,9 +478,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
host.WireKeyboard(keyboard);
checkpoint(InteractionRetainedUiCompositionPoint.KeyboardInputWired);
(uint, int, int) ResolveChrome(uint id)
(GpuTextureSlot, int, int) ResolveChrome(uint id)
{
uint texture = d.TextureCache.GetOrUploadRenderSurface(
GpuTextureSlot texture = d.TextureCache.GetOrUploadRenderSurface(
id,
out int width,
out int height);

View file

@ -38,6 +38,7 @@ namespace AcDream.App.Composition;
internal sealed record LivePresentationDependencies(
RuntimeOptions Options,
GL Gl,
IGpuDevice GpuDevice,
IWindow Window,
object DatLock,
RuntimeSettingsController Settings,
@ -795,7 +796,8 @@ internal sealed class LivePresentationCompositionPhase
paperdollLease.Resource,
new RetailPaperdollFrameView(
viewport,
new PaperdollInventoryVisibility(inventoryFrame)),
new PaperdollInventoryVisibility(inventoryFrame),
d.GpuDevice),
new RetailPaperdollDollFactory(
new LivePaperdollEntityLookup(liveEntities),
d.PlayerIdentity,
@ -842,7 +844,8 @@ internal sealed class LivePresentationCompositionPhase
new RetailCreatureAppraisalFrameView(
creatureViewport,
examinationFrame,
appraisalController),
appraisalController,
d.GpuDevice),
new RetailCreatureAppraisalCloneFactory(
new LiveCreatureAppraisalEntityLookup(liveEntities)));
}

View file

@ -52,6 +52,7 @@ internal sealed record WorldRenderDependencies(
WorldEnvironmentController Environment,
IGameRenderResourceLifetime RenderResources,
IGpuResourceRetirementQueue ResourceRetirement,
IGpuDevice GpuDevice,
ResidencyBudgetOptions ResidencyBudgets,
uint InitialCenterLandblockId,
string DiagnosticsDirectory,
@ -89,10 +90,10 @@ internal interface IWorldRenderCompositionFactory
void SetTerrainAnisotropic(TerrainAtlas atlas, int level);
Shader CreateTerrainShader(GL gl, string shadersDirectory);
SceneLightingUboBinding CreateSceneLighting(GL gl);
DebugLineRenderer CreateDebugLines(GL gl, string shadersDirectory);
DebugLineRenderer CreateDebugLines(IGpuDevice device);
byte[]? TryLoadDebugFont();
BitmapFont CreateDebugFont(GL gl, byte[] bytes);
TextRenderer CreateTextRenderer(GL gl, string shadersDirectory);
BitmapFont CreateDebugFont(IGpuDevice device, byte[] bytes);
TextRenderer CreateTextRenderer(IGpuDevice device);
TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
@ -112,6 +113,7 @@ internal interface IWorldRenderCompositionFactory
ResidencyBudgetOptions budgets);
TextureCache CreateTextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
@ -214,21 +216,15 @@ internal sealed class RetailWorldRenderCompositionFactory
public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl);
public DebugLineRenderer CreateDebugLines(
GL gl,
string shadersDirectory) =>
new(gl, shadersDirectory);
public DebugLineRenderer CreateDebugLines(IGpuDevice device) => new(device);
public byte[]? TryLoadDebugFont() =>
BitmapFont.TryLoadSystemMonospaceFont();
public BitmapFont CreateDebugFont(GL gl, byte[] bytes) =>
new(gl, bytes, pixelHeight: 15f, atlasSize: 512);
public BitmapFont CreateDebugFont(IGpuDevice device, byte[] bytes) =>
new(device, bytes, pixelHeight: 15f, atlasSize: 512);
public TextRenderer CreateTextRenderer(
GL gl,
string shadersDirectory) =>
new(gl, shadersDirectory);
public TextRenderer CreateTextRenderer(IGpuDevice device) => new(device);
public TerrainModernRenderer CreateTerrain(
GL gl,
@ -294,6 +290,7 @@ internal sealed class RetailWorldRenderCompositionFactory
public TextureCache CreateTextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
BindlessSupport bindless,
IGpuResourceRetirementQueue retirement,
@ -301,6 +298,7 @@ internal sealed class RetailWorldRenderCompositionFactory
ResidencyBudgetOptions budgets) =>
new(
gl,
device,
dats,
bindless,
retirement,
@ -482,12 +480,12 @@ internal sealed class WorldRenderCompositionPhase
DebugLineRenderer debugLines = AcquireAndPublish(
scope,
"debug lines",
() => _factory.CreateDebugLines(gl, shadersDirectory),
() => _factory.CreateDebugLines(_dependencies.GpuDevice),
_publication.PublishDebugLines,
WorldRenderCompositionPoint.DebugLinesPublished);
(BitmapFont? debugFont, TextRenderer? textRenderer) =
ComposeOptionalHudResources(scope, gl, shadersDirectory);
ComposeOptionalHudResources(scope);
TerrainModernRenderer terrain = AcquireAndPublish(
scope,
@ -535,6 +533,7 @@ internal sealed class WorldRenderCompositionPhase
"texture cache",
() => _factory.CreateTextureCache(
gl,
_dependencies.GpuDevice,
content.Dats,
bindless,
_dependencies.ResourceRetirement,
@ -586,9 +585,7 @@ internal sealed class WorldRenderCompositionPhase
}
private (BitmapFont? Font, TextRenderer? Text) ComposeOptionalHudResources(
CompositionAcquisitionScope scope,
GL gl,
string shadersDirectory)
CompositionAcquisitionScope scope)
{
byte[]? fontBytes = _factory.TryLoadDebugFont();
if (fontBytes is null)
@ -600,13 +597,13 @@ internal sealed class WorldRenderCompositionPhase
var fontLease = scope.Acquire(
"world HUD font",
() => _factory.CreateDebugFont(gl, fontBytes),
() => _factory.CreateDebugFont(_dependencies.GpuDevice, fontBytes),
_factory.Release);
BitmapFont font = fontLease.Resource;
Fault(WorldRenderCompositionPoint.DebugFontCreated);
var textLease = scope.Acquire(
"world HUD text renderer",
() => _factory.CreateTextRenderer(gl, shadersDirectory),
() => _factory.CreateTextRenderer(_dependencies.GpuDevice),
_factory.Release);
TextRenderer text = textLease.Resource;
Fault(WorldRenderCompositionPoint.TextRendererCreated);

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
@ -10,11 +10,11 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>Stage indices for per-frame CPU attribution.</summary>
public enum FrameStage
internal enum FrameStage
{
/// <summary>Whole OnUpdate body (simulation + streaming apply).</summary>
Update = 0,
/// <summary>WbMeshAdapter.Tick staged mesh/texture GPU upload drain.</summary>
/// <summary>WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.</summary>
Upload = 1,
/// <summary>ImGui Render (dev overlay).</summary>
ImGui = 2,
@ -23,11 +23,11 @@ public enum FrameStage
}
/// <summary>
/// One <c>ACDREAM_FRAME_HISTORY</c> CSV row every field the aggregated
/// One <c>ACDREAM_FRAME_HISTORY</c> CSV row — every field the aggregated
/// <c>[frame-prof]</c> report discards when its 5-second window resets.
/// Stage fields mirror <see cref="FrameStage"/> positionally (Update /
/// Upload / ImGui / Pacing, matching <see cref="FrameProfiler.FormatReport"/>'s
/// <c>names</c> array) if <see cref="FrameStage"/> grows, extend this
/// <c>names</c> array) — if <see cref="FrameStage"/> grows, extend this
/// record, <see cref="FrameProfiler.WriteHistoryCsv"/>, and the CSV header
/// together. <c>GpuUs</c> is <c>-1</c> for a frame with no available GPU
/// sample (warm-up, or <c>ACDREAM_WB_DIAG=1</c> self-disable).
@ -44,7 +44,7 @@ internal readonly record struct FrameHistoryRecord(
long PacingUs);
/// <summary>
/// MP0 (2026-07-05) the permanent honest frame profiler. One
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of the accepted render transaction
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present) and samples per-frame allocated
@ -56,29 +56,29 @@ internal readonly record struct FrameHistoryRecord(
/// <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.
/// <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>
///
/// <para>2026-07-24 measurement-tooling review the aggregated report
/// <para>2026-07-24 measurement-tooling review — the aggregated report
/// resets its ring buffers every ~5 s (<see cref="FrameStatsBuffer.Reset"/>),
/// so route-wide p50/p95/p99 distributions across a whole soak cannot be
/// reconstructed after the fact. <see cref="RenderingDiagnostics.FrameHistoryPath"/>
/// (<c>ACDREAM_FRAME_HISTORY=&lt;path&gt;</c>) opts into a SEPARATE
/// per-frame history: one <see cref="FrameHistoryRecord"/> per frame in a
/// preallocated, grow-as-needed <see cref="List{T}"/> (1 int + 1 double +
/// 7 longs 72 bytes/record; a multi-hour capture at 165 fps is roughly
/// 165 * 3600 * 72 bytes ≈ 43 MB/hour — fine for a bounded diagnostic run,
/// 7 longs ≈ 72 bytes/record; a multi-hour capture at 165 fps is roughly
/// 165 * 3600 * 72 bytes ≈ 43 MB/hour — fine for a bounded diagnostic run,
/// not for unattended day-long capture). Its initial capacity is about 9 MiB
/// and covers the canonical route above 300 FPS without a frame-thread resize.
/// ZERO frame-thread I/O: the CSV is written once, from
/// <see cref="Dispose"/>, at shutdown. Recording only takes effect while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is ALSO true it
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is ALSO true — it
/// reuses that instrumentation rather than duplicating it. Does not
/// change the <c>[frame-prof]</c> report format or any existing metric.</para>
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameProfiler : IDisposable
internal sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const int HistoryInitialCapacity = 131072;
@ -136,7 +136,7 @@ public sealed class FrameProfiler : IDisposable
if (_wasEnabled)
{
// Dispose (not just Stop) so a later re-enable rebuilds the
// query ring fresh a kept instance would poll slots left
// 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.
@ -163,7 +163,7 @@ public sealed class FrameProfiler : IDisposable
{
// 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
// 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.
@ -274,7 +274,7 @@ public sealed class FrameProfiler : IDisposable
internal void EndStage(FrameStage stage, long startTimestamp)
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
/// <summary>Pure report formatter unit-tested; invariant culture.</summary>
/// <summary>Pure report formatter — unit-tested; invariant culture.</summary>
public static string FormatReport(
int frameCount,
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
@ -301,7 +301,7 @@ public sealed class FrameProfiler : IDisposable
return sb.ToString();
}
/// <summary>Pure CSV formatter unit-tested; invariant culture. One header row plus one row per record.</summary>
/// <summary>Pure CSV formatter — unit-tested; invariant culture. One header row plus one row per record.</summary>
internal static void WriteHistoryCsv(
IEnumerable<FrameHistoryRecord> records,
TextWriter writer,
@ -330,7 +330,7 @@ public sealed class FrameProfiler : IDisposable
/// <summary>
/// Shutdown-only write of the accumulated <see cref="_history"/> as CSV
/// (the ONLY I/O this feature performs never from <see cref="FrameBoundary"/>).
/// (the ONLY I/O this feature performs — never from <see cref="FrameBoundary"/>).
/// Failures are logged, not thrown: a history-export problem must never
/// block the rest of the render-owner shutdown chain
/// (<c>GameWindowLifetime</c>'s <c>Hard("frame profiler", ...)</c> stage).
@ -355,7 +355,7 @@ public sealed class FrameProfiler : IDisposable
}
/// <summary>Disposable stage scope; default instance is a no-op.</summary>
public readonly struct StageScope : IDisposable
internal readonly struct StageScope : IDisposable
{
private readonly FrameProfiler? _owner;
private readonly FrameStage _stage;

View file

@ -1,16 +1,16 @@
using System;
using System;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) fixed-capacity ring buffer of long samples
/// 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.
/// 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
internal sealed class FrameStatsBuffer
{
private readonly long[] _samples;
private readonly long[] _scratch;
@ -41,7 +41,7 @@ public sealed class FrameStatsBuffer
/// <summary>
/// Nearest-rank percentile over the current window: element at
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
/// </summary>
public long Percentile(double q)
{

View file

@ -2,3 +2,8 @@ global using AcDream.Runtime.Gameplay;
global using AcDream.Runtime.Physics;
global using ILocalPlayerMotionSource =
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
// Campaign V slice V4a: GpuTextureSlot (and the rest of the RHI contract) is
// used pervasively once TextRenderer/TextureCache's UI path stop dealing in
// raw GL texture names, so it is imported project-wide rather than adding a
// `using AcDream.App.Rendering.Gpu;` to every UI file that resolves a sprite.
global using AcDream.App.Rendering.Gpu;

View file

@ -1,4 +1,4 @@
using AcDream.Core.World;
using AcDream.Core.World;
using AcDream.App.Physics;
using AcDream.App.World;
@ -59,7 +59,7 @@ internal sealed class LiveLocalPlayerProjectionRuntime
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
/// </summary>
public sealed class LocalPlayerProjectionController
internal sealed class LocalPlayerProjectionController
{
private readonly ILocalPlayerProjectionRuntime _runtime;

View file

@ -1,4 +1,4 @@
using System;
using System;
using AcDream.App.Net;
using AcDream.App.Streaming;
using AcDream.App.World;
@ -6,7 +6,7 @@ using AcDream.App.World;
namespace AcDream.App.Input;
/// <summary>
/// Phase K.2 one-shot guard that auto-enters player mode after a
/// Phase K.2 — one-shot guard that auto-enters player mode after a
/// successful login once every prerequisite is satisfied. The update-frame
/// orchestrator ticks it through a typed production context.
///
@ -16,7 +16,7 @@ namespace AcDream.App.Input;
/// entity has been streamed into the world dictionary, the player
/// movement controller is constructible, and the initial world is fully
/// drawable and collidable) plus a manual-override path
/// (the user can flip into fly mode before the auto-entry fires
/// (the user can flip into fly mode before the auto-entry fires —
/// their choice wins). All five interact with each other in a way
/// that's painful to test through GameWindow but trivial here against
/// fakes.
@ -25,11 +25,11 @@ namespace AcDream.App.Input;
/// <para>
/// The public surface is:
/// <list type="bullet">
/// <item><see cref="Arm"/> call after <c>EnterWorld</c> succeeds to
/// <item><see cref="Arm"/> — call after <c>EnterWorld</c> succeeds to
/// arm the entry trigger.</item>
/// <item><see cref="Cancel"/> call when the user manually enters
/// <item><see cref="Cancel"/> — call when the user manually enters
/// fly mode (or any other code path that pre-empts the auto-entry).</item>
/// <item><see cref="TryEnter"/> call once per frame; runs the
/// <item><see cref="TryEnter"/> — call once per frame; runs the
/// guard and fires the entry callback when armed AND every
/// precondition is satisfied; returns true on the firing tick.</item>
/// </list>
@ -101,7 +101,7 @@ internal sealed class LivePlayerModeAutoEntryContext
}
}
public sealed class PlayerModeAutoEntry
internal sealed class PlayerModeAutoEntry
{
private sealed class DelegateContext : IPlayerModeAutoEntryContext
{
@ -163,7 +163,7 @@ public sealed class PlayerModeAutoEntry
/// Retail keeps position completion behind one blocking cell-load edge;
/// acdream's asynchronous domains must converge before entry.</param>
/// <param name="enterPlayerMode">Action invoked on the firing
/// tick. The same routine the manual Tab handler invokes (fly
/// tick. The same routine the manual Tab handler invokes (fly →
/// player transition). Must construct the controller + chase
/// camera and switch the active camera; the auto-entry doesn't
/// reach inside.</param>
@ -202,7 +202,7 @@ public sealed class PlayerModeAutoEntry
/// <summary>
/// Disarm the trigger without firing the callback. Call when the
/// user has manually entered fly mode (or any other code path
/// that pre-empts the auto-entry) the user's choice wins.
/// that pre-empts the auto-entry) — the user's choice wins.
/// </summary>
public void Cancel() => _armed = false;

View file

@ -1,4 +1,4 @@
using AcDream.App.Update;
using AcDream.App.Update;
using AcDream.Runtime;
namespace AcDream.App.Input;
@ -17,7 +17,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
{
private readonly RuntimeLocalPlayerFrameController _runtime;
public readonly record struct PresentationFrame(
internal readonly record struct PresentationFrame(
MovementResult Movement,
bool Hidden,
bool AdvancedBeforeNetwork);

View file

@ -1,4 +1,4 @@
using AcDream.App.Rendering;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
@ -73,7 +73,7 @@ internal sealed class SilkKeyboardEventSurface : IKeyboardEventSurface
/// subscription. Logical deactivation makes copied delegates inert while
/// physical removal is retried.
/// </summary>
public sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
internal sealed class SilkKeyboardSource : IKeyboardSource, IDisposable
{
private readonly IKeyboardEventSurface _surface;
private readonly HostQuiescenceGate _quiescence;

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
@ -100,7 +100,7 @@ internal sealed class SilkMouseEventSurface : IMouseEventSurface
}
/// <summary>Reversible Silk mouse bridge with immediate logical cutoff.</summary>
public sealed class SilkMouseSource : IMouseSource, IDisposable
internal sealed class SilkMouseSource : IMouseSource, IDisposable
{
private readonly IMouseEventSurface _surface;
private readonly IInputCaptureSource _capture;

View file

@ -1,11 +1,11 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Physics;
/// <summary>Session-scoped cache of the local player's last published shadow pose.</summary>
internal sealed class LocalPlayerShadowState
{
public readonly record struct Snapshot(
internal readonly record struct Snapshot(
Vector3 Position,
Quaternion Orientation,
uint CellId);

View file

@ -1,4 +1,4 @@
using AcDream.Core.Physics;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
@ -8,7 +8,7 @@ namespace AcDream.App.Physics;
/// testable while the concrete movement/interpolation/target owners remain
/// in the composition root.
/// </summary>
public static class RemoteTeleportHook
internal static class RemoteTeleportHook
{
private const WeenieError TeleportCancelContext = (WeenieError)0x3Cu;
@ -46,7 +46,7 @@ public static class RemoteTeleportHook
}
}
public sealed record RemoteTeleportHookActions(
internal sealed record RemoteTeleportHookActions(
Action<WeenieError> CancelMoveTo,
Action UnStick,
Action StopInterpolating,

View file

@ -1,8 +1,8 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
public sealed class AppPluginHost : IPluginHost
internal sealed class AppPluginHost : IPluginHost
{
public AppPluginHost(
IPluginLogger log,

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
@ -8,9 +8,9 @@ namespace AcDream.App.Plugins;
/// Program.cs before the GL window opens) until GameWindow drains them into the
/// UiHost tree after construction.
/// </summary>
public sealed class BufferedUiRegistry : IUiRegistry
internal sealed class BufferedUiRegistry : IUiRegistry
{
public readonly record struct Pending(string MarkupPath, object Binding);
internal readonly record struct Pending(string MarkupPath, object Binding);
private readonly List<Pending> _pending = new();

View file

@ -1,8 +1,8 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.Plugins;
public sealed class SerilogAdapter : IPluginLogger
internal sealed class SerilogAdapter : IPluginLogger
{
private readonly Serilog.ILogger _log;
public SerilogAdapter(Serilog.ILogger log) => _log = log;

View file

@ -1,20 +1,20 @@
using System;
using System;
using System.IO;
using Silk.NET.OpenGL;
using StbTrueTypeSharp;
namespace AcDream.App.Rendering;
/// <summary>
/// A pixel-font atlas rasterized from a TTF at load time using stb_truetype.
/// Glyphs are packed into a single-channel (R8) GL texture. Call
/// <see cref="TryGetGlyph"/> to resolve an ASCII codepoint to UV + metrics.
/// Glyphs are packed into a single-channel (R8) texture registered in the
/// device's global texture table. Call <see cref="TryGetGlyph"/> to resolve an
/// ASCII codepoint to UV + metrics.
///
/// Only printable ASCII (32..127) is supported for the debug overlay.
/// </summary>
public sealed unsafe class BitmapFont : IDisposable
internal sealed unsafe class BitmapFont : IDisposable
{
public readonly struct Glyph
internal readonly struct Glyph
{
public readonly float UvMinX;
public readonly float UvMinY;
@ -34,23 +34,23 @@ public sealed unsafe class BitmapFont : IDisposable
}
}
private readonly GL _gl;
private readonly Glyph[] _glyphs;
private readonly int _firstChar;
private readonly int _numChars;
private readonly ResourceCleanupGroup _resources;
private readonly IGpuDevice _device;
private readonly IGpuTexture _texture;
public uint TextureId { get; }
public GpuTextureSlot TextureId { get; }
public float PixelHeight { get; }
public float LineHeight { get; }
public float Ascent { get; }
public int AtlasWidth { get; }
public int AtlasHeight { get; }
public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight,
public BitmapFont(IGpuDevice device, byte[] ttfBytes, float pixelHeight,
int atlasSize = 512, int firstChar = 32, int numChars = 96)
{
_gl = gl;
_device = device ?? throw new ArgumentNullException(nameof(device));
PixelHeight = pixelHeight;
AtlasWidth = atlasSize;
AtlasHeight = atlasSize;
@ -96,65 +96,31 @@ public sealed unsafe class BitmapFont : IDisposable
adv: bc.xadvance);
}
// Upload atlas as a single-channel GL texture (R8). Publish the GL
// name into the construction ledger before any later upload/state
// command can fail.
var resources = new ResourceCleanupGroup();
uint texture = 0;
// Upload atlas as a single-channel texture (R8) and register it in the
// device's global texture table. Linear + clamp-to-edge matches the
// GL path's prior fixed sampler state exactly (mip filtering is moot —
// the atlas is a single mip level).
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
"bitmap-font-atlas",
GpuTextureKind.Texture2D,
GpuTextureFormat.R8Unorm,
Width: AtlasWidth,
Height: AtlasHeight,
LayerCount: 1,
MipLevelCount: 1));
try
{
texture = GlResourceCommand.CreateTexture(_gl, "BitmapFont atlas");
uint ownedTexture = texture;
resources.Add(
"bitmap-font atlas",
() => GlResourceCommand.DeleteTexture(
_gl,
ownedTexture,
$"delete BitmapFont atlas {ownedTexture}"));
_gl.GetInteger(GetPName.TextureBinding2D, out int previousTexture);
_gl.GetInteger(GetPName.UnpackAlignment, out int previousAlignment);
GlResourceCommand.Execute(_gl, "initialize BitmapFont atlas", () =>
{
try
{
_gl.BindTexture(TextureTarget.Texture2D, texture);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
fixed (byte* ptr = pixels)
{
_gl.TexImage2D(TextureTarget.Texture2D, 0,
(int)InternalFormat.R8,
(uint)AtlasWidth, (uint)AtlasHeight, 0,
PixelFormat.Red, PixelType.UnsignedByte, ptr);
texture.Upload(0, 0, pixels);
IGpuSampler sampler = _device.CreateSampler(GpuSamplerDescription.WorldClamp);
TextureId = _device.RegisterTexture(texture, sampler);
}
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
}
finally
catch
{
_gl.PixelStore(
PixelStoreParameter.UnpackAlignment,
previousAlignment);
_gl.BindTexture(
TextureTarget.Texture2D,
unchecked((uint)previousTexture));
}
});
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"BitmapFont construction failed and its GL atlas did not cleanly roll back.",
constructionFailure);
texture.Dispose();
throw;
}
TextureId = texture;
_resources = resources;
_texture = texture;
}
public bool TryGetGlyph(char c, out Glyph g)
@ -183,7 +149,8 @@ public sealed unsafe class BitmapFont : IDisposable
public void Dispose()
{
_resources.RetryCleanup();
_device.ReleaseTextureSlot(TextureId);
_texture.Dispose();
}
/// <summary>

View file

@ -1,9 +1,9 @@
// src/AcDream.App/Rendering/CameraController.cs
// src/AcDream.App/Rendering/CameraController.cs
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
public sealed class CameraController
internal sealed class CameraController
{
internal readonly record struct CameraState(
int ModeCode,
@ -19,7 +19,7 @@ public sealed class CameraController
/// The renderer-facing active camera. Both the legacy and retail
/// chase cameras are held simultaneously so that flipping
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> takes effect
/// on the very next access to this property no re-entry required,
/// on the very next access to this property — no re-entry required,
/// no notification mechanism, no stale state.
/// </summary>
public ICamera Active
@ -59,7 +59,7 @@ public sealed class CameraController
/// <summary>
/// Store both cameras simultaneously; <see cref="Active"/> picks
/// between them per-read via the flag no re-entry needed on flip.
/// between them per-read via the flag — no re-entry needed on flip.
/// </summary>
public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
{

View file

@ -1,11 +1,11 @@
// CellVisibility.cs — portal-based interior cell visibility system.
// CellVisibility.cs — portal-based interior cell visibility system.
//
// Stage 3 (2026-06-02): FindCameraCell + grace-frame AABB fallback deleted.
// The physics membership answer (CellGraph.CurrCell) is now the mandatory root;
// ComputeVisibilityFromRoot(null, ) returns null (outdoor root) rather than
// ComputeVisibilityFromRoot(null, …) returns null (outdoor root) rather than
// falling back to an independent AABB position resolve. This matches retail's
// CellManager::ChangePosition (0x004559B0) which does not re-derive the cell
// from a static position it reads the swept transition-owned CurrCell.
// from a static position — it reads the swept transition-owned CurrCell.
//
// This file is intentionally free of GL / rendering types. It depends only on
// System.Numerics so it can be unit-tested without a GPU context.
@ -23,7 +23,7 @@ namespace AcDream.App.Rendering;
/// A loaded EnvCell with portal connectivity and spatial data, used by
/// <see cref="CellVisibility"/> for portal-traversal visibility decisions.
/// </summary>
public sealed class LoadedCell
internal sealed class LoadedCell
{
/// <summary>Full 32-bit cell ID, e.g. 0xA9B40105.</summary>
public uint CellId;
@ -87,7 +87,7 @@ public sealed class LoadedCell
public uint? BuildingId { get; internal set; }
/// <summary>
/// Phase U.4c: the stab_list PVS as full (landblock-prefixed) cell ids retail
/// Phase U.4c: the stab_list PVS as full (landblock-prefixed) cell ids — retail
/// CEnvCell.stab_list (acclient.h ~30925), the stable set of cells potentially
/// visible from this cell, precomputed by the AC content tools. Refreshed only at
/// hydration (= retail's per-cell-entry grab_visible_cells, decomp:311878).
@ -98,7 +98,7 @@ public sealed class LoadedCell
public IReadOnlyList<uint> VisibleCells = System.Array.Empty<uint>();
/// <summary>
/// Phase U.4c: retail CEnvCell.seen_outside (acclient.h ~30925) this cell sees
/// Phase U.4c: retail CEnvCell.seen_outside (acclient.h ~30925) — this cell sees
/// the exterior (an exit portal is reachable from it). Retail gates the landscape
/// data + draw decision on the camera cell's value (RenderNormalMode decomp:92649,
/// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test.
@ -107,7 +107,7 @@ public sealed class LoadedCell
/// <summary>
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
/// <see cref="OutdoorCellNode.Build"/> the outdoor world modelled as a flood-graph cell whose
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a flood-graph cell whose
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView
/// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell).
/// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but
@ -123,19 +123,19 @@ public sealed class LoadedCell
/// <see cref="OtherPortalId"/> is the dat's reciprocal back-link: the index of
/// the portal WITHIN the neighbour cell's portal list that points back through
/// this same opening. Retail indexes the reciprocal directly via this field
/// (<c>arg2-&gt;other_portal_id</c>, decomp:433557) rather than scanning which
/// (<c>arg2-&gt;other_portal_id</c>, decomp:433557) rather than scanning — which
/// is what lets a cell with TWO portals to the same neighbour resolve each
/// opening against its OWN reciprocal polygon instead of the first match.
/// </para>
/// </summary>
public readonly record struct CellPortalInfo(
internal readonly record struct CellPortalInfo(
ushort OtherCellId, ushort PolygonId, ushort Flags, ushort OtherPortalId);
/// <summary>
/// Clip plane derived from a portal polygon, in cell-local space.
/// Plane equation: Normal.X*x + Normal.Y*y + Normal.Z*z + D = 0.
/// </summary>
public struct PortalClipPlane
internal struct PortalClipPlane
{
/// <summary>Plane normal (cell-local space, unit length).</summary>
public Vector3 Normal;
@ -146,8 +146,8 @@ public struct PortalClipPlane
/// <summary>
/// Which half-space is "inside" this cell (the side from which you look outward
/// through the portal):
/// 0 camera dot-product must be >= 0 (positive half-space is inside)
/// 1 camera dot-product must be &lt;= 0 (negative half-space is inside)
/// 0 → camera dot-product must be >= 0 (positive half-space is inside)
/// 1 → camera dot-product must be &lt;= 0 (negative half-space is inside)
/// Determined from cell centroid position relative to the portal plane.
/// Ported from ACME EnvCellManager.cs ~line 404.
/// </summary>
@ -155,12 +155,12 @@ public struct PortalClipPlane
}
/// <summary>
/// Phase U.4c flap probe (diagnostic OBSOLETE as of Stage 3). Previously tracked
/// Phase U.4c flap probe (diagnostic — OBSOLETE as of Stage 3). Previously tracked
/// which branch of FindCameraCell (now deleted) resolved the camera cell. Retained
/// for binary compatibility with the [flap-cam] probe log site in GameWindow.cs that
/// still prints <see cref="LastCameraCellResolution"/> (always None post-Stage 3).
/// </summary>
public enum CameraCellResolution
internal enum CameraCellResolution
{
/// <summary>No cell contains the eye (outdoors), or not yet resolved.</summary>
None,
@ -171,14 +171,14 @@ public enum CameraCellResolution
/// <summary>The eye is inside a cell found by the full brute-force scan.</summary>
BruteForce,
/// <summary>The eye is inside NO cell, but the previous cell is kept alive for a
/// few grace frames the "stale root" case the flap probe watches for.</summary>
/// few grace frames — the "stale root" case the flap probe watches for.</summary>
Grace,
}
/// <summary>
/// Result of a portal-based visibility BFS from the camera cell.
/// </summary>
public sealed class VisibilityResult
internal sealed class VisibilityResult
{
/// <summary>Full cell IDs (e.g. 0x01D90105) that should be rendered this frame.</summary>
public HashSet<uint> VisibleCellIds { get; init; } = new();
@ -207,7 +207,7 @@ public sealed class VisibilityResult
/// Ported faithfully from ACME's EnvCellManager.cs portal-visibility region.
/// Constants and control flow match the ACME implementation.
/// </summary>
public sealed class CellVisibility
internal sealed class CellVisibility
{
// ------------------------------------------------------------------
// Constants (ACME ground-truth values)
@ -234,7 +234,7 @@ public sealed class CellVisibility
public VisibilityResult? LastVisibilityResult { get; private set; }
/// <summary>
/// Stage 3 (2026-06-02): always <see cref="CameraCellResolution.None"/> the FindCameraCell
/// Stage 3 (2026-06-02): always <see cref="CameraCellResolution.None"/> — the FindCameraCell
/// AABB grace-frame resolver was deleted; the physics membership answer is the sole root.
/// Retained for the [flap-cam] probe log line in GameWindow.cs.
/// </summary>
@ -292,7 +292,7 @@ public sealed class CellVisibility
/// <summary>
/// Phase A8 (2026-05-28): enumerates the loaded cells that belong to a
/// landblock prefix. Used by <c>LandblockRenderPublisher</c> when building
/// the per-landblock <c>BuildingRegistry</c> the per-frame
/// the per-landblock <c>BuildingRegistry</c> — the per-frame
/// <c>drainedCells</c> dict misses cells loaded on prior frames, so the
/// stamping loop in <see cref="Wb.BuildingLoader.Build"/> needs access to
/// every cell currently in the landblock to ensure <c>BuildingId</c> is set.
@ -356,15 +356,15 @@ public sealed class CellVisibility
/// <summary>
/// UCG W2/Stage 3: compute visibility from a supplied root cell (the physics membership
/// answer). When <paramref name="root"/> is null (pre-spawn, or player outside all indoor
/// cells), returns <c>null</c> the caller interprets null as the outdoor root (no portal
/// cells), returns <c>null</c> — the caller interprets null as the outdoor root (no portal
/// frame, everything slot 0, terrain ungated). The legacy AABB FindCameraCell fallback is
/// deleted as of Stage 3; <see cref="CellGraph.CurrCell"/> is the sole authority.
/// Retail anchor: CellManager::ChangePosition @ 0x004559B0 reads the transition-owned
/// curr_cell it does NOT re-derive from a static position.
/// curr_cell — it does NOT re-derive from a static position.
/// </summary>
/// <param name="root">
/// The render-registered <see cref="LoadedCell"/> that physics determined the player is inside,
/// or null when pre-spawn or the player is in an outdoor landcell. Null outdoor root path.
/// or null when pre-spawn or the player is in an outdoor landcell. Null → outdoor root path.
/// </param>
/// <param name="fallbackPos">
/// Used as the viewer position for the portal-side test in the BFS when root is non-null.
@ -389,13 +389,13 @@ public sealed class CellVisibility
}
// ------------------------------------------------------------------
// FindCameraCell DELETED in Stage 3 (2026-06-02)
// FindCameraCell — DELETED in Stage 3 (2026-06-02)
// ------------------------------------------------------------------
// The AABB + grace-frame camera-cell resolver was removed. Production code
// now exclusively uses ComputeVisibilityFromRoot(root, ) where root is the
// now exclusively uses ComputeVisibilityFromRoot(root, …) where root is the
// transition-owned CellGraph.CurrCell (set by ResolveCellId/Stage 2 physics).
// Retail anchor: CellManager::ChangePosition (0x004559B0) reads curr_cell
// from the sweep it never re-derives from a static position.
// from the sweep — it never re-derives from a static position.
//
// GetVisibleCells (used by ComputeVisibility below for test compatibility)
// still uses the brute-force AABB scan internally.
@ -471,12 +471,12 @@ public sealed class CellVisibility
/// <summary>
/// UCG W2: BFS visibility traversal from a pre-resolved root cell.
/// The root is the correct membership answer (supplied by the caller
/// The root is the correct membership answer (supplied by the caller —
/// physics CurrCell via <see cref="ComputeVisibilityFromRoot"/>, or AABB
/// scan via <see cref="GetVisibleCells"/> for test compat).
///
/// The BFS body is byte-identical to the original GetVisibleCells
/// implementation only root acquisition was extracted out.
/// implementation — only root acquisition was extracted out.
/// </summary>
private VisibilityResult? GetVisibleCellsFromRoot(LoadedCell cameraCell, Vector3 cameraPos)
{
@ -499,7 +499,7 @@ public sealed class CellVisibility
{
var portal = cell.Portals[i];
// Exit portal outdoor terrain should be visible.
// Exit portal → outdoor terrain should be visible.
if (portal.OtherCellId == 0xFFFF)
{
result.HasExitPortalVisible = true;
@ -522,8 +522,8 @@ public sealed class CellVisibility
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
// InsideSide == 0 → inside is positive half-space; reject if dot < -ε.
// InsideSide == 1 → inside is negative half-space; reject if dot > ε.
// InsideSide == 0 → inside is positive half-space; reject if dot < -ε.
// InsideSide == 1 → inside is negative half-space; reject if dot > ε.
// Source: ACME EnvCellManager.cs lines 1458-1459.
if (plane.InsideSide == 0 && dot < -PointInCellEpsilon)
continue;

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering;
/// character. Implements <see cref="ICamera"/> so it plugs into the
/// existing renderer pipeline.
/// </summary>
public sealed class ChaseCamera : ICamera
internal sealed class ChaseCamera : ICamera
{
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
@ -37,9 +37,9 @@ public sealed class ChaseCamera : ICamera
// (at distance * sin(Pitch)) so the player can be viewed from a low
// angle. Clamped to -0.7 to avoid pushing the camera deep underground;
// at -0.7 and Distance=8 the camera is ~5m below player-Z which will
// clip terrain on hills but is OK on flat ground. 1.4 looking
// clip terrain on hills but is OK on flat ground. 1.4 ≈ looking
// straight down. Wider than the old [0.05, 1.4] so mouse-Y moves the
// camera in both directions from the neutral [~20°] default.
// camera in both directions from the neutral [~20°] default.
private const float PitchMin = -0.7f;
private const float PitchMax = 1.4f;
@ -47,7 +47,7 @@ public sealed class ChaseCamera : ICamera
private Vector3 _lookAt;
// K-fix12 (2026-04-26): retail-feel jump camera. The camera Z is
// pinned to the LAST GROUNDED Z while the player is airborne the
// pinned to the LAST GROUNDED Z while the player is airborne — the
// character rises above the camera on screen, visually matching
// retail's "you can see yourself jump" feedback. Walking on the
// ground tracks Z directly (no lag on hill transitions); falling
@ -90,10 +90,10 @@ public sealed class ChaseCamera : ICamera
{
_trackedZ = playerPosition.Z; // catch up to falls / drops
}
// else: airborne and rising keep _trackedZ pinned.
// else: airborne and rising — keep _trackedZ pinned.
// Look-at uses the actual player Z so the camera always points
// at the character when the player rises above the pinned
// at the character — when the player rises above the pinned
// camera the look-at tilts up to keep them centered in frame.
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
@ -109,7 +109,7 @@ public sealed class ChaseCamera : ICamera
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
_trackedZ + EyeHeight + verticalDist); // uses tracked Z (pinned to ground while airborne)
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
}
/// <summary>

View file

@ -1,20 +1,20 @@
// ClipFrame.cs
// ClipFrame.cs
//
// Phase U.3: the GPU-side container + uploader for the SHARED per-frame clip
// data consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert
// (UBO binding=2). This is the "shared" half of the U.3 clip mechanism; the
// per-instance slot index buffer (SSBO binding=3) is PER-RENDERER and owned by
// each renderer (WbDrawDispatcher / EnvCellRenderer), parallel to its instance
// buffer it is NOT here.
// buffer — it is NOT here.
//
// === The contract (both shader sides obey) ===================================
// binding=2 mesh SSBO holds an array of CellClip, one per "slot":
// struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
// std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16
// (vec4 stride 16) 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0).
// (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0).
// binding=2 terrain UBO holds the single OutsideView region:
// layout(std140) { int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; };
// std140 layout: count at byte 0 (padded to 16), planes[8] at 16 144 bytes.
// std140 layout: count at byte 0 (padded to 16), planes[8] at 16 → 144 bytes.
//
// In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0) and a
// terrain count of 0. Everything renders exactly as before. U.4 populates real
@ -40,16 +40,16 @@ namespace AcDream.App.Rendering;
/// std430 / std140 byte layout. Per-instance slot buffers (binding=3) are owned by
/// each renderer, not here.
/// </summary>
public sealed class ClipFrame : IDisposable
internal sealed class ClipFrame : IDisposable
{
// ---- Layout constants (mirror mesh_modern.vert + terrain_modern.vert) ----
/// <summary>Max planes per clip region matches the shader's <c>planes[8]</c>
/// <summary>Max planes per clip region — matches the shader's <c>planes[8]</c>
/// and GL's guaranteed <c>GL_MAX_CLIP_DISTANCES &gt;= 8</c>.</summary>
public const int MaxPlanes = 8;
/// <summary>std430 stride of one <c>CellClip</c>: 16 (count + 3 pad uints) +
/// 8 × 16 (vec4 planes) = 144 bytes.</summary>
/// 8 × 16 (vec4 planes) = 144 bytes.</summary>
public const int CellClipStrideBytes = 16 + MaxPlanes * 16; // 144
/// <summary>Byte offset of <c>planes[0]</c> within a <c>CellClip</c> (after the
@ -57,7 +57,7 @@ public sealed class ClipFrame : IDisposable
public const int CellClipPlanesOffset = 16;
/// <summary>std140 size of the terrain UBO block: int count padded to 16, then
/// 8 × 16 (vec4 planes) = 144 bytes. Same number as the SSBO stride by
/// 8 × 16 (vec4 planes) = 144 bytes. Same number as the SSBO stride by
/// coincidence of the 16-byte vec4 rule, but a DIFFERENT layout family.</summary>
public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144
@ -66,7 +66,7 @@ public sealed class ClipFrame : IDisposable
public const uint MeshClipSsboBinding = 2;
/// <summary>UBO binding index for the terrain OutsideView clip region
/// (terrain_modern.vert binding=2). UBO namespace distinct from the SSBO
/// (terrain_modern.vert binding=2). UBO namespace — distinct from the SSBO
/// binding=2 above.</summary>
public const uint TerrainClipUboBinding = 2;
@ -139,23 +139,23 @@ public sealed class ClipFrame : IDisposable
/// <summary>
/// The U.3 default frame: exactly slot 0 (no-clip, count 0) and a terrain
/// count of 0. The whole scene renders ungated identical to pre-U.3. U.4
/// count of 0. The whole scene renders ungated — identical to pre-U.3. U.4
/// replaces this with a frame built from real portal visibility.
/// </summary>
public static ClipFrame NoClip()
{
// One slot, all zeros: count=0 shader passes every plane.
// One slot, all zeros: count=0 ⇒ shader passes every plane.
var bytes = new byte[CellClipStrideBytes];
return new ClipFrame(bytes, slotCount: 1);
}
/// <summary>Number of clip slots currently packed (always &gt;= 1 slot 0 is
/// <summary>Number of clip slots currently packed (always &gt;= 1 — slot 0 is
/// the reserved no-clip slot).</summary>
public int SlotCount => _slotCount;
/// <summary>
/// Phase U.4: reset this frame back to the NoClip state exactly slot 0
/// (no-clip, count 0) and a terrain count of 0 WITHOUT allocating a new
/// Phase U.4: reset this frame back to the NoClip state — exactly slot 0
/// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new
/// frame or new GL buffers. The single long-lived <c>_clipFrame</c> in
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
/// then uploaded through one SSBO and one terrain arena per fenced frame slot.
@ -209,7 +209,7 @@ public sealed class ClipFrame : IDisposable
/// <summary>
/// Append one clip region (becomes the next slot index) from a
/// <see cref="ClipPlaneSet"/>. Only the convex-plane case is supported in
/// U.3 <c>Count &gt; 0</c> packs that many planes; <c>Count == 0</c> packs a
/// U.3 — <c>Count &gt; 0</c> packs that many planes; <c>Count == 0</c> packs a
/// no-clip region (pass-all). The scissor / nothing-visible fallbacks that
/// <see cref="ClipPlaneSet"/> can carry are deferred to U.4 (which will draw
/// the AABB box or skip the cell on the CPU side, not via this slot). Returns
@ -259,7 +259,7 @@ public sealed class ClipFrame : IDisposable
/// <summary>
/// Set the terrain OutsideView clip region (the single region the terrain
/// shader gates against). <paramref name="planes"/> length 0 ungates terrain
/// (count 0). U.3 callers never touch this <see cref="NoClip"/> leaves it
/// (count 0). U.3 callers never touch this — <see cref="NoClip"/> leaves it
/// at count 0. U.4 calls it with the OutsideView planes.
/// </summary>
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
@ -579,7 +579,7 @@ public sealed class ClipFrame : IDisposable
}
/// <summary>A single std140 terrain-clip record within a frame-slot UBO arena.</summary>
public readonly record struct TerrainClipBufferBinding(
internal readonly record struct TerrainClipBufferBinding(
uint Buffer,
int OffsetBytes,
int SizeBytes)

View file

@ -1,4 +1,4 @@
// ClipFrameAssembler.cs
// ClipFrameAssembler.cs
//
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
// view graph: one portal_view list per visible cell plus an outside_view list.
@ -21,7 +21,7 @@ namespace AcDream.App.Rendering;
/// <summary>
/// How the landscape-through-outside_view pass should be interpreted.
/// </summary>
public enum TerrainClipMode
internal enum TerrainClipMode
{
/// <summary>All outside_view slices have convex plane clips.</summary>
Planes,
@ -37,13 +37,13 @@ public enum TerrainClipMode
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
/// for passes that cannot write gl_ClipDistance and must use scissor.
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
internal readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
/// plus routing data consumed by the render orchestration.
/// </summary>
public sealed class ClipFrameAssembly
internal sealed class ClipFrameAssembly
{
public ClipFrame Frame { get; private set; } = null!;
@ -241,7 +241,7 @@ public sealed class ClipFrameAssembly
}
}
public static class ClipFrameAssembler
internal static class ClipFrameAssembler
{
public static ClipFrameAssembly Assemble(
ClipFrame frame,

View file

@ -1,4 +1,4 @@
// ClipPlaneSet.cs
// ClipPlaneSet.cs
//
// Phase U.2c: turn a CellView (a cell's accumulated screen-space clip region,
// in NDC) into a small set of clip-space half-space planes for the GPU's
@ -6,18 +6,18 @@
// convex plane set.
//
// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c U.2e).
// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e).
// Pure System.Numerics math; NO GL. The shader consumes each plane as
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 keep)
// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep)
// where (nx, ny, dw) = the plane's (normal.xy, offset). z is always 0 because a
// screen-space (NDC) edge is a vertical slab in clip space independent of depth.
// screen-space (NDC) edge is a vertical slab in clip space — independent of depth.
//
// === The convexity rule (read before touching this file) =====================
// gl_ClipDistance planes are a CONJUNCTION of half-spaces, i.e. exactly ONE
// convex region (their intersection). A CellView with MORE THAN ONE polygon is a
// UNION of convex regions, which is in general NOT convex and CANNOT be
// represented by one plane set. Emitting just the first/largest polygon's planes
// would clip away the others a real visibility bug (under-inclusion).
// would clip away the others → a real visibility bug (under-inclusion).
//
// Therefore From() NEVER emits a single polygon's planes when the CellView holds
// several. Multi-polygon (and >8-edge) regions degrade to the UNION AABB scissor:
@ -27,13 +27,13 @@
//
// === The three Count==0 states (how a consumer tells them apart) =============
// Count == 0 can mean three different things; the consumer MUST distinguish:
// (a) Empty IsNothingVisible == true, UseScissorFallback == false.
// The cell/region isn't visible at all DRAW NOTHING. The
// (a) Empty — IsNothingVisible == true, UseScissorFallback == false.
// The cell/region isn't visible at all → DRAW NOTHING. The
// ScissorNdcAabb is a degenerate inverted box (min > max) so that a
// consumer which naively scissors on it still draws nothing.
// (b) Scissor UseScissorFallback == true, IsNothingVisible == false.
// (b) Scissor — UseScissorFallback == true, IsNothingVisible == false.
// The convex-plane budget was exceeded (multi-polygon or >8 edges)
// DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle).
// → DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle).
// There is no third Count==0 state produced by From(). (A separate "no-clip,
// pass-all" slot 0 is constructed by the consumer directly, not via From().)
// When Count > 0, Planes carries the convex gate and the scissor fields are unused.
@ -45,26 +45,26 @@ using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// An NDC convex view region reduced to 8 clip-space gl_ClipDistance planes, or a
/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a
/// scissor AABB fallback. See the file header for the convexity rule and the three
/// Count==0 states.
/// </summary>
public readonly struct ClipPlaneSet
internal readonly struct ClipPlaneSet
{
// Max simultaneous hardware clip planes we target (GL guarantees >= 8).
private const int MaxPlanes = 8;
// Collinear-edge merge threshold. Two consecutive edge directions are treated as
// the same edge when the turn between them is below ~0.5° (retail copy_view does a
// ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265.
// the same edge when the turn between them is below ~0.5° (retail copy_view does a
// ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265.
private const float CollinearSinEps = 0.0087265f;
// Drop a vertex whose two incident edges are shorter than this (NDC) a duplicate
// Drop a vertex whose two incident edges are shorter than this (NDC) — a duplicate
// or near-duplicate point that would otherwise yield a garbage normalized normal.
private const float DegenerateEdgeLen = 1e-6f;
// A polygon whose absolute signed area (full area, not 2x) falls below this is a line
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
// or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far
// above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here.
private const float MinPolygonArea = 1e-7f;
private readonly Vector4[] _planes;
@ -77,7 +77,7 @@ public readonly struct ClipPlaneSet
ScissorNdcAabb = scissorNdcAabb;
}
/// <summary>Number of active clip planes, 0..8. 0 inspect <see cref="UseScissorFallback"/>
/// <summary>Number of active clip planes, 0..8. 0 ⇒ inspect <see cref="UseScissorFallback"/>
/// and <see cref="IsNothingVisible"/> to decide between "draw the AABB" and "draw nothing".</summary>
public int Count => _planes?.Length ?? 0;
@ -89,11 +89,11 @@ public readonly struct ClipPlaneSet
// its frame-scoped slice instead of cloning every plane payload a second time.
internal Vector4[] PlaneArray => _planes ?? Array.Empty<Vector4>();
/// <summary>True the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
/// <summary>True ⇒ the convex-plane budget was exceeded; gate on <see cref="ScissorNdcAabb"/>
/// instead (draw the box). Always false when <see cref="Count"/> &gt; 0 or when the region is empty.</summary>
public bool UseScissorFallback { get; }
/// <summary>True the region is not visible at all; the consumer draws NOTHING.
/// <summary>True ⇒ the region is not visible at all; the consumer draws NOTHING.
/// Mutually exclusive with <see cref="UseScissorFallback"/>, and only meaningful when Count == 0.</summary>
public bool IsNothingVisible { get; }
@ -106,20 +106,20 @@ public readonly struct ClipPlaneSet
public static ClipPlaneSet Empty { get; } =
new(Array.Empty<Vector4>(), useScissorFallback: false, isNothingVisible: true, scissorNdcAabb: DegenerateAabb);
// Inverted box (min > max) any sane AABB intersection against it is empty.
// Inverted box (min > max) — any sane AABB intersection against it is empty.
private static Vector4 DegenerateAabb => new(1f, 1f, -1f, -1f);
/// <summary>
/// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (8 edges
/// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor;
/// empty/degenerate <see cref="Empty"/>. See the file header for the full rule.
/// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (≤8 edges
/// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor;
/// empty/degenerate → <see cref="Empty"/>. See the file header for the full rule.
/// </summary>
public static ClipPlaneSet From(CellView region)
{
if (region is null || region.IsEmpty || region.Polygons.Count == 0)
return Empty;
// MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes.
// MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes.
// Over-include via the union AABB (safe). region.Min/Max already track the union.
if (region.Polygons.Count > 1)
return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY);
@ -152,15 +152,15 @@ public readonly struct ClipPlaneSet
{
int count = NormalizeAndMerge(input, verts);
// Fewer than 3 distinct edges survive a sliver/line with no area. There is no
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no
// meaningful AABB to over-include (a zero-area region), so treat it as nothing visible.
if (count < 3)
return Empty;
ReadOnlySpan<Vector2> normalized = verts[..count];
// A single convex polygon with too many edges to fit the hardware budget scissor
// on ITS own AABB (still a superset of the polygon over-include, safe).
// A single convex polygon with too many edges to fit the hardware budget ⇒ scissor
// on ITS own AABB (still a superset of the polygon → over-include, safe).
if (count > MaxPlanes)
return Scissor(normalized);
@ -173,10 +173,10 @@ public readonly struct ClipPlaneSet
Vector2 q = normalized[(i + 1) % count];
Vector2 dir = q - p;
// Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's
// interior (the "left" side of the directed edge pq).
// interior (the "left" side of the directed edge p→q).
Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
// Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w:
// dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep)
planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p));
}
return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb);
@ -233,9 +233,9 @@ public readonly struct ClipPlaneSet
if (SignedArea2(points[..count]) < 0f)
points[..count].Reverse();
// 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
// 3) Merge collinear edges: drop vertex i when edge (i-1â†i) and edge (iâ†i+1) point the same
// way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new
// collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal.
bool changed = true;
while (changed && count >= 3)
{
@ -260,12 +260,12 @@ public readonly struct ClipPlaneSet
d0 /= l0;
d1 /= l1;
float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ
float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ
if (dot > 0f && MathF.Abs(cross) < CollinearSinEps)
{
points[(i + 1)..count].CopyTo(points[i..]);
count--; // cur lies on the straight line prevnext
count--; // cur lies on the straight line prev→next
changed = true;
break;
}
@ -276,7 +276,7 @@ public readonly struct ClipPlaneSet
return 0;
// Final degeneracy gate: a polygon with negligible area is a line/point even if it still
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
// has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5°
// merge didn't quite collapse). Emitting its planes would yield an empty half-space
// intersection that silently gates out everything; report it honestly as nothing-visible.
if (MathF.Abs(SignedArea2(points[..count])) * 0.5f < MinPolygonArea)
@ -285,7 +285,7 @@ public readonly struct ClipPlaneSet
return count;
}
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
private static float SignedArea2(ReadOnlySpan<Vector2> poly)
{
float a = 0f;

View file

@ -1,4 +1,4 @@
using AcDream.Core.Textures;
using AcDream.Core.Textures;
using AcDream.Core.World;
using Silk.NET.OpenGL;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering;
/// Location of one decoded entity-material composite in a resident bindless
/// texture array. The modern mesh shader consumes this exact pair.
/// </summary>
public readonly record struct BindlessTextureLocation(ulong Handle, uint Layer);
internal readonly record struct BindlessTextureLocation(ulong Handle, uint Layer);
internal enum CompositeTextureKind : byte
{

View file

@ -114,15 +114,18 @@ internal sealed class RetailCreatureAppraisalFrameView :
private readonly UiViewport _viewport;
private readonly UiElement _windowFrame;
private readonly AppraisalUiController _controller;
private readonly ExternalViewportTextureBridge _textureBridge;
public RetailCreatureAppraisalFrameView(
UiViewport viewport,
UiElement windowFrame,
AppraisalUiController controller)
AppraisalUiController controller,
IGpuDevice gpuDevice)
{
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
_windowFrame = windowFrame ?? throw new ArgumentNullException(nameof(windowFrame));
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
_textureBridge = new ExternalViewportTextureBridge(gpuDevice);
}
public bool TryGetVisibleTarget(
@ -149,7 +152,7 @@ internal sealed class RetailCreatureAppraisalFrameView :
}
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureHandle = textureHandle;
_viewport.TextureHandle = _textureBridge.Resolve(textureHandle);
private static bool IsEffectivelyVisible(UiElement element)
{

View file

@ -1,95 +1,57 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Minimal GL debug line renderer for visualizing collision shapes,
/// Minimal debug line renderer for visualizing collision shapes,
/// bounding boxes, and other debug geometry. Collect lines each frame
/// via <see cref="AddLine"/> / <see cref="AddCylinder"/>, then call
/// <see cref="Flush"/> to upload + draw them.
/// <see cref="Flush"/> to upload + draw them through the current
/// <see cref="IGpuFrame"/>.
///
/// Uses a single shared VBO that's respecialized each frame. Vertex
/// format is (vec3 pos, vec3 color) = 24 bytes per vertex.
/// Campaign V slice V4a: ported onto <see cref="IGpuDevice"/>. Owns one
/// pipeline (LINE_LIST topology, depth disabled — lines must show through
/// geometry, matching the prior explicit <c>DepthTest</c> disable) and draws
/// with the frame's ring rather than a persistent respecialized VBO. Vertex
/// format is (vec3 pos, vec3 color) = 24 bytes per vertex, unchanged.
/// </summary>
public sealed unsafe class DebugLineRenderer : IDisposable
internal sealed class DebugLineRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly ResourceCleanupGroup _resources;
private readonly IGpuDevice _device;
private readonly IGpuPipeline _pipeline;
private static readonly GpuVertexLayout VertexLayout = new(
StrideBytes: 24,
[
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0),
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12),
]);
private readonly List<float> _buffer = new(4096);
private int _vertexCount;
private int _capacityBytes;
public DebugLineRenderer(GL gl, string shaderDir)
public DebugLineRenderer(IGpuDevice device)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
ArgumentException.ThrowIfNullOrWhiteSpace(shaderDir);
var resources = new ResourceCleanupGroup();
Shader? shader = null;
uint vao = 0;
uint vbo = 0;
try
_device = device ?? throw new ArgumentNullException(nameof(device));
_pipeline = _device.CreatePipeline(new GpuPipelineDescription
{
shader = new Shader(gl,
Path.Combine(shaderDir, "debug_line.vert"),
Path.Combine(shaderDir, "debug_line.frag"));
resources.Add("debug-line shader", shader.Dispose);
vao = GlResourceCommand.CreateName(
gl,
"debug-line VAO",
gl.GenVertexArray,
gl.DeleteVertexArray);
uint ownedVao = vao;
resources.Add(
"debug-line VAO",
() => GlResourceCommand.DeleteVertexArray(
gl,
ownedVao,
$"delete debug-line VAO {ownedVao}"));
vbo = GlResourceCommand.CreateName(
gl,
"debug-line VBO",
gl.GenBuffer,
gl.DeleteBuffer);
uint ownedVbo = vbo;
resources.Add(
"debug-line VBO",
() => GlResourceCommand.DeleteBuffer(
gl,
ownedVbo,
$"delete debug-line VBO {ownedVbo}"));
GlResourceCommand.Execute(gl, "configure debug-line vertex state", () =>
{
gl.BindVertexArray(vao);
gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
// 24-byte stride: vec3 pos + vec3 color
gl.EnableVertexAttribArray(0);
gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0);
gl.EnableVertexAttribArray(1);
gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float)));
gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
gl.BindVertexArray(0);
Name = "debug-line",
Shaders = new GpuShaderSet("debug_line"),
VertexLayout = VertexLayout,
Topology = GpuPrimitiveTopology.LineList,
Blend = GpuBlendMode.None,
// Retail debug lines are drawn visible THROUGH geometry — the
// prior GL path captured+disabled DepthTest around the draw and
// restored whatever the caller had before. A dedicated pipeline
// bakes "always visible" directly, which is simpler and exactly
// as behaviour-preserving since nothing else shares this pipeline.
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
ColorWrite = true,
});
}
catch (Exception constructionFailure)
{
resources.RollbackConstructionAndThrow(
"DebugLineRenderer construction failed and its GL prefix did not cleanly roll back.",
constructionFailure);
}
_resources = resources;
_shader = shader!;
_vao = vao;
_vbo = vbo;
}
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
public void Begin()
@ -169,45 +131,40 @@ public sealed unsafe class DebugLineRenderer : IDisposable
AddLine(c[2], c[6], color); AddLine(c[3], c[7], color);
}
/// <summary>Upload + draw all accumulated lines.</summary>
public void Flush(Matrix4x4 view, Matrix4x4 projection)
/// <summary>Upload + draw all accumulated lines against the current frame.</summary>
public void Flush(Matrix4x4 view, Matrix4x4 projection, IGpuFrame frame)
{
if (_vertexCount == 0) return;
ArgumentNullException.ThrowIfNull(frame);
_shader.Use();
_shader.SetMatrix4("uView", view);
_shader.SetMatrix4("uProjection", projection);
int byteCount = _buffer.Count * sizeof(float);
GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex);
CollectionsMarshal.AsSpan(_buffer).CopyTo(allocation.AsSpan<float>());
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
int neededBytes = _buffer.Count * sizeof(float);
if (neededBytes > _capacityBytes)
using IGpuPassEncoder pass = frame.BeginPass(new GpuPassDescription
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw);
_capacityBytes = neededBytes;
}
else
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr);
}
// Depth test on so lines get occluded by geometry (but we want them
// visible through geometry — disable depth test so everything shows).
bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest);
_gl.Disable(EnableCap.DepthTest);
_gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount);
if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
Name = "debug-lines",
Color = new GpuColorAttachment(
Target: null,
Load: GpuLoadOp.Load,
Store: GpuStoreOp.Store,
ClearColor: default),
Depth = null,
SampleCount = 1,
});
pass.BindPipeline(_pipeline);
pass.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes);
// Same combined-matrix convention every other ported shader uses
// (WbDrawDispatcher, TerrainModernRenderer, ParticleRenderer):
// C# multiplies view * projection once and uploads the single
// uViewProjection the shader now declares, replacing the separate
// uView/uProjection uniforms.
pass.SetPushConstants(GpuPushConstants.Default with { ViewProjection = view * projection });
pass.Draw((uint)_vertexCount, instanceCount: 1, firstVertex: 0, firstInstance: 0);
}
public void Dispose()
{
_resources.RetryCleanup();
_pipeline.Dispose();
}
}

View file

@ -1,26 +1,26 @@
using System;
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Fixed camera for the paperdoll mini-scene retail-exact, ported from the gmPaperDollUI viewport
/// setup (decomp 0x004a5a390x004a5a69). The viewport (element <c>0x100001d5</c>) is configured by
/// Fixed camera for the paperdoll mini-scene — retail-exact, ported from the gmPaperDollUI viewport
/// setup (decomp 0x004a5a39–0x004a5a69). The viewport (element <c>0x100001d5</c>) is configured by
/// <c>UIElement_Viewport::SetCamera(position, direction)</c> with:
/// <list type="bullet">
/// <item>position = (0.12, 2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae]</item>
/// <item>direction = (0, 0, 0) <c>CreatureMode::SetCameraDirection</c> resets the view frame to
/// <item>position = (0.12, −2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae]</item>
/// <item>direction = (0, 0, 0) ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view frame to
/// IDENTITY (<c>euler_set_rotate(0,0,0)</c> then <c>rotate(0,0,0)</c>), so the camera looks
/// straight down +Y with +Z up NO yaw, NO pitch.</item>
/// straight down +Y with +Z up — NO yaw, NO pitch.</item>
/// </list>
/// The doll is framed purely by camera POSITION + FOV, NOT by aiming at the body: at distance 2.4 with
/// a π/4 (45°) vertical FOV the visible vertical band is z≈[0.11, 1.87], which covers the whole ~1.6 m
/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is <c>CreatureMode</c>'s default
/// a π/4 (45°) vertical FOV the visible vertical band is z≈[−0.11, 1.87], which covers the whole ~1.6 m
/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is <c>CreatureMode</c>'s default
/// <c>m_fFOVRadians</c> (ctor 0x004543cf, hex 0x3f490fdb); default ambient is (0.3,0.3,0.3); the
/// paperdoll uses <c>UseSharpMode</c> (not SmartboxFOV) so <c>Render::SetFOVRad(m_fFOVRadians)</c> applies.
///
/// <para>An earlier hand-tune aimed the camera at mid-body to "fit the figure", which introduced a
/// spurious yaw (Target.x Eye.x) that rotated the view and turned the doll's face away from the
/// spurious yaw (Target.x ≠ Eye.x) that rotated the view and turned the doll's face away from the
/// viewer. This restores retail's zero-yaw frame, where full-body framing comes from the eye height +
/// FOV, not from aiming.</para>
///
@ -28,15 +28,15 @@ namespace AcDream.App.Rendering;
/// convention as <see cref="ChaseCamera"/> so the doll's triangle winding + back-face culling match the
/// world render pass. AC up-axis = +Z.
/// </summary>
public sealed class DollCamera : ICamera
internal sealed class DollCamera : ICamera
{
// Retail paperdoll camera origin (decomp 0x004a5a510x004a5a61).
// Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61).
internal static readonly Vector3 RetailEye = new(0.12f, -2.4f, 0.88f);
// Identity view orientation look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
// Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0).
private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f);
private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera
/// <summary>Vertical field of view — retail <c>CreatureMode</c> default <c>m_fFOVRadians</c> = π/4 (45°).</summary>
/// <summary>Vertical field of view — retail <c>CreatureMode</c> default <c>m_fFOVRadians</c> = π/4 (45°).</summary>
public float FovRadians { get; set; } = MathF.PI / 4f;
public float Near { get; set; } = 0.1f; // same near plane as ChaseCamera / retail znear

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
@ -6,13 +6,13 @@ using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Builds the dedicated paperdoll WorldEntity retail's <c>makeObject(player)</c>
/// Builds the dedicated paperdoll WorldEntity — retail's <c>makeObject(player)</c>
/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays
/// + part overrides), posed at the scene origin facing the viewer.
///
/// <para>
/// The palette / part-override mapping mirrors the inline construction in
/// <c>GameWindow.cs</c> around lines 33903431. Extracted here so it is
/// <c>GameWindow.cs</c> around lines 3390–3431. Extracted here so it is
/// unit-testable without dats and so the paperdoll renderer owns a clean
/// seam: it calls <see cref="Build"/> with fresh player state each time the
/// ObjDesc changes, and the renderer only has to swap the entity into its
@ -26,7 +26,7 @@ namespace AcDream.App.Rendering;
/// server-assigned guid.
/// </para>
/// </summary>
public static class DollEntityBuilder
internal static class DollEntityBuilder
{
/// <summary>
/// Reserved synthetic guid for the paperdoll clone. High, deliberately
@ -38,18 +38,18 @@ public static class DollEntityBuilder
/// Reserved render-local entity id for the doll. FIXED (the doll is a singleton)
/// and high enough never to collide with LiveEntityRuntime's local ids. The
/// renderer passes this in <c>animatedEntityIds</c> so the dispatcher treats the
/// doll as animated which BYPASSES the Tier-1 classification cache
/// doll as animated — which BYPASSES the Tier-1 classification cache
/// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW
/// WorldEntity with this SAME id; without the cache bypass the dispatcher would
/// serve the previous doll's cached batches and the new gear wouldn't appear.
/// </summary>
public const uint DollRenderId = 0xDA11_D012u;
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
// retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h)
// (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC
// North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about
// +Z; System.Numerics rotates +Y → (sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
// NEGATED (θ = h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
// +Z; System.Numerics rotates +Y → (âˆsin θ, cos θ), so to land on retail's (sin h, cos h) the angle is
// NEGATED (θ = âˆh). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer).
private const float _headingDegrees = 191.367905f;
private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f);
private static readonly Quaternion _dollRotation =
@ -62,17 +62,17 @@ public static class DollEntityBuilder
/// <param name="meshRefs">Pre-resolved mesh refs (may be empty; caller fills them in).</param>
/// <param name="basePaletteId">
/// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are
/// also present mirrors GameWindow which only builds a PaletteOverride
/// also present — mirrors GameWindow which only builds a PaletteOverride
/// when <c>SubPalettes.Count &gt; 0</c>.
/// </param>
/// <param name="subPalettes">
/// Subpalette overlays from the server ObjDesc. Each tuple carries the
/// subpalette dat id, byte offset into the base palette, and byte length.
/// Null or empty <c>PaletteOverride</c> on the returned entity is null.
/// Null or empty → <c>PaletteOverride</c> on the returned entity is null.
/// </param>
/// <param name="partOverrides">
/// AnimPartChange swaps from the server ObjDesc. Each tuple is a
/// (PartIndex, replacement GfxObj id) pair. Null or empty empty array.
/// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array.
/// </param>
public static WorldEntity Build(
uint setupId,
@ -82,7 +82,7 @@ public static class DollEntityBuilder
IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null)
{
// --- palette override (mirrors GameWindow:3395-3405) ---
// Only build when there are sub-palette overlays same gate as GameWindow.
// Only build when there are sub-palette overlays — same gate as GameWindow.
PaletteOverride? paletteOverride = null;
if (subPalettes is { Count: > 0 } spList)
{

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.App.World;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Items;
@ -23,7 +23,7 @@ namespace AcDream.App.Rendering;
/// <see cref="WorldEntity"/> and recomposes it after the parent's animation
/// advances each frame.
/// </summary>
public sealed class EquippedChildRenderController : IDisposable
internal sealed class EquippedChildRenderController : IDisposable
{
private readonly IDatReaderWriter _dats;
private readonly object _datLock;
@ -1674,7 +1674,7 @@ public sealed class EquippedChildRenderController : IDisposable
"has no exact projection key.");
}
public enum ChildUnparentDisposition
internal enum ChildUnparentDisposition
{
NotAttached,
Completed,

View file

@ -0,0 +1,73 @@
namespace AcDream.App.Rendering;
/// <summary>
/// Campaign V slice V4a bridge: memoizes a <see cref="GpuTextureSlot"/> for the
/// most recent raw GL colour-texture name a still-unmigrated private-viewport
/// renderer (<see cref="PrivateEntityViewportRenderer"/>,
/// <see cref="PaperdollViewportRenderer"/>) produced, so
/// <c>UiViewport.TextureHandle</c> — now a <see cref="GpuTextureSlot"/> — has
/// something to draw. Those renderers still allocate their FBO colour
/// attachment directly on <c>GL</c> (V4g's scope: "PrivateEntityViewportRenderer
/// → IGpuRenderTarget"), so this bridge — not their own campaign slice — is
/// what lets the RETAINED UI side of the seam move onto the RHI now.
///
/// Registers lazily and only re-registers when the producer hands back a
/// DIFFERENT raw name (the FBO's colour texture is stable across frames at a
/// fixed viewport size and only regenerates on resize) — a naive
/// register-every-frame would exhaust the 16384-slot table in seconds.
/// Deleted at slice V4g once those renderers publish a real
/// <see cref="GpuTextureSlot"/> directly.
/// </summary>
internal sealed class ExternalViewportTextureBridge
{
private readonly IGpuDevice _device;
private readonly IGpuSampler _sampler;
private uint _lastRawName;
private GpuTextureSlot _lastSlot = GpuTextureSlot.Unassigned;
public ExternalViewportTextureBridge(IGpuDevice device)
{
_device = device ?? throw new ArgumentNullException(nameof(device));
// Matches the FBO colour attachment's own fixed GL_LINEAR / CLAMP_TO_EDGE
// parameters (PrivateEntityViewportRenderer.EnsureFramebuffer,
// PaperdollViewportRenderer's equivalent): a bindless handle's
// filtering comes from the bound SAMPLER object, not the texture's
// own — now irrelevant — TEXTURE_MIN_FILTER/TEXTURE_MAG_FILTER.
_sampler = device.CreateSampler(GpuSamplerDescription.WorldClamp);
}
/// <summary>
/// Resolves <paramref name="rawGlColorTextureName"/> (0 = nothing rendered
/// this call, matching the producers' existing "0 = no texture" return)
/// to a texture-table slot, registering it once per distinct name.
/// </summary>
public GpuTextureSlot Resolve(uint rawGlColorTextureName)
{
if (rawGlColorTextureName == 0)
{
Release();
return GpuTextureSlot.Unassigned;
}
if (rawGlColorTextureName == _lastRawName && _lastSlot.IsAssigned)
return _lastSlot;
Release();
if (_device is not Gpu.Gl.GlGpuDevice glDevice)
{
throw new NotSupportedException(
"ExternalViewportTextureBridge only supports the GL backend. " +
"Slice V4g removes this bridge before any other backend ships.");
}
_lastSlot = glDevice.RegisterExternalColorTexture(rawGlColorTextureName, _sampler);
_lastRawName = rawGlColorTextureName;
return _lastSlot;
}
private void Release()
{
if (_lastSlot.IsAssigned)
_device.ReleaseTextureSlot(_lastSlot);
_lastSlot = GpuTextureSlot.Unassigned;
_lastRawName = 0;
}
}

View file

@ -1,9 +1,9 @@
// src/AcDream.App/Rendering/FlyCamera.cs
// src/AcDream.App/Rendering/FlyCamera.cs
using System.Numerics;
namespace AcDream.App.Rendering;
public sealed class FlyCamera : ICamera
internal sealed class FlyCamera : ICamera
{
public Vector3 Position { get; set; } = new(96, 96, 150);
public float Yaw { get; set; } = MathF.PI / 2f; // facing +Y

View file

@ -1,13 +1,13 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Six normalized view-frustum planes extracted from a View×Projection matrix.
/// Six normalized view-frustum planes extracted from a View×Projection matrix.
/// Each plane is represented as (normal.X, normal.Y, normal.Z, distance) where
/// dot(normal, point) + distance >= 0 means the point is on the visible side.
/// </summary>
public readonly struct FrustumPlanes
internal readonly struct FrustumPlanes
{
public readonly Vector4 Left;
public readonly Vector4 Right;
@ -27,7 +27,7 @@ public readonly struct FrustumPlanes
}
/// <summary>
/// Extracts the six frustum planes from a combined View×Projection matrix
/// Extracts the six frustum planes from a combined View×Projection matrix
/// using the Gribb-Hartmann method. System.Numerics.Matrix4x4 is row-major,
/// so rows are accessed directly via M{row}{col} fields.
/// </summary>
@ -64,7 +64,7 @@ public readonly struct FrustumPlanes
/// <summary>
/// Conservative AABB-vs-frustum culling. Zero allocations; suitable for per-frame use.
/// </summary>
public static class FrustumCuller
internal static class FrustumCuller
{
/// <summary>
/// Returns true if the axis-aligned bounding box defined by
@ -74,7 +74,7 @@ public static class FrustumCuller
/// </summary>
public static bool IsAabbVisible(FrustumPlanes planes, Vector3 min, Vector3 max)
{
// For each plane, test the AABB's "most-positive vertex"
// For each plane, test the AABB's "most-positive vertex" —
// the corner most in the direction of the plane normal. If
// that corner is behind the plane, the entire box is outside.
return TestPlane(planes.Left, min, max)

View file

@ -1,4 +1,4 @@
using AcDream.Core.Plugins;
using AcDream.Core.Plugins;
using AcDream.App.Composition;
using AcDream.App.Physics;
using AcDream.App.Rendering.Gpu;
@ -21,7 +21,7 @@ using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
public sealed class GameWindow :
internal sealed class GameWindow :
IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowHostInputCameraPublication,
@ -70,7 +70,7 @@ public sealed class GameWindow :
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup missing bindless throws
/// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache;
@ -78,14 +78,14 @@ public sealed class GameWindow :
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
// wireframes are noisy outdoors and confuse first-time users into
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
// → Diagnostics → "Toggle collision wires" button toggles too.
// → Diagnostics → "Toggle collision wires" button toggles too.
private readonly AcDream.App.Rendering.WorldSceneDebugState
_worldSceneDebugState = new();
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
// TextRenderer + BitmapFont fields stay alive because they're shared
// with UiHost and reserved for the future world-space HUD (D.6
// with UiHost and reserved for the future world-space HUD (D.6 —
// damage floaters, name plates) where ImGui can't reach into the 3D
// scene. They are no longer used for any debug overlay.
private TextRenderer? _textRenderer;
@ -98,7 +98,7 @@ public sealed class GameWindow :
"1",
System.StringComparison.Ordinal);
// MP0 (2026-07-05): permanent frame profiler one FrameBoundary call
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
@ -116,6 +116,7 @@ public sealed class GameWindow :
private IDisposable? _frameGraphPublication;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private IGpuDevice? _gpuDevice;
private AcDream.App.Rendering.GpuDeviceFrameLifetime? _gpuFrameLifetime;
private readonly AcDream.App.Rendering.GameFrameGraphSlot _frameGraphs = new();
private readonly AcDream.App.Rendering.GameRenderResourceLifetime
_renderResourceLifetime = new();
@ -144,11 +145,11 @@ public sealed class GameWindow :
_localPlayerTeleport;
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
// Phase B.3: physics engine populated from the streaming pipeline.
// Phase B.3: physics engine — populated from the streaming pipeline.
private AcDream.Core.Physics.PhysicsEngine _physicsEngine =>
_runtimeEntityObjects.Physics.Engine;
// Task 4: physics data cache BSP trees + collision shapes extracted from
// Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe.
private AcDream.Core.Physics.PhysicsDataCache _physicsDataCache =>
@ -185,7 +186,7 @@ public sealed class GameWindow :
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
// DatReaderWriter's DatBinReader uses a shared buffer position internally
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
// the render thread (LandblockBuildFactory on the worker; live-spawn
// handlers + animation ticks on the render
@ -226,7 +227,7 @@ public sealed class GameWindow :
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
// REUSED across frames its GL buffers persist; only the cheap CPU-side no-clip
// REUSED across frames — its GL buffers persist; only the cheap CPU-side no-clip
// state is re-uploaded each frame before terrain/entities draw, so the whole
// scene renders ungated (identical to pre-U.3). The buffer ids are handed to the
// three renderers so each re-binds binding=2 immediately before its own draw.
@ -248,7 +249,7 @@ public sealed class GameWindow :
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
/// paired with <see cref="_animatedEntities"/> that dictionary is the
/// paired with <see cref="_animatedEntities"/> — that dictionary is the
/// gating predicate, this cache is the lookup that depends on it.
/// Passed to <see cref="AcDream.App.Rendering.Wb.WbDrawDispatcher"/> at
/// construction time. Tasks 9-10 of the cache plan wire the per-entity
@ -283,7 +284,7 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.Vfx.ParticleVisibilityController _particleVisibility = new();
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 retail PhysicsScript runtime. Receives PlayScript (0xF754)
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
// and nested/default-script routing at its StartTime offset.
@ -294,7 +295,7 @@ public sealed class GameWindow :
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue;
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs retail observer pattern per
// position has stalled for >StopIdleMs — retail observer pattern per
// ACE Player_Tick.cs line 368: the client never sends "released forward"
// MoveToState, so the server never broadcasts an explicit stop. Observer
// must infer it from position deltas.
@ -362,21 +363,21 @@ public sealed class GameWindow :
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts =>
_runtimeInventory.Shortcuts.Items;
// Issue #5 caches CreatureProfile.{Stamina, Mana, *Max} from
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 wired to SpellBook so GetMaxApprox folds enchantment
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public AcDream.Core.Player.LocalPlayerState LocalPlayer =>
_runtimeCharacter.LocalPlayer;
// Phase D.2a ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private DevToolsCompositionOwner? _devToolsComposition;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private readonly AcDream.App.UI.RetailUiRuntimeLease _retailUiLease = new();
@ -398,7 +399,7 @@ public sealed class GameWindow :
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter;
@ -406,7 +407,7 @@ public sealed class GameWindow :
_creatureAppraisalViewportRenderer;
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
_creatureAppraisalFramePresenter;
// Phase D.2b Task 9 plugin UI registrations buffered before OnLoad; drained in OnLoad.
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns
// its panel; the VM remains here because runtime feedback producers bind
@ -427,7 +428,7 @@ public sealed class GameWindow :
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
// #188 TransparentPartHook fires from the animation pipeline drive
// #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
// per frame. Wired into the hook router in OnLoad, advanced once per
// frame in the main loop regardless of _animatedEntities membership
@ -472,10 +473,10 @@ public sealed class GameWindow :
_localPlayerAnimation;
private AcDream.App.Physics.LocalPlayerShadowSynchronizer?
_localPlayerShadowSynchronizer;
// Phase D.2b-C live character-sheet assembly + raise flow (extracted
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
// Phase K.2 auto-enter player mode after a successful login. Armed
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by LiveSessionHost's entered-world transition; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely
@ -484,7 +485,7 @@ public sealed class GameWindow :
// the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// Phase K.1b / Slice 8 F one semantic input path. Transitional actions
// Phase K.1b / Slice 8 F — one semantic input path. Transitional actions
// flow through GameplayInputActionRouter; held movement is polled through
// InputDispatcher. Raw axis motion belongs to CameraPointerInputController.
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
@ -506,7 +507,7 @@ public sealed class GameWindow :
// configuration path,
// falling back to the retail-faithful defaults if the file is missing
// or corrupt. This is THE single source of truth for the keymap at
// startup no other call to RetailDefaults() / AcdreamCurrentDefaults()
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path.
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings;
private readonly GraphicalHostPlatformServices _platformServices;
@ -522,7 +523,7 @@ public sealed class GameWindow :
}
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment fully backward compatible with
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
// Runtime owns the canonical session generation and transport lifetime.
// The window retains only the composition handles needed for startup and
@ -538,7 +539,7 @@ public sealed class GameWindow :
// ACDREAM_LIVE=1 was set when the window came up.
// Backed by RuntimeOptions.LiveMode via the _options field.
/// <summary>
/// Phase 6.6/6.7: server-guid local WorldEntity lookup so
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. This is the canonical materialized top-level
/// projection, including live objects parked in pending landblocks;
@ -557,7 +558,7 @@ public sealed class GameWindow :
/// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// R5-V2: the local player's CPhysicsObj stand-in owns the player's
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields.
@ -566,14 +567,14 @@ public sealed class GameWindow :
private EntityPhysicsHost? _playerHost
=> _playerHostSlot.Host;
// R5-V2: guid per-entity IPhysicsObjHost registry (retail's
// R5-V2: guid → per-entity IPhysicsObjHost registry (retail's
// CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
// giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated for remotes plus the PlayerModeController local entry
// (player); pruned only by logical LiveEntityRuntime teardown.
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
// Slice 2a the DR tick's stale-velocity anim-stop was its only user).
// Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
public GameWindow(
AcDream.App.RuntimeOptions options,
WorldGameState worldGameState,
@ -684,7 +685,7 @@ public sealed class GameWindow :
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream phase 1",
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
@ -748,6 +749,10 @@ public sealed class GameWindow :
IGpuDevice value) =>
PublishCompositionOwner(ref _gpuDevice, value, "GPU device (RHI)");
void IGameWindowHostInputCameraPublication.PublishGpuFrameLifetime(
GpuDeviceFrameLifetime value) =>
PublishCompositionOwner(ref _gpuFrameLifetime, value, "GPU frame lifetime");
void IGameWindowHostInputCameraPublication.PublishKeyboardSource(
AcDream.App.Input.SilkKeyboardSource value) =>
PublishCompositionOwner(ref _kbSource, value, "keyboard source");
@ -1303,6 +1308,7 @@ public sealed class GameWindow :
_worldEnvironment,
_renderResourceLifetime,
_gpuFrameFlights!,
_gpuDevice!,
_options.ResidencyBudgets,
initialCenterLandblockId,
_applicationPaths.DiagnosticsDirectory,
@ -1322,9 +1328,10 @@ public sealed class GameWindow :
new InteractionRetainedUiDependencies(
_options,
platformResult.Graphics,
hostInputCamera.GpuDevice,
() => hostInputCamera.GpuFrameLifetime.Current,
_window!,
platformResult.Input,
worldRender.Foundation.ShadersDirectory,
contentEffectsAudio.Dats,
_datLock,
worldRender.Foundation.TextureCache,
@ -1374,6 +1381,7 @@ public sealed class GameWindow :
new LivePresentationDependencies(
_options,
platformResult.Graphics,
_gpuDevice!,
_window!,
_datLock,
_runtimeSettings,
@ -1541,7 +1549,7 @@ public sealed class GameWindow :
_frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt));
}
// Performance overlay state updated every ~0.5s and written to the
// Performance overlay state — updated every ~0.5s and written to the
// window title so there's zero rendering cost (no font/overlay needed).
private void OnRender(double deltaSeconds)
{
@ -1557,20 +1565,20 @@ public sealed class GameWindow :
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
// animatedIds build site every Sequencer entity is now added
// animatedIds build site — every Sequencer entity is now added
// unconditionally, which is the known-good pre-optimization behavior.
// R3-W6: UpdatePlayerAnimation DELETED the player's sequencer is
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
// PlayerMovementController; airborne-Falling falls out of
// contact_allows_move + apply_current_movement). The #45 sidestep
// 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it
// 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
/// <summary>
/// L.0 Display tab: framebuffer-resize handler update GL viewport
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
/// the corner OR by the runtime display target applying a saved
/// Resolution). Without this, the viewport stays pinned at the

View file

@ -190,9 +190,26 @@ internal sealed class GlGpuDevice : IGpuDevice
string fragmentPath = Path.Combine(_shadersDirectory, $"{description.Shaders.Name}.frag");
string vertexSource = File.ReadAllText(vertexPath);
string fragmentSource = File.ReadAllText(fragmentPath);
// Every RHI-created pipeline gets the same common.glsl preamble the
// legacy Shader(gl, vert, frag, includeCommonPreamble: true) call
// sites already opt into (mesh_modern, terrain_modern): the set-0
// binding-9 texture-table buffer + ACDREAM_TEXTURE_HANDLE macro that
// slice V2 introduced. A pipeline whose shaders never reference the
// macro simply carries an unused SSBO declaration — harmless — so
// there is no reason for callers to opt in per pipeline.
string commonSource = CommonPreambleSource;
vertexSource = Shader.InjectPreamble(vertexSource, commonSource);
fragmentSource = Shader.InjectPreamble(fragmentSource, commonSource);
return new GlGpuPipeline(_gl, Retirement, description, vertexSource, fragmentSource);
}
private string? _commonPreambleSource;
private string CommonPreambleSource => _commonPreambleSource ??=
File.ReadAllText(Path.Combine(_shadersDirectory, "common.glsl"));
public IGpuRenderTarget CreateRenderTarget(in GpuRenderTargetDescription description)
{
ThrowIfDisposed();
@ -215,6 +232,33 @@ internal sealed class GlGpuDevice : IGpuDevice
return new GpuTextureSlot(slot);
}
/// <summary>
/// Registers an externally-created, externally-owned GL texture name
/// (a private-viewport FBO colour attachment) into the same texture table
/// <see cref="RegisterTexture"/> uses. Not part of <see cref="IGpuDevice"/>
/// — <c>PrivateEntityViewportRenderer</c>/<c>PaperdollViewportRenderer</c>
/// still allocate their own FBO textures directly on <c>GL</c> pre-V4g, so
/// their retained-UI consumers (<see cref="UiViewport"/>) need a bridge
/// that does not require the caller to own an <see cref="IGpuTexture"/>.
/// The caller keeps deleting the GL name itself; this only ever writes
/// the bindless handle into the table and returns the slot. Deleted with
/// the GL backend once slice V4g moves those renderers onto
/// <see cref="IGpuRenderTarget"/> and they can call
/// <see cref="RegisterTexture"/> like every other consumer.
/// </summary>
internal GpuTextureSlot RegisterExternalColorTexture(uint glTextureName, IGpuSampler sampler)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(sampler);
if (sampler is not GlGpuSampler glSampler)
throw new ArgumentException("The GL backend can only register a GL sampler.", nameof(sampler));
uint slot = _textureSlotAllocator.Allocate();
ulong handle = _bindless.GetResidentHandle(glTextureName, glSampler.GlName);
WriteHandle(slot, handle);
return new GpuTextureSlot(slot);
}
public void ReleaseTextureSlot(GpuTextureSlot slot)
{
ThrowIfDisposed();
@ -239,6 +283,17 @@ internal sealed class GlGpuDevice : IGpuDevice
long serial = ++_nextSerial;
int slot = _frameFlights.CurrentSlot;
_ringStates[slot].Reset();
// Campaign V slice V4a: during the migration, every still-legacy
// renderer (WbDrawDispatcher, terrain, particles, EnvCellRenderer, ...)
// mutates real GL program/blend/depth/cull state directly, outside this
// device's ApplyRenderState. _renderState has no way to observe those
// calls, so its cached "current" snapshot goes stale the moment any of
// them runs between two RHI binds. Resetting once per frame is the same
// defensive move BeginPass already makes after a forced clear (see the
// comment there) — it costs one redundant state application on the
// frame's first BindPipeline, in exchange for never diffing against a
// baseline the driver has since moved past.
_renderState.Reset();
return new GlGpuFrame(this, slot, serial);
}
@ -283,6 +338,17 @@ internal sealed class GlGpuDevice : IGpuDevice
_textureHandleTable.AsSpan(tableStart, tableEnd - tableStart));
_textureTableBuffer.Upload((long)tableStart * sizeof(ulong), bytes);
}
// Bind the device's own table to binding 9 immediately before every
// RHI-issued draw. Storage-buffer bindings are global GL context
// state, and the still-unmigrated legacy renderers (WbDrawDispatcher,
// EnvCellRenderer, TerrainModernRenderer, ParticleRenderer) rebind
// their OWN interim per-renderer table to the same binding right
// before their own draws (slice V2's documented pattern) — so this
// device must re-claim binding 9 before ITS draws too, or an RHI
// draw issued after a legacy draw in the same frame would read the
// wrong table.
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, GpuBindingModel.StorageTextureTable, _textureTableBuffer.GlName);
}
internal void BeginPass(GpuPassDescription description)
@ -395,6 +461,24 @@ internal sealed class GlGpuDevice : IGpuDevice
{
_gl.ColorMask(desired.ColorWrite, desired.ColorWrite, desired.ColorWrite, desired.ColorWrite);
}
if (changes.Multisample)
{
// Vulkan bakes multisample rasterization into the pipeline via its
// declared sample count — there is no separate enable bit. The GL
// backend mirrors that: GL_MULTISAMPLE tracks
// GpuPipelineDescription.SampleCount rather than being left at
// whatever the previous pass happened to leave it. This matters
// because the default framebuffer can itself be multisampled
// (QualitySettings MSAA): a single-sample UI pipeline binding
// while GL_MULTISAMPLE is still enabled from an earlier world
// pass converts each glyph's soft alpha edge into dithered MSAA
// coverage instead of a clean alpha blend (the pre-RHI
// TextRenderer explicitly disabled it for the same reason).
if (desired.Multisample)
_gl.Enable(EnableCap.Multisample);
else
_gl.Disable(EnableCap.Multisample);
}
GLHelpers.ThrowOnResourceError(_gl, "apply GL render state");
}

View file

@ -51,7 +51,8 @@ internal sealed class GlGpuPassEncoder : IGpuPassEncoder
description.Cull,
description.FrontFace,
description.AlphaToCoverage,
description.ColorWrite);
description.ColorWrite,
Multisample: description.SampleCount > 1);
_device.ApplyRenderState(desired);
_gl.BindVertexArray(p.GlVertexArray);

View file

@ -15,7 +15,8 @@ internal readonly record struct GlRenderStateSnapshot(
GpuCullMode Cull,
GpuFrontFace FrontFace,
bool AlphaToCoverage,
bool ColorWrite);
bool ColorWrite,
bool Multisample);
/// <summary>Which GL state calls are needed to move from the previous snapshot to the new one.</summary>
internal readonly record struct GlRenderStateChanges(
@ -27,14 +28,15 @@ internal readonly record struct GlRenderStateChanges(
bool Cull,
bool FrontFace,
bool AlphaToCoverage,
bool ColorWrite)
bool ColorWrite,
bool Multisample)
{
public bool AnyChange =>
Program || Blend || DepthTest || DepthWrite || DepthCompare
|| Cull || FrontFace || AlphaToCoverage || ColorWrite;
|| Cull || FrontFace || AlphaToCoverage || ColorWrite || Multisample;
/// <summary>Every dimension reported changed — used for the first apply after a reset.</summary>
internal static GlRenderStateChanges All { get; } = new(true, true, true, true, true, true, true, true, true);
internal static GlRenderStateChanges All { get; } = new(true, true, true, true, true, true, true, true, true, true);
}
/// <summary>
@ -68,7 +70,8 @@ internal sealed class GlRenderStateCache
p.Cull != desired.Cull,
p.FrontFace != desired.FrontFace,
p.AlphaToCoverage != desired.AlphaToCoverage,
p.ColorWrite != desired.ColorWrite);
p.ColorWrite != desired.ColorWrite,
p.Multisample != desired.Multisample);
}
/// <summary>Discards the cached baseline — the next <see cref="Apply"/> reports every dimension changed.</summary>

View file

@ -1,8 +1,8 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
public interface ICamera
internal interface ICamera
{
Matrix4x4 View { get; }
Matrix4x4 Projection { get; }

View file

@ -1,14 +1,14 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Result of a camera spring-arm sweep: the collided eye position AND the cell the swept
/// viewer-sphere ended in (retail <c>viewer_cell = sphere_path.curr_cell</c>, update_viewer
/// pc:92871). The cell is graph-tracked by the transition — no AABB, no grace frames — so it
/// pc:92871). The cell is graph-tracked by the transition — no AABB, no grace frames — so it
/// is the robust per-frame "which cell is the camera in?" answer that roots the render.
/// </summary>
public readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId);
internal readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId);
/// <summary>
/// Sweeps a small sphere from the camera pivot (player head) toward the
@ -16,7 +16,7 @@ public readonly record struct CameraSweepResult(Vector3 Eye, uint ViewerCellId);
/// lets <see cref="RetailChaseCamera"/> collide its eye without depending on
/// the physics engine directly (and stay unit-testable with a fake).
/// </summary>
public interface ICameraCollisionProbe
internal interface ICameraCollisionProbe
{
/// <summary>
/// Roll a collision sphere from <paramref name="pivot"/> to

View file

@ -1,18 +1,18 @@
// IndoorDrawPlan.cs
// IndoorDrawPlan.cs
//
// Pure (GL-free) port of the membership half of retail PView::DrawCells (0x5a4840):
// the reverse cell_draw_list iterated per portal_view slice. EVERY visible cell with a
// non-empty view is included there is NO "drawable" filter. Dropping cells without a
// clip-slot was the grey-walls bug (the cell's sealed shell never drew clear color showed).
// non-empty view is included — there is NO "drawable" filter. Dropping cells without a
// clip-slot was the grey-walls bug (the cell's sealed shell never drew → clear color showed).
using System.Collections.Generic;
namespace AcDream.App.Rendering;
public readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
internal readonly record struct CellDrawEntry(uint CellId, IReadOnlyList<ViewPolygon> Slices);
public static class IndoorDrawPlan
internal static class IndoorDrawPlan
{
/// <summary>Reverse OrderedVisibleCells (farnear), each visible cell with its view
/// <summary>Reverse OrderedVisibleCells (far→near), each visible cell with its view
/// slices. Mirrors DrawCells' shell/object loops. Cells whose view is empty are skipped
/// (they are not actually visible); no other cell is ever dropped.</summary>
public static List<CellDrawEntry> ShellPass(PortalVisibilityFrame frame)

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
@ -8,27 +8,27 @@ namespace AcDream.App.Rendering;
/// Splits a frame's landblock entities into the draw buckets used by the
/// retail-style DrawInside flood.
///
/// <para>T1 (fused BR-2/3, 2026-06-11) retail draw-order contract: the
/// <para>T1 (fused BR-2/3, 2026-06-11) — retail draw-order contract: the
/// frame draws STATIC world first (terrain, building shells, scenery, then
/// flooded interior cells + their static object lists), and every DYNAMIC
/// (server-spawned: player, NPCs, doors, items) draws LAST, depth-tested,
/// never hard-clipped. This is what makes the aperture depth punch safe
/// never hard-clipped. This is what makes the aperture depth punch safe —
/// when the punch erases depth inside a doorway, no dynamic has been drawn
/// yet, so nothing visible is destroyed (retail: objects draw per cell AFTER
/// cells, PView::DrawCells epilogue Ghidra 0x005a4840; the first BR-2 attempt
/// punched after dynamics and erased the player, reverted 88be519).</para>
///
/// <list type="bullet">
/// <item><see cref="Result.ByCell"/> indoor STATICS (dat-baked, ServerGuid==0)
/// <item><see cref="Result.ByCell"/> — indoor STATICS (dat-baked, ServerGuid==0)
/// per visible cell, drawn with their cell.</item>
/// <item><see cref="Result.OutdoorStatic"/> outdoor statics (building
/// <item><see cref="Result.OutdoorStatic"/> — outdoor statics (building
/// shells, scenery stabs), drawn with the world/landscape pass.</item>
/// <item><see cref="Result.Dynamics"/> ALL server-spawned entities
/// <item><see cref="Result.Dynamics"/> — ALL server-spawned entities
/// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities;
/// drawn in the frame's single LAST entity pass.</item>
/// </list>
/// </summary>
public static class InteriorEntityPartition
internal static class InteriorEntityPartition
{
internal enum ProjectionClass : byte
{
@ -51,13 +51,13 @@ public static class InteriorEntityPartition
void AbortFrame();
}
public sealed class Result
internal sealed class Result
{
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
// MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames
// so pruning itself doesn't allocate.
private readonly List<uint> _emptyCellScratch = new();
@ -82,7 +82,7 @@ public static class InteriorEntityPartition
/// 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
/// 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>
@ -100,7 +100,7 @@ public static class InteriorEntityPartition
}
/// <summary>
/// Allocating overload always returns a brand-new <see cref="Result"/>.
/// 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"/>).
@ -121,7 +121,7 @@ public static class InteriorEntityPartition
/// in place (see <see cref="Result.ClearForReuse"/>) and refills it,
/// reusing each cell's existing <c>List&lt;WorldEntity&gt;</c> when the
/// cell key survives from the previous frame instead of allocating a new
/// one the per-cell dictionary entries persist across frames (cleared,
/// 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.
@ -141,7 +141,7 @@ public static class InteriorEntityPartition
if (e.MeshRefs.Count == 0) continue;
// Retail contract: every server-spawned entity is a DYNAMIC
// and draws in the last pass indoor, outdoor, or unresolved.
// and draws in the last pass — indoor, outdoor, or unresolved.
if (e.ServerGuid != 0)
{
result.Dynamics.Add(e);
@ -237,7 +237,7 @@ public static class InteriorEntityPartition
}
}
/// <summary>Shared indoor classification keep DrawDynamicsLast, the
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
/// outside-stage assignment (#118), and the partition in lockstep.</summary>
public static bool IsIndoorCellId(uint cellId)
{

View file

@ -1,29 +1,29 @@
// NdcScissorRect.cs
// NdcScissorRect.cs
//
// NDC AABB framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// NDC AABB → framebuffer-pixel scissor box, CONSERVATIVE (outer bound).
// The scissor that brackets a landscape/doorway slice is a fallback BOUND on
// the slice's view region (AD-17 in the divergence register): it must CONTAIN
// every fragment the per-fragment plane clip would keep. Under-inclusion is
// the bug class the #130 doorway top-edge background strip was this box
// the bug class — the #130 doorway top-edge background strip was this box
// computed as Floor(origin) + Ceiling(size), whose far edge
// floor(min)+ceil(maxmin) lands up to one pixel SHORT of the true max edge
// floor(min)+ceil(max−min) lands up to one pixel SHORT of the true max edge
// at unlucky fractional alignments, scissoring away the aperture's top/right
// pixel row for the whole slice (sky, terrain, statics, weather) while the
// seal still stamps it a strip of clear color no later pass can fill.
// seal still stamps it — a strip of clear color no later pass can fill.
//
// Correct outer bound: floor both mins, ceil both maxes, width = difference.
// A fragment at pixel (i,j) rasterizes iff its CENTER (i+0.5, j+0.5) lies in
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
// i ≥ X00.5 ⇒ i ≥ floor(X0) and i ≤ X10.5 ⇒ i < ceil(X1). So
// the region ⊆ the NDC box [X0,X1]×[Y0,Y1] (pixel units). Center-inside ⇒
// i ≥ X0âˆ0.5 ⇒ i ≥ floor(X0) and i ≤ X1âˆ0.5 ⇒ i < ceil(X1). So
// [floor(X0), ceil(X1)) admits every center-inside pixel, over-including by
// at most one pixel per edge safe per AD-17's doctrine (the wall shell /
// at most one pixel per edge — safe per AD-17's doctrine (the wall shell /
// plane clip repaints or kills the surplus).
using System;
using System.Numerics;
namespace AcDream.App.Rendering;
public static class NdcScissorRect
internal static class NdcScissorRect
{
/// <summary>Convert an NDC AABB (minX, minY, maxX, maxY in [-1,1]) to a
/// framebuffer-pixel scissor box that CONTAINS it. Inputs are clamped to

View file

@ -2,7 +2,7 @@
namespace AcDream.App.Rendering;
public sealed class OrbitCamera : ICamera
internal sealed class OrbitCamera : ICamera
{
public Vector3 Target { get; set; } = new(96, 96, 0); // center of a 192x192 landblock
public float Distance { get; set; } = 300f;

View file

@ -1,24 +1,24 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Factory for the OUTDOOR render root the cell the render roots at when the camera eye is outdoors.
/// Retail roots every in-world frame at <c>viewer_cell</c> (SmartBox::RenderNormalMode
/// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors.
/// Retail roots every in-world frame at <c>viewer_cell</c> (SmartBox::RenderNormalMode →
/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a <c>CLandCell</c>. acdream models it
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> (so
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN terrain/sky/scenery draw
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
///
/// <para>R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail
/// does NOT flood buildings from the land root buildings flood SEPARATELY, per-building, during the
/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827).
/// does NOT flood buildings from the land root — buildings flood SEPARATELY, per-building, during the
/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827).
/// acdream issues those via <see cref="PortalVisibilityBuilder.ConstructViewBuilding"/> per nearby
/// building inside <see cref="RetailPViewRenderer.DrawInside"/>. The pre-R-A2 design flooded all
/// buildings from one root through reverse portals, coupling their interior membership to a single
/// root-level portal-side test that oscillated as the chase eye grazed a doorway the indoor flap.</para>
/// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap.</para>
/// </summary>
public static class OutdoorCellNode
internal static class OutdoorCellNode
{
public static LoadedCell Build(uint outdoorCellId) => new LoadedCell
{

View file

@ -191,13 +191,16 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
{
private readonly UiViewport _viewport;
private readonly IPaperdollInventoryVisibility _inventory;
private readonly ExternalViewportTextureBridge _textureBridge;
public RetailPaperdollFrameView(
UiViewport viewport,
IPaperdollInventoryVisibility inventory)
IPaperdollInventoryVisibility inventory,
IGpuDevice gpuDevice)
{
_viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
_inventory = inventory ?? throw new ArgumentNullException(nameof(inventory));
_textureBridge = new ExternalViewportTextureBridge(gpuDevice);
}
public bool TryGetVisibleSize(out int width, out int height)
@ -215,7 +218,7 @@ internal sealed class RetailPaperdollFrameView : IPaperdollFrameView
}
public void SetTextureHandle(uint textureHandle) =>
_viewport.TextureHandle = textureHandle;
_viewport.TextureHandle = _textureBridge.Resolve(textureHandle);
}
/// <summary>Narrow visibility adapter for the paperdoll's inventory host.</summary>

View file

@ -1,4 +1,4 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
@ -10,7 +10,7 @@ namespace AcDream.App.Rendering;
/// Paperdoll-specific facade over the shared private creature viewport. The
/// fixed camera remains the verbatim retail <c>gmPaperDollUI</c> camera.
/// </summary>
public sealed class PaperdollViewportRenderer :
internal sealed class PaperdollViewportRenderer :
IUiViewportRenderer,
IPaperdollDollRenderer,
IDisposable

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
@ -22,7 +22,7 @@ namespace AcDream.App.Rendering;
/// compositing order is shared with ordinary translucent GfxObj parts. Sky and
/// sealed off-screen passes retain their independent immediate path.
/// </summary>
public sealed unsafe class ParticleRenderer : IDisposable
internal sealed unsafe class ParticleRenderer : IDisposable
{
// The texture is per instance through GL_ARB_bindless_texture. Only blend
// state remains a draw-call boundary, so stable retail distance order no
@ -69,8 +69,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
/// <summary>
/// Vertex-instance ABI shared with particle.vert. Campaign V slice V2c
/// (2026-07-27): TextureHandleLow/High (the split halves of a raw 64-bit
/// ARB_bindless_texture handle) became one TextureIndex a slot into the
/// binding=9 handle table so ordered particles using different textures
/// ARB_bindless_texture handle) became one TextureIndex — a slot into the
/// binding=9 handle table — so ordered particles using different textures
/// still remain one instanced draw when their blend mode matches.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
@ -121,8 +121,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
// Campaign V slice V2c (2026-07-27): GL-only emulation of the eventual
// Vulkan global texture descriptor array (binding=9,
// GpuBindingModel.StorageTextureTable). Owns its own table see
// GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why
// GpuBindingModel.StorageTextureTable). Owns its own table — see
// GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for why
// particles don't share WbDrawDispatcher's/EnvCellRenderer's/
// TerrainModernRenderer's tables. There is no automated pixel-gate
// coverage for particles (the offline gate's fixed outdoor view has none
@ -1157,7 +1157,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, instanceStride, (void*)(12 * sizeof(float)));
_gl.VertexAttribDivisor(5, 1);
// Campaign V slice V2c: one uint table slot (was uvec2 low/high
// handle halves) BillboardGpuInstance shrank by 4 bytes.
// handle halves) — BillboardGpuInstance shrank by 4 bytes.
_gl.EnableVertexAttribArray(6);
_gl.VertexAttribIPointer(
6,

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering;
@ -12,7 +12,7 @@ namespace AcDream.App.Rendering;
/// cell walls (<c>FindEnvCollisions</c>) AND outdoor/baked GfxObj shells
/// (<c>FindObjCollisions</c>) in one faithful path.
/// </summary>
public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
internal sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
{
/// <summary>Retail <c>viewer_sphere</c> radius (acclient :93314).</summary>
public const float ViewerSphereRadius = 0.3f;
@ -23,13 +23,13 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
public CameraSweepResult SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId, Vector3 playerPos)
{
// update_viewer: player->cell == 0 set_viewer(player_pos, 1), viewer_cell = null
// (acclient_2013_pseudo_c.txt:92775). No cell to sweep against snap to the player.
// update_viewer: player->cell == 0 → set_viewer(player_pos, 1), viewer_cell = null
// (acclient_2013_pseudo_c.txt:92775). No cell to sweep against → snap to the player.
if (cellId == 0) return new CameraSweepResult(playerPos, 0u);
// === Start cell (pc:92824-92844) ===
// Indoor (objcell_id >= 0x100): seat the sweep's start cell at the head-PIVOT via
// CPhysicsObj::AdjustPosition (pc:92832) the head can sit in a different cell than
// CPhysicsObj::AdjustPosition (pc:92832) — the head can sit in a different cell than
// the feet (the cellar lip: feet in the low connector, head up at floor level). On
// failure retail falls back to player->cell. Outdoor: cell = player->cell (no AdjustPosition).
uint startCell = cellId;
@ -39,10 +39,10 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
if (found) startCell = pivotCell;
}
// === Sweep the viewer_sphere pivot sought-eye from the start cell (pc:92860-92868) ===
// === Sweep the viewer_sphere pivot → sought-eye from the start cell (pc:92860-92868) ===
// SpherePath.InitPath puts sphere0's center at pathPos + (0,0,radius) (the player
// foot-capsule convention). Retail's viewer_sphere center is (0,0,0), so shift the
// path DOWN by the radius to make the SPHERE CENTER travel pivoteye, then add it back.
// path DOWN by the radius to make the SPHERE CENTER travel pivot→eye, then add it back.
Vector3 begin = ToSpherePath(pivot, ViewerSphereRadius);
Vector3 end = ToSpherePath(desiredEye, ViewerSphereRadius);
@ -59,7 +59,7 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
// Retail SmartBox::update_viewer calls init_object(player, 0x5c) =
// IsViewer | PathClipped | FreeRotate | PerfectClip (acclient
// pseudo-C :92864; enum TransitionTypes.cs:24-33). PathClipped makes
// the sweep HARD-STOP at first contact (TransitionTypes.cs:811) the
// the sweep HARD-STOP at first contact (TransitionTypes.cs:811) — the
// spring-arm pull-in, not the player's edge-slide. IsViewer lets the
// eye pass through creatures, colliding only with world geometry
// (CollisionExemption.cs:83-85). FreeRotate/PerfectClip are no-ops in
@ -95,7 +95,7 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
// === Fallback 1 (pc:92878-92883): AdjustPosition at the sought eye ===
// The sweep found no valid position; try to seat the eye at its own cell.
// (Seed with the player cell acdream's camera doesn't track the sought-eye's
// (Seed with the player cell — acdream's camera doesn't track the sought-eye's
// cell separately; the eye is near the player so its stab-list is the right one.)
var (eyeCell, eyeFound) = _physics.AdjustPosition(cellId, desiredEye);
if (eyeFound) return new CameraSweepResult(desiredEye, eyeCell);
@ -104,11 +104,11 @@ public sealed class PhysicsCameraCollisionProbe : ICameraCollisionProbe
return new CameraSweepResult(playerPos, 0u);
}
/// <summary>Eye/pivot point InitPath path point (subtract the sphere-center offset).</summary>
/// <summary>Eye/pivot point → InitPath path point (subtract the sphere-center offset).</summary>
internal static Vector3 ToSpherePath(Vector3 spherePoint, float radius)
=> spherePoint - new Vector3(0f, 0f, radius);
/// <summary>InitPath path point eye point (add the sphere-center offset back).</summary>
/// <summary>InitPath path point → eye point (add the sphere-center offset back).</summary>
internal static Vector3 FromSpherePath(Vector3 pathPoint, float radius)
=> pathPoint + new Vector3(0f, 0f, radius);
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Numerics;
using AcDream.App.Rendering.Wb;
@ -8,15 +8,15 @@ namespace AcDream.App.Rendering;
/// <summary>
/// BR-2 (holistic building-render port): retail's invisible portal depth
/// writes the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
/// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
/// (Ghidra 0x0059bc90, pc:424490).
///
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
/// on outdoor / look-in roots, via <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c>
/// (the <c>DrawExitPortalMasks</c> slice callback) safe alongside the
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)
/// added the two-pass stencil depth gate on the punch side see
/// added the two-pass stencil depth gate on the punch side — see
/// <see cref="DrawDepthFan"/>.</para>
///
/// <para>Retail projects a portal polygon, software-clips it against the
@ -25,12 +25,12 @@ namespace AcDream.App.Rendering;
/// <list type="bullet">
/// <item><b>Seal</b> (retail <c>maxZ2=6</c>, bit0 clear, data 0x00820e14):
/// z = the polygon's true projected depth. Drawn on portals leading OUTSIDE
/// (<c>other_cell_id==0xFFFF</c>) after the landscape pass terrain seen
/// (<c>other_cell_id==0xFFFF</c>) after the landscape pass — terrain seen
/// through a doorway keeps its pixels because farther interior geometry
/// z-fails inside the aperture (PView::DrawCells loop 1, Ghidra 0x005a4840,
/// pc:432783-432786).</item>
/// <item><b>Punch</b> (retail <c>maxZ1=7</c>, bit0 set, data 0x00820e18):
/// z forced to the far plane (0.99999988) erases depth inside a building
/// z forced to the far plane (0.99999988) — erases depth inside a building
/// aperture so the interior cells drawn next land cleanly
/// (ConstructView(CBldPortal) mode-1, pc:433827). BR-2 commit 2 wires this
/// side.</item>
@ -38,7 +38,7 @@ namespace AcDream.App.Rendering;
///
/// <para>Where retail clips the polygon on the CPU against the view, we apply
/// the SAME view region via <c>gl_ClipDistance</c> from the slice's clip-space
/// half-planes (≤8, the validated <see cref="ClipPlaneSet"/> output) — the
/// half-planes (≤8, the validated <see cref="ClipPlaneSet"/> output) — the
/// depth write lands only inside the slice region, matching retail's clipped
/// fan.</para>
///
@ -46,7 +46,7 @@ namespace AcDream.App.Rendering;
/// sets everything it depends on, restores the frame-global convention on
/// exit, no early-outs between set and restore.</para>
/// </summary>
public sealed class PortalDepthMaskRenderer : IDisposable
internal sealed class PortalDepthMaskRenderer : IDisposable
{
private const string VertSrc = @"#version 430 core
layout(location = 0) in vec3 aPos;
@ -215,28 +215,28 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// <summary>
/// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the
/// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because
/// retail's outdoor pass is painter's-ordered farnear (anything nearer
/// retail's outdoor pass is painter's-ordered far→near (anything nearer
/// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame
/// has no such order, so an unconditional far-Z punch erased the depth
/// of NEARER occluders (terrain hills, closer buildings) at aperture
/// pixels doors/interiors painted through them (the T5 #117 report).
/// pixels — doors/interiors painted through them (the T5 #117 report).
/// The z-buffer-correct equivalent: punch ONLY where the aperture
/// polygon itself wins a depth test at its true depth (two-pass
/// stencil below). The bias keeps the #108 case covered terrain
/// stencil below). The bias keeps the #108 case covered — terrain
/// hugging the door plane (centimeters in front of the aperture) must
/// still be punched; a hill or another house meters nearer must not.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary>
/// #129 (2026-06-12): NDC depth is non-linear a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
/// ~190 m at a landblock away: every hill/house in front of a distant
/// aperture passed the mark and got far-Z punched door-shaped leaks
/// aperture passed the mark and got far-Z punched — door-shaped leaks
/// through occluders. Fix: cap the bias's EYE-SPACE span at
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
/// bit-identical to the T5-validated close-range behavior (#108 grass
/// coverage untouched); beyond it the punch can never reach an occluder
/// more than the cap in front of the aperture plane.
@ -245,7 +245,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
/// plane; the small f/(f−n) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
@ -261,13 +261,13 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
/// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth).
///
/// <para><b>Seal</b> (interior root): one pass, retail-verbatim
/// <para><b>Seal</b> (interior root): one pass, retail-verbatim —
/// depth ALWAYS + true projected depth. It runs immediately after the
/// gated full depth clear, so there is no nearer content to stomp.</para>
///
/// <para><b>Punch</b> (outdoor root / look-in): two passes (#117).
/// Pass A marks stencil where the aperture fan passes a LEQUAL depth
/// test at its (biased) true depth i.e. where the aperture is
/// test at its (biased) true depth — i.e. where the aperture is
/// actually visible against everything drawn so far. Pass B writes the
/// far-Z punch with depth ALWAYS but stencil-gated to the marked
/// pixels, and zeroes the stencil as it goes (self-cleaning). This is
@ -301,7 +301,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.Disable(EnableCap.CullFace); // portal fans face either way
_gl.Disable(EnableCap.ScissorTest);
_gl.Enable(EnableCap.DepthTest);
_gl.ColorMask(false, false, false, false); // alpha-0 fan no color
_gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
for (int i = 0; i < planeCount; i++)
_gl.Enable(EnableCap.ClipDistance0 + i);
@ -351,7 +351,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
if (!forceFarZ)
{
// ── SEAL: retail-verbatim single pass ──
// ── SEAL: retail-verbatim single pass ──
_gl.DepthFunc(DepthFunction.Always);
_gl.DepthMask(true);
_gl.Uniform1(_locForceFarZ, 0);
@ -360,7 +360,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
}
else
{
// ── PUNCH pass A: stencil-mark visible aperture pixels ──
// ── PUNCH pass A: stencil-mark visible aperture pixels ──
_gl.Enable(EnableCap.StencilTest);
_gl.StencilFunc(StencilFunction.Always, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
@ -373,8 +373,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, firstVertex, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ──
// ── PUNCH pass B: far-Z write on marked pixels only;
// zero the stencil as we go (self-cleaning) ──
_gl.StencilFunc(StencilFunction.Equal, 1, 0xFF);
_gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero);
_gl.DepthFunc(DepthFunction.Always);

View file

@ -1,11 +1,11 @@
// PortalProjection.cs
// PortalProjection.cs
//
// Phase A8.F: project a cell-local portal polygon to NDC screen space. Homogeneous frustum clip
// in CLIP SPACE (before the perspective divide): first the IN-FRONT-OF-EYE half-space (keep where
// w > MinW) so a portal straddling the camera does not invert under the divide and the divide
// stays bounded away from the w=0 eye singularity, then the 4 SIDE planes (x,y within ±w) so every
// stays bounded away from the w=0 eye singularity, then the 4 SIDE planes (x,y within ±w) so every
// surviving vertex lands on the screen [-1,1] by construction. The side-plane clip is the R1
// void-flap fix (2026-06-05) see ProjectToNdc.
// void-flap fix (2026-06-05) — see ProjectToNdc.
//
// The clip is NEAR-INDEPENDENT on purpose. We only use the projected x/y for the visibility clip
// REGION, so a vertex in front of the eye is meaningful even if it is closer than the projection's
@ -23,7 +23,7 @@ using System.Numerics;
namespace AcDream.App.Rendering;
public static class PortalProjection
internal static class PortalProjection
{
internal ref struct ClipPolygonLease
{
@ -118,17 +118,17 @@ public static class PortalProjection
Vector4[]? second = null;
// Homogeneous frustum clip in CLIP SPACE, before the perspective divide. First the
// in-front-of-eye half-space (w > MinW) near-INDEPENDENT, so a portal the camera is
// standing in still projects (see header); then the 4 SIDE planes (x,y within ±w). The
// in-front-of-eye half-space (w > MinW) — near-INDEPENDENT, so a portal the camera is
// standing in still projects (see header); then the 4 SIDE planes (x,y within ±w). The
// side clip is the R1 void-flap fix (2026-06-05): without it, a portal WITHIN the near
// plane projected small-w verts to wildly off-screen NDC (the probe saw (10.2,-67.4)),
// which corrupted the downstream 2D ScreenPolygonClip into an EMPTY region -> OutsideView
// empty -> terrain Skip -> the bluish doorway "void". Clipping the side planes here bounds
// every surviving vertex to the screen [-1,1] by construction, so a screen-covering doorway
// clips to the screen (non-empty) instead of collapsing. The eye plane is clipped FIRST so
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// all survivors have w > 0, making the side-plane functionals (w ± x, w ± y) well defined.
// Near/far are intentionally NOT clipped (near-independence). Retail PView::GetClip
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
// (decomp:0x005a4320) projects + frustum-clips the portal poly likewise (research doc A §3.5).
try
{
second = vectorPool.Rent(capacity);
@ -155,7 +155,7 @@ public static class PortalProjection
(current, output) = (output, current);
}
// Perspective divide NDC xy. This is the only result allocation.
// Perspective divide → NDC xy. This is the only result allocation.
var ndc = new Vector2[currentCount];
for (int i = 0; i < currentCount; i++)
{
@ -174,21 +174,21 @@ public static class PortalProjection
/// <summary>Faithful homogeneous projection (retail PrimD3DRender::xformStart + the W=0 clip of
/// ACRender::polyClipFinish, decomp 424310 / 702749): transform the portal to clip space and clip
/// ONLY the eye plane (w &gt;= 0, EXACT), keeping homogeneous coords NO perspective divide, NO
/// ONLY the eye plane (w &gt;= 0, EXACT), keeping homogeneous coords — NO perspective divide, NO
/// frustum side-plane clamp. The screen bound is applied later by <see cref="ClipToRegion"/>
/// against the view region (the root region is the full screen), exactly as retail clips the portal
/// against the accumulated portal_view rather than fixed side planes.
///
/// <para>The W=0 clip is exact on purpose (the knife-edge port, 2026-06-11; pseudocode at
/// docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md): boundary intersections land
/// at w == 0 — homogeneous DIRECTIONS — so a portal the eye is crossing (stair openings, decks)
/// at w == 0 — homogeneous DIRECTIONS — so a portal the eye is crossing (stair openings, decks)
/// yields the correct UNBOUNDED half-region, which the bounded view-region clip then cuts to the
/// screen. The previous EyePlaneW = 1e-4 produced finite ~1e4-NDC boundary verts whose region
/// intersections sat at the dedup/merge degeneracy threshold the climb-strobe class. A w=0
/// intersections sat at the dedup/merge degeneracy threshold — the climb-strobe class. A w=0
/// vertex can never survive ClipToRegion into its divide (a nonzero direction fails at least one
/// edge test of any BOUNDED convex region), so no divide-by-zero path exists; the measure-zero
/// corner case is guarded in ClipToRegion. Matches polyClipFinish part 1: clip pass runs only
/// when some vertex has w &lt; 0; &lt;3 survivors reject (empty).</para></summary>
/// when some vertex has w &lt; 0; &lt;3 survivors → reject (empty).</para></summary>
public static Vector4[] ProjectToClip(IReadOnlyList<Vector3> localPoly, Matrix4x4 cellToWorld, Matrix4x4 viewProj)
{
using ClipPolygonLease lease = ProjectToClipLease(localPoly, cellToWorld, viewProj);
@ -274,7 +274,7 @@ public static class PortalProjection
/// (CCW convex) with w-aware Sutherland-Hodgman edge tests, then divide the survivors to NDC and
/// normalize to CCW. Ports retail ACRender::polyClipFinish's view-region clip (decomp 702749): the
/// edge test multiplies through w (which is &gt; 0 after the eye-plane clip) so it never divides a
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region
/// near-eye vertex, and the final divide runs only on survivors already bounded to the region —
/// stable by construction. Returns &lt;3 verts when the portal does not intersect the region.</summary>
public static Vector2[] ClipToRegion(IReadOnlyList<Vector4> subjectClip, IReadOnlyList<Vector2> regionCcwNdc)
{
@ -339,7 +339,7 @@ public static class PortalProjection
// Homogeneous Sutherland-Hodgman: clip the (w > 0) subject against each CCW edge of the NDC
// region. f(P) below is the NDC inside test cross(edge, P_ndc - a) multiplied through P.W,
// which is > 0 after the eye-plane clip so the sign is the NDC sign yet no near-eye vertex
// which is > 0 after the eye-plane clip — so the sign is the NDC sign yet no near-eye vertex
// is ever divided (retail polyClipFinish, decomp 702749).
int regionCount = regionCcwNdc.Count;
int capacity = checked(subjectClip.Length + regionCount);
@ -370,7 +370,7 @@ public static class PortalProjection
if (currentCount < 3)
return System.Array.Empty<Vector2>();
// Divide survivors NDC. They are already inside the bounded region. A w=0
// Divide survivors → NDC. They are already inside the bounded region. A w=0
// measure-zero corner remains the same empty knife-edge result as the prior path.
ndcScratch = vector2Pool.Rent(currentCount);
Span<Vector2> ndc = ndcScratch.AsSpan(0, currentCount);
@ -408,8 +408,8 @@ public static class PortalProjection
// Retail copy_view's ~1-pixel vertex merge (see ClipToRegion). Collapses
// runs of consecutive near-identical vertices, including across the
// wrap-around. A polygon that collapses below 3 distinct vertices is
// degenerate (sub-pixel sliver) and returns empty exactly retail's
// "<3 surviving verts output count 0".
// degenerate (sub-pixel sliver) and returns empty — exactly retail's
// "<3 surviving verts → output count 0".
private const float VertexMergeEpsilonNdc = 2f / 1080f;
private static int MergeSubPixelVertices(Span<Vector2> poly)
@ -428,7 +428,7 @@ public static class PortalProjection
}
poly[kept++] = vertex;
}
// Wrap-around: last first.
// Wrap-around: last ≈ first.
while (kept >= 2)
{
Vector2 first = poly[0];
@ -442,9 +442,9 @@ public static class PortalProjection
return kept;
}
// One Sutherland-Hodgman half-plane against the directed NDC edge ab, keeping the CCW-inside
// One Sutherland-Hodgman half-plane against the directed NDC edge a→b, keeping the CCW-inside
// (left) part of a HOMOGENEOUS polygon. Inside test for vertex P (clip space): the NDC cross
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// product cross(b-a, P/P.W - a) scaled by P.W (> 0): ex·(P.Y - P.W·a.Y) - ey·(P.X - P.W·a.X) ≥ 0.
// Crossings interpolate in homogeneous coords (perspective-correct), via the shared Lerp.
private static int ClipHomogeneousEdge(
ReadOnlySpan<Vector4> polygon,
@ -489,7 +489,7 @@ public static class PortalProjection
if (area2 < 0f) System.Array.Reverse(poly);
}
// Minimum clip-space w ( metres in front of the eye) to keep a vertex. Excludes the eye
// Minimum clip-space w (≈ metres in front of the eye) to keep a vertex. Excludes the eye
// (w=0) singularity and the ~5 cm right at it (bounding the perspective divide), but is
// INTENTIONALLY far closer than the projection's 1.0 m near plane so a doorway the camera is
// standing in still projects and the cell behind it stays visible. See the file header.

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering;
/// (<c>0x00453760</c>). Identity looks along AC +Y with +Z up; the animated
/// angle rolls that view around its own +Y forward axis.
/// </summary>
public sealed class PortalTunnelCamera : ICamera
internal sealed class PortalTunnelCamera : ICamera
{
public static readonly Vector3 RetailEye = new(0.24f, -2.7f, 0.88f);

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Content.Vfx;
@ -22,7 +22,7 @@ namespace AcDream.App.Rendering;
/// 40 frames/second and drawn as a replacement 3-D viewport beneath the
/// retained gameplay UI.
/// </summary>
public sealed class PortalTunnelPresentation : IDisposable
internal sealed class PortalTunnelPresentation : IDisposable
{
public const uint SetupClientEnum = 0x10000001u;
public const uint AnimationClientEnum = 0x10000002u;

View file

@ -1,4 +1,4 @@
// PortalView.cs
// PortalView.cs
//
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
@ -10,7 +10,7 @@ using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
public readonly struct ViewPolygon
internal readonly struct ViewPolygon
{
public readonly Vector2[] Vertices;
public readonly float MinX, MinY, MaxX, MaxY;
@ -101,7 +101,7 @@ internal sealed class PortalPolygonVertexStore
}
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
public sealed class CellView
internal sealed class CellView
{
// ViewPolygon exposes its vertex array for the renderer, so this seed must
// be owned by the CellView rather than shared globally. Pooling the
@ -170,7 +170,7 @@ public sealed class CellView
MaxY = float.MinValue;
}
/// <summary>A region covering the entire NDC viewport the camera cell's seed region
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
public static CellView FullScreen()
{
@ -192,7 +192,7 @@ public sealed class CellView
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
@ -206,14 +206,14 @@ public sealed class CellView
// #120 convergence (2026-06-11): reject a polygon CONTAINED in one already
// stored. The reciprocal ping-pong (eye within PortalSideEpsilon of a
// portal plane → BOTH side tests pass → views lap A→B→A…) re-emits, each
// lap, a region that is — in exact arithmetic — a SUBSET of the polygon
// portal plane → BOTH side tests pass → views lap Aâ†Bâ†A…) re-emits, each
// lap, a region that is — in exact arithmetic — a SUBSET of the polygon
// that originated it; near-edge-on apertures make the re-clip wobble by
// more than the 1e-3 key grid, so every lap keyed as "new" and the
// in-place growth recursed to the depth-128 tripwire (chain dumps:
// 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests
// 0xA9B4015C↔0x0162, 0xA9B30103↔0x010F; Issue120ReciprocalPingPongTests
// reproduces deterministically). Containment rejection makes growth
// strictly area-increasing no new visible area, no propagation. The
// strictly area-increasing — no new visible area, no propagation. The
// key stays recorded so the exact emission also short-circuits later.
// Bonus: back-emission into a full-screen view (the root cell) is now
// always rejected outright.
@ -228,7 +228,7 @@ public sealed class CellView
}
// #120: is polygon p entirely inside ONE stored polygon (with DedupGridNdc
// slack)? Single-polygon containment is sufficient for the ping-pong class
// slack)? Single-polygon containment is sufficient for the ping-pong class —
// a round-trip re-emission descends from exactly one originator. Stored
// polygons are convex (Sutherland-Hodgman / full-screen seed outputs); the
// edge test adapts to either winding via the polygon's signed area.
@ -252,7 +252,7 @@ public sealed class CellView
{
if (convex.Length < 3) return false;
// signed area winding (CCW positive); inside = left of every CCW edge.
// signed area → winding (CCW positive); inside = left of every CCW edge.
float area2 = 0f;
for (int i = 0; i < convex.Length; i++)
{
@ -268,10 +268,10 @@ public sealed class CellView
var b = convex[(i + 1) % convex.Length];
var ab = b - a;
float len = ab.Length();
if (len < 1e-9f) continue; // degenerate edge no constraint
if (len < 1e-9f) continue; // degenerate edge — no constraint
foreach (var pt in pts)
{
// signed perpendicular distance of pt from edge ab (positive = inside for CCW)
// signed perpendicular distance of pt from edge a→b (positive = inside for CCW)
float cross = sign * (ab.X * (pt.Y - a.Y) - ab.Y * (pt.X - a.X));
if (cross < -eps * len)
return false; // a vertex lies outside this edge by more than eps
@ -280,7 +280,7 @@ public sealed class CellView
return true;
}
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p finer than the gap between distinct portal openings
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
private const float DedupGridNdc = 1e-3f;
@ -293,24 +293,24 @@ public sealed class CellView
//
// W=0 port (2026-06-11): an ALL-COLLINEAR polygon (zero area) keys as its snapped segment
// ("L:" + extreme points) instead of null. A portal whose plane contains the eye projects to
// exactly this and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
// exactly this — and retail PROPAGATES it: PView::ClipPortals (decomp:433651-433711) forwards
// any GetClip output with count != 0 to copy_view/OtherPortalClip with no area gate anywhere,
// so the neighbour cell stays in the draw list (cells draw whole; onward floods die naturally
// against the zero-area region). Rejecting these views dropped the whole chain behind an
// exactly-in-plane portal for the frame the parked-eye knife-edge band (tower deck, spiral
// exactly-in-plane portal for the frame — the parked-eye knife-edge band (tower deck, spiral
// landings). The segment key space is finite like the area-key space, so dedup + the strict
// growth convergence invariant are unchanged. Degenerate is returned only when fewer than 2
// distinct snapped points survive (a true sub-grid point not a real region OR segment).
// distinct snapped points survive (a true sub-grid point — not a real region OR segment).
//
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
// §4 corner/doorway fix (2026-06-10) — the collinear pass: the homogeneous region clipper
// (PortalProjection.ClipToRegion, used by the forward AND — as of today — the reciprocal hop)
// legitimately inserts intersection vertices ON a subject edge when a region edge grazes it, so
// BFS re-clip rounds re-emit the SAME geometric region with 1-2 extra collinear edge vertices.
// Without collinear canonicalization those re-emissions key as distinct, defeating the dedup and
// accumulating duplicate polygons (the pre-2026-06-06 unbounded-growth hang in miniature, and the
// exact reason the reciprocal clip was previously parked on the unstable divide-first path).
// Dropping collinear snapped points makes the key purely a function of the region's CORNERS, so
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
// any re-emission of the same shape — drifted, rotated, vertex-count-inflated — deduplicates.
private CanonicalKeyResult TryAddCanonicalKey(Vector2[]? verts)
{
if (verts is null || verts.Length < 3)

View file

@ -1,4 +1,4 @@
// PortalVisibilityBuilder.cs
// PortalVisibilityBuilder.cs
//
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
@ -12,7 +12,7 @@ using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>Per-frame output of the portal-frame BFS.</summary>
public sealed class PortalVisibilityFrame
internal sealed class PortalVisibilityFrame
{
private const int MaxRetainedCellViews = 512;
internal const int MaxRetainedBuildCollectionCapacity = 512;
@ -31,7 +31,7 @@ public sealed class PortalVisibilityFrame
internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount;
internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount;
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw exit portals
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
public CellView OutsideView { get; private set; } = new();
@ -39,7 +39,7 @@ public sealed class PortalVisibilityFrame
public Dictionary<uint, CellView> CellViews { get; } = new();
/// <summary>Visible interior cells in the exact order they were first dequeued from the
/// distance-priority work list closest-first (Phase U.2a). Mirrors retail's
/// distance-priority work list — closest-first (Phase U.2a). Mirrors retail's
/// PView::cell_draw_list, appended in PView::ConstructView (decomp:433783) as each cell pops
/// off the nearest-vertex-sorted cell_todo_list (InsCellTodoList 433183). Deduplicated: a cell
/// appears exactly once, on its first dequeue. The camera cell is always first.</summary>
@ -243,7 +243,7 @@ public sealed class PortalVisibilityFrame
}
}
public static class PortalVisibilityBuilder
internal static class PortalVisibilityBuilder
{
// Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995
// (const @0x007c8c70; PView::InitCell Ghidra 0x005a4b70). T2 (BR-4)
@ -253,20 +253,20 @@ public static class PortalVisibilityBuilder
// side test never sees a stale root more than F_EPSILON past a plane. Our
// root can lag the eye by up to ~1 cm at pressed corners (the harness's
// fixed-root sweep models this), and 0.01 is that documented root-lag
// tolerance NOT a retail constant. Tighten to F_EPSILON only together
// tolerance — NOT a retail constant. Tighten to F_EPSILON only together
// with eye-exact viewer-cell tracking verification (the #108-membership
// family) + the cdstW near-clip pin.
private const float PortalSideEpsilon = 0.01f;
// Retail F_EPSILON proper used where the semantic is knife-edge
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE return 0,
// Retail F_EPSILON proper — used where the semantic is knife-edge
// REJECTION (ConstructView(CBldPortal) Sidedness IN_PLANE → return 0,
// Ghidra 0x005a59a0), which must NOT inherit the root-lag tolerance above
// (a 1 cm-wide in-plane band would reject look-in seeds whenever the eye
// stands near a doorway plane).
private const float SeedInPlaneEpsilon = 0.0002f;
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
// localâ†NDCâ†clipped portal geometry for the first 2 Build calls per distinct camera cell.
private static readonly bool s_pvDump =
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
@ -275,14 +275,14 @@ public static class PortalVisibilityBuilder
/// #120 observable: total convergence-tripwire firings across both the
/// interior <see cref="Build"/> and the exterior look-in propagation.
/// The tripwire firing means the in-place growth's fixpoint invariant
/// broke (T2/BR-4) tests reset this and assert it stays 0.
/// broke (T2/BR-4) — tests reset this and assert it stays 0.
/// </summary>
public static int ConvergenceTripwireCount;
/// <summary>
/// #120 self-attribution dump: the growth-recursion path that exceeded
/// the tripwire, as a per-cell frequency summary plus the chain tail
/// the cycle's structure (e.g. 01740175 ping-pong vs a 3-cycle lap)
/// the tripwire, as a per-cell frequency summary plus the chain tail —
/// the cycle's structure (e.g. 0174↔0175 ping-pong vs a 3-cycle lap)
/// reads directly off the output.
/// </summary>
private static void DumpPropagationChain(uint[] chain, int depth, uint rootCellId, Vector3 eye)
@ -309,11 +309,11 @@ public static class PortalVisibilityBuilder
/// <summary>The +Z world lift applied to DRAWN cell shells (z-fighting vs
/// terrain; applied in GameWindow's cell registration). The visibility
/// graph stays in PHYSICS (unlifted) space feeding the lift into portal
/// graph stays in PHYSICS (unlifted) space — feeding the lift into portal
/// planes broke horizontal-portal side tests (#119-residual, f35cb8b).
/// Draw-space consumers of portal polygons (the OutsideView color gate
/// here, the seal/punch depth fans in GameWindow) must apply this lift so
/// they meet the drawn shell's aperture edge the unlifted gate left a
/// they meet the drawn shell's aperture edge — the unlifted gate left a
/// 2 cm background strip under the drawn lintel (#130).</summary>
public const float ShellDrawLiftZ = 0.02f;
@ -347,7 +347,7 @@ public static class PortalVisibilityBuilder
// Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor
// node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC
// quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell"
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" —
// the very same callback that already draws the doorway slice when an INTERIOR root reaches
// outdoors. Keyed on the explicit IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a
// cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior
@ -357,7 +357,7 @@ public static class PortalVisibilityBuilder
frame.OutsideView.Add(frame.CopyPolygon(FullScreenQuad));
// Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first;
// each cell carries the cameranearest-portal-vertex distance that put it on the list
// each cell carries the camera→nearest-portal-vertex distance that put it on the list
// (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The
// camera cell seeds at distance 0 (retail InsCellTodoList(this, arg2, 0f) at 433758) so it
// always pops first.
@ -367,10 +367,10 @@ public static class PortalVisibilityBuilder
// Fixpoint termination replacing the old MaxReprocessPerCell hard cap. This mirrors the
// retail portal_view slice offset 0x44 (last-incorporated view-poly watermark) vs 0x38
// (current view_count) decision in AddViewToPortals (433446): a cell is INSERTED into the
// todo list exactly once on first discovery (retail's ecx_5==0 branch calls
// todo list exactly once — on first discovery (retail's ecx_5==0 branch calls
// InsCellTodoList; the ecx_5!=eax_2 growth branch calls AddToCell IN PLACE and never
// re-enqueues). Later growth into an already-discovered cell is unioned into its CellView but
// does NOT re-enqueue it the `cell_view_done` guarantee (ConstructView sets it at 433784
// does NOT re-enqueue it — the `cell_view_done` guarantee (ConstructView sets it at 433784
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
@ -398,11 +398,11 @@ public static class PortalVisibilityBuilder
{
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
// portal, the exact inputs the BFS guards read BEFORE the guards run, so
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
// a portal the loop silently `continue`s past is still visible here. An
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
// polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the
// portal a legitimately-empty result, not a bug), or (if both OK) a
// portal — a legitimately-empty result, not a bug), or (if both OK) a
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
{
@ -417,7 +417,7 @@ public static class PortalVisibilityBuilder
}
// T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue
// PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
// — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first
// discovery enqueues via InsCellTodoList; growth into a cell whose
// cell_view_done is set calls AdjustCellView (pc:433741-433745), which
// re-clips ONLY the new views (the update_count watermark) through that
@ -426,7 +426,7 @@ public static class PortalVisibilityBuilder
// neighbour; it processes exactly the new tail and recurses further
// growth. Termination is physical: recursion fires only when AddRegion
// added a DISTINCT polygon (CanonicalKey dedup) that survived the 1-px
// vertex merge the finite fixpoint floor that replaced the old
// vertex merge — the finite fixpoint floor that replaced the old
// MaxReprocessPerCell=16 drift cap (deleted). The depth tripwire below
// is a loud failsafe, not control flow: it firing means the convergence
// invariant broke and must be fixed, not tuned.
@ -434,7 +434,7 @@ public static class PortalVisibilityBuilder
// #120 self-attribution: the recursion path (cell id per depth), so a
// tripwire firing names the growth CYCLE instead of just the tip.
// Harness sweeps (CornerFloodReplayTests *Converges tests) could not
// reproduce the T5 firing production-only ingredients (full lookup
// reproduce the T5 firing — production-only ingredients (full lookup
// graph / real camera path) are suspected; this dump pins them on the
// next natural occurrence.
uint[] propagationChain = frame.PropagationChainScratch;
@ -444,7 +444,7 @@ public static class PortalVisibilityBuilder
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} convergence invariant broken, investigate");
Console.WriteLine($"[pv-ERROR] in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, cameraCell.CellId, cameraPos);
return;
}
@ -497,7 +497,7 @@ public static class PortalVisibilityBuilder
// Portal-side test (retail PView::InitCell side test, decomp:432962): only traverse a portal
// the camera is on the INTERIOR side of. Retail culls the back-facing portal (the doorway just
// flooded through) by this test ALONE there is NO eye-in-opening bypass. R-A2b: the old
// flooded through) by this test ALONE — there is NO eye-in-opening bypass. R-A2b: the old
// `&& !eyeInsideOpening` bypass let a back portal within 1.75 m through, forming the
// 0171<->0173 flood cycle -> re-enqueue churn -> the doorway flap (pinned in flap-sidechk.log:
// back portals show camInterior=False eyeIn=True).
@ -529,8 +529,8 @@ public static class PortalVisibilityBuilder
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
// Empty clip = no flood through this portal, period retail's empty-GetClip rule
// (polyClipFinish <3 survivors reject; ClipPortals adds no view). The
// Empty clip = no flood through this portal, period — retail's empty-GetClip rule
// (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The
// EyeInsidePortalOpening rescue that used to substitute the current view here was
// the documented compensation for ProjectToClip's old EyePlaneW=1e-4 divergence
// from polyClipFinish's exact W=0 clip; with the W=0 port (2026-06-11, pseudocode
@ -554,7 +554,7 @@ public static class PortalVisibilityBuilder
// Exit portal -> outdoors visible through this (clipped) opening.
// OutsideView gates DRAWN color (terrain/sky/scissor), and the
// shell that rasterizes this aperture draws +drawLiftZ above
// the physics transform project the region in the SAME
// the physics transform — project the region in the SAME
// lifted space or terrain stops a lift-height short of the
// drawn lintel (#130 strip). Flood semantics keep the
// unlifted clippedRegion path above.
@ -607,20 +607,20 @@ public static class PortalVisibilityBuilder
continue;
}
// Phase U.2b neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
// decomp:433524). The portal opening seen from THIS cell may be wider than the
// SAME opening seen from the neighbour (skewed/oblique apertures), so retail
// re-clips the already-near-side-clipped region against the neighbour's matching
// (reciprocal) portal polygon the propagated region is the intersection of the
// (reciprocal) portal polygon — the propagated region is the intersection of the
// opening "seen from A" AND "seen from B". This can only TIGHTEN, never widen, and
// degrades to the prior near-side-only region when the reciprocal is unresolvable
// (over-include is the safe default). The reciprocal is the portal at index
// `portal.OtherPortalId` in the NEIGHBOUR's portal list retail's direct back-link
// `portal.OtherPortalId` in the NEIGHBOUR's portal list — retail's direct back-link
// (arg2->other_portal_id, 433557), NOT a scan for the first OtherCellId match. The
// direct index is what lets a cell with TWO portals to the same neighbour clip each
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
// in place before the union below.
// T2 (BR-4): reciprocal-empty culls retail OtherPortalClip
// T2 (BR-4): reciprocal-empty culls — retail OtherPortalClip
// returning nothing means the opening is invisible from the
// neighbour's side; the old eye-in-opening restore was part of
// the deleted rescue.
@ -645,8 +645,8 @@ public static class PortalVisibilityBuilder
if (grew)
{
// First discovery enqueue once (retail InsCellTodoList in
// the ecx_5==0 branch). Distance = cameranearest portal-
// First discovery → enqueue once (retail InsCellTodoList in
// the ecx_5==0 branch). Distance = camera→nearest portal-
// opening vertex (retail InitCell min-vertex distance,
// pc:432988-433004).
if (queued.Add(neighbourId))
@ -655,10 +655,10 @@ public static class PortalVisibilityBuilder
InsertTodo(todo, neighbour, dist);
inserted = true;
}
// Growth into an already-POPPED cell retail AdjustCellView:
// Growth into an already-POPPED cell → retail AdjustCellView:
// process only the new views, immediately, in place. A cell
// discovered but still pending in the todo list needs nothing
// its pop processes everything to date via the watermark.
// — its pop processes everything to date via the watermark.
else if (drawListed.Contains(neighbourId))
{
inPlace = true;
@ -678,8 +678,8 @@ public static class PortalVisibilityBuilder
// draw position (retail appends to cell_draw_list once per pop,
// pc:433783). Note: retail also RE-SORTS the draw list when a
// late-grown cell's dependency order changes (AdjustCellPlace,
// pc:433247); we keep first-pop order under T1's whole-cell
// farnear draws + depth testing, order affects only transparent-
// pc:433247); we keep first-pop order — under T1's whole-cell
// far→near draws + depth testing, order affects only transparent-
// pass compositing in exotic chains (documented residual for T5).
if (drawListed.Add(cell.CellId))
frame.OrderedVisibleCells.Add(cell.CellId);
@ -691,7 +691,7 @@ public static class PortalVisibilityBuilder
if (pvDump)
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) read-only per-frame snapshot of the
// Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
@ -715,7 +715,7 @@ public static class PortalVisibilityBuilder
/// camera cell. It keeps the same retail distance-priority traversal and
/// neighbour reciprocal clipping once inside the building.
/// </summary>
/// <param name="seedRegion">Optional NDC region the seed apertures clip against
/// <param name="seedRegion">Optional NDC region the seed apertures clip against —
/// retail's GetClip runs under the CURRENTLY INSTALLED view (PView::GetClip
/// 0x005a4320): full screen when the viewer is outdoors, the accumulated
/// outside (doorway) view when a building is looked into from an interior
@ -756,7 +756,7 @@ public static class PortalVisibilityBuilder
// If the camera is on the cell-interior side, the normal indoor
// DrawInside path owns this portal instead. T2 (BR-4): a seed
// portal the eye is IN-PLANE with (|dist| <= F_EPSILON) rejects
// OUTRIGHT retail ConstructView(CBldPortal) returns 0 on
// OUTRIGHT — retail ConstructView(CBldPortal) returns 0 on
// Sidedness IN_PLANE (Ghidra 0x005a59a0); no degenerate view is
// ever built from a knife-edge aperture.
if (i < cell.ClipPlanes.Count)
@ -788,7 +788,7 @@ public static class PortalVisibilityBuilder
// T2 (BR-4): empty clip = no seed, no exceptions (retail's
// empty-GetClip rule; the full-screen substitute rescue is
// deleted see Build()).
// deleted — see Build()).
if (clippedRegion.Count == 0)
continue;
@ -800,19 +800,19 @@ public static class PortalVisibilityBuilder
}
}
// T2 (BR-4): in-place growth propagation mirrors Build()'s
// T2 (BR-4): in-place growth propagation — mirrors Build()'s
// ProcessCellPortals (retail AdjustCellView via the watermark); the
// re-enqueue + MaxReprocessPerCell cap and the eye-in-opening rescues
// are deleted (empty clip culls, period).
const int RecursionTripwire = 128;
uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution see Build()
uint[] propagationChain = frame.PropagationChainScratch; // #120 self-attribution — see Build()
void ProcessCellPortals(LoadedCell cell, int depth)
{
if (depth >= RecursionTripwire)
{
System.Threading.Interlocked.Increment(ref ConvergenceTripwireCount);
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} convergence invariant broken, investigate");
Console.WriteLine($"[pv-ERROR] look-in in-place propagation tripwire at depth {depth} on cell=0x{cell.CellId:X8} — convergence invariant broken, investigate");
DumpPropagationChain(propagationChain, depth, 0u, cameraPos);
return;
}
@ -841,7 +841,7 @@ public static class PortalVisibilityBuilder
if (portal.OtherCellId == 0xFFFF)
continue; // already outdoors; exterior terrain was drawn by the caller.
// R-A2b: cull back portals by the side test alone see Build().
// R-A2b: cull back portals by the side test alone — see Build().
if (i < cell.ClipPlanes.Count
&& !CameraOnInteriorSide(cell, i, cameraPos))
continue;
@ -905,15 +905,15 @@ public static class PortalVisibilityBuilder
}
/// <summary>
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) <c>DrawPortal</c>
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, but the CONTRACT is
/// per-building: the caller passes exactly one building's cells, so the seed is that building's
/// FINITE entrance opening (bounded flood depth the stable ~2-cell view retail draws per visible
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves the
/// 26 oscillation. Robustness is validated by the conformance test, not assumed.
/// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible
/// building, measured live §3.4). This differs from the synthetic outdoor node's single unified
/// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the
/// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed.
/// </summary>
public static PortalVisibilityFrame ConstructViewBuilding(
IEnumerable<LoadedCell> buildingCells,
@ -1053,12 +1053,12 @@ public static class PortalVisibilityBuilder
}
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
// signed distance D (eyeportal plane), traverse/cull decision, and NDC projection
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
// `localEye` is the eye in root-local space its component along an interior portal
// `localEye` is the eye in root-local space — its component along an interior portal
// plane reveals when the eye has crossed past that plane (the stale-root region that
// makes the side test cull a still-needed portal). Read-only recompute; no effect on
// the returned frame. Throwaway apparatus strip with the probe.
// the returned frame. Throwaway apparatus — strip with the probe.
private static void EmitFlapProbe(
LoadedCell cameraCell, Vector3 cameraPos, Matrix4x4 viewProj, PortalVisibilityFrame frame)
{
@ -1080,9 +1080,9 @@ public static class PortalVisibilityBuilder
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
}
// Replicate the walk's faithful path exactly (ProjectToClip ClipToRegion(FullScreen)) so
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 the portal is
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1;
string ndcText = "";
@ -1115,21 +1115,21 @@ public static class PortalVisibilityBuilder
}
// Mirrors CellVisibility's portal-side test (InsideSide convention).
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side retail
// In-plane (|dot| <= PortalSideEpsilon) counts as interior-side — retail
// InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra
// 0x005a4b70); building/exterior SEED portals additionally reject in-plane
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE 0).
// via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0).
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
{
var plane = cell.ClipPlanes[portalIndex];
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane allow
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
}
// T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
// (Ghidra 0x005a59a0): |eye·N + d| <= F_EPSILON → the building/exterior
// portal contributes nothing this frame (knife-edge aperture). Uses the
// true retail epsilon, NOT the side test's root-lag tolerance.
private static bool EyeInPlaneOfPortal(LoadedCell cell, int portalIndex, Vector3 cameraPos)
@ -1153,22 +1153,22 @@ public static class PortalVisibilityBuilder
if (area2 < 0f) Array.Reverse(poly);
}
// Phase U.2b reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Phase U.2b — reciprocal OtherPortalClip (retail PView::OtherPortalClip decomp:433524).
// Resolves the neighbour's reciprocal back-portal by DIRECT INDEX (`otherPortalId`), projects
// that reciprocal polygon through the NEIGHBOUR's world transform to NDC, and intersects it into
// every polygon of `clippedRegion` (already clipped against the near-side opening + current
// view). The net region is "opening seen from the near cell" "opening seen from the
// neighbour" a strict tightening that prevents over-inclusion through skewed apertures.
// view). The net region is "opening seen from the near cell" ∩ "opening seen from the
// neighbour" — a strict tightening that prevents over-inclusion through skewed apertures.
//
// `otherPortalId` is the near-side portal's reciprocal back-link, straight from the dat's
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 rather than scanning for
// CellPortal.OtherPortalId. Retail indexes the neighbour's portal array with it directly —
// `portals->portal[arg2->other_portal_id ...]` at 005a54b2/005a54f6 — rather than scanning for
// the first OtherCellId match. A scan picks the FIRST back-portal for EVERY near-side portal to
// the same neighbour, so a cell with two openings into one neighbour clips both against the same
// (first) reciprocal hiding the second opening when the apertures are disjoint (under-inclusion
// (first) reciprocal — hiding the second opening when the apertures are disjoint (under-inclusion
// bug #102 M-4). The direct index gives each opening its own reciprocal.
//
// GUARDS degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// GUARDS — degrade to over-include (leave `clippedRegion` untouched), NEVER clip against a
// guessed polygon: the index is out of range, OR the indexed polygon is missing/degenerate
// (< 3 verts), OR it projects entirely behind the camera. Over-inclusion is the safe default;
// mis-resolution is the bug this method exists to remove. PortalPolygons is in lockstep with
@ -1184,38 +1184,38 @@ public static class PortalVisibilityBuilder
{
if (clippedRegion.Count == 0) return;
// Retail skips OtherPortalClip entirely for exact-match portals both cells share
// Retail skips OtherPortalClip entirely for exact-match portals — both cells share
// the SAME opening polygon, so re-clipping against the reciprocal can only re-derive
// the near-side clip: PView::ClipPortals decomp:433689
// `if (exact_match != 0 || other_portal_id < 0) goto propagate-without-reciprocal`.
if ((portalFlags & PortalFlagExactMatch) != 0) return;
// Direct back-link index (retail arg2->other_portal_id). Out-of-range over-include.
// Direct back-link index (retail arg2->other_portal_id). Out-of-range → over-include.
if (otherPortalId >= neighbour.PortalPolygons.Count) return;
Vector3[]? reciprocalPoly = neighbour.PortalPolygons[otherPortalId];
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate over-include
if (reciprocalPoly == null || reciprocalPoly.Length < 3) return; // missing/degenerate → over-include
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
// pipeline as the forward clip retail PView::OtherPortalClip (decomp:433524-433563) routes
// the reciprocal polygon through the very same GetClip(finish=1) ACRender::polyClipFinish
// §4 corner/doorway fix (2026-06-10): the reciprocal clip now runs the SAME homogeneous
// pipeline as the forward clip — retail PView::OtherPortalClip (decomp:433524-433563) routes
// the reciprocal polygon through the very same GetClip(finish=1) → ACRender::polyClipFinish
// homogeneous clipper as the near-side portal; there is no divide-first special case.
//
// HISTORY: this used to be ProjectToNdc + 2D ScreenPolygonClip.Intersect, justified by "the
// reciprocal is a back-portal one hop away never near the eye". That assumption is FALSE
// reciprocal is a back-portal one hop away — never near the eye". That assumption is FALSE
// exactly at doorways/corners: the reciprocal IS the same opening whose plane the eye presses
// against (2-60 cm). ProjectToNdc's MinW=0.05 eye-clip + side-plane clip + divide is knife-edge
// there 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
// hairline, which CellView.Add's snap-dedup then rejected the neighbour room dropped from the
// flood for isolated frames the corner/transition background strobe (CornerFloodReplayTests
// there — 2 cm eye moves flipped its output between "covers the region" and a duplicated-vertex
// hairline, which CellView.Add's snap-dedup then rejected → the neighbour room dropped from the
// flood for isolated frames → the corner/transition background strobe (CornerFloodReplayTests
// pins this deterministically; the glitch steps die with this change). The old path's other
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// rationale — per-round float drift defeating the exact-match CellView dedup — is obsolete:
// CanonicalKey's 1e-3-grid snap dedup (2026-06-06) absorbs re-clip drift by construction.
using PortalProjection.ClipPolygonLease reciprocalClip =
PortalProjection.ProjectToClipLease(
reciprocalPoly,
neighbour.WorldTransform,
viewProj);
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye no constraint (over-include)
if (reciprocalClip.Count < 3) return; // reciprocal entirely behind the eye → no constraint (over-include)
// Intersect the reciprocal opening into each near-side polygon; drop any that fall away.
// ClipToRegion(subject=homogeneous reciprocal, region=near-side NDC polygon) = the same
@ -1252,7 +1252,7 @@ public static class PortalVisibilityBuilder
return grew;
}
// Cameranearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
@ -1272,10 +1272,10 @@ public static class PortalVisibilityBuilder
/// <summary>
/// Distance-sorted work list for the portal BFS, ported from retail PView::cell_todo_list +
/// InsCellTodoList (decomp:433183). Insertion keeps the list ordered so the NEAREST cell sits at
/// the tail; <see cref="PopNearest"/> removes the tail giving closest-first traversal exactly
/// the tail; <see cref="PopNearest"/> removes the tail — giving closest-first traversal exactly
/// as ConstructView's pop-from-(cell_todo_num-1) does (433767-433769). The insertion only shifts
/// entries strictly farther than the newcomer (retail's flag test breaks on the first
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST
/// not-greater entry), so an equal-distance newcomer lands at the tail and pops FIRST —
/// LIFO on ties, matching retail's break-on-first-not-greater + pop-from-tail.
/// </summary>
private static void InsertTodo(

View file

@ -1,4 +1,4 @@
using System.Collections.Concurrent;
using System.Collections.Concurrent;
using AcDream.Content;
using DatReaderWriter;
using Microsoft.Extensions.Logging.Abstractions;
@ -12,7 +12,7 @@ namespace AcDream.App.Rendering;
/// classes and the same order as <see cref="GameWindow.OnLoad"/>, minus
/// terrain / sky / physics / streaming.
/// </summary>
public sealed record RenderStack(
internal sealed record RenderStack(
GL Gl,
IDatReaderWriter Dats,
string ShaderDir,
@ -28,15 +28,13 @@ public sealed record RenderStack(
AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable
{
internal GpuFrameFlightController FrameFlights { get; init; } = null!;
internal IGpuDevice GpuDevice { get; init; } = null!;
internal GpuDeviceFrameLifetime FrameLifetime { get; init; } = null!;
private ResourceShutdownTransaction? _shutdown;
internal void BeginFrame()
{
FrameFlights.BeginFrame();
UiHost.TextRenderer.BeginFrame(FrameFlights.CurrentSlot);
}
internal void BeginFrame() => FrameLifetime.BeginFrame();
internal void EndFrame() => FrameFlights.EndFrame();
internal void EndFrame() => FrameLifetime.EndFrame();
/// <summary>Dispose the GL pieces this stack OWNS (everything created in
/// <see cref="RenderBootstrap.Create"/>). <see cref="Dats"/> + <see cref="Gl"/> are caller-owned
@ -63,6 +61,10 @@ public sealed record RenderStack(
new("lighting UBO", LightingUbo.Dispose),
new("UI host", UiHost.Dispose),
]),
new ResourceShutdownStage("GPU device (RHI)",
[
new("GPU device", GpuDevice.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", FrameFlights.Dispose),
@ -71,17 +73,17 @@ public sealed record RenderStack(
}
/// <summary>
/// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple.
/// Copied verbatim from GameWindow's ResolveChrome closure it calls
/// Resolves a sprite id (0x06xxxxxx) to a (texture-table slot, width, height) triple.
/// Copied verbatim from GameWindow's ResolveChrome closure — it calls
/// TextureCache.GetOrUploadRenderSurface(id, out w, out h).
/// </summary>
public (uint handle, int width, int height) ResolveChrome(uint spriteId)
public (GpuTextureSlot handle, int width, int height) ResolveChrome(uint spriteId)
{
uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
GpuTextureSlot t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h);
return (t, w, h);
}
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
// ── Font cache (per-stack, keyed by FontDid) ─────────────────────────────
/// <summary>
/// Cache of loaded dat fonts keyed by FontDid (0x40000000-range).
@ -93,7 +95,7 @@ public sealed record RenderStack(
/// <summary>
/// Lazily load and cache a dat font by its FontDid. Returns null (and
/// caches null) when the Font DBObj is absent or has no foreground surface
/// caches null) when the Font DBObj is absent or has no foreground surface —
/// callers fall back to the global font in that case.
///
/// <para>Pre-seeds <see cref="VitalsDatFont"/> (0x40000000) and
@ -123,7 +125,7 @@ public sealed record RenderStack(
}
/// <summary>Options for <see cref="RenderBootstrap.Create"/>.</summary>
public sealed record RenderBootstrapOptions(
internal sealed record RenderBootstrapOptions(
AcDream.UI.Abstractions.Settings.QualitySettings Quality,
string DiagnosticsDirectory);
@ -131,12 +133,12 @@ public sealed record RenderBootstrapOptions(
/// Constructs the UI Studio's render stack from the production classes,
/// in the same order as <see cref="GameWindow.OnLoad"/>.
/// </summary>
public static class RenderBootstrap
internal static class RenderBootstrap
{
/// <summary>
/// Build the studio's render stack. Throws <see cref="NotSupportedException"/>
/// (same message as GameWindow) if GL_ARB_bindless_texture or
/// GL_ARB_shader_draw_parameters are absent the modern path is mandatory.
/// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory.
/// </summary>
public static RenderStack Create(
GL gl,
@ -165,8 +167,15 @@ public static class RenderBootstrap
// --- TextureCache (GameWindow ~1774) ---
var frameFlights = new GpuFrameFlightController(gl);
// Campaign V slice V4a: the RHI device UI Studio's TextureCache/UiHost
// now need for their retained-UI texture/pipeline path, constructed
// the same way HostInputCameraCompositionPhase does for the main
// GameWindow (V1).
var gpuDevice = new AcDream.App.Rendering.Gpu.Gl.GlGpuDevice(gl, frameFlights, shaderDir);
var frameLifetime = new GpuDeviceFrameLifetime(gpuDevice);
var textureCache = new TextureCache(
gl,
gpuDevice,
dats,
bindless,
frameFlights,
@ -201,7 +210,7 @@ public static class RenderBootstrap
return new AcDream.Core.Physics.AnimationSequencer(
setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table no-op sequencer.
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
@ -219,10 +228,10 @@ public static class RenderBootstrap
var entitySpawnAdapter = new Wb.EntitySpawnAdapter(
textureCache, SequencerFactory, meshAdapter);
// --- EntityClassificationCache (GameWindow ~217 field initializer, new()) ---
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache();
// --- TranslucencyFadeManager (GameWindow field initializer, new()) ---
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
@ -237,13 +246,13 @@ public static class RenderBootstrap
// --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text.
// The default font (0x40000000, 16px) renders the row names too small; the 18px
// variant (confirmed in client_portal.dat 2026-06-26) matches the retail character
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
// window list more closely (≈ icon height ≈ 24px target, 18px is best available).
var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u);
// --- UiHost (GameWindow ~1790); pass null for debugFont (only used as
// a fallback BitmapFont for the world-space HUD not needed for the
// a fallback BitmapFont for the world-space HUD — not needed for the
// UI Studio, and BitmapFont requires a system font byte array) ---
var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null);
var uiHost = new AcDream.App.UI.UiHost(gpuDevice, () => frameLifetime.Current, defaultFont: null);
var stack = new RenderStack(
Gl: gl,
@ -261,6 +270,8 @@ public static class RenderBootstrap
LargeDatFont: largeDatFont)
{
FrameFlights = frameFlights,
GpuDevice = gpuDevice,
FrameLifetime = frameLifetime,
};
// Pre-seed the font cache with the two already-uploaded atlas instances

View file

@ -38,6 +38,52 @@ internal interface IRenderFrameLifetime
void EndFrame();
}
/// <summary>
/// Campaign V slice V4a: the current frame's <see cref="IGpuFrame"/>, readable
/// by any ported renderer that needs to allocate a ring or open a pass — the
/// structural piece this slice adds so <c>TextRenderer</c>/<c>DebugLineRenderer</c>
/// have somewhere to reach the frame lifecycle already bracketing every render
/// callback (<see cref="RenderFrameOrchestrator"/>'s <see cref="IRenderFrameLifetime"/>).
/// </summary>
internal interface ICurrentGpuFrameSource
{
/// <summary>The frame opened by the most recent <see cref="IRenderFrameLifetime.BeginFrame"/>. Throws if none is open.</summary>
IGpuFrame Current { get; }
}
/// <summary>
/// Wires <see cref="IGpuDevice.BeginFrame"/>/<see cref="IGpuFrame.End"/> into
/// the same <see cref="IRenderFrameLifetime"/> bracket <see cref="GpuFrameFlightController"/>
/// occupied before this slice — additive, not a frame-graph restructuring:
/// <see cref="Gpu.Gl.GlGpuDevice.BeginFrame"/> already calls the frame-flight
/// controller's <c>BeginFrame</c> internally, so this type OWNS the
/// <see cref="IRenderFrameLifetime"/> slot rather than running alongside the
/// controller (which would double-begin the same flight fence). Clears,
/// framebuffer management, and every non-ported renderer's own per-frame
/// bracket are untouched — slice V4h formalizes the rest of the frame spine.
/// </summary>
internal sealed class GpuDeviceFrameLifetime : IRenderFrameLifetime, ICurrentGpuFrameSource
{
private readonly IGpuDevice _device;
private IGpuFrame? _current;
public GpuDeviceFrameLifetime(IGpuDevice device) =>
_device = device ?? throw new ArgumentNullException(nameof(device));
public IGpuFrame Current => _current
?? throw new InvalidOperationException(
"No GPU frame is open — BeginFrame must run before Current is read.");
public void BeginFrame() => _current = _device.BeginFrame();
public void EndFrame()
{
IGpuFrame frame = Current;
_current = null;
frame.End();
}
}
internal interface IRenderFrameResourcePhase
{
void Prepare(RenderFrameInput input);

View file

@ -88,8 +88,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
private readonly WbDrawDispatcher? _dispatcher;
private readonly EnvCellRenderer? _environmentCells;
private readonly PortalDepthMaskRenderer? _portalDepth;
private readonly TextRenderer? _worldText;
private readonly TextRenderer? _uiText;
private readonly ClipFrame? _clip;
private readonly TerrainModernRenderer? _terrain;
private readonly SceneLightingUboBinding? _lighting;
@ -98,8 +96,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
WbDrawDispatcher? dispatcher,
EnvCellRenderer? environmentCells,
PortalDepthMaskRenderer? portalDepth,
TextRenderer? worldText,
TextRenderer? uiText,
ClipFrame? clip,
TerrainModernRenderer? terrain,
SceneLightingUboBinding? lighting)
@ -108,8 +104,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_dispatcher = dispatcher;
_environmentCells = environmentCells;
_portalDepth = portalDepth;
_worldText = worldText;
_uiText = uiText;
_clip = clip;
_terrain = terrain;
_lighting = lighting;
@ -122,8 +116,11 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_dispatcher?.BeginFrame(gpuSlot);
_environmentCells?.BeginFrame(gpuSlot);
_portalDepth?.BeginFrame(gpuSlot);
_worldText?.BeginFrame(gpuSlot);
_uiText?.BeginFrame(gpuSlot);
// TextRenderer (world-hud + retained UI) is off this per-slot int
// pattern as of Campaign V slice V4a — it allocates rings directly
// from the current IGpuFrame at Flush time, with no separate begin
// step (GpuDeviceFrameLifetime resets the device's ring watermark
// when IGpuDevice.BeginFrame runs).
_clip?.BeginFrame(gpuSlot);
_terrain?.BeginFrame(gpuSlot);
_lighting?.BeginFrame(gpuSlot);

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace AcDream.App.Rendering.Residency;
@ -6,7 +6,7 @@ namespace AcDream.App.Rendering.Residency;
/// Startup-time residency ceilings. Values preserve the pre-Slice-D cache
/// behavior by default and are changed atomically as one profile.
/// </summary>
public sealed record ResidencyBudgetOptions(
internal sealed record ResidencyBudgetOptions(
long ObjectMeshGpuBytes,
int ObjectMeshUnownedEntries,
long PreparedMeshCpuBytes,

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Numerics;
using AcDream.Core.Rendering;
@ -10,7 +10,7 @@ namespace AcDream.App.Rendering;
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// 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
/// 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>.
@ -27,7 +27,7 @@ namespace AcDream.App.Rendering;
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
public sealed class RetailChaseCamera : ICamera
internal sealed class RetailChaseCamera : ICamera
{
// ICamera surface.
public Vector3 Position { get; private set; }
@ -35,19 +35,19 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>
/// The cell the collided viewer-sphere ended in (retail <c>viewer_cell =
/// sphere_path.curr_cell</c>). Roots the render mode + indoor visibility + the portal
/// side-test in <see cref="GameWindow"/> (Phase W single-viewpoint V1) the ONE viewpoint.
/// side-test in <see cref="GameWindow"/> (Phase W single-viewpoint V1) — the ONE viewpoint.
/// Equals the passed player cell when camera collision is off / the probe is null.
/// </summary>
public uint ViewerCellId { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 —
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).
// MUST be smaller than the 0.3 m camera-collision sphere (PhysicsCameraCollisionProbe.
// ViewerSphereRadius): with a 1.0 m near, a wall the collided eye sits 0.3 m from
// falls INSIDE the near plane and is clipped away pressing the camera into a corner
// let you see straight through the wall (§4 corner residual). History: 0.1 landed
// falls INSIDE the near plane and is clipped away — pressing the camera into a corner
// let you see straight through the wall (§4 corner residual). History: 0.1 landed
// (137b4f2), was reverted (8bd3492) after correlating with missing indoor textures,
// and re-landed once #110 resolved: the textures were the pre-existing #105
// staged-texture-flush drop (WbMeshAdapter.Tick), and 0.1 merely raised its trigger
@ -55,12 +55,12 @@ public sealed class RetailChaseCamera : ICamera
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
// ── Public tunables (per-instance) ──────────────────────────────
// ── Public tunables (per-instance) ──────────────────────────────
/// <summary>Length of the viewer_offset vector. Retail default 2.61.</summary>
/// <summary>Length of the viewer_offset vector. Retail default ≈ 2.61.</summary>
public float Distance { get; set; } = 2.61f;
/// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
/// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
public float Pitch { get; set; } = 0.291f;
/// <summary>
@ -92,27 +92,27 @@ public sealed class RetailChaseCamera : ICamera
public const float PitchMax = 1.4f;
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// acclient_2013_pseudo_c.txt, 0x00456fcd–0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon =
// 0.000199999995 the Frame::close_rotation tolerance (0x00456fdd). Without the
// 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the
// snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye
// across a portal plane and flipping the viewer cell the indoor flicker.
// across a portal plane and flipping the viewer cell → the indoor flicker.
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Stateful camera state (retail SmartBox's two Positions) ─────
// ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position the persisted sweep
// _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
// _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.
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount;
@ -121,12 +121,12 @@ public sealed class RetailChaseCamera : ICamera
private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised;
// Mouse-filter state shared by FilterMouseDelta entrypoint.
// Mouse-filter state — shared by FilterMouseDelta entrypoint.
private float _lastMouseDeltaX;
private float _lastMouseDeltaY;
private float _lastFilterTimeSec;
// ── Per-frame entry point ────────────────────────────────────────
// ── Per-frame entry point ────────────────────────────────────────
/// <summary>
/// Advance the camera one frame. Caller passes the player's current
@ -177,7 +177,7 @@ public sealed class RetailChaseCamera : ICamera
// 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
// (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:
@ -193,15 +193,15 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// 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).
// 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 dead-band (0x00456fcd0x00457035): once the step
// 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
// 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, _) =
@ -209,13 +209,13 @@ public sealed class RetailChaseCamera : ICamera
}
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps the viewer_sphere pivot viewer_sought_position
// (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
// — 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
// 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.
// 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
@ -227,13 +227,13 @@ public sealed class RetailChaseCamera : ICamera
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
// (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).
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
@ -241,7 +241,7 @@ public sealed class RetailChaseCamera : ICamera
Position = publishedEye;
View = Matrix4x4.CreateLookAt(publishedEye, publishedEye + _dampedForward, new Vector3(0f, 0f, 1f));
// 7. Auto-fade translucency uses the published (collided) eye so the
// 7. Auto-fade translucency — uses the published (collided) eye so the
// player fades once the eye is pulled in close.
float d = Vector3.Distance(publishedEye, pivotWorld);
PlayerTranslucency = ComputeTranslucency(d);
@ -295,12 +295,12 @@ public sealed class RetailChaseCamera : ICamera
/// </summary>
public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
{
// X first advances the shared timestamp.
// X first — advances the shared timestamp.
float x = FilterMouseAxis(rawX, weight, nowSec,
ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
// Y uses a throwaway timestamp so the within-window check still uses the original delta
// (X already advanced _lastFilterTimeSec to nowSec; if Y reused it, the within-window
// check would be 0 < windowSec which is always true which is what we want here, since
// check would be 0 < windowSec which is always true — which is what we want here, since
// both axes are sampled simultaneously and should both blend.).
float yTimeShadow = _lastFilterTimeSec - 1f; // force within-window path for the Y axis
float y = FilterMouseAxis(rawY, weight, nowSec,
@ -308,7 +308,7 @@ public sealed class RetailChaseCamera : ICamera
return (x, y);
}
// Math primitives pure, internal-static for unit-testability.
// Math primitives — pure, internal-static for unit-testability.
/// <summary>
/// Pick the heading vector that drives the camera basis. Mirrors
@ -316,8 +316,8 @@ public sealed class RetailChaseCamera : ICamera
/// path (decomp <c>acclient_2013_pseudo_c.txt:95644-95795</c>):
/// <list type="number">
/// <item><description>Base heading is the player's facing
/// direction in world space <c>(cos yaw, sin yaw, 0)</c>
/// not the velocity vector. Velocity only gates whether
/// direction in world space — <c>(cos yaw, sin yaw, 0)</c>
/// — not the velocity vector. Velocity only gates whether
/// slope-alignment fires.</description></item>
/// <item><description>If <paramref name="alignToSlope"/> is off
/// OR the player's horizontal velocity is below epsilon (i.e.
@ -337,7 +337,7 @@ public sealed class RetailChaseCamera : ICamera
/// </summary>
/// <param name="avgVelocity">5-frame averaged player velocity in world space.</param>
/// <param name="yaw">Player facing yaw + any orbit offset, radians.</param>
/// <param name="isOnGround">Player's <c>transient_state &amp; 1</c> does <paramref name="contactPlaneNormal"/> describe a valid contact plane?</param>
/// <param name="isOnGround">Player's <c>transient_state &amp; 1</c> — does <paramref name="contactPlaneNormal"/> describe a valid contact plane?</param>
/// <param name="contactPlaneNormal">Player's current contact plane normal in world space; ignored when <paramref name="isOnGround"/> is false.</param>
/// <param name="alignToSlope">User-tunable; when false skips the projection and returns the flat facing direction.</param>
internal static Vector3 ComputeHeading(
@ -356,15 +356,15 @@ public sealed class RetailChaseCamera : ICamera
// |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The
// horizontal-magnitude-squared form is a cleaner equivalent.
// Without this, the airborne path would still project against
// world up (no-op) which is fine but the standing-jump case
// world up (no-op) which is fine — but the standing-jump case
// wants the historical `direction` fallback that retail uses.
float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y;
if (hMagSq < 1e-4f) return baseHeading;
// Pick the projection plane normal:
// grounded contact_plane.N (slope-aligned camera basis)
// airborne world up (projection becomes a no-op because
// baseHeading is already in the XY plane but
// grounded → contact_plane.N (slope-aligned camera basis)
// airborne → world up (projection becomes a no-op because
// baseHeading is already in the XY plane — but
// keeping the code path uniform makes the airborne
// case impossible to swing vertically).
Vector3 normal;
@ -375,13 +375,13 @@ public sealed class RetailChaseCamera : ICamera
// Project baseHeading onto plane perpendicular to normal:
// projected = forward - normal * dot(forward, normal)
// On flat ground this is a no-op (dot 0). On a slope the
// On flat ground this is a no-op (dot ≈ 0). On a slope the
// projected vector gains a Z component matching the slope angle,
// which tilts the camera basis with the terrain.
float dot = Vector3.Dot(baseHeading, normal);
Vector3 projected = baseHeading - normal * dot;
// Degenerate: facing nearly parallel to normal (rare would
// Degenerate: facing nearly parallel to normal (rare — would
// require player rotated to face into the ground). Fall back to
// the unprojected base heading.
if (projected.LengthSquared() < 1e-4f) return baseHeading;
@ -449,7 +449,7 @@ public sealed class RetailChaseCamera : ICamera
Vector3 right;
if (MathF.Abs(forward.Z) > 0.99f)
{
// Near-vertical forward use world +X as the secondary axis.
// Near-vertical forward — use world +X as the secondary axis.
right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
}
else
@ -495,7 +495,7 @@ public sealed class RetailChaseCamera : ICamera
/// <summary>
/// Exponential-damping rate per frame.
/// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> <c>~0.075</c>
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
/// (~150 ms half-life). Matches retail's
/// <c>x_1 = stiffness * dt * 10</c> formulation.
/// </summary>
@ -508,11 +508,11 @@ public sealed class RetailChaseCamera : ICamera
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// 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
/// <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),
@ -562,7 +562,7 @@ public sealed class RetailChaseCamera : ICamera
/// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
/// Opaque at and beyond 0.45 m; fully transparent at and within
/// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
/// UpdateCamera</c> distance check (decomp :9770397725).
/// UpdateCamera</c> distance check (decomp :97703–97725).
/// </summary>
internal static float ComputeTranslucency(float distance)
{

View file

@ -1,4 +1,4 @@
using AcDream.App.UI;
using AcDream.App.UI;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
@ -9,7 +9,7 @@ using Silk.NET.Input;
namespace AcDream.App.Rendering;
/// <summary>Applies retail cursor feedback to Silk using dat MediaDescCursor art when available.</summary>
public sealed class RetailCursorManager
internal sealed class RetailCursorManager
{
private readonly IDatReaderWriter _dats;
private readonly object _datLock;

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Scene;
@ -11,7 +11,7 @@ namespace AcDream.App.Rendering;
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
/// </summary>
public sealed class RetailPViewRenderer
internal sealed class RetailPViewRenderer
{
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
private readonly ICurrentRenderPViewObserver? _candidateObserver;
@ -44,7 +44,7 @@ public sealed class RetailPViewRenderer
private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) never merged into the
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
@ -59,7 +59,7 @@ public sealed class RetailPViewRenderer
// 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
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
// place and reuses each cell's list across frames when the cell stays
// visible.
// Slice G4: this is now a diagnostic/fallback oracle only. Normal
@ -88,7 +88,7 @@ public sealed class RetailPViewRenderer
}
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
// caller's per-building frustum pre-gate on aperture bounds (GameWindow's
// gather); seeds themselves are unbounded.
@ -113,28 +113,28 @@ public sealed class RetailPViewRenderer
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
reuseFrame: _mainPortalFrameScratch);
// R-A2: outdoor root flood each nearby building SEPARATELY from its own entrance and merge
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
// the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the
// land root itself has no portals (it floods nothing into buildings). Per-building seeding is
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
// oscillated as the chase eye grazed a doorway (the indoor flap).
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
// the landscape stage for ANY root LScape::draw is the FIRST call of
// the landscape stage for ANY root — LScape::draw is the FIRST call of
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
// building seen through our doorway floods clipped to the INSTALLED
// outside view (GetClip vs current view, ConstructView(CBldPortal)
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
// (inside the landscape stage), NEVER merged into the main frame a
// (inside the landscape stage), NEVER merged into the main frame — a
// merged cell would draw post-clear and z-fail against the root's seal
// (its geometry is beyond the door plane). The eye-side seed test
// self-excludes the root's own building (the eye is on its interior
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots the merged form is equivalent
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
@ -149,7 +149,7 @@ public sealed class RetailPViewRenderer
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
// so every visible cell's shell has a prepared batch and seals killing the grey
// 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).
_drawableCellsScratch.Clear();
@ -158,7 +158,7 @@ public sealed class RetailPViewRenderer
passes.UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns the main
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
@ -174,22 +174,22 @@ public sealed class RetailPViewRenderer
}
// (#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
// 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.)
passes.PrepareCellBatches(ctx, prepareCells);
// T1 (fused BR-2/3): retail's frame order static world, then the
// aperture depth writes, then interior cells WHOLE farnear, then
// T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then
// per-cell statics, then ALL dynamics last (retail draws objects after
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
// DELETED retail never clips cell geometry; aperture exactness comes
// DELETED — retail never clips cell geometry; aperture exactness comes
// from the punch/seal depth writes + the z-buffer, and the dynamics-
// last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519).
// T3 (BR-5): retail viewconeCheck meshes are sphere-CULLED per view,
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(
@ -256,19 +256,19 @@ public sealed class RetailPViewRenderer
passes.EmitDiagnostics(ctx, result);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage
// draws the OUTSIDE world's objects inside the landscape stage —
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
// full depth clear (pc:432731-432732) and the exit-portal SEALS
// (pc:432785-432786); DrawBlock draws every landcell's objects via
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
// single last pass instead z-fails against the seal's true-depth stamp
// the moment it stands beyond the door plane the house-exit
// the moment it stands beyond the door plane — the house-exit
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
// interior root: outdoor-classified dynamics draw in the outside
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
// plane. Outdoor roots keep ALL dynamics in the last pass our
// plane. Outdoor roots keep ALL dynamics in the last pass — our
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
// BR-2 punch-after-dynamics lesson, reverted 88be519).
_outsideStageDynamics.Clear();
@ -362,11 +362,11 @@ public sealed class RetailPViewRenderer
// on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0;
// a cell visible through two apertures holds two views, all consumed
// downstream). The old first-wins (`ContainsKey -> continue`) dropped the
// second building flood's views whenever a cell was already in the frame
// second building flood's views whenever a cell was already in the frame —
// the multiview-loss-first-wins divergence (a named #109 suspect: per-frame
// winner flips between apertures). CellView.Add dedups exact/collinear
// re-emissions (the dac8f6a CanonicalKey), so unioning is convergent.
// OutsideView is NOT merged the outdoor root already seeds full-screen
// OutsideView is NOT merged — the outdoor root already seeds full-screen
// terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView
// empty (it stops at exit portals once inside the building).
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
@ -393,8 +393,8 @@ public sealed class RetailPViewRenderer
}
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view
// the accumulated doorway region so a far building floods only within the
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
@ -445,15 +445,15 @@ public sealed class RetailPViewRenderer
private void ResetBuildingGroups()
=> _buildingGroups.Reset();
// #124: draw the interior-root look-ins INSIDE the landscape stage
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
// pc:432719, before the depth clear + seals). Per building: punch ALL
// apertures first (retail finishes build_draw_portals_only pass 1 the
// far-Z maxZ1 punch across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics farnear (the nested
// apertures first (retail finishes build_draw_portals_only pass 1 — the
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
@ -491,12 +491,12 @@ public sealed class RetailPViewRenderer
}
}
// Pass 2: shells + statics, farnear.
// Pass 2: shells + statics, far→near.
passes.UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and
// lighting is per-instance CellId-keyed) was one heavy per-frame
// lighting is per-instance CellId-keyed) — was one heavy per-frame
// Render per cell. Per-cell entity/particle work stays in the loop.
_shellBatch.Clear();
foreach (uint cid in frame.OrderedVisibleCells)
@ -509,7 +509,7 @@ public sealed class RetailPViewRenderer
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
// Opaque shell batched above. Transparent stays per-cell (farnear)
// Opaque shell batched above. Transparent stays per-cell (far→near)
// for correct compositing; skipped for opaque-only cells.
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
@ -523,7 +523,7 @@ public sealed class RetailPViewRenderer
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
// under an interior root DrawDynamicsLast viewcone-culls
// under an interior root — DrawDynamicsLast viewcone-culls
// them (the main cone has no entries for look-in cells), and
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
@ -576,7 +576,7 @@ public sealed class RetailPViewRenderer
_cellStaticScratch,
_oneCell);
// The cell-particles pass for look-in cells retail's
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
@ -600,7 +600,7 @@ public sealed class RetailPViewRenderer
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) so translucent
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
@ -609,12 +609,12 @@ public sealed class RetailPViewRenderer
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) the portal-swirl/candle-flame class.
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
// T3 (BR-5): entities are never hard-clipped retail viewcone-
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
@ -663,7 +663,7 @@ public sealed class RetailPViewRenderer
});
}
// #124: far-building look-ins draw HERE still inside the landscape
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
@ -675,11 +675,11 @@ public sealed class RetailPViewRenderer
frameEntityPasses,
in frameView);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 drawn
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted) + the scene-particle owners (statics +
// dynamics cone survivors flames ride here for the same reason).
// dynamics cone survivors — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
@ -700,7 +700,7 @@ public sealed class RetailPViewRenderer
if (ownerPass)
_lateParticleOwnerScratch.Add(e.Id);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here one line per watched
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
passes.EmitOutStageOwner(
e,
@ -764,11 +764,11 @@ public sealed class RetailPViewRenderer
});
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 portal swirls,
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice alpha particles
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
@ -780,7 +780,7 @@ public sealed class RetailPViewRenderer
passes.FlushLandscapeAlpha();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
// open question, staged as "any outside slice drawn"), then re-stamps
// every outside-leading portal's TRUE depth (the seals,
@ -819,20 +819,20 @@ public sealed class RetailPViewRenderer
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame)
{
// T1 (fused BR-2/3): retail DrawCells Loop 2 every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (farnear), drawn once.
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
// Retail NEVER clips cell geometry: the production path is the
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
// planeMask=0xffffffff legacy submit means skip-all-edges), and
// aperture exactness comes from the punch/seal depth writes + the
// z-buffer + this order. The former gl_ClipDistance chop
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
// Per-cell opaque+transparent keeps the farnear transparent
// Per-cell opaque+transparent keeps the far→near transparent
// compositing the per-cell loop already provided.
passes.UseIndoorMembershipOnlyRouting();
// Opaque: ONE batched Render for all shell cells (was one heavy per-frame
// Render call PER cell the dense-town FPS sink, ~94 calls/24.75ms at
// Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at
// Arwic). Opaque needs no draw order (z-buffer), and lighting is
// per-instance (CellId-keyed light SSBO in EnvCellRenderer.RenderModernMDI-
// Internal), so cross-cell batching is visually identical. The filtered
@ -856,16 +856,16 @@ public sealed class RetailPViewRenderer
passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells);
}
// T1: the frame's single LAST entity pass ALL server-spawned dynamics
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
// (player, NPCs, doors, items), indoor or out, drawn after the static
// world + punches + interior cells. Depth-tested, never hard-clipped
// (retail draws objects per cell AFTER cells and viewcone-culls them
// (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe.
// T3 (BR-5): each dynamic is viewcone-culled like retail sphere vs its
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
// cell's views; outdoor/unresolved vs the outside views (pass-all under
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
// room culls HERE retail never reaches an object whose cell is not in
// room culls HERE — retail never reaches an object whose cell is not in
// the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast(
@ -928,12 +928,12 @@ public sealed class RetailPViewRenderer
&& AcDream.App.Streaming.EntityVanishProbe.PlayerGuid != 0
&& e.ServerGuid == AcDream.App.Streaming.EntityVanishProbe.PlayerGuid;
// #118: under an interior root, outdoor-classified dynamics drew in
// the outside stage (pre-clear, seal-protected) retail draws them
// the outside stage (pre-clear, seal-protected) — retail draws them
// via LScape::draw's per-landcell DrawSortCell, never in the
// post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
// pc:432878). Drawing them here instead z-fails them against the
// seal. Indoor dynamics (incl. exit-portal straddlers, which drew
// in BOTH stages) stay this pass is retail's loop C.
// in BOTH stages) stay — this pass is retail's loop C.
if (!rootIsOutdoor && !indoor)
{
if (isProbePlayer)
@ -976,12 +976,12 @@ public sealed class RetailPViewRenderer
visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes
// gate through the SAME cone-surviving owner set as their meshes —
// retail draws emitters with the owner object. Before this callback,
// dynamics' emitters fell through EVERY particle filter under the pview
// path (the landscape slice carries outdoor statics + #118 outside-
// stage dynamics; the cell callback carries cell statics; T4 deleted
// the old clipRoot==null global pass from normal frames) all world
// the old clipRoot==null global pass from normal frames) — all world
// portals went invisible. Outside-stage dynamics are excluded here:
// their emitters already drew in the landscape slice (alpha-blended
// particles must not double-draw, unlike the depth-idempotent meshes).
@ -1049,29 +1049,29 @@ public sealed class RetailPViewRenderer
return;
}
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics)
// dynamics moved to DrawDynamicsLast. Farnear with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// tested against ITS CELL's views (retail viewconeCheck) the
// tested against ITS CELL's views (retail viewconeCheck) — the
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
// Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching):
// the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic
// (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs +
// full state setup). Collapse them into ONE cross-cell batched draw the
// full state setup). Collapse them into ONE cross-cell batched draw — the
// shipped cells-shell batching pattern applied to cell OBJECTS. Two loops
// preserve the statics-before-particles depth order: loop 1 culls +
// accumulates every cell's survivors and draws them once; loop 2 runs the
// per-cell particle passes AFTER the statics own the depth buffer (particles
// depth-test but write no depth). The dispatcher sorts opaque front-to-back
// and transparent back-to-front by group distance (WbDrawDispatcher.cs:
// 1469-1470), so cross-cell batching composites correctly equal-or-better
// 1469-1470), so cross-cell batching composites correctly — equal-or-better
// than the old per-cell-bucketed order. visibleCellIds = the union of cells,
// so the dispatcher admits exactly the same survivor set.
// Loop 1: per-cell viewcone cull accumulate survivors + the union of cells.
// Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells.
_allCellStatics.Clear();
_cellObjCells.Clear();
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
@ -1099,7 +1099,7 @@ public sealed class RetailPViewRenderer
}
// ONE batched static-object draw for every visible cell (was N per-cell
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only dynamics
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics
// draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against
// ITS cell's views above (the statics-through-walls fix is preserved by the
// cull; only the draw is batched).
@ -1124,20 +1124,20 @@ public sealed class RetailPViewRenderer
_cellObjCells);
}
// Cell-particle pass consolidated across ALL visible cells into ONE
// Cell-particle pass — consolidated across ALL visible cells into ONE
// draw. Was per-cell, and each call re-walked the ENTIRE live particle set
// (RetailPViewPassExecutor.DrawCellParticles ParticleRenderer.Draw enumerates every
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
// (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
// sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION
// (= _allCellStatics, already accumulated above for the batched draw) draws
// EXACTLY the same emitters: the callback gates on owner id (the cone-
// surviving set), the renderer sorts globally back-to-front, and the per-
// cell slice was never used for clipping (the scissor gate was deleted in
// T3 RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
// T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
// the batched static draw so emitters depth-test against the statics now in
// the buffer (the statics-before-particles order). cellId/slice are unused
// by the particle pass pass NoClipSlice + the union owner list. This also
// drops the per-cell BuildDrawList allocations (N 1).
// by the particle pass — pass NoClipSlice + the union owner list. This also
// drops the per-cell BuildDrawList allocations (N → 1).
if (frameEntityPasses is not null
|| _allCellStatics.Count > 0)
{
@ -1199,7 +1199,7 @@ public sealed class RetailPViewRenderer
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
// only) outdoor-classified + exit-portal straddlers. Cleared per frame.
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
private readonly List<WorldEntity> _outsideStageDynamics = new();
// Dense-town FPS iteration-1 (cellobject batching): all visible cells'
// viewcone-surviving statics accumulated for ONE batched DrawEntityBucket,
@ -1274,17 +1274,17 @@ public sealed class RetailPViewRenderer
/// <summary>
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
/// in the OUTSIDE (landscape) stage before the gated depth clear and the
/// exit-portal seals like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
/// exit-portal seals — like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// the top of PView::DrawCells pc:432719)?
///
/// True for outdoor-classified dynamics (their fragments lie beyond the
/// door plane and would z-fail the seal in the last pass), and for INDOOR
/// dynamics whose sphere straddles an exit-portal plane of their flood-
/// visible cell retail draws an object once per overlapped shadow cell
/// visible cell — retail draws an object once per overlapped shadow cell
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
/// both stages and neither half clips at the plane. Pure also driven
/// both stages and neither half clips at the plane. Pure — also driven
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
/// </summary>
public static bool DynamicDrawsInOutsideStage(
@ -1299,7 +1299,7 @@ public sealed class RetailPViewRenderer
uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId))
return false; // not in the flood the last-pass cone cull owns it
return false; // not in the flood — the last-pass cone cull owns it
var cell = cells.Find(cellId);
if (cell is null)
return false;
@ -1320,7 +1320,7 @@ public sealed class RetailPViewRenderer
return false;
}
// Conservative bounding sphere from the entity's cached AABB the same
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
{
@ -1343,7 +1343,7 @@ public sealed class RetailPViewRenderer
}
public interface IRetailPViewCellSource
internal interface IRetailPViewCellSource
{
LoadedCell? Find(uint cellId);
}
@ -1354,7 +1354,7 @@ public interface IRetailPViewCellSource
/// pass only; visibility construction and draw ordering remain renderer-owned.
/// All frame inputs and results are borrowed for the duration of the call.
/// </summary>
public interface IRetailPViewPassExecutor
internal interface IRetailPViewPassExecutor
{
void AbortFrame();
void BeginFrame();
@ -1565,7 +1565,7 @@ internal sealed class BuildingGroupScratch
}
}
public sealed class RetailPViewFrameInput
internal sealed class RetailPViewFrameInput
{
public LoadedCell RootCell { get; private set; } = null!;
@ -1669,7 +1669,7 @@ public sealed class RetailPViewFrameInput
/// frame objects are deliberately reused to keep the render loop allocation
/// free; consumers must copy any state they need to retain asynchronously.
/// </summary>
public sealed class RetailPViewFrameResult
internal sealed class RetailPViewFrameResult
{
public PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
@ -1712,17 +1712,17 @@ public sealed class RetailPViewFrameResult
diagnosticPartition);
}
public readonly record struct RetailPViewLandscapeSliceContext(
internal readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <summary>#131/#132: the late landscape phase's per-slice payload
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
internal readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlySet<uint> ParticleOwnerIds)
@ -1730,7 +1730,7 @@ public readonly record struct RetailPViewLandscapeLateSliceContext(
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
public readonly record struct RetailPViewCellSliceContext(
internal readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);

View file

@ -1,4 +1,4 @@
using System;
using System;
using AcDream.Core.Meshing;
using DatReaderWriter.DBObjs;
@ -10,7 +10,7 @@ namespace AcDream.App.Rendering;
/// degrade has mode 1, is drawn as its authored 3D mesh. Every other first
/// degrade mode uses the camera-facing 2D presentation.
/// </summary>
public static class RetailParticleGeometryClassifier
internal static class RetailParticleGeometryClassifier
{
public static RetailParticleGeometryKind Classify(uint? firstDegradeMode)
=> firstDegradeMode is uint mode && mode != 1u

View file

@ -1,4 +1,4 @@
using System;
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -7,7 +7,7 @@ namespace AcDream.App.Rendering;
/// Two persistent GL sampler objects (Repeat + ClampToEdge) created once
/// per GL context. Renderers <see cref="GL.BindSampler"/> the appropriate
/// one to a texture unit instead of mutating per-texture
/// <c>GL_TEXTURE_WRAP_S/T</c> state sampler state overrides the
/// <c>GL_TEXTURE_WRAP_S/T</c> state — sampler state overrides the
/// texture's own wrap parameters, so two renderers can share the same
/// texture handle but sample it with different wrap modes safely.
///
@ -16,7 +16,7 @@ namespace AcDream.App.Rendering;
/// <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132</c>.
/// Filter modes match <see cref="TextureCache"/>'s upload defaults
/// (Linear / Linear, no mipmaps) so binding either sampler doesn't
/// change the visual filtering behavior only the wrap behavior at
/// change the visual filtering behavior — only the wrap behavior at
/// UVs outside [0, 1].
/// </para>
///
@ -28,7 +28,7 @@ namespace AcDream.App.Rendering;
/// per-texture wrap state.
/// </para>
/// </summary>
public sealed class SamplerCache : IDisposable
internal sealed class SamplerCache : IDisposable
{
private readonly GL _gl;
private readonly ResourceCleanupGroup _resources;

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Lighting;
@ -13,7 +13,7 @@ namespace AcDream.App.Rendering;
/// consistent data without per-shader re-upload.
///
/// <para>
/// Usage (r12 §13.2 + r13 §12.3):
/// Usage (r12 §13.2 + r13 §12.3):
/// <list type="number">
/// <item><description>Instantiate once at startup, after the GL context exists.</description></item>
/// <item><description>Each frame, after <see cref="LightManager.Tick"/>, call <see cref="Upload"/> with a freshly-built <see cref="SceneLightingUbo"/>.</description></item>
@ -21,7 +21,7 @@ namespace AcDream.App.Rendering;
/// </list>
/// </para>
/// </summary>
public sealed unsafe class SceneLightingUboBinding : IDisposable
internal sealed unsafe class SceneLightingUboBinding : IDisposable
{
private readonly GL _gl;
private uint _ubo;

View file

@ -1,4 +1,4 @@
// ScreenPolygonClip.cs
// ScreenPolygonClip.cs
//
// Phase A8.F: 2D convex-polygon intersection (Sutherland-Hodgman).
// Ports the BEHAVIOR of retail ACRender::polyClipFinish (the screen-space
@ -11,7 +11,7 @@ using System.Numerics;
namespace AcDream.App.Rendering;
public static class ScreenPolygonClip
internal static class ScreenPolygonClip
{
private const float Eps = 1e-7f;

View file

@ -1,9 +1,9 @@
using System.Numerics;
using System.Numerics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
public sealed class Shader : IDisposable
internal sealed class Shader : IDisposable
{
private readonly GL _gl;
private readonly Dictionary<string, int> _uniformLocations = new(StringComparer.Ordinal);
@ -15,10 +15,10 @@ public sealed class Shader : IDisposable
}
/// <summary>
/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4): when
/// Campaign V slice V2 (docs/plans/2026-07-27-vulkan-campaign.md §3.4): when
/// <paramref name="includeCommonPreamble"/> is true, the text of
/// <c>Shaders/common.glsl</c> sitting alongside <paramref name="vertexPath"/>
/// is spliced into both sources right after their leading
/// <c>Shaders/common.glsl</c> — sitting alongside <paramref name="vertexPath"/>
/// — is spliced into both sources right after their leading
/// <c>#version</c>/<c>#extension</c> block. GL has no <c>#include</c>, so this
/// is plain string concatenation at load time rather than a GLSL-level
/// mechanism. Every existing two-argument-path caller is unaffected: the
@ -49,10 +49,14 @@ public sealed class Shader : IDisposable
/// Inserts <paramref name="preamble"/> right after the shader's leading
/// <c>#version</c>/<c>#extension</c>/blank-line block. GLSL requires
/// <c>#version</c> to be the very first statement in the source, so the
/// preamble cannot simply be prepended it has to land after that block,
/// preamble cannot simply be prepended — it has to land after that block,
/// before the first real declaration.
///
/// Internal (not private) so <see cref="Gpu.Gl.GlGpuDevice.CreatePipeline"/>
/// can reuse the exact same splice for RHI-created pipelines rather than
/// a second copy of this parsing.
/// </summary>
private static string InjectPreamble(string source, string preamble)
internal static string InjectPreamble(string source, string preamble)
{
int insertAt = 0;
int lineStart = 0;

View file

@ -2,12 +2,16 @@
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
uniform mat4 uView;
uniform mat4 uProjection;
// Campaign V slice V4a: the shared GpuPushConstants block carries ONE
// combined view-projection matrix (uViewProjection), the same convention
// every other ported shader uses (mesh_modern.vert, terrain_modern.vert,
// particle.vert) rather than the separate uView/uProjection this shader used
// pre-migration.
uniform mat4 uViewProjection;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = uProjection * uView * vec4(aPos, 1.0);
gl_Position = uViewProjection * vec4(aPos, 1.0);
}

View file

@ -1,19 +1,36 @@
#version 430 core
#extension GL_ARB_bindless_texture : require
in vec2 vUv;
in vec4 vColor;
out vec4 FragColor;
uniform sampler2D uTex;
uniform int uUseTexture;
// Campaign V slice V4a: the bound sprite/glyph texture arrives as a
// texture-table slot index (GpuTextureSlot.Index) via the shared
// push-constant block, rather than a raw sampler2D bound to a fixed texture
// unit. common.glsl's ACDREAM_TEXTURE_HANDLE macro (spliced in below this
// shader's leading #version/#extension block) resolves the slot to the GL
// bindless handle; the Vulkan backend will index its set-2 sampled-texture
// descriptor array with the same integer.
uniform uint uTextureIndexA;
// uUseTexture reuses the shared block's uRenderPass scalar: the block has no
// other spare per-draw int, and "shaders declare only the fields they read,
// interpreted as they need" is the documented design
// (docs/plans/2026-07-27-vulkan-campaign.md §3.4) — mesh_modern's
// "0=opaque,1=translucent" meaning for this field does not apply here.
uniform int uRenderPass;
#define uUseTexture uRenderPass
void main() {
if (uUseTexture == 1) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
sampler2D tex = sampler2D(ACDREAM_TEXTURE_HANDLE(uTextureIndexA));
float coverage = texture(tex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else if (uUseTexture == 2) {
// RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha.
FragColor = texture(uTex, vUv) * vColor;
sampler2D tex = sampler2D(ACDREAM_TEXTURE_HANDLE(uTextureIndexA));
FragColor = texture(tex, vUv) * vColor;
} else {
FragColor = vColor;
}

View file

@ -3,12 +3,19 @@ layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
layout(location = 1) in vec2 aUv;
layout(location = 2) in vec4 aColor;
uniform vec2 uScreenSize;
// Campaign V slice V4a: this vertex shader has no projection matrix to carry
// (V3 audit, docs/plans/2026-07-27-vulkan-campaign.md §4.10: ui_text converts
// pixel coordinates straight to NDC with a constant z=0), so the screen size
// travels through the shared push-constant block's two spare scalars instead
// of a dedicated uScreenSize uniform.
uniform float uParamA; // screen width in pixels
uniform float uParamB; // screen height in pixels
out vec2 vUv;
out vec4 vColor;
void main() {
vec2 uScreenSize = vec2(uParamA, uParamB);
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
vec2 ndc = vec2(
aPos.x / uScreenSize.x * 2.0 - 1.0,

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
@ -16,7 +16,7 @@ namespace AcDream.App.Rendering.Sky;
/// <summary>
/// Port of <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs</c>.
/// Draws the retail sky as a stack of independent celestial meshes (the
/// "it's not a dome" insight from r12 §2) rather than a cube/sphere
/// "it's not a dome" insight from r12 §2) rather than a cube/sphere
/// with a gradient texture. Each <see cref="SkyObjectData"/> is
/// visible in a window of day-fraction space, sweeps from
/// <c>BeginAngle</c> to <c>EndAngle</c> across the sky, and samples its
@ -25,11 +25,11 @@ namespace AcDream.App.Rendering.Sky;
/// <para>
/// GL state delta per frame:
/// <list type="bullet">
/// <item><description>Depth mask OFF, depth test OFF, cull OFF the sky
/// <item><description>Depth mask OFF, depth test OFF, cull OFF — the sky
/// should never occlude scene geometry.</description></item>
/// <item><description>Separate projection matrix with a 0.11e6 near/far
/// <item><description>Separate projection matrix with a 0.1–1e6 near/far
/// so mesh vertices at large distance don't clip.</description></item>
/// <item><description>View matrix with translation zeroed sky is
/// <item><description>View matrix with translation zeroed — sky is
/// always camera-centred; moving doesn't get you closer to the
/// sun.</description></item>
/// </list>
@ -38,12 +38,12 @@ namespace AcDream.App.Rendering.Sky;
/// <para>
/// Meshes are built lazily per GfxObj id on first reference. The
/// per-object arc transform matches WorldBuilder's composition:
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
/// come from AC's Z-up right-handed convention where heading is
/// measured clockwise from north.
/// </para>
/// </summary>
public sealed unsafe class SkyRenderer : IDisposable
internal sealed unsafe class SkyRenderer : IDisposable
{
private readonly GL _gl;
private readonly IDatReaderWriter _dats;
@ -54,11 +54,11 @@ public sealed unsafe class SkyRenderer : IDisposable
// Lazily-built GPU resources per sky-GfxObj.
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
// When did we start running used to accumulate TexVelocityX/Y over
// When did we start running — used to accumulate TexVelocityX/Y over
// real time (independent of the day-fraction clock).
private readonly DateTime _startedAt = DateTime.UtcNow;
// Configurable render distance retail uses ~1e6; anything larger
// Configurable render distance — retail uses ~1e6; anything larger
// than the scene far plane works.
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 1_000_000f;
@ -73,7 +73,7 @@ public sealed unsafe class SkyRenderer : IDisposable
}
/// <summary>
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds
/// Draw all NON-WEATHER sky objects (dome, sun, moon, stars, clouds —
/// every <c>SkyObject</c> with <c>Properties &amp; 0x04 == 0</c>).
/// Called BEFORE the scene; terrain / meshes / debug lines / overlay
/// land on top via depth-test.
@ -82,7 +82,7 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Mirrors the first half of retail's <c>LScape::draw</c> at
/// <c>0x00506330</c>: that function calls <c>GameSky::Draw(0)</c>
/// (sky pass) before the landblock loop, then <c>GameSky::Draw(1)</c>
/// (weather pass) after. acdream splits the same way see
/// (weather pass) after. acdream splits the same way — see
/// <see cref="RenderWeather"/> for the post-scene companion.
/// </para>
///
@ -90,17 +90,17 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Each submesh renders with retail's per-vertex lighting formula:
/// <c>tint = clamp(emissive + ambient + max(dot(N, -sunDir), 0) * sunColor, 0, 1)</c>
/// where <c>emissive</c> is the submesh's <c>Surface.Luminosity</c>
/// float (1.0 for dome + sun + moon texture passthrough via
/// saturation; 0.0 for clouds get the full time-of-day tint).
/// float (1.0 for dome + sun + moon → texture passthrough via
/// saturation; 0.0 for clouds → get the full time-of-day tint).
/// <paramref name="keyframe"/> supplies the AmbientColor and SunColor
/// already pre-multiplied by AmbBright / DirBright (loader-side).
/// </para>
/// <para>
/// See <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6 for
/// See <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6 for
/// the full decompile citation. The empirical Dereth dump (
/// <c>ACDREAM_DUMP_SKY=1</c>, logged 2026-04-23) confirmed the
/// <c>SurfaceType.Luminous</c> flag bit is NOT set on any Dereth sky
/// mesh the differentiator is the <c>Surface.Luminosity</c> FLOAT
/// mesh — the differentiator is the <c>Surface.Luminosity</c> FLOAT
/// field.
/// </para>
/// </summary>
@ -118,13 +118,13 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Draw the POST-SCENE sky objects (the foreground rain mesh
/// <c>0x01004C44</c> on Rainy DayGroups, plus any other SkyObject with
/// <c>Properties &amp; 0x01 != 0</c>). Called AFTER the scene so these
/// meshes paint on top of terrain and entities retail-faithful order
/// meshes paint on top of terrain and entities — retail-faithful order
/// from <c>LScape::draw</c> at <c>0x00506330</c>, where
/// <c>GameSky::Draw(1)</c> fires after the <c>DrawBlock</c> loop and
/// renders the <c>after_sky_cell</c> contents. With depth-test
/// disabled and additive blend (the rain Surface flag includes
/// Additive), the 815m-tall rain cylinder's bright streak texels add
/// over the scene making rain appear in the air between camera and
/// over the scene — making rain appear in the air between camera and
/// character instead of only at the horizon.
/// <para>
/// Method name kept as <c>RenderWeather</c> for API stability; the
@ -149,7 +149,7 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Sets up the same GL state for both (depth-test off, additive +
/// alpha-blend per submesh, camera-anchored translation) and iterates
/// only the SkyObjects matching the requested partition by
/// <see cref="SkyObjectData.IsPostScene"/> bit <c>0x01</c> per the
/// <see cref="SkyObjectData.IsPostScene"/> — bit <c>0x01</c> per the
/// retail decomp at <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>).
/// </summary>
private void RenderPass(
@ -171,7 +171,7 @@ public sealed unsafe class SkyRenderer : IDisposable
// that FOV here, including the near-180-degree teleport transition.
var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
// View with translation zeroed keeps the sky at camera origin
// View with translation zeroed — keeps the sky at camera origin
// regardless of camera position in the world.
var skyView = camera.View;
skyView.M41 = 0f;
@ -183,7 +183,7 @@ public sealed unsafe class SkyRenderer : IDisposable
_shader.SetMatrix4("uSkyProjection", skyProj);
// Retail per-vertex lighting inputs (AdjustPlanes formula).
// AmbColor/SunColor are already × AmbBright/DirBright from
// AmbColor/SunColor are already × AmbBright/DirBright from
// SkyDescLoader. SunDir is the unit vector FROM surface TO sun
// derived from the keyframe's DirHeading/DirPitch.
_shader.SetVec3("uAmbientColor", keyframe.AmbientColor);
@ -204,14 +204,14 @@ public sealed unsafe class SkyRenderer : IDisposable
bool wasCullFace = _gl.IsEnabled(EnableCap.CullFace);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
// Default blend overridden per-submesh inside the inner loop.
// Default blend — overridden per-submesh inside the inner loop.
// Additive surfaces (sun/moon/stars via SurfaceType.Additive =
// 0x10000) get GL_SRC_ALPHA / GL_ONE; alpha-blended (clouds, dome
// with Alpha flag) get GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA.
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Look up the keyframe's override list so we can apply
// SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
// SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
// override + transparency fade + luminosity cap.
var replaces = PickReplaces(group, dayFraction);
@ -220,19 +220,19 @@ public sealed unsafe class SkyRenderer : IDisposable
for (int i = 0; i < group.SkyObjects.Count; i++)
{
var obj = group.SkyObjects[i];
// Partition by post-scene flag (Properties bit 0x01) the
// Partition by post-scene flag (Properties bit 0x01) — the
// caller chose either the pre-scene sky pass (bit clear) or
// the post-scene pass (bit set). Mirrors retail
// GameSky::CreateDeletePhysicsObjects at 0x005073c0 / decomp
// line 269036 which routes (Properties & 1) into
// before_sky_cell vs after_sky_cell, and GameSky::Draw at
// 0x00506ff0 which renders those cells in the two passes.
// NOTE: bit 0x04 (IsWeather) is independent it gates whether
// NOTE: bit 0x04 (IsWeather) is independent — it gates whether
// the object is instantiated when weather_enabled is false.
// Earlier acdream incorrectly used IsWeather for this
// partition, putting the outer rain cylinder 0x01004C42
// (Props=0x04, NO bit 0x01) into the post-scene pass with the
// foreground rain double-thick rain not matching retail.
// foreground rain — double-thick rain not matching retail.
if (obj.IsPostScene != postScenePass) continue;
if (!obj.IsVisible(dayFraction)) continue;
// Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02
@ -254,7 +254,7 @@ public sealed unsafe class SkyRenderer : IDisposable
// fallback at the inner loop never fired (1f is always > 0).
// RainMeshProbe (committed b8e0857) confirmed empirically that
// NO Dereth sky surface carries the SurfaceType.Luminous flag
// bit (0x40) the differentiator is purely the float field.
// bit (0x40) — the differentiator is purely the float field.
float replaceLuminosity = float.NaN;
float replaceDiffuse = float.NaN;
if (replaces.TryGetValue((uint)i, out var rep))
@ -289,7 +289,7 @@ public sealed unsafe class SkyRenderer : IDisposable
// int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f
//
// Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every
// post-scene SkyObject bit 0x01 (post-scene) is independent
// post-scene SkyObject — bit 0x01 (post-scene) is independent
// of bit 0x04 (weather). Today's Dereth ships every post-scene
// entry as also weather-flagged so the previous unconditional
// offset was a no-op divergence, but a future DayGroup with a
@ -302,15 +302,15 @@ public sealed unsafe class SkyRenderer : IDisposable
// cylinder bottom sits at z=0.11 ABOVE the camera (skyView
// translation is zeroed so model-origin == camera); looking
// horizontally shows nothing. With -120m the cylinder spans z
// = (camera-119.89)..(camera+694.90) camera is inside,
// looking in any direction shows surrounding walls the
// = (camera-119.89)..(camera+694.90) — camera is inside,
// looking in any direction shows surrounding walls — the
// volumetric foreground-rain look retail has.
if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u)
model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f);
_shader.SetMatrix4("uModel", model);
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
// so long-running sessions don't accumulate float precision
// loss in the fragment UV.
float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f;
@ -328,7 +328,7 @@ public sealed unsafe class SkyRenderer : IDisposable
// sky dome is Base1Image (Opaque, mapped to
// SrcAlpha/InvSrcAlpha for a no-op blend at alpha=1).
// See FUN_00508010 (chunk_00500000.c:7535) for the retail
// pattern retail routes sky meshes through the normal
// pattern — retail routes sky meshes through the normal
// mesh pipeline where Surface flags dictate state.
if (sub.IsAdditive)
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One);
@ -338,24 +338,24 @@ public sealed unsafe class SkyRenderer : IDisposable
// Emissive source picks the surface's authored Luminosity by
// default; the per-keyframe replace data can OVERRIDE
// (rep.Luminosity > 0) or CAP (rep.MaxBright). This matches
// retail's FUN_0059da60: surface.Luminosity D3DMATERIAL.Emissive
// retail's FUN_0059da60: surface.Luminosity → D3DMATERIAL.Emissive
// (via material cache +0x3c), with the keyframe replace
// promoting bright-keyframe clouds when the keyframe asks.
//
// Empirical Dereth sky surfaces (RainMeshProbe, b8e0857):
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
// dome/sun/moon → Lum=1.0 → vTint saturates → texture
// passthrough (correct retail look);
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
// stars/clouds → Lum=0.0 → vTint = ambient + diffuse →
// picks up the time-of-day tint;
// rain → Lum=0.1484 → faint emissive baseline,
// rain → Lum=0.1484 → faint emissive baseline,
// ambient+diffuse adds atmospheric tint.
//
// Pre-fix: the replace-override variable defaulted to 1f and
// the fallback `(luminosity > 0) ? luminosity : sub.SurfLuminosity`
// never fired every sky mesh got effEmissive=1.0,
// never fired — every sky mesh got effEmissive=1.0,
// saturating vTint. That made stars/clouds look full-bright
// instead of time-of-day-tinted, and made rain streaks
// 6.7× too bright (one of two factors compounding the
// 6.7× too bright (one of two factors compounding the
// foreground-rim visibility bug).
float effEmissive = float.IsNaN(replaceLuminosity)
? sub.SurfLuminosity
@ -374,7 +374,7 @@ public sealed unsafe class SkyRenderer : IDisposable
// Retail D3DPolyRender::SetSurface at 0x59c882 calls
// SetFFFogAlphaDisabled(1) when the Additive flag (0x10000)
// is set on the Surface so the sun, moon, stars, and any
// is set on the Surface — so the sun, moon, stars, and any
// additive cloud sheet are drawn WITHOUT fog. Skipping fog
// on additive surfaces keeps the sun bright at horizon
// dusk/dawn (where fog would otherwise dim it to fog color).
@ -477,13 +477,13 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Lazy mesh build for a sky object. Handles two cases:
/// <list type="bullet">
/// <item><description>
/// <c>0x010xxxxx</c> direct <see cref="GfxObj"/>. Reuses
/// <c>0x010xxxxx</c> — direct <see cref="GfxObj"/>. Reuses
/// <see cref="GfxObjMesh.Build"/> so the pos/neg polygon
/// splitting logic stays consistent with the main static-mesh
/// pipeline. Most sky meshes are single-surface.
/// </description></item>
/// <item><description>
/// <c>0x020xxxxx</c> <see cref="Setup"/>. The agent at
/// <c>0x020xxxxx</c> — <see cref="Setup"/>. The agent at
/// 2026-04-27 found these Setup-backed sky objects (e.g.
/// <c>0x02000588</c>, <c>0x02000589</c>, <c>0x02000714</c>,
/// <c>0x02000BA6</c>) were silently dropped: every cache miss
@ -496,7 +496,7 @@ public sealed unsafe class SkyRenderer : IDisposable
/// <c>Setup.Parts</c> at the default placement frame and
/// <see cref="GfxObjMesh.Build"/> produces submeshes for each
/// part. Per-part transforms are baked into vertex positions
/// (sky setups are static no animation needed for the static
/// (sky setups are static — no animation needed for the static
/// mesh half of the visual).
/// </description></item>
/// </list>
@ -504,7 +504,7 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Even with this fix the visible aurora-style sheen most retail
/// rainy/cloudy setups produce comes from the <c>pes_id</c> field
/// on each <see cref="DatReaderWriter.Types.SkyObject"/> (a Particle
/// Effect Schedule) that's a separate Phase-level feature.
/// Effect Schedule) — that's a separate Phase-level feature.
/// Rendering the Setup's static parts here is the geometry half;
/// the dynamic particle half is deferred.
/// </para>
@ -550,8 +550,8 @@ public sealed unsafe class SkyRenderer : IDisposable
// Phase 1 diagnostic: dump Surface.Type flags on every sky GfxObj
// once, so we can determine which submeshes carry Luminous (0x40)
// vs plain-lit. This settles the retail "cloud tint = per-vertex
// lighting on non-Luminous meshes" hypothesis see
// docs/research/2026-04-23-sky-retail-verbatim.md §6.
// lighting on non-Luminous meshes" hypothesis — see
// docs/research/2026-04-23-sky-retail-verbatim.md §6.
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
DumpGfxObjSurfaces(gfxObjId, gfx, subMeshes);
@ -565,14 +565,14 @@ public sealed unsafe class SkyRenderer : IDisposable
/// Setup-backed sky object loader. Walks <see cref="Setup.Parts"/> at
/// the default placement frame, builds submeshes via
/// <see cref="GfxObjMesh.Build"/>, and bakes the per-part transform
/// into the vertex positions before upload. Static-pose only sky
/// into the vertex positions before upload. Static-pose only — sky
/// setups don't animate in any meaningful way for the visual we care
/// about (the dynamic look comes from <c>pes_id</c> particles, not
/// the underlying mesh).
/// <para>
/// Mirrors retail's <see cref="CPhysicsObj.InitPartArrayObject"/> at
/// decomp <c>280484</c> dispatching type 7 <c>CPartArray::CreateSetup</c>
/// <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
/// decomp <c>280484</c> dispatching type 7 → <c>CPartArray::CreateSetup</c>
/// → <c>CSetup::SetSetupID</c>, which loads the setup and instantiates
/// each part as a separate <c>CPhysicsObj</c> child. We collapse the
/// children into a flat submesh list because the sky pass renders
/// without per-part transforms anyway.
@ -654,13 +654,13 @@ public sealed unsafe class SkyRenderer : IDisposable
continue;
}
// SurfaceType is a flag enum `ToString()` gives the
// SurfaceType is a flag enum — `ToString()` gives the
// comma-joined names (e.g. "Base1Image, Additive").
uint rawType = (uint)surface.Type;
string names = surface.Type.ToString();
uint origTex = surface.OrigTextureId?.DataId ?? 0u;
var trans = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
// Surface's own Luminosity (0..1 fraction per test fixture
// Surface's own Luminosity (0..1 fraction per test fixture —
// different from SkyObjectReplace.Luminosity which lives in the keyframe).
Console.WriteLine(
$"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " +
@ -703,7 +703,7 @@ public sealed unsafe class SkyRenderer : IDisposable
//
// NOTE: earlier revision also treated `SurfaceType.Luminous = 0x40`
// as additive, but that flag is present on the sky DOME itself and
// on cloud sheets turning those additive blew the whole sky to
// on cloud sheets — turning those additive blew the whole sky to
// white. `Luminous` means "self-illuminated / unshaded" in retail's
// render pipeline, not "additive blend". Only the Additive bit
// toggles the blend mode.
@ -753,18 +753,18 @@ public sealed unsafe class SkyRenderer : IDisposable
/// </summary>
public bool IsAdditive;
/// <summary>
/// <c>Surface.Luminosity</c> float (0..1 NOT the SurfaceType.Luminous
/// <c>Surface.Luminosity</c> float (0..1 — NOT the SurfaceType.Luminous
/// flag bit). Passed to the sky fragment shader as <c>uEmissive</c>;
/// when 1.0 it saturates the lighting math so the mesh renders at
/// full texture brightness (dome, sun). When 0.0 the mesh picks up
/// the time-of-day ambient+diffuse tint (clouds). See
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// <c>docs/research/2026-04-23-sky-retail-verbatim.md</c> §6.
/// </summary>
public float SurfLuminosity;
public float SurfDiffuse;
/// <summary>
/// True when the source mesh's authored UVs exceed [0,1] (e.g.
/// the inner sky/star layer 0x010015EF and the cloud meshes
/// the inner sky/star layer 0x010015EF and the cloud meshes —
/// they tile their texture across the geometry). The renderer
/// must use <c>GL_REPEAT</c> for these or only the small region
/// where UVs fall in [0,1] samples the actual texture; the rest

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
@ -12,7 +12,7 @@ namespace AcDream.App.Rendering;
/// from portal space to the destination world at the transition projection;
/// there is no black-alpha compositor between them.
/// </summary>
public sealed class TeleportViewPlaneController
internal sealed class TeleportViewPlaneController
{
public const float TransitionViewPlaneDistance = 0.001f;

View file

@ -1,4 +1,4 @@
using AcDream.Core.Textures;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
@ -13,21 +13,21 @@ namespace AcDream.App.Rendering;
/// Holds both texture arrays the terrain renderer samples from:
/// <list type="bullet">
/// <item><description>
/// <b>Terrain atlas</b> one GL_TEXTURE_2D_ARRAY layer per terrain type
/// <b>Terrain atlas</b> — one GL_TEXTURE_2D_ARRAY layer per terrain type
/// (grass, dirt, sand, forest...), sourced from
/// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc.
/// </description></item>
/// <item><description>
/// <b>Alpha atlas</b> one GL_TEXTURE_2D_ARRAY layer per blend mask,
/// <b>Alpha atlas</b> — one GL_TEXTURE_2D_ARRAY layer per blend mask,
/// sourced from CornerTerrainMaps / SideTerrainMaps / RoadMaps in the
/// same TexMerge. Used by the fragment shader to blend up to three
/// terrain overlays and two roads on top of a base cell texture.
/// </description></item>
/// </list>
/// The alpha atlas is built but not yet sampled by any shader that wiring
/// The alpha atlas is built but not yet sampled by any shader — that wiring
/// lands in Phase 3c.4 along with the shader rewrite.
/// </summary>
public sealed unsafe class TerrainAtlas : IDisposable
internal sealed unsafe class TerrainAtlas : IDisposable
{
private readonly GL _gl;
@ -276,7 +276,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary>
/// Load corner, side, and road alpha maps from the TexMerge into a second
/// GL_TEXTURE_2D_ARRAY. AC ships these as 512×512 PFID_A8 textures;
/// GL_TEXTURE_2D_ARRAY. AC ships these as 512×512 PFID_A8 textures;
/// <see cref="SurfaceDecoder.DecodeRenderSurface"/> expands each alpha byte
/// into all four RGBA channels so the shader can sample from any channel.
///
@ -368,7 +368,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
cornerTCodes, sideTCodes, roadRCodes);
}
// Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
// Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
// Fall back to the max observed so a stray mismatch doesn't crash us.
int aMaxW = 1, aMaxH = 1;
foreach (var d in decoded)
@ -433,7 +433,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
// format) or the more generic PFID_A8; terrain blending alpha masks
// MUST use isAdditive=true so R=G=B=A=val the terrain fragment shader
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader
// reads .r for the blend weight. Palette is not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
if (ReferenceEquals(d, DecodedTexture.Magenta))
@ -515,7 +515,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent calling with
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters
/// are mutated; we temporarily make it non-resident if needed.

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering;
/// <summary>
/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
/// textures bound via bindless handles set per-frame as sampler uniforms.
@ -16,9 +16,9 @@ namespace AcDream.App.Rendering;
/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
/// </summary>
public sealed unsafe class TerrainModernRenderer : IDisposable
internal sealed unsafe class TerrainModernRenderer : IDisposable
{
// VertsPerLandblock MUST stay divisible by 6 terrain_modern.vert uses
// VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses
// `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and
// because we bake `slot * VertsPerLandblock` into indices CPU-side and
// pass BaseVertex=0 to MultiDrawElementsIndirect, gl_VertexID becomes
@ -83,7 +83,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private uint _fallbackClipUbo;
// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2)
// became uTextureIndexA/uTextureIndexB (uint table slots) cached
// became uTextureIndexA/uTextureIndexB (uint table slots) — cached
// uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTextureIndexALoc;
private int _uTextureIndexBLoc;
@ -91,8 +91,8 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private bool _textureTilingUploaded;
// GL-only emulation of the eventual Vulkan global texture descriptor array
// (binding=9, GpuBindingModel.StorageTextureTable). Owns its own table
// see GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for
// (binding=9, GpuBindingModel.StorageTextureTable). Owns its own table —
// see GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for
// why terrain doesn't share WbDrawDispatcher's/EnvCellRenderer's tables.
private readonly GlBindlessHandleTable _textureTable = new();
private uint _textureTableSsbo;
@ -495,7 +495,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Bind shader + uniforms + atlas handles.
// Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
// no separate landscape viewpoint to sync. Both uView and uProjection derive
// from the ICamera passed into this method the same camera used for all other
// from the ICamera passed into this method — the same camera used for all other
// renderers in the unified pipeline. Retail's LScape::update_viewpoint
// pre-positions terrain to the outdoor landcell, but acdream uses the
// unified camera matrix everywhere, so no separate viewpoint divergence can occur.
@ -508,7 +508,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Campaign V slice V2b: pass each handle's binding=9 table slot
// instead of the raw uvec2 handle. GLSL reconstructs
// sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA)) at the use
// site see terrain_modern.frag.
// site — see terrain_modern.frag.
uint terrainSlot = _textureTable.GetOrAdd(terrainHandle);
uint alphaSlot = _textureTable.GetOrAdd(alphaHandle);
FlushAndBindTextureTable();
@ -519,7 +519,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// when wired, else the no-clip fallback (count 0 = ungated terrain).
BindClipUboBinding2();
// #108-residual: retail terrain is SINGLE-SIDED ACRender::landPolysDraw
// #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
// (0x006b7040) draws each land triangle ONLY when the camera is on the
// POSITIVE (upper) side of its plane (Plane::which_side2 vs
// Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
@ -527,13 +527,13 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// LandblockMesh emits every triangle CCW in world XY seen from above
// (LandblockMeshTests winding pin), which the unified camera chain
// (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
// winding from above / CW from below (TerrainCullOrientationTests)
// winding from above / CW from below (TerrainCullOrientationTests) —
// so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
// underside. WB drew the whole world with culling DISABLED
// frame-globally (WB GameScene.cs:841 an editor camera goes
// frame-globally (WB GameScene.cs:841 — an editor camera goes
// underground); inheriting that drew terrain DOUBLE-SIDED, and a
// below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
// sheet through the exit-door aperture the #108 grass window.
// sheet through the exit-door aperture — the #108 grass window.
// Self-contained state per feedback_render_self_contained_gl_state;
// the frame-global CW + cull-off baseline is restored after the draw.
_gl.Enable(EnableCap.CullFace);

View file

@ -1,156 +0,0 @@
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
internal interface ITextRenderGlStateApi
{
bool IsEnabled(EnableCap capability);
int GetInteger(GetPName parameter);
bool GetBoolean(GetPName parameter);
void SetCapability(EnableCap capability, bool enabled);
void DepthMask(bool enabled);
void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha);
void UseProgram(uint program);
void BindVertexArray(uint vertexArray);
void BindBuffer(BufferTargetARB target, uint buffer);
void ActiveTexture(TextureUnit unit);
void BindTexture(TextureTarget target, uint texture);
}
internal sealed class SilkTextRenderGlStateApi : ITextRenderGlStateApi
{
private readonly GL _gl;
public SilkTextRenderGlStateApi(GL gl) =>
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
public bool IsEnabled(EnableCap capability) => _gl.IsEnabled(capability);
public int GetInteger(GetPName parameter) => _gl.GetInteger(parameter);
public bool GetBoolean(GetPName parameter) => _gl.GetBoolean(parameter);
public void SetCapability(EnableCap capability, bool enabled)
{
if (enabled)
_gl.Enable(capability);
else
_gl.Disable(capability);
}
public void DepthMask(bool enabled) => _gl.DepthMask(enabled);
public void BlendFuncSeparate(
BlendingFactor sourceRgb,
BlendingFactor destinationRgb,
BlendingFactor sourceAlpha,
BlendingFactor destinationAlpha) =>
_gl.BlendFuncSeparate(
sourceRgb,
destinationRgb,
sourceAlpha,
destinationAlpha);
public void UseProgram(uint program) => _gl.UseProgram(program);
public void BindVertexArray(uint vertexArray) => _gl.BindVertexArray(vertexArray);
public void BindBuffer(BufferTargetARB target, uint buffer) =>
_gl.BindBuffer(target, buffer);
public void ActiveTexture(TextureUnit unit) => _gl.ActiveTexture(unit);
public void BindTexture(TextureTarget target, uint texture) =>
_gl.BindTexture(target, texture);
}
/// <summary>
/// Exact, focused state transaction for <see cref="TextRenderer.Flush"/>. It
/// captures every GL value that Flush or DrawLayer mutates, while avoiding the
/// dozens of unrelated synchronous reads made by the broad diagnostic scope.
/// </summary>
internal readonly struct TextRenderGlStateScope : IDisposable
{
private readonly ITextRenderGlStateApi _gl;
private readonly bool _depthTest;
private readonly bool _blend;
private readonly bool _cullFace;
private readonly bool _alphaToCoverage;
private readonly bool _multisample;
private readonly bool _depthWrite;
private readonly int _blendSourceRgb;
private readonly int _blendDestinationRgb;
private readonly int _blendSourceAlpha;
private readonly int _blendDestinationAlpha;
private readonly int _program;
private readonly int _vertexArray;
private readonly int _arrayBuffer;
private readonly int _activeTexture;
private readonly int _texture0Binding2D;
public TextRenderGlStateScope(ITextRenderGlStateApi gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_depthTest = gl.IsEnabled(EnableCap.DepthTest);
_blend = gl.IsEnabled(EnableCap.Blend);
_cullFace = gl.IsEnabled(EnableCap.CullFace);
_alphaToCoverage = gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
_multisample = gl.IsEnabled(EnableCap.Multisample);
_depthWrite = gl.GetBoolean(GetPName.DepthWritemask);
_blendSourceRgb = gl.GetInteger(GetPName.BlendSrcRgb);
_blendDestinationRgb = gl.GetInteger(GetPName.BlendDstRgb);
_blendSourceAlpha = gl.GetInteger(GetPName.BlendSrcAlpha);
_blendDestinationAlpha = gl.GetInteger(GetPName.BlendDstAlpha);
_program = gl.GetInteger(GetPName.CurrentProgram);
_vertexArray = gl.GetInteger(GetPName.VertexArrayBinding);
_arrayBuffer = gl.GetInteger(GetPName.ArrayBufferBinding);
_activeTexture = gl.GetInteger(GetPName.ActiveTexture);
try
{
gl.ActiveTexture(TextureUnit.Texture0);
_texture0Binding2D = gl.GetInteger(GetPName.TextureBinding2D);
}
finally
{
gl.ActiveTexture((TextureUnit)_activeTexture);
}
}
public void Dispose()
{
_gl.UseProgram((uint)_program);
_gl.BindVertexArray((uint)_vertexArray);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, (uint)_arrayBuffer);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, (uint)_texture0Binding2D);
_gl.ActiveTexture((TextureUnit)_activeTexture);
_gl.DepthMask(_depthWrite);
_gl.BlendFuncSeparate(
(BlendingFactor)_blendSourceRgb,
(BlendingFactor)_blendDestinationRgb,
(BlendingFactor)_blendSourceAlpha,
(BlendingFactor)_blendDestinationAlpha);
_gl.SetCapability(EnableCap.DepthTest, _depthTest);
_gl.SetCapability(EnableCap.Blend, _blend);
_gl.SetCapability(EnableCap.CullFace, _cullFace);
_gl.SetCapability(EnableCap.SampleAlphaToCoverage, _alphaToCoverage);
_gl.SetCapability(EnableCap.Multisample, _multisample);
}
}

View file

@ -1,11 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Wb;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -15,36 +11,39 @@ namespace AcDream.App.Rendering;
/// <see cref="Begin"/> at the start of a HUD pass, queue geometry via
/// <see cref="DrawString"/> / <see cref="DrawRect"/>, then <see cref="Flush"/>.
///
/// Uses two internal vertex buffers (text and rect) flushed in two draw calls
/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text
/// sits on top of background panels.
/// Campaign V slice V4a: ported onto <see cref="IGpuDevice"/>. One pipeline
/// (blend, depth-disable, and the MSAA/alpha-to-coverage isolation the prior
/// <c>TextRenderGlStateScope</c> restored by hand are now baked into the
/// pipeline description) and one pass per <see cref="Flush"/>, with each
/// bucket/segment getting its own per-frame ring allocation instead of a
/// shared, growable VBO. Rects are drawn first so text sits on top of
/// background panels — bucket ORDER is unchanged, only how each bucket
/// reaches the GPU.
/// </summary>
public sealed unsafe class TextRenderer : IDisposable
internal sealed class TextRenderer : IDisposable
{
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private const int VertexStrideBytes = FloatsPerVertex * sizeof(float);
private readonly GL _gl;
private readonly ITextRenderGlStateApi _glState;
private readonly Shader _shader;
private readonly ResourceCleanupGroup _resources;
private uint _vao;
private uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes;
private static readonly GpuVertexLayout VertexLayout = new(
StrideBytes: VertexStrideBytes,
[
new GpuVertexAttribute(0, GpuVertexFormat.Float2, 0),
new GpuVertexAttribute(1, GpuVertexFormat.Float2, 8),
new GpuVertexAttribute(2, GpuVertexFormat.Float4, 16),
]);
private sealed class FrameBufferSet
{
public uint Vao;
public uint Vbo;
public int CapacityBytes;
public int UsedBytes;
}
// uUseTexture values the ui_text.frag shader branches on (reusing the
// shared push-constant block's uRenderPass scalar — see the shader's own
// comment for why there is no dedicated field).
private const int UseTextureNone = 0;
private const int UseTextureFont = 1;
private const int UseTextureSprite = 2;
private readonly FrameBufferSet[] _frameBuffers;
private FrameBufferSet? _activeFrameBuffer;
private readonly IGpuDevice _device;
private readonly IGpuPipeline _pipeline;
internal long DynamicBufferCapacityBytes =>
_frameBuffers.Sum(set => (long)set.CapacityBytes);
private sealed class SpriteSeg { public GpuTextureSlot TextureSlot; public readonly List<float> Verts = new(256); }
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
@ -53,16 +52,14 @@ public sealed unsafe class TextRenderer : IDisposable
// Drawing segments in submission order preserves painter z-order for
// sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture
// at its FIRST-insertion point, so later bar sprites covered glyphs emitted
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private sealed class SpriteSeg { public uint Texture; public readonly List<float> Verts = new(256); }
// earlier via the shared dat-font atlas — the stamina/mana numbers vanished.)
private readonly List<SpriteSeg> _spriteSegs = new();
private int _segUsed;
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
// Overlay layer a parallel set of buckets drawn AFTER the normal sprite/rect/text
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
@ -77,142 +74,38 @@ public sealed unsafe class TextRenderer : IDisposable
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
public bool OverlayMode { get; set; }
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
_glState = new SilkTextRenderGlStateApi(gl);
var resources = new ResourceCleanupGroup();
Shader? shader = null;
var frameBuffers = new FrameBufferSet[3];
uint whiteTexture = 0;
try
{
shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
resources.Add("text shader", shader.Dispose);
for (int i = 0; i < frameBuffers.Length; i++)
frameBuffers[i] = CreateFrameBufferSet(resources);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
whiteTexture = GlResourceCommand.CreateTexture(
_gl,
"TextRenderer white texture");
uint ownedWhiteTexture = whiteTexture;
resources.Add(
"white texture",
() => GlResourceCommand.DeleteTexture(
_gl,
ownedWhiteTexture,
$"delete TextRenderer white texture {ownedWhiteTexture}"));
GlResourceCommand.Execute(
_gl,
"initialize TextRenderer white texture",
() =>
{
_gl.BindTexture(TextureTarget.Texture2D, whiteTexture);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
});
}
catch (Exception constructionFailure)
{
try
{
resources.RetryCleanup();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
"TextRenderer construction failed and its published GL resources did not cleanly roll back.",
resources,
[constructionFailure, cleanupFailure]);
}
throw;
}
_resources = resources;
_shader = shader;
_frameBuffers = frameBuffers;
_whiteTex = whiteTexture;
}
/// <summary>
/// Selects the GPU-fenced frame slot and resets its append cursor. Every
/// UI segment rendered during the frame receives a distinct byte range;
/// later text or sprite batches cannot overwrite an earlier in-flight draw.
/// No longer meaningful post-V4a: per-frame vertex data comes from the
/// device's shared ring rather than a VBO this class owns. Kept (returning
/// 0) so <c>RenderFrameDiagnosticSources</c>'s telemetry read still compiles;
/// the dynamic-buffer dimension it reported is now a device-wide, not a
/// per-renderer, concern.
/// </summary>
public void BeginFrame(int frameSlot)
{
if ((uint)frameSlot >= (uint)_frameBuffers.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
internal long DynamicBufferCapacityBytes => 0;
FrameBufferSet set = _frameBuffers[frameSlot];
set.UsedBytes = 0;
_activeFrameBuffer = set;
_vao = set.Vao;
_vbo = set.Vbo;
_vboCapacityBytes = set.CapacityBytes;
}
private FrameBufferSet CreateFrameBufferSet(ResourceCleanupGroup resources)
public TextRenderer(IGpuDevice device)
{
uint vao = TrackedGlResource.CreateVertexArray(
_gl,
"TextRenderer frame VAO creation");
RetryableGpuResourceRelease vaoRelease =
TrackedGlResource.CreateRetryableVertexArrayDeletion(
_gl,
vao,
"TextRenderer frame VAO disposal");
resources.Add("frame VAO", vaoRelease.Run);
var set = new FrameBufferSet { Vao = vao };
uint vbo = TrackedGlResource.CreateBuffer(
_gl,
"TextRenderer frame VBO creation");
set.Vbo = vbo;
RetryableGpuResourceRelease? vboRelease = null;
resources.Add(
"frame VBO",
() =>
_device = device ?? throw new ArgumentNullException(nameof(device));
_pipeline = _device.CreatePipeline(new GpuPipelineDescription
{
vboRelease ??= TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
vbo,
set.CapacityBytes,
"TextRenderer frame VBO disposal");
vboRelease.Run();
Name = "ui-text",
Shaders = new GpuShaderSet("ui_text"),
VertexLayout = VertexLayout,
Topology = GpuPrimitiveTopology.TriangleList,
Blend = GpuBlendMode.StraightAlpha,
// The retained UI is a self-contained 2-D pass: depth is
// irrelevant (feedback_render_self_contained_gl_state) and the
// world pass's alpha-to-coverage/multisample state must not leak
// in — SampleCount=1 drives the GL backend's GL_MULTISAMPLE
// toggle off when this pipeline binds (GlGpuDevice.ApplyRenderState),
// which is what the deleted TextRenderGlStateScope used to restore
// by hand around every Flush.
Depth = GpuDepthState.Disabled,
Cull = GpuCullMode.None,
AlphaToCoverage = false,
ColorWrite = true,
SampleCount = 1,
});
GlResourceCommand.Execute(
_gl,
"initialize TextRenderer frame VAO and VBO",
() =>
{
_gl.BindVertexArray(set.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, set.Vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
});
return set;
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -241,11 +134,11 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
=> DrawSprite(_device.DefaultTextureSlot, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
@ -313,7 +206,7 @@ public sealed unsafe class TextRenderer : IDisposable
}
if (!font.TryGetGlyph(c, out var g))
{
// Unknown glyph skip its advance width if '?' exists.
// Unknown glyph — skip its advance width if '?' exists.
if (font.TryGetGlyph('?', out var q))
cursorX += q.Advance;
continue;
@ -344,9 +237,9 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>
/// Draw a textured sprite quad in screen pixel space with an explicit
/// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per
/// GL texture handle; flushed with uUseTexture=2 (RGBA modulate).
/// texture-table slot, flushed with uUseTexture=2 (RGBA modulate).
/// </summary>
public void DrawSprite(uint texture, float x, float y, float w, float h,
public void DrawSprite(GpuTextureSlot texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint)
{
SpriteSeg seg = OverlayMode
@ -358,18 +251,18 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, GpuTextureSlot texture)
{
if (used > 0 && segs[used - 1].Texture == texture)
if (used > 0 && segs[used - 1].TextureSlot == texture)
return segs[used - 1];
if (used < segs.Count)
{
var s = segs[used++];
s.Texture = texture;
s.TextureSlot = texture;
s.Verts.Clear();
return s;
}
var ns = new SpriteSeg { Texture = texture };
var ns = new SpriteSeg { TextureSlot = texture };
segs.Add(ns);
used++;
return ns;
@ -381,10 +274,10 @@ public sealed unsafe class TextRenderer : IDisposable
{
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
// because the vertex shader flips Y, so OpenGL's default front-face
// is GL_CCW we rely on cull-face being disabled during HUD pass.
// (x, y) (x+w, y)
// │ │
// (x, y+h) (x+w, y+h)
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
// (x, y) ─ (x+w, y)
// │ │
// (x, y+h) ─ (x+w, y+h)
//
// Triangle 1: (x,y) (x+w,y+h) (x+w,y)
// Triangle 2: (x,y) (x,y+h) (x+w,y+h)
@ -402,131 +295,105 @@ public sealed unsafe class TextRenderer : IDisposable
V(x + w, y + h, u1, v1);
}
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
/// <summary>Upload + draw accumulated rects + text against the current frame. font may
/// be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font, IGpuFrame frame)
{
bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
ArgumentNullException.ThrowIfNull(frame);
// Retained UI is a private render pass: an upload or draw failure must
// not leak its depth/cull/blend/MSAA state into a later recoverable
// frame. The focused scope restores from Flush's generated finally,
// including when either DrawLayer call throws.
using var stateScope = new TextRenderGlStateScope(_glState);
using IGpuPassEncoder pass = frame.BeginPass(new GpuPassDescription
{
Name = "ui-text",
// GL's BeginPass deliberately does not touch viewport/scissor for
// a Target:null pass, and Load/Store against the backbuffer
// reproduces exactly what this pass did before the RHI existed —
// the frame spine still owns clears until slice V4h.
Color = new GpuColorAttachment(
Target: null,
Load: GpuLoadOp.Load,
Store: GpuStoreOp.Store,
ClearColor: default),
Depth = null,
SampleCount = 1,
});
pass.BindPipeline(_pipeline);
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
GpuPushConstants baseConstants = GpuPushConstants.Default;
baseConstants.ParamA = _screenSize.X;
baseConstants.ParamB = _screenSize.Y;
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// Establish the self-contained UI pass state.
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// LAYERED compositing for the UI (background → fill → text):
// 1. RGBA dat sprites — window chrome / panel backgrounds (behind)
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// composite on top, in that order. The OVERLAY layer repeats all three
// AFTER the normal layer, so open popups beat even the rect backgrounds.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
DrawLayer(pass, frame, in baseConstants, _spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(pass, frame, in baseConstants, _overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
/// <summary>Draw one compositing layer: sprites (submission order, one draw per
/// segment) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; pipeline + pass are already bound by <see cref="Flush"/>.</summary>
private void DrawLayer(
IGpuPassEncoder pass,
IGpuFrame frame,
in GpuPushConstants baseConstants,
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font)
{
// 1. RGBA dat sprites — one draw call per distinct GL texture.
if (segUsed > 0)
{
_shader.SetInt("uUseTexture", 2);
_gl.ActiveTexture(TextureUnit.Texture0);
_shader.SetInt("uTex", 0);
// 1. RGBA dat sprites — one draw per distinct texture-table slot.
for (int i = 0; i < segUsed; i++)
{
var seg = spriteSegs[i];
SpriteSeg seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
int firstVertex = UploadBuffer(seg.Verts);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)(seg.Verts.Count / FloatsPerVertex));
}
DrawBucket(pass, frame, in baseConstants, seg.Verts, UseTextureSprite, seg.TextureSlot);
}
// 2. Untextured rects widget fills on top of the chrome.
// 2. Untextured rects — widget fills on top of the chrome.
if (rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
int firstVertex = UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)rectVerts);
}
DrawBucket(pass, frame, in baseConstants, rectBuf, UseTextureNone, GpuTextureSlot.Unassigned);
// 3. Textured debug-font text glyphs on top.
if (textVerts > 0 && font is not null)
{
_shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
int firstVertex = UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, firstVertex, (uint)textVerts);
}
DrawBucket(pass, frame, in baseConstants, textBuf, UseTextureFont, font.TextureId);
}
private int UploadBuffer(List<float> buf)
private static void DrawBucket(
IGpuPassEncoder pass,
IGpuFrame frame,
in GpuPushConstants baseConstants,
List<float> verts,
int useTexture,
GpuTextureSlot textureSlot)
{
int bytes = buf.Count * sizeof(float);
if (bytes == 0) return 0;
FrameBufferSet set = _activeFrameBuffer
?? throw new InvalidOperationException("BeginFrame must be called before rendering text.");
int byteOffset = set.UsedBytes;
int requiredBytes = checked(byteOffset + bytes);
int byteCount = verts.Count * sizeof(float);
if (byteCount == 0) return;
if (requiredBytes > _vboCapacityBytes)
{
int newCapacity = DynamicBufferCapacity.Grow(
_vboCapacityBytes,
requiredBytes);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ArrayBuffer,
_vbo,
_vboCapacityBytes,
newCapacity,
GLEnum.DynamicDraw,
"TextRenderer frame VBO growth");
_vboCapacityBytes = newCapacity;
}
GpuRingAllocation allocation = frame.AllocateRing(byteCount, GpuRingUsage.Vertex);
CollectionsMarshal.AsSpan(verts).CopyTo(allocation.AsSpan<float>());
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, (nint)byteOffset, (nuint)bytes, p);
GpuPushConstants constants = baseConstants;
constants.RenderPass = useTexture;
// Unassigned (the untextured-rect bucket) never reaches the shader as
// a slot index — uUseTexture==0 skips the sample entirely — but a
// defined value is still written so no stale slot lingers in the
// uniform between draws.
constants.TextureIndexA = textureSlot.IsAssigned ? textureSlot.Index : 0u;
set.UsedBytes = requiredBytes;
set.CapacityBytes = _vboCapacityBytes;
return byteOffset / (FloatsPerVertex * sizeof(float));
pass.BindVertexBuffer(allocation.Buffer, allocation.OffsetBytes);
pass.SetPushConstants(in constants);
pass.Draw((uint)(verts.Count / FloatsPerVertex), instanceCount: 1, firstVertex: 0, firstInstance: 0);
}
public void Dispose()
{
_resources.RetryCleanup();
_pipeline.Dispose();
}
}

View file

@ -1,4 +1,4 @@
// src/AcDream.App/Rendering/TextureCache.cs
// src/AcDream.App/Rendering/TextureCache.cs
using AcDream.Core.Textures;
using AcDream.Core.World;
using AcDream.Content;
@ -12,11 +12,12 @@ using AcDream.App.Rendering.Residency;
namespace AcDream.App.Rendering;
public sealed unsafe class TextureCache
internal sealed unsafe class TextureCache
: Wb.IEntityTextureLifetime,
IDisposable
{
private readonly GL _gl;
private readonly IGpuDevice _device;
private readonly IDatReaderWriter _dats;
private readonly string _diagnosticsDirectory;
// Handle and decoded dimensions are one atomic cache entry. Keeping them
@ -28,17 +29,40 @@ public sealed unsafe class TextureCache
_decodedDimensionsByTexture = new();
private uint _magentaHandle;
// Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids
// decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, uint> _handlesByRenderSurfaceId = new();
private readonly Dictionary<uint, (int w, int h)> _rsSizeById = new();
/// <summary>
/// Campaign V slice V4a: one registered <see cref="IGpuTexture"/> plus its
/// device texture-table <see cref="GpuTextureSlot"/> and decoded pixel
/// size. Direct-RenderSurface caches for UI sprites: 0x06xxxxxx
/// RenderSurface ids decoded directly (Portal/HighRes →
/// DecodeRenderSurface), bypassing the Surfaceâ†SurfaceTexture chain that
/// GetOrUpload uses for world materials — that world path stays on raw GL
/// (<see cref="_surfacesById"/>) until its own campaign slice.
/// </summary>
private readonly record struct GpuUiTextureEntry(
GpuTextureSlot Slot,
IGpuTexture Texture,
int Width,
int Height);
// Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper
// (used by IconComposer for composited item icons). These are NOT stored in any
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
// GL texture objects until process exit.
private readonly List<uint> _adhocHandles = new();
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
// Ad-hoc GPU textures produced by the public UploadRgba8(byte[],int,int,bool)
// wrapper (used by IconComposer for composited item icons). These are NOT
// stored in the keyed cache above, so Dispose must sweep this list to avoid
// leaking GPU texture-table slots until process exit.
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
// Every UI-path upload uses REPEAT addressing (existing behaviour: panel
// fills and tiled chrome sample UVs > 1) with the caller-selected filter.
// Nearest+Repeat has no predefined GpuSamplerDescription (UiNearest clamps),
// so it is constructed once here; CreateSampler de-duplicates by value.
private static readonly GpuSamplerDescription UiSpriteNearestRepeat = new(
GpuFilter.Nearest,
GpuFilter.Nearest,
GpuMipFilter.None,
GpuAddressMode.Repeat,
GpuAddressMode.Repeat,
MaxAnisotropy: 1f);
private readonly Wb.BindlessSupport? _bindless;
private readonly CompositeTextureArrayCache? _compositeTextures;
@ -82,13 +106,14 @@ public sealed unsafe class TextureCache
// Frame counter for the one-shot ACDREAM_DUMP_SURFACES=1 trigger.
// Increments per Tick call; fires the dump once at frame index 600
// and never again for the session. See spec §5.
// and never again for the session. See spec §5.
private int _dumpFrameCounter;
private bool _surfaceHistogramAlreadyDumped;
public TextureCache(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
public TextureCache(GL gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
: this(
gl,
device,
dats,
bindless,
ImmediateGpuResourceRetirementQueue.Instance,
@ -101,6 +126,7 @@ public sealed unsafe class TextureCache
internal TextureCache(
GL gl,
IGpuDevice device,
IDatReaderWriter dats,
Wb.BindlessSupport? bindless,
IGpuResourceRetirementQueue retirementQueue,
@ -109,6 +135,7 @@ public sealed unsafe class TextureCache
{
budgets ??= ResidencyBudgetOptions.Default;
_gl = gl;
_device = device ?? throw new ArgumentNullException(nameof(device));
_dats = dats;
_bindless = bindless;
ArgumentException.ThrowIfNullOrWhiteSpace(diagnosticsDirectory);
@ -219,23 +246,22 @@ public sealed unsafe class TextureCache
/// <summary>
/// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded
/// DIRECTLY (Portal/HighRes DecodeRenderSurface) rather than through the
/// SurfaceSurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the
/// Surface→SurfaceTexture chain that <see cref="GetOrUpload(uint)"/> uses
/// for world-geometry materials. This is the correct path for retail UI
/// chrome + font glyph sheets, which reference RenderSurface directly.
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites e.g. the selected-object
/// health-bar track 0x0600193E are decoded against the RenderSurface's own
/// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object
/// health-bar track 0x0600193E — are decoded against the RenderSurface's own
/// <c>DefaultPaletteId</c> (same starting palette <see cref="DecodeFromDats"/>
/// uses); non-paletted formats have DefaultPaletteId==0 palette null. Returns
/// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns
/// a 1x1 magenta handle on miss.
/// </summary>
public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
public GpuTextureSlot GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false)
{
if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing)
&& _rsSizeById.TryGetValue(renderSurfaceId, out var sz))
if (_renderSurfaceGpuTextures.TryGetValue(renderSurfaceId, out GpuUiTextureEntry existing))
{
width = sz.w; height = sz.h;
return existing;
width = existing.Width; height = existing.Height;
return existing.Slot;
}
DecodedTexture decoded;
@ -256,16 +282,43 @@ public sealed unsafe class TextureCache
decoded = DecodedTexture.Magenta;
}
uint h = UploadRgba8(decoded, nearest);
_handlesByRenderSurfaceId[renderSurfaceId] = h;
_rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height);
GpuUiTextureEntry entry = UploadUiTexture(decoded, nearest, $"ui-rendersurface-0x{renderSurfaceId:X8}");
_renderSurfaceGpuTextures[renderSurfaceId] = entry;
width = decoded.Width; height = decoded.Height;
return h;
return entry.Slot;
}
/// <summary>
/// Campaign V slice V4a: creates an <see cref="IGpuTexture"/> for one
/// decoded UI sprite/atlas and registers it into the device's global
/// texture table. Every UI-path texture uses REPEAT addressing (existing
/// behaviour — panel fills and tiled chrome sample UVs greater than 1) and
/// a single mip level (UI sprites never mip). <paramref name="nearest"/>
/// selects point sampling for pixel-crisp glyphs/icons versus bilinear for
/// everything else, matching the GL path's prior per-call choice.
/// </summary>
private GpuUiTextureEntry UploadUiTexture(DecodedTexture decoded, bool nearest, string debugName)
{
int width = Math.Max(1, decoded.Width);
int height = Math.Max(1, decoded.Height);
IGpuTexture texture = _device.CreateTexture(new GpuTextureDescription(
debugName,
GpuTextureKind.Texture2D,
GpuTextureFormat.Rgba8Unorm,
Width: width,
Height: height,
LayerCount: 1,
MipLevelCount: 1));
texture.Upload(0, 0, decoded.Rgba8);
IGpuSampler sampler = _device.CreateSampler(
nearest ? UiSpriteNearestRepeat : GpuSamplerDescription.WorldRepeat);
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
return new GpuUiTextureEntry(slot, texture, decoded.Width, decoded.Height);
}
/// <summary>
/// Alpha-channel histogram for one decoded texture. Used to diagnose
/// "why are clouds not transparent" — if cloud textures come out with
/// "why are clouds not transparent" — if cloud textures come out with
/// alpha = 1.0 everywhere we know the decode path strips the alpha
/// channel somewhere. Printed once per unique surfaceId under
/// <c>ACDREAM_DUMP_SKY=1</c>. Adds ~2ms per texture upload, negligible.
@ -530,7 +583,7 @@ public sealed unsafe class TextureCache
{
if (_bindless is null)
throw new InvalidOperationException(
"TextureCache constructed without BindlessSupport cannot generate bindless handles. " +
"TextureCache constructed without BindlessSupport — cannot generate bindless handles. " +
"WbDrawDispatcher requires the bindless-aware ctor overload (pass non-null BindlessSupport).");
}
@ -585,7 +638,7 @@ public sealed unsafe class TextureCache
/// </summary>
internal static ulong HashPaletteOverride(PaletteOverride p)
{
// Not cryptographic just needs to distinguish override setups
// Not cryptographic — just needs to distinguish override setups
// for caching. Start with base palette id, fold in each entry.
ulong h = 0xCBF29CE484222325UL; // FNV-1a offset basis
const ulong prime = 0x100000001B3UL;
@ -606,17 +659,17 @@ public sealed unsafe class TextureCache
/// Phase N.6 slice 1: one-shot surface-format histogram dump for the
/// atlas-opportunity audit. Activated by ACDREAM_DUMP_SURFACES=1; fires
/// once after BOTH gates pass:
/// 1. <c>_dumpFrameCounter &gt;= 600</c> at least 600 OnRender ticks
/// 1. <c>_dumpFrameCounter &gt;= 600</c> — at least 600 OnRender ticks
/// have elapsed (catches the "we're already past startup boilerplate"
/// bound; ~10s at 60fps, ~3s at 200fps).
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> the cache contains at
/// 2. <c>_uploadMetadata.Count &gt;= 100</c> — the cache contains at
/// least 100 uploaded textures, indicating streaming has actually
/// pulled in world content (not just sky/UI/font). The original
/// frame-only gate fired during the login/handshake phase where
/// OnRender ticks at GUI rates but no world has streamed in.
/// Output goes to the host-provided portable diagnostics directory.
/// Zero cost
/// when off. See spec §5 in
/// when off. See spec §5 in
/// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
/// </summary>
public void TickSurfaceHistogramDumpIfEnabled()
@ -641,7 +694,7 @@ public sealed unsafe class TextureCache
{
// Diagnostic-only path. If the dump file can't be written
// (disk full, permission denied, antivirus lock, path too
// long) we must NOT crash OnRender that would invalidate
// long) we must NOT crash OnRender — that would invalidate
// the very measurement pass this diagnostic is meant to
// support. Log to stderr and let the caller mark the dump
// as "already done" so it doesn't retry every frame.
@ -657,7 +710,7 @@ public sealed unsafe class TextureCache
"n6-surfaces.txt");
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# acdream surface-format histogram generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine($"# acdream surface-format histogram — generated {DateTime.UtcNow:yyyy-MM-ddTHH:mm:ssZ}");
sb.AppendLine("# Per-entry: surfaceId(hex), width, height, format, byteCount");
sb.AppendLine();
@ -710,7 +763,7 @@ public sealed unsafe class TextureCache
foreach (var kv in bucketsByFormat.OrderByDescending(kv => kv.Value))
sb.AppendLine($"# {kv.Key}: {kv.Value}");
sb.AppendLine("# Top 10 (W,H,format) triples atlas-opportunity input:");
sb.AppendLine("# Top 10 (W,H,format) triples — atlas-opportunity input:");
foreach (var kv in bucketsByTriple.OrderByDescending(kv => kv.Value).Take(10))
sb.AppendLine($"# {kv.Key.W}x{kv.Key.H} {kv.Key.F}: {kv.Value}");
@ -729,8 +782,8 @@ public sealed unsafe class TextureCache
}
// Base1Solid surfaces (and any with OrigTextureId==0) carry a ColorValue
// instead of a texture chain. Overrides are irrelevant here there's
// no texture chain to swap so the override is ignored for solid-color
// instead of a texture chain. Overrides are irrelevant here — there's
// no texture chain to swap — so the override is ignored for solid-color
// surfaces. Translucency is honored so Base1Solid|Translucent surfaces
// with Translucency=1.0 become alpha=0, which the mesh shader's discard
// cutout makes invisible.
@ -759,7 +812,7 @@ public sealed unsafe class TextureCache
// Start with the texture's default palette, then apply overlays.
// ACViewer's Render/TextureCache.IndexToColor does the same and never
// consults ObjDesc.BasePaletteId for palette-indexed textures the
// consults ObjDesc.BasePaletteId for palette-indexed textures — the
// RenderSurface's own default palette is the starting point.
Palette? basePalette = rs.DefaultPaletteId != 0
? _dats.Get<Palette>(rs.DefaultPaletteId)
@ -817,12 +870,13 @@ public sealed unsafe class TextureCache
/// <see cref="AcDream.App.UI.IconComposer"/> to upload CPU-composited icon layers.
/// The returned handle is tracked in <see cref="_adhocHandles"/> and deleted by
/// <see cref="Dispose"/>. Callers must NOT also store the handle in any of the
/// keyed caches that would cause a double-delete on Dispose.</summary>
public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
/// keyed caches — that would cause a double-delete on Dispose.</summary>
public GpuTextureSlot UploadRgba8(byte[] rgba, int width, int height, bool nearest = false)
{
uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest);
_adhocHandles.Add(h);
return h;
GpuUiTextureEntry entry = UploadUiTexture(
new DecodedTexture(rgba, width, height), nearest, "ui-adhoc-icon");
_adhocGpuTextures.Add(entry);
return entry.Slot;
}
private uint UploadRgba8(DecodedTexture decoded, bool nearest = false)
@ -846,7 +900,7 @@ public sealed unsafe class TextureCache
PixelType.UnsignedByte,
p);
// Point (nearest) sampling for pixel-exact UI text bilinear softens the dat
// Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat
// font's small glyphs. Other surfaces use bilinear.
int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear;
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter);
@ -962,16 +1016,28 @@ public sealed unsafe class TextureCache
_magentaHandle = 0;
}
// RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated
// by GetOrUploadRenderSurface but was not swept here before this fix.
foreach (var h in _handlesByRenderSurfaceId.Values)
DeleteUploadedTexture(h);
_handlesByRenderSurfaceId.Clear();
// RenderSurface (UI sprite) GPU textures — pre-existing gap: this dict was
// populated by GetOrUploadRenderSurface but was not swept here before that fix.
foreach (GpuUiTextureEntry entry in _renderSurfaceGpuTextures.Values)
DisposeUiTexture(entry);
_renderSurfaceGpuTextures.Clear();
// Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper
// Ad-hoc GPU textures from the public UploadRgba8(byte[],int,int,bool) wrapper
// (IconComposer composited icons). Not stored in any keyed cache.
foreach (var h in _adhocHandles)
DeleteUploadedTexture(h);
_adhocHandles.Clear();
foreach (GpuUiTextureEntry entry in _adhocGpuTextures)
DisposeUiTexture(entry);
_adhocGpuTextures.Clear();
}
/// <summary>
/// Releases a UI-path texture's table slot before disposing the backing
/// <see cref="IGpuTexture"/>. Both route through the device's retirement
/// queue, so releasing the slot first is purely bookkeeping order, not a
/// use-after-free concern.
/// </summary>
private void DisposeUiTexture(GpuUiTextureEntry entry)
{
_device.ReleaseTextureSlot(entry.Slot);
entry.Texture.Dispose();
}
}

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Vfx;
using DatReaderWriter.Types;
@ -16,7 +16,7 @@ namespace AcDream.App.Rendering.Vfx;
/// here preserves that observable order and avoids firing a hand or weapon
/// effect against the previous animation frame.
/// </remarks>
public sealed class AnimationHookFrameQueue
internal sealed class AnimationHookFrameQueue
{
private readonly AnimationHookRouter _router;
private readonly IEntityEffectPoseSource _poses;

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@ -23,7 +23,7 @@ namespace AcDream.App.Rendering.Vfx;
/// (<c>0x00513260</c>) and both <c>play_default_script</c> overloads
/// (<c>0x005132B0</c>, <c>0x00513300</c>).
/// </remarks>
public sealed class EntityEffectController : IAnimationHookSink,
internal sealed class EntityEffectController : IAnimationHookSink,
IEntityEffectAdvanceSource
{
private readonly LiveEntityRuntime _liveEntities;

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
@ -15,7 +15,7 @@ namespace AcDream.App.Rendering.Vfx;
/// (<c>0x0051D180</c>). This registry is the modern, read-only seam exposing
/// those same final frames without coupling Core effects to the renderer.
/// </remarks>
public sealed class EntityEffectPoseRegistry :
internal sealed class EntityEffectPoseRegistry :
IEntityEffectPoseSource,
IEntityEffectCellSource,
IEntityEffectPoseChangeSource,
@ -314,7 +314,7 @@ public sealed class EntityEffectPoseRegistry :
}
}
public interface IEntityEffectPoseLifetimeSource
internal interface IEntityEffectPoseLifetimeSource
{
ulong GetPoseOwnerLifetimeVersion(uint localEntityId);
}

View file

@ -1,4 +1,4 @@
using AcDream.App.World;
using AcDream.App.World;
using AcDream.Core.Net.Messages;
using AcDream.Core.Vfx;
using DatReaderWriter.DBObjs;
@ -16,7 +16,7 @@ namespace AcDream.App.Rendering.Vfx;
/// a network PhysicsDesc then unconditionally replaces the typed table, even
/// when PeTable was absent or zero.
/// </remarks>
public sealed class EntityEffectProfile : ILiveEntityEffectProfile
internal sealed class EntityEffectProfile : ILiveEntityEffectProfile
{
private EntityEffectProfile(Setup setup)
{

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.Vfx;
using AcDream.Core.World;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering.Vfx;
/// Setup script/profile data resolved once when a logical effect owner is
/// registered. Part transforms are indexed and root-local.
/// </summary>
public sealed record ScriptActivationInfo(
internal sealed record ScriptActivationInfo(
uint ScriptId,
IReadOnlyList<Matrix4x4> PartTransforms,
EntityEffectProfile? EffectProfile = null,
@ -26,7 +26,7 @@ public sealed record ScriptActivationInfo(
/// initialization. Live registration invokes this class once per logical
/// generation; spatial rebucketing never replays it.
/// </remarks>
public sealed class EntityScriptActivator
internal sealed class EntityScriptActivator
{
private sealed class StaticOwnerState
{

View file

@ -1,4 +1,4 @@
using AcDream.App.World;
using AcDream.App.World;
using AcDream.Core.Lighting;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
@ -17,7 +17,7 @@ namespace AcDream.App.Rendering.Vfx;
/// <see cref="LiveEntityRuntime"/>; leaving the world removes only this
/// cell-scoped presentation and re-entry registers it again.
/// </remarks>
public sealed class LiveEntityLightController : IDisposable
internal sealed class LiveEntityLightController : IDisposable
{
private readonly LiveEntityRuntime _liveEntities;
private readonly EntityEffectPoseRegistry _poses;

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
using AcDream.Core.Vfx;
namespace AcDream.App.Rendering.Vfx;
@ -18,7 +18,7 @@ internal interface IWorldSceneParticleVisibility
/// frame meaning: one completed viewer position plus the AC cells admitted by
/// that completed view. It neither creates emitters nor performs rendering.
/// </summary>
public sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
internal sealed class ParticleVisibilityController : IWorldSceneParticleVisibility
{
public const float ExtendedRangeMultiplier = 2f;

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Numerics;
@ -6,31 +6,31 @@ namespace AcDream.App.Rendering;
/// <summary>
/// T3 (BR-5): the port of retail's <c>Render::viewconeCheck</c> (Ghidra
/// 0x0054c250) meshes (characters, statics, emitters) are CULLED per portal
/// 0x0054c250) — meshes (characters, statics, emitters) are CULLED per portal
/// view by a bounding-sphere test against the view's edge planes, never
/// clipped. Retail stores each view vertex with its 3D eye-edge plane
/// (<c>view_vertex { Vec2D pt; Plane plane }</c>, acclient.h:32483) and tests
/// the object's drawing sphere against the installed view's plane set;
/// OUTSIDE skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
/// OUTSIDE → skipped (RenderDeviceD3D::DrawMesh per-view loop pc:429290-429310,
/// and the DrawCells per-cell object epilogue, Ghidra 0x005a4840).
///
/// <para>Our views are clip-space half-planes (8 per slice,
/// <para>Our views are clip-space half-planes (≤8 per slice,
/// <see cref="ClipPlaneSet"/> output: (nx,ny,0,d) satisfied when
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
/// the view_vertex.plane analog, a plane through the EYE and the view edge
/// nx·Cx + ny·Cy + d·Cw ≥ 0 for clip-space C). Lifting one to world space —
/// the view_vertex.plane analog, a plane through the EYE and the view edge —
/// is one matrix fold: with row-vector convention (System.Numerics),
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
/// C = world·VP, so C·P = world·(VP·P); L = VP·P (rows of VP dotted with P)
/// is the world-space homogeneous half-plane. Sphere-vs-half-plane keeps the
/// sphere when L.xyz·c + L.w ≥ r·|L.xyz| (not entirely outside).</para>
/// sphere when L.xyz·c + L.w ≥ ∷|L.xyz| (not entirely outside).</para>
///
/// <para>A sphere is visible through a SLICE when it is not entirely outside
/// any of the slice's planes (convex region); visible for a CELL when any of
/// the cell's slices passes. A slice with zero planes is pass-all (the
/// NoClipSlice / full-screen outdoor case). A cell with no views culls in
/// NoClipSlice / full-screen outdoor case). A cell with no views culls — in
/// retail an object whose cell is not in the draw list is simply never
/// reached.</para>
/// </summary>
public sealed class ViewconeCuller
internal sealed class ViewconeCuller
{
private const int MaxRetainedCellPlaneSets = 512;
private const int MaxRetainedPlanesPerCell = 256;
@ -65,7 +65,7 @@ public sealed class ViewconeCuller
}
/// <summary>True when the outside view is a full-screen pass-all (the
/// synthetic outdoor root) every outside-test passes.</summary>
/// synthetic outdoor root) — every outside-test passes.</summary>
public bool OutsideIsFullScreen { get; private set; }
public static ViewconeCuller Build(
@ -158,7 +158,7 @@ public sealed class ViewconeCuller
Vector4 l = plane.Equation;
float nLen = plane.NormalLength;
if (nLen < 1e-12f)
continue; // degenerate plane no constraint
continue; // degenerate plane — no constraint
float dist = l.X * center.X + l.Y * center.Y + l.Z * center.Z + l.W;
if (dist < -radius * nLen)
return false; // entirely outside this edge plane
@ -167,7 +167,7 @@ public sealed class ViewconeCuller
}
/// <summary>Sphere-vs-the-cell's-views: visible when any slice passes.
/// A cell with no views culls (not in the draw list never reached in
/// A cell with no views culls (not in the draw list ⇒ never reached in
/// retail). A zero-plane slice is pass-all.</summary>
public bool SphereVisibleInCell(uint cellId, in Vector3 center, float radius)
{

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using AcDream.Core.Physics;
namespace AcDream.App.Rendering.Wb;
@ -8,7 +8,7 @@ namespace AcDream.App.Rendering.Wb;
/// equipped items). Holds AC-specific per-instance customizations the WB
/// atlas cache doesn't carry: <c>AnimPartChange</c> override map +
/// <c>HiddenParts</c> bitmask. Also holds a reference to acdream's existing
/// <see cref="AnimationSequencer"/> Phase N.4 explicitly does not touch
/// <see cref="AnimationSequencer"/> — Phase N.4 explicitly does not touch
/// the sequencer; we just route through it at draw time.
///
/// <para>
@ -16,11 +16,11 @@ namespace AcDream.App.Rendering.Wb;
/// a server <c>CreateObject</c> is processed; destroyed by
/// <c>EntitySpawnAdapter.OnRemove</c> on <c>RemoveObject</c>. The mesh
/// data backing each part is cached in WB's <c>ObjectMeshManager</c>;
/// per-instance customizations don't go through the atlas they overlay
/// per-instance customizations don't go through the atlas — they overlay
/// at draw time.
/// </para>
/// </summary>
public sealed class AnimatedEntityState
internal sealed class AnimatedEntityState
{
private readonly Dictionary<int, ulong> _partGfxObjOverrides = new();
private ulong _hiddenMask = 0;
@ -49,7 +49,7 @@ public sealed class AnimatedEntityState
}
/// <summary>Override the GfxObj id for a Setup part. Used for
/// AnimPartChange e.g. wielding a weapon swaps the hand-part's
/// AnimPartChange — e.g. wielding a weapon swaps the hand-part's
/// GfxObj.</summary>
public void SetPartOverride(int partIdx, ulong gfxObjId)
=> _partGfxObjOverrides[partIdx] = gfxObjId;

View file

@ -1,4 +1,4 @@
using Silk.NET.OpenGL;
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ARB;
using AcDream.App.Rendering;
@ -9,7 +9,7 @@ namespace AcDream.App.Rendering.Wb;
/// for the modern rendering path. Constructed once at startup via
/// <see cref="TryCreate"/>, which returns false if the extension isn't present.
/// </summary>
public sealed class BindlessSupport
internal sealed class BindlessSupport
{
private readonly GL _gl;
private readonly ArbBindlessTexture _ext;
@ -63,7 +63,7 @@ public sealed class BindlessSupport
/// make it resident. Idempotent per (texture, sampler) pair.
///
/// Added for Campaign V slice V1's <c>GlGpuDevice.RegisterTexture</c>,
/// which registers a (texture, sampler) pair per the RHI contract "the
/// which registers a (texture, sampler) pair per the RHI contract — "the
/// same texture registered with two samplers occupies two slots." The
/// texture-only <see cref="GetResidentHandle(uint)"/> above cannot express
/// that; <c>ManagedGLTextureArray</c> already calls the equivalent
@ -117,7 +117,7 @@ public sealed class BindlessSupport
// and removed when terrain rendering surfaced GL_INVALID_OPERATION on
// NVIDIA Windows for the `uniform sampler2DArray` + glProgramUniformHandleARB
// combination. The replacement pattern (uvec2 handle uniform + GLSL
// sampler-from-handle constructor see terrain_modern.frag) lives at the
// sampler-from-handle constructor — see terrain_modern.frag) lives at the
// call site via plain `_gl.ProgramUniform2(program, loc, low, high)`. If
// you re-introduce a sampler-handle helper, restrict it to drivers known
// to accept the direct sampler-uniform path.

View file

@ -1,4 +1,4 @@
using Chorizite.Core.Render.Enums;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using System;
using System.Collections.Generic;
@ -7,7 +7,7 @@ using System.Text;
using System.Threading.Tasks;
namespace AcDream.App.Rendering.Wb {
public static class BufferUsageExtensions {
internal static class BufferUsageExtensions {
/// <summary>
/// Converts a BufferUsage to a GL BufferUsageARB
/// </summary>

View file

@ -1,12 +1,12 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): a logical building one or more EnvCells linked
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
/// walls, inn walls <c>IsBuildingShell=true</c> entities) are scoped to this
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) are scoped to this
/// building's cells via their dat-derived anchor. The exit portal polygons are
/// stencil-marked so outdoor visibility leaks through portal silhouettes only.
///
@ -19,7 +19,7 @@ namespace AcDream.App.Rendering.Wb;
/// Retail reference: <c>docs/research/named-retail/acclient.h:32035</c>
/// (<c>BuildInfo</c>) + <c>32094</c> (<c>CBldPortal</c>).</para>
/// </summary>
public sealed class Building
internal sealed class Building
{
/// <summary>Unique within a landblock; allocated sequentially by <see cref="BuildingLoader"/>
/// starting at 1 (0 is reserved for "no building" semantics on <c>LoadedCell</c>).</summary>

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using DatReaderWriter.DBObjs;
@ -14,11 +14,11 @@ namespace AcDream.App.Rendering.Wb;
/// <para>Algorithm (mirrors WB's <c>PortalService.GetPortalsByBuilding</c> at
/// <c>WorldBuilder.Shared/Services/PortalService.cs:43-97</c>):</para>
/// <list type="bullet">
/// <item>Step A seed the cell set from <c>BuildingInfo.Portals</c> entry portals.</item>
/// <item>Step B BFS through <see cref="LoadedCell.Portals"/> to discover all
/// <item>Step A — seed the cell set from <c>BuildingInfo.Portals</c> entry portals.</item>
/// <item>Step B — BFS through <see cref="LoadedCell.Portals"/> to discover all
/// interior cells reachable from the entry portals (interior portals only;
/// exit portals — <c>OtherCellId == 0xFFFF</c> — terminate each BFS branch).</item>
/// <item>Step C collect exit portal polygons in world space for the stencil
/// exit portals — <c>OtherCellId == 0xFFFF</c> — terminate each BFS branch).</item>
/// <item>Step C — collect exit portal polygons in world space for the stencil
/// pipeline (Phase A8 Steps 1+2, RR7 scope).</item>
/// </list>
///
@ -57,7 +57,7 @@ internal sealed class BuildingRegistryPublication
internal bool PublicationCommitted { get; set; }
}
public static class BuildingLoader
internal static class BuildingLoader
{
/// <summary>
/// Builds a <see cref="BuildingRegistry"/> from the supplied landblock data.

View file

@ -1,16 +1,16 @@
using System;
using System;
using System.Collections.Generic;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): per-landblock registry of <see cref="Building"/>s.
/// Two-way indexed for O(1) cell→building and building-id→building lookups.
/// Two-way indexed for O(1) cellâ†building and building-idâ†building lookups.
/// Built once per landblock at load time by <see cref="BuildingLoader"/>;
/// no mutations occur after initial population.
///
/// <para>The cellbuilding index uses a <c>List&lt;Building&gt;</c> value type
/// to handle the (rare but valid) case where two buildings share an EnvCell
/// <para>The cell→building index uses a <c>List&lt;Building&gt;</c> value type
/// to handle the (rare but valid) case where two buildings share an EnvCell —
/// each building performs its own BFS so a shared boundary cell ends up in both
/// <c>EnvCellIds</c> sets. <see cref="GetBuildingsContainingCell"/> returns all
/// owners so RR7's render path can pick the correct one.</para>
@ -19,13 +19,13 @@ namespace AcDream.App.Rendering.Wb;
/// (<c>BuildingPortalGroup</c>). Design:
/// <c>docs/superpowers/specs/2026-05-26-phase-a8-wb-full-port-design.md</c>.</para>
/// </summary>
public sealed class BuildingRegistry
internal sealed class BuildingRegistry
{
// Index 1: cell-id list of buildings containing that cell.
// Index 1: cell-id → list of buildings containing that cell.
// Cells may belong to multiple buildings (rare; handled via List<Building>).
private readonly Dictionary<uint, List<Building>> _byCellId = new();
// Index 2: building-id Building.
// Index 2: building-id → Building.
private readonly Dictionary<uint, Building> _byBuildingId = new();
/// <summary>

View file

@ -1,10 +1,10 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering.Wb {
// Extracted verbatim from WorldBuilder.Shared/Models/DebugRenderSettings.cs.
// LandscapeColorsSettings dependency (editor-only, CommunityToolkit.Mvvm) stripped;
// default color values inlined from LandscapeColorsSettings field initializers.
public class DebugRenderSettings {
internal class DebugRenderSettings {
public bool ShowBoundingBoxes { get; set; } = false;
public bool SelectVertices { get; set; } = true;
public bool SelectBuildings { get; set; } = true;

View file

@ -1,4 +1,4 @@
using System.Runtime.InteropServices;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering.Wb;
@ -7,7 +7,7 @@ namespace AcDream.App.Rendering.Wb;
/// Total size 20 bytes; arrays are typically uploaded with stride = sizeof(this).
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct DrawElementsIndirectCommand
internal struct DrawElementsIndirectCommand
{
public uint Count; // index count for this draw
public uint InstanceCount; // number of instances

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using AcDream.Core.Physics;
using AcDream.Core.World;
@ -11,7 +11,7 @@ namespace AcDream.App.Rendering.Wb;
/// logical removal has been queued and must be retried by the live-entity
/// teardown owner after the transition unwinds.
/// </summary>
public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
internal sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
: InvalidOperationException(
$"Live entity 0x{serverGuid:X8} presentation removal is deferred until its active reference transition completes.");
@ -43,7 +43,7 @@ public sealed class EntityPresentationRemovalDeferredException(uint serverGuid)
/// both to the created <see cref="AnimatedEntityState"/>.
/// </para>
/// </summary>
public sealed class EntitySpawnAdapter
internal sealed class EntitySpawnAdapter
{
private readonly IEntityTextureLifetime _textureLifetime;
private readonly Func<WorldEntity, AnimationSequencer> _sequencerFactory;
@ -161,14 +161,14 @@ public sealed class EntitySpawnAdapter
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null
// the MotionTable (in production). Factory must not return null —
// if the entity has no motion table the factory should construct a
// no-op sequencer (Setup + empty MotionTable + NullAnimationLoader).
var sequencer = _sequencerFactory(entity);
@ -185,7 +185,7 @@ public sealed class EntitySpawnAdapter
// Snapshot each unique GfxObj id for the shorter presentation lifetime.
// Includes both the entity's natural MeshRefs AND any server-sent
// PartOverride GfxObjs (weapons, clothing, helmets) those replace the
// PartOverride GfxObjs (weapons, clothing, helmets) — those replace the
// Setup default and need their own mesh data uploaded.
// Construct the replacement completely before displacing a live owner.
// Sequencer/appearance construction is allowed to fail; in that case

View file

@ -1,4 +1,4 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Rendering.Wb;
using DatReaderWriter.DBObjs;
@ -12,7 +12,7 @@ namespace AcDream.App.Rendering.Wb;
/// placement data; the render thread schedules mesh preparation when it commits
/// the containing <see cref="EnvCellLandblockBuild"/>.
/// </summary>
public sealed record EnvCellShellPlacement(
internal sealed record EnvCellShellPlacement(
uint CellId,
ulong GeometryId,
uint EnvironmentId,
@ -29,7 +29,7 @@ public sealed record EnvCellShellPlacement(
/// both portal-visibility cells and drawable shell placements so neither can be
/// drained by, or mixed with, another streaming completion.
/// </summary>
public sealed class EnvCellLandblockBuild
internal sealed class EnvCellLandblockBuild
{
public EnvCellLandblockBuild(
uint landblockId,
@ -57,7 +57,7 @@ public sealed class EnvCellLandblockBuild
/// global pending bags, instances of this class are never shared between jobs or
/// observed by the render thread before <see cref="Build"/> returns.
/// </summary>
public sealed class EnvCellLandblockBuildBuilder
internal sealed class EnvCellLandblockBuildBuilder
{
private readonly uint _landblockId;
private readonly List<LoadedCell> _visibilityCells = new();

View file

@ -1,4 +1,4 @@
using System.Numerics;
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
@ -7,7 +7,7 @@ namespace AcDream.App.Rendering.Wb;
/// one private shell at a time; commit publishes the completed immutable
/// landblock snapshot with one owner dictionary replacement.
/// </summary>
public interface IEnvCellLandblockPublisher
internal interface IEnvCellLandblockPublisher
{
EnvCellLandblockPublication PreparePublication(
EnvCellLandblockBuild build);
@ -17,7 +17,7 @@ public interface IEnvCellLandblockPublisher
void CommitPublication(EnvCellLandblockPublication publication);
}
public sealed class EnvCellLandblockPublication
internal sealed class EnvCellLandblockPublication
{
internal EnvCellLandblockPublication(
object owner,

View file

@ -1,4 +1,4 @@
namespace AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Starts CPU mesh extraction after the completed EnvCell build has been
@ -7,7 +7,7 @@ namespace AcDream.App.Rendering.Wb;
/// current streaming generation; stale portal destinations never keep decoder
/// jobs or surface lists alive.
/// </summary>
public static class EnvCellMeshPreparationScheduler
internal static class EnvCellMeshPreparationScheduler
{
public static void Schedule(
EnvCellLandblockBuild build,

View file

@ -1,4 +1,4 @@
// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the
// Phase A8 (2026-05-28): port of WB's EnvCellRenderManager. This is the
// production cell-rendering pipeline for indoor visibility, replacing the
// broken "cell as WorldEntity with MeshRef(envCellId)" approach that the
// four reverted RR7 variants couldn't fix.
@ -12,7 +12,7 @@
//
// Note: we do NOT inherit from WB's ObjectRenderManagerBase. That base
// class owns the landblock-streaming loop (Update, _pendingGeneration,
// _uploadQueue). acdream's StreamingController already does that work
// _uploadQueue). acdream's StreamingController already does that work —
// running a parallel loop would compete for dat I/O. Instead, streaming builds
// a private EnvCellLandblockBuild and CommitLandblock publishes the completed
// snapshot on the render thread.
@ -29,7 +29,7 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
public sealed unsafe class EnvCellRenderer :
internal sealed unsafe class EnvCellRenderer :
IDisposable,
IEnvCellLandblockPublisher
{
@ -39,7 +39,7 @@ public sealed unsafe class EnvCellRenderer :
private readonly WbFrustum _frustum;
// Per-landblock storage. Key = full 32-bit landblock dat id (e.g. 0xA9B4FFFF).
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks
// WB EnvCellRenderManager.cs:75 uses ConcurrentDictionary<ushort, ObjectLandblock> _landblocks —
// we use uint (full LB id) because acdream uses 32-bit landblock keys throughout.
private readonly ConcurrentDictionary<uint, EnvCellLandblock> _landblocks = new();
@ -64,7 +64,7 @@ public sealed unsafe class EnvCellRenderer :
private Matrix4x4 _lastViewProjection = Matrix4x4.Identity;
private bool _initialized;
// List pool copied from WB ObjectRenderManagerBase.
// List pool — copied from WB ObjectRenderManagerBase.
// WB ObjectRenderManagerBase.cs:83-86: protected readonly List<List<InstanceData>> _listPool = new(); protected int _poolIndex = 0;
private readonly List<List<InstanceData>> _listPool = new();
private int _poolIndex = 0;
@ -78,7 +78,7 @@ public sealed unsafe class EnvCellRenderer :
private readonly ThreadLocal<PrepareScratch> _prepareScratch =
new(() => new PrepareScratch(), trackAllValues: true);
// Modern-MDI scratch buffers (single slot we re-upload every frame).
// Modern-MDI scratch buffers (single slot — we re-upload every frame).
// WB BaseObjectRenderManager.cs:43-48: _scratchMdiCommandBuffers, _scratchModernBatchBuffers, _modernInstanceBuffers
// We collapse the ring-of-3 to a single slot since we have no persistent/consolidated draws.
private uint _mdiCommandBuffer;
@ -95,7 +95,7 @@ public sealed unsafe class EnvCellRenderer :
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
// _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
// indexed by the same BaseInstance + gl_InstanceID the shader uses for
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
// binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
private uint _clipSlotBuffer;
private int _clipSlotCapacity;
private uint[] _clipSlotData = Array.Empty<uint>();
@ -156,17 +156,17 @@ public sealed unsafe class EnvCellRenderer :
// Campaign V slice V2 (2026-07-27): GL-only emulation of the eventual
// Vulkan global texture descriptor array (binding=9,
// GpuBindingModel.StorageTextureTable). Owns its own table rather than
// sharing WbDrawDispatcher's EnvCellRenderer never had a TextureCache
// sharing WbDrawDispatcher's — EnvCellRenderer never had a TextureCache
// dependency and nothing requires index agreement between renderers (each
// rebinds its own buffer to binding=9 immediately before its own draw
// call). See GlBindlessHandleTable's doc comment and the campaign doc's
// §5.2. Lazily created; grown/uploaded only when a genuinely new handle
// appears (rare see FlushAndBindTextureTable).
// §5.2. Lazily created; grown/uploaded only when a genuinely new handle
// appears (rare — see FlushAndBindTextureTable).
private readonly GlBindlessHandleTable _textureTable = new();
private uint _textureTableSsbo;
private int _textureTableSsboCapacityBytes;
// Reusable scratch arrays avoid per-frame allocation.
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty<DrawElementsIndirectCommand>();
private ModernBatchData[] _modernBatches = Array.Empty<ModernBatchData>();
@ -192,7 +192,7 @@ public sealed unsafe class EnvCellRenderer :
private readonly Dictionary<ulong, List<InstanceData>> _activeSnapshotGlobalGroups = new();
private readonly List<ulong> _activeSnapshotGlobalGfxObjIds = new();
// Static render-state tracking matches WB BaseObjectRenderManager.cs:24-28.
// Static render-state tracking — matches WB BaseObjectRenderManager.cs:24-28.
// Shared across all manager instances on the same GL context.
private static uint _currentVao;
private static CullMode? _currentCullMode;
@ -204,8 +204,8 @@ public sealed unsafe class EnvCellRenderer :
// inputs changed: landblock commits/removals (NeedsPrepare), the visible-cell
// filter, the trim window, mesh render-data availability (the snapshot bakes
// per-cell transparency from TryGetRenderData), or the view-projection.
// NeedsPrepare existed since A8 but was never read this wires it. The VP
// tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
// NeedsPrepare existed since A8 but was never read — this wires it. The VP
// tolerance must swallow the ~36 µm eye rest jitter (RetailPViewRenderer
// R-A2 note) while any real camera motion crosses it in the same frame.
private Matrix4x4 _preparedViewProjection;
private Vector3 _preparedCameraPosition;
@ -223,14 +223,14 @@ public sealed unsafe class EnvCellRenderer :
public bool IsDisposed { get; private set; }
public LastFrameStats Stats => _lastFrameStats;
public struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
internal struct LastFrameStats { public int CellsRendered; public int TrianglesDrawn; }
private LastFrameStats _lastFrameStats;
/// <summary>
/// Diagnostic accessor for the [envcells] probe (Phase A8 apparatus 2026-05-28).
/// Returns (pool-list count total, snapshot's PostPreparePoolIndex high-water).
/// A divergence between expected and actual values would indicate a pool-
/// management regression exactly the bug class the 2026-05-28 audit caught.
/// management regression — exactly the bug class the 2026-05-28 audit caught.
/// </summary>
public (int PoolTotal, int SnapshotPoolHwm) GetPoolDiagnostics()
{
@ -338,20 +338,20 @@ public sealed unsafe class EnvCellRenderer :
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
// Phase U.4: per-frame cellIdCellClip-slot map for the cell shells. When
// Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When
// non-null, RenderModernMDIInternal writes instanceClipSlot[i] =
// _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are
// gated to that cell's portal-clip region. When null (U.3 path), every
// instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0
// (no-clip) but the caller's Render filter already restricts the draw to the
// (no-clip) — but the caller's Render filter already restricts the draw to the
// map's keys, so that fallback should not fire in practice.
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
/// <summary>
/// Phase U.4: install the per-frame cellIdslot map used to gate cell shells
/// Phase U.4: install the per-frame cellId→slot map used to gate cell shells
/// to their portal-clip regions. Call once per frame BEFORE
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
/// the U.3 no-clip behavior (every shell instance slot 0).
/// the U.3 no-clip behavior (every shell instance → slot 0).
/// </summary>
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = cellIdToSlot;
@ -386,7 +386,7 @@ public sealed unsafe class EnvCellRenderer :
surfaces);
// ---------------------------------------------------------------------------
// CommitLandblock render-thread transaction boundary
// CommitLandblock — render-thread transaction boundary
// ---------------------------------------------------------------------------
/// <summary>
@ -586,7 +586,7 @@ public sealed unsafe class EnvCellRenderer :
int? renderRadius = null)
{
// Phase U.4 fix: stash the view-projection so Render() can upload it itself.
// Stashed even when the gate below skips the rebuild Render must always
// Stashed even when the gate below skips the rebuild — Render must always
// project with the CURRENT frame's matrix (the U.4 stale-matrix root cause).
_lastViewProjection = viewProjection;
@ -614,7 +614,7 @@ public sealed unsafe class EnvCellRenderer :
return;
}
// Prepare gate: every snapshot input unchanged keep the active snapshot.
// Prepare gate: every snapshot input unchanged → keep the active snapshot.
// (Same-thread discipline makes the version sample exact: publish, release
// tickets, and this method all run on the render thread.)
if (_hasPreparedSnapshot
@ -633,7 +633,7 @@ public sealed unsafe class EnvCellRenderer :
lock (_renderLock) { _poolIndex = 0; }
// WB skips _cameraLbX/Y update (from LandscapeDoc.Region) here in our variant
// because we don't need camera-LB tracking for the snapshot just frustum tests.
// because we don't need camera-LB tracking for the snapshot — just frustum tests.
// WB EnvCellRenderManager.cs:262:
// Filter loaded landblocks by GpuReady + Instances non-empty.
@ -674,7 +674,7 @@ public sealed unsafe class EnvCellRenderer :
PrepareScratch scratch = _prepareScratch.Value!;
// WB EnvCellRenderManager.cs:279-295: fast path LB fully inside.
// WB EnvCellRenderManager.cs:279-295: fast path — LB fully inside.
if (testResult == FrustumTestResult.Inside)
{
foreach (var (gfxObjId, instances) in lb.BuildingPartGroups)
@ -692,7 +692,7 @@ public sealed unsafe class EnvCellRenderer :
return;
}
// WB EnvCellRenderManager.cs:298-324: slow path per-cell frustum test.
// WB EnvCellRenderManager.cs:298-324: slow path — per-cell frustum test.
HashSet<uint> visibleCells = scratch.VisibleCells;
visibleCells.Clear();
foreach (var kvp in lb.EnvCellBounds)
@ -804,13 +804,13 @@ public sealed unsafe class EnvCellRenderer :
/// <summary>
/// Pure half of the prepare gate's camera test (regression-tested without a
/// GL context, same pattern as <see cref="CreateCommittedSnapshot"/>).
/// Eye position uses a 1 mm ABSOLUTE epsilon: it swallows the ~36 µm rest
/// Eye position uses a 1 mm ABSOLUTE epsilon: it swallows the ~36 µm rest
/// jitter but dirties on any real movement (a slow walk moves 20+ mm/frame).
/// Position must not be tested through the matrix the view-projection's
/// Position must not be tested through the matrix — the view-projection's
/// translation row scales with world coordinates (~5e4 in AC), where a
/// relative tolerance would mask sub-meter motion. Rows 13 of
/// view × projection are position-independent (rotation × projection), so a
/// relative 1e-5 there dirties at ≈0.001° of rotation and on any
/// relative tolerance would mask sub-meter motion. Rows 1–3 of
/// view × projection are position-independent (rotation × projection), so a
/// relative 1e-5 there dirties at ≈0.001° of rotation and on any
/// projection (FOV/aspect/near/far) change.
/// </summary>
internal static bool CameraApproximatelyEqual(
@ -926,7 +926,7 @@ public sealed unsafe class EnvCellRenderer :
// Verbatim port of WB EnvCellRenderManager.cs:395-511.
// Deviations from WB (all documented):
// - Drop the _useModernRendering branch (our codebase asserts modern at startup per Phase N.5).
// - Drop SelectedInstance/HoveredInstance highlight block (lines 486-510) no editor state.
// - Drop SelectedInstance/HoveredInstance highlight block (lines 486-510) — no editor state.
// - Replace RenderModernMDI(base) with private RenderModernMDIInternal.
// - shader.Bind() / SetUniform API: mapped to acdream's legacy Shader
// class (Use() + SetInt/SetVec4/SetMatrix4) to match the existing
@ -947,7 +947,7 @@ public sealed unsafe class EnvCellRenderer :
/// filter (the drawable visible cells from the PView traversal; each cell's
/// shell instances are clip-gated to its CellClip slot by the caller's
/// binding=3 map). NOTE: this is NOT the old two-pipe RenderInsideOut approach
/// that flat camera-inside-building stencil pass was deleted in Phase U.1.
/// — that flat camera-inside-building stencil pass was deleted in Phase U.1.
/// Source: WB EnvCellRenderManager.cs:399-511 (verbatim minus selection highlights).
/// </summary>
public void Render(WbRenderPass renderPass, HashSet<uint>? filter)
@ -979,7 +979,7 @@ public sealed unsafe class EnvCellRenderer :
// WB EnvCellRenderManager.cs:403-404:
_shader.Use();
// FIX 2026-05-28 (pool aliasing root cause): mirror WB
// EnvCellRenderManager.cs:405 restore the pool cursor to the
// EnvCellRenderManager.cs:405 — restore the pool cursor to the
// high-water mark Prepare's merge phase reached, so any
// GetPooledList calls below return lists past the snapshot's
// owned region. Original code used `snapshot.BatchedByCell.Count`
@ -999,7 +999,7 @@ public sealed unsafe class EnvCellRenderer :
// RenderInsideOutAcdream stencil pipeline) change the actual GL
// state without updating these caches. The cache then lies, and
// the per-batch SetCullMode in RenderModernMDIInternal skips its
// glCullFace call leaving stale cull state from the prior
// glCullFace call — leaving stale cull state from the prior
// consumer. For a cottage with mixed CullMode batches, half the
// walls end up culled and the user sees "missing walls".
//
@ -1012,15 +1012,15 @@ public sealed unsafe class EnvCellRenderer :
_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.
// #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
// WbDrawDispatcher. The opaque shell pass runs BEFORE the dispatcher's
// Draw (GameWindow ~7411 vs ~7418, the only other setter), so without
// this the opaque shells used the PREVIOUS frame's matrix a stale
// gl_Position against this frame's clip planes pose-dependent clipping,
// this the opaque shells used the PREVIOUS frame's matrix — a stale
// gl_Position against this frame's clip planes → pose-dependent clipping,
// worst while moving. Same self-contained-GL-state precedent as the
// 2026-05-28 cull-state cache fix above.
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
@ -1059,7 +1059,7 @@ public sealed unsafe class EnvCellRenderer :
else if (filter is null)
{
RebuildUnfilteredGroups(snapshot);
// WB EnvCellRenderManager.cs:418-429: optimized path global groups.
// WB EnvCellRenderManager.cs:418-429: optimized path — global groups.
foreach (var gfxObjId in _activeSnapshotGlobalGfxObjIds)
{
if (_activeSnapshotGlobalGroups.TryGetValue(gfxObjId, out var transforms))
@ -1144,7 +1144,7 @@ public sealed unsafe class EnvCellRenderer :
renderPass);
}
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights DROPPED (no editor state).
// WB EnvCellRenderManager.cs:486-510: selection/hover highlights — DROPPED (no editor state).
// WB EnvCellRenderManager.cs:506-509: cleanup.
_shader.SetVec4("uHighlightColor", new System.Numerics.Vector4(0, 0, 0, 0));
@ -1170,12 +1170,12 @@ public sealed unsafe class EnvCellRenderer :
? dc.renderData.Batches[0].IndexCount / 3
: 0) * dc.count;
// Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) THROWAWAY.
// Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY.
// Per opaque-pass call: totals + per visible (filtered) cell whether it is
// present in the prepared snapshot, and its geometry/flags. Answers why the
// interior walls/ceiling don't appear: NOSNAP / gfx=0 no shell geometry
// prepared for the cell; idx>0 + zh>0 prepared but missing bindless texture
// (invisible); idx>0 + zh=0 + tr=0 opaque geometry drawn (fault is depth/
// interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry
// prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture
// (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/
// occlusion or the geometry isn't the wall). Opaque pass only (halves noise).
if (renderPass == WbRenderPass.Opaque
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled)
@ -1217,7 +1217,7 @@ public sealed unsafe class EnvCellRenderer :
/// <summary>
/// True if the cell's prepared snapshot has any transparent render batch.
/// The pview shell pass uses this to skip the (heavy per-frame) transparent
/// <see cref="Render"/> call for opaque-only cells most cell geometry is
/// <see cref="Render"/> call for opaque-only cells — most cell geometry is
/// opaque walls/floors/ceilings, so this removes the bulk of the per-cell
/// transparent draws. Read-only; mirrors the [shell] probe's batch scan.
/// </summary>
@ -1227,7 +1227,7 @@ public sealed unsafe class EnvCellRenderer :
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet keyed on the cell's world bounds.
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
@ -1248,21 +1248,21 @@ public sealed unsafe class EnvCellRenderer :
var snap = _pointSnapshot;
// Landblocks are keyed by the streaming landblock id 0xXXYYFFFF
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 so the landblock
// (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock
// key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key
// (0xXXYY0000) NEVER matched a registered landblock, so this lookup always
// missed: SelectForObject never ran and every EnvCell wall received ZERO
// point lights (the entire "indoor torches/lanterns don't light the room"
// bug confirmed by the [cell-light] probe: inBounds=False for every cell).
// bug — confirmed by the [cell-light] probe: inBounds=False for every cell).
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) ALL
// #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.
// let the portal set flip as the flood shifted → floor-lighting flap.
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
}
cached.FrameGeneration = _lightFrameGeneration;
@ -1414,9 +1414,9 @@ public sealed unsafe class EnvCellRenderer :
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak
// was established leaked it (same early-out shape as the totalDraws==0 leak —
// see the comment on the state-establish block below).
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (globalVao == 0) return;
@ -1468,14 +1468,14 @@ public sealed unsafe class EnvCellRenderer :
// transparent). Restored to opaque defaults at the end of the draw loop so a
// Transparent pass can't leak into later draws.
//
// §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the
// §4 outdoor full-world flap fix (2026-06-10): this block MOVED below the
// totalDraws==0 early-out above. It used to run before the batch grouping, so a
// Transparent pass over a cell whose batches are ALL opaque (a plain cottage
// interior) set Blend-on/DepthMask-off and then returned at the count check
// WITHOUT reaching the restore. The frame ended with dmask=0; the NEXT frame's
// glClear(DEPTH) silently no-oped (depth clears honor glDepthMask), every world
// fragment failed GL_LESS against its own previous-frame depth ghost, and the
// whole screen dropped to the fog-tinted clear color onset-locked to the
// whole screen dropped to the fog-tinted clear color — onset-locked to the
// building-flood merge (the first frame a flooded building shell draws), holding
// until camera rotation dropped the cell from the flood. From here down every
// path reaches the end-of-pass restore.
@ -1689,14 +1689,14 @@ public sealed unsafe class EnvCellRenderer :
// Phase U.4: upload the per-instance clip-slot buffer (binding=3). When
// _cellIdToSlot is set (indoor routing), each cell shell instance is gated
// to its cell's CellClip slot via allInstances[i].CellId; cells absent from
// the map (shouldn't happen the Render filter is the map's keys) and the
// the map (shouldn't happen — the Render filter is the map's keys) and the
// U.3 path both map to slot 0 (no-clip). allInstances is laid out in the
// SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
if (_clipSlotData.Length < uniqueInstanceCount)
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell
// instance to slot 0 (no-clip) retail draws cell shells WHOLE.
// instance to slot 0 (no-clip) — retail draws cell shells WHOLE.
if (_cellIdToSlot is null
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
{
@ -1728,7 +1728,7 @@ public sealed unsafe class EnvCellRenderer :
// #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.
// _pointSnapshot — the exact data the SSBO upload below carries.
if (renderPass == WbRenderPass.Opaque
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
@ -1766,7 +1766,7 @@ public sealed unsafe class EnvCellRenderer :
PersistActiveDynamicBufferCapacities();
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method a return here would leak the
// (globalVao validated at the top of the method — a return here would leak the
// pass state established above.)
if (_currentVao != globalVao)
{
@ -1865,16 +1865,16 @@ public sealed unsafe class EnvCellRenderer :
}
// ---------------------------------------------------------------------------
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) throwaway apparatus.
// #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
// 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; n2 for one (cell,gfx) = the runtime double-draw), and the
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
// intensity; raw indices shuffle when the pool rebuilds). Plus the
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
// ~12). Change-deduped block with a 2 s heartbeat: a purple identity
// 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.
// ---------------------------------------------------------------------------
@ -1995,8 +1995,8 @@ public sealed unsafe class EnvCellRenderer :
/// Uploads <see cref="_textureTable"/>'s handles to <see cref="_textureTableSsbo"/>
/// when a new one was registered since the last flush, then (re)binds it at
/// <see cref="AcDream.App.Rendering.Gpu.GpuBindingModel.StorageTextureTable"/>.
/// A genuinely new handle is rare new dat surfaces/atlases, not every
/// frame so this is not part of the ring-buffered per-frame SSBO set;
/// A genuinely new handle is rare — new dat surfaces/atlases, not every
/// frame — so this is not part of the ring-buffered per-frame SSBO set;
/// see GlBindlessHandleTable's doc comment.
/// </summary>
private void FlushAndBindTextureTable()
@ -2068,7 +2068,7 @@ public sealed unsafe class EnvCellRenderer :
GLEnum.DynamicDraw,
"allocating EnvCell fallback clip SSBO");
allocated = true;
// One CellClip slot, all zeros: count 0 shader passes every plane.
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
Span<byte> zero = stackalloc byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
zero.Clear();
fixed (byte* p = zero)
@ -2103,12 +2103,12 @@ public sealed unsafe class EnvCellRenderer :
private List<InstanceData> GetPooledList()
{
// Mirrors WB ObjectRenderManagerBase.cs:1221-1233 the reuse
// Mirrors WB ObjectRenderManagerBase.cs:1221-1233 — the reuse
// branch MUST clear the list before returning. PrepareRenderBatches'
// merge phase pattern is `gfxDict[k] = list; list.AddRange(...)`,
// which assumes the list is empty. Without the clear, lists grow
// unbounded across frames and each frame's draw includes all prior
// frames' stale data. Original port omitted the Clear() call root
// frames' stale data. Original port omitted the Clear() call — root
// cause of post-Wave-5 visual chaos (FIX 2026-05-28). See
// docs/research/2026-05-28-a8-env-cell-renderer-audit-findings.md.
lock (_listPool)

Some files were not shown because too many files have changed in this diff Show more