refactor(render): extract world frame preparation
Move camera/root resolution, live settings and listener preview, sky/lighting/fog preparation, animated classification, and building visibility scratch behind a typed WorldRenderFrameBuilder while preserving retail frame order and borrowed lifetimes. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
bc6f09f987
commit
6d6e5b5fa5
8 changed files with 1463 additions and 583 deletions
|
|
@ -77,12 +77,12 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD |
|
||||
| AD-13 | 1-second dedup window for identical system chat messages (retail has none) | `src/AcDream.Core/Chat/ChatLog.cs:29` | ACE dual-sends the same system text (0xF7E0 + 0x02EB) for back-compat; without dedup every line doubled (Phase J compromise) | Two genuinely distinct but textually identical system messages within 1 s collapse to one line where retail shows both | ACE dual-send 0xF7E0 + 0x02EB |
|
||||
| AD-15 | `IsEnv` masks low-16 of the cell id (`(Id & 0xFFFF) >= 0x100`) where retail tests the full id | `src/AcDream.Core/World/Cells/ObjCell.cs:25` | Every real prefixed EnvCell id has low-16 ≥ 0x100 and every outdoor cell ≤ 0x40 — identical answers for all real dat ids, works for both bare and prefixed forms | None for real dat data; a hypothetical convention-violating id would route to the wrong (BSP vs terrain) point-in-cell logic | `CObjCell::GetVisible` pc:308215 |
|
||||
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/GameWindow.cs:7634` | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
|
||||
| AD-16 | Building-flood gate is a CPU frustum test on each building's `PortalBounds` AABB; retail floods exactly when the shell draws and an aperture survives (no bounds constant anywhere) | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameBuildingSource.Gather`) | Documented as the tight equivalent of the shell viewconeCheck for flood purposes (the FPS fix the Chebyshev≤1 hack approximated); per-portal admission still goes through BuildFromExterior's screen clip; missing-bounds buildings always flood (safe over-include) | A too-small/stale PortalBounds AABB means the interior never floods — doorway shows a hole/black aperture from outside (inverse of the vanishing-staircase class) | `DrawBuilding` 0x0059f2a0; `BSPPORTAL::portal_draw_portals_only` 0x53d870 |
|
||||
| AD-17 | ≤8 GPU `gl_ClipDistance` half-planes per view region, degrading to a union-AABB scissor (over-include) on multi-polygon / >8-edge views; particles always scissor; scissor slices disable per-object viewcone culling. Retail CPU-clips against the exact portal polygon | `src/AcDream.App/Rendering/ClipPlaneSet.cs:23` | GL guarantees only 8 simultaneous clip planes; invariant documented: over-inclusion is safe, under-inclusion is the bug class | Fallback on complex multi-aperture views draws terrain/sky/particles/objects outside the true aperture but inside its AABB — background/interior bleed strips at doorways (the **#130** family) | `ACRender::polyClipFinish` decomp:702749; PView portal_view slices |
|
||||
| AD-18 | Aperture far-Z punch is two-pass stencil-gated with an invented mark bias: 0.0005 NDC capped to a 0.5 m EYE-SPACE span (`MarkBiasNdc`); retail's single DEPTHTEST_ALWAYS punch is safe only under painter's far→near order we don't have | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs:149` | **#117** (2026-06-11): the unconditional punch erased nearer occluders, painting interiors through them; the two-pass form is the z-buffered equivalent of retail's ordering safety. **#129** (2026-06-12): the constant-NDC bias spanned ~190 m of eye depth at a landblock (non-linear depth) → distant occluders punched; the eye-space cap bounds the reach (`Issue129PunchBiasTests`). DO-NOT-RETRY: punch must stay depth-gated (ISSUES #108) | Door-plane-hugging geometry beyond the 0.5 m cap re-occludes the aperture (a **#108**-class regression at >10 m viewing range); an occluder within the cap in front of a distant aperture still punches through | `D3DPolyRender::DrawPortalPolyInternal` 0x0059bc90 (maxZ1=7 / maxZ2=6) |
|
||||
| AD-19 | Under outdoor roots, ALL dynamics draw in one z-buffered final pass; retail draws objects painter-ordered per landcell inside the landscape pass (interior roots route per **#118**) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs:126` | The dynamics-drawn-LAST invariant is what makes the aperture depth punch safe (first BR-2 attempt punched after dynamics and erased the player, reverted `88be519`); z-buffer substitutes for painter's order on opaque geometry | Punch/seal correctness hinges on an ordering invariant — any pass added after DrawDynamicsLast, or alpha content needing painter order, gets erased inside apertures or composites wrong | `LScape::draw` → `DrawBlock` 0x005a17c0 → DrawSortCell pc:430124; `PView::DrawCells` 0x005a4840 |
|
||||
| AD-20 | Camera sweep fallback seeds the eye's `AdjustPosition` from the PLAYER's cell; retail re-seats at the sought eye's own tracked cell (rest of function is a verbatim `update_viewer` port) | `src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs:97` | acdream's camera doesn't track the sought-eye's cell separately; the eye is near the player so the player-cell stab list is assumed to cover it | An eye outside the player cell's stab-list coverage (boundary corners, cross-landblock pull-back) seats in the wrong cell — and the viewer cell roots the whole render: one-frame wrong root (flap-class flash) | `SmartBox::update_viewer` 0x00453ce0, pc:92878-92883 |
|
||||
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/GameWindow.cs:7671` | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
||||
| AD-21 | Null-clipRoot legacy outdoor safety path (no portal visibility, no punches/seals, no-clip terrain) for pre-spawn / login / legacy cameras; in-world retail always has a viewer_cell root | `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameRootSource`, `WorldRenderFrame.ClipRoot`); `src/AcDream.App/Rendering/GameWindow.cs` (null-root safety draw) | Result is null ONLY when neither an interior root nor the synthetic outdoor node exists; kept so the login screen shows the live sky | If viewer-root resolution ever returns null in-world (membership bug, fly-camera edge), the frame silently degrades — interiors stop drawing through doorways; the old two-branch FLAP reappears for those frames | `SmartBox::RenderNormalMode` decomp:92635 |
|
||||
| AD-22 | Async streamed mesh loading with bounded CPU replay residency, per-frame upload budgets, and point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's mesh-missing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadCaches.cs`; `src/AcDream.App/Rendering/Wb/MeshUploadFrameBudget.cs` | Immutable preparation descriptors and the bounded CPU cache can re-stage an evicted mesh; dispatcher self-heal makes absence transient while upload budgets prevent a portal arrival from monopolizing a frame | A future consumer that neither retains an owner nor reaches the self-heal/replay path can remain invisible; under heavy admission pressure a valid mesh can pop in later than retail's synchronous path | retail synchronous content load; `docs/architecture/worldbuilder-inventory.md` portal-readiness and bounded-residency seams |
|
||||
| AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal |
|
||||
| AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) |
|
||||
|
|
@ -145,7 +145,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 |
|
||||
| AP-34 | The world now shares one delayed alpha queue across Wb GfxObj/Setup entities and scene particles and drains it at retail's landscape/final boundaries. Residual: the modern reconstruction uses one stable scope-global CYpt sort rather than retail's per-`CPartCell` `CShadowPart` sort followed by cell traversal; `EnvCellRenderer` transparent shell batches also remain immediate and outside this queue | `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `RetailPViewRenderer.cs` (`FlushLandscapeAlpha`); `Rendering/Wb/WbDrawDispatcher.cs`; `ParticleRenderer.cs` | The mandatory modern renderer no longer owns retail `CPartCell` shadow lists. The shared queue restores the material consequence that motivated the port—particles and ordinary translucent parts can interleave—without rebuilding a second scene graph; stable sequence retains authored order on equal CYpt | Transparent objects from different cells can exchange order at a narrow overlap compared with retail cell traversal; an alpha-blended EnvCell shell cannot interleave with a particle or Wb entity, so those rare overlaps can still overpaint differently | `RenderDeviceD3D::DrawObjCellForDummies` 0x005A0760; `CShadowPart::insertion_sort` 0x006B5130; `D3DPolyRender::FlushAlphaList` 0x0059D2E0; `PView::DrawCells` 0x005A4840 |
|
||||
| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 |
|
||||
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
||||
| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) |
|
||||
| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 |
|
||||
| AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures |
|
||||
| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) |
|
||||
|
|
@ -183,7 +183,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) |
|
||||
| AP-81 | **Remote-DR VectorUpdate adds airborne/contact state beyond retail and toggles gravity via the Gravity STATE bit**: the handler writes velocity/omega, then may set Airborne, set `Body.State \|= Gravity`, clear contact, and call `LeaveGround`; both landing blocks clear Gravity after `HitGround()`. Retail `DoVectorUpdate` only writes velocity and omega, keeps GRAVITY set for the object's whole life, and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear). | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (VectorUpdate jump handler); `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (landing blocks) | The extra branch makes an inbound nonzero vertical velocity start the current remote airborne integration even without the complete retail contact-gated acceleration chain; the flag dance delivers gravity only while airborne and the #161 ordering fix keeps the retail HitGround contract satisfied. Slice 4 isolates it rather than changing accepted remote motion during extraction. | A VectorUpdate sent while contact state should remain authoritative can make the remote leave ground earlier than retail; any new call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate; grounded remotes carry a non-retail state word. | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::calc_acceleration`; `set_on_walkable @ 0x00511310`; retire when the complete contact-gated acceleration path owns remote motion. |
|
||||
| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) |
|
||||
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/GameWindow.cs` (`_lightPoolVisibleCells`/`_lightPoolVisibleCellsValid`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||
| AP-85 | **Point-light pool = single 128-cap player-nearest list, optionally FILTERED by LAST FRAME's rendered visible-cell set, vs retail's dual pools (7 dynamic + 40 static, degrade-scaled) collected from a DBObj-load/flush-bounded resident registry** (A7.L1, 2026-07-09 — third revision, Town Network starvation fix #79/#93/#176/#177): retail's `CEnvCell::visible_cell_table` (`add_visible_cell` 0x0052de40) is populated ON DEMAND as cells are approached/seen (`DBObj::Get`-loads) and pruned by `flush_cells` — so a real dungeon's per-frame candidate set stays small (naturally proximity-bounded) even though the collection walk itself (`add_dynamic_lights` 0x0052d410) is "the whole resident table, not a re-flood." acdream's `_all` list instead registers at LANDBLOCK-granularity load/unload (a whole single-landblock dungeon streams as ONE unit), so for the Town Network (463 registered fixtures, one landblock) `_all` is effectively "everything ever loaded in this dungeon," not a proximity-bounded set — wide enough that the player-nearest-128 cap alone let a straight-line-closer-but-wall-disconnected corridor's fixtures out-rank the player's own room, starving it. Fix: `BuildPointLightSnapshot(playerWorldPos, visibleCells)` takes an optional candidacy FILTER — a light joins the pool iff `CellId==0` (cell-less, always in) or `visibleCells.Contains(CellId)` — narrowing candidates to the frame's actual visible cells BEFORE the existing dynamics-first player-nearest cap runs; `GameWindow` feeds LAST FRAME's already-rendered `RetailPViewFrameResult.DrawableCells` back to `WorldRenderFrameBuilder` (one frame / ~16 ms latency, chosen specifically to avoid re-threading a mid-`DrawInside` callback — the exact mechanism, `c500912b`, that caused the #176 seam-floor flicker regression when it re-flooded an independent CAMERA-seeded set mid-frame). The distance-sort anchor stays the PLAYER (unchanged from the prior revision) — only candidacy narrows. Remaining deviation: this is a RENDER-visibility approximation of retail's true on-demand-load/flush RESIDENCY bound, with one frame of latency, not a port of the DBObj-load/flush mechanism itself; and the pool is still ONE 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.ObserveDrawableCells`, `ClearDrawableCells`, `Prepare`); pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_VisibleCellScoping_RoomLightsSurviveOverEuclideanCloserInvisibleCell`, `BuildPointLightSnapshot_VisibleCellScoping_CellLessLightAlwaysIncluded` | The render already computes a visible-cell set every frame for drawing (single source of truth, no duplicate flood) — reusing it as a candidacy filter approximates retail's proximity-bounded residency without porting DBObj on-demand load/flush; one-frame latency is imperceptible at normal camera speeds and structurally differs from the reverted mechanism (no independent re-flood mid-frame) | On a portal crossing, the FIRST indoor frame after re-entry (or after any outdoor-only frame) is unscoped (fail-open) — one frame may show slightly wider pool composition than steady-state; a room with >7 resident dynamics still shows them all (retail trims to 7 player-nearest) — slightly purpler wedge than retail; adopt the dual pools + degrade caps + true DBObj-bounded residency in later A7-arc work | `insert_light` 0x0054d1b0 (player-sorted, capped); `add_visible_cell` 0x0052de40 (on-demand-load resident registry + flush); `add_dynamic_lights` 0x0052d410 (whole-table walk); caller 0x00452d30; `calc_point_light` 0x0059c8b0 (static 1/d³ curve — A7 fix #2) |
|
||||
| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities |
|
||||
| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` |
|
||||
| AP-91 | **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` |
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ port, or resource-lifetime redesign.
|
|||
introduce data-only frame contracts plus deterministic order/failure tests.
|
||||
- [x] B — extract paperdoll, panel-layout/devtools, and render-diagnostics leaf
|
||||
owners while `GameWindow` still orders the frame.
|
||||
- [ ] C — extract frame-resource begin/upload/publication and live display,
|
||||
- [x] C — extract frame-resource begin/upload/publication and live display,
|
||||
and weather/display foundations that do not depend on camera/root
|
||||
classification.
|
||||
- [ ] D — extract frame-data classification and reusable world scratch without
|
||||
- [x] D — extract frame-data classification and reusable world scratch without
|
||||
duplicating live identity, spatial, visibility, or resource ownership; then
|
||||
run the dependent audio, sky, lighting, and listener preparation.
|
||||
- [ ] E — replace `RetailPViewDrawContext`'s callback bag with a typed
|
||||
|
|
@ -404,6 +404,19 @@ corrected-diff reviews are clean.
|
|||
fallback/null-root, sealed/SeenOutside, and borrowed scratch reset;
|
||||
- preserve allocation-free warmed frames and hysteretic scratch retention.
|
||||
|
||||
Completed 2026-07-22. `WorldRenderFrameBuilder` now owns the ordered camera,
|
||||
visibility, live settings/listener, player-lighting versus collided-viewer root,
|
||||
sky/lighting/fog, animated-ID, and building-candidate preparation. Reusable
|
||||
animated/building/light-cell collections retain their explicit one-frame
|
||||
borrowed lifetime; PView drawable-cell feedback returns through the builder and
|
||||
is copied before the renderer reuses its own scratch. The diagnostic sky-PES
|
||||
experiment moved to `SkyPesFrameController`, and fog startup overrides now enter
|
||||
through typed `RuntimeOptions`. The frame graph is withdrawn before its borrowed
|
||||
equipped/effect/audio owners during shutdown. `GameWindow.cs` is 5,715 raw
|
||||
lines, 63.6% below the 15,723-line campaign baseline. Twenty-nine focused tests,
|
||||
the Release build, and the complete suite pass (7,281 passed / 5 skipped).
|
||||
Retail, architecture, and adversarial corrected-diff reviews are clean.
|
||||
|
||||
### E — typed PView pass executor
|
||||
|
||||
- replace `Action`/`Func` draw callbacks in `RetailPViewDrawContext`;
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ public sealed class GameWindow : IDisposable
|
|||
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
|
||||
|
||||
private readonly AcDream.App.RuntimeOptions _options;
|
||||
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
|
||||
private readonly string _datDir;
|
||||
|
|
@ -108,6 +106,9 @@ public sealed class GameWindow : IDisposable
|
|||
_renderFrameLivePreparation;
|
||||
private AcDream.App.Rendering.RenderWeatherFrameController?
|
||||
_renderWeatherFrame;
|
||||
private AcDream.App.Rendering.WorldRenderFrameBuilder?
|
||||
_worldRenderFrameBuilder;
|
||||
private AcDream.App.Rendering.SkyPesFrameController? _skyPesFrame;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private readonly DisplayFramePacingController _displayFramePacing;
|
||||
|
||||
|
|
@ -126,8 +127,18 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Streaming.LocalPlayerTeleportController?
|
||||
_localPlayerTeleport;
|
||||
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
|
||||
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
|
||||
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
|
||||
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
|
||||
new(nearRadius: 4, farRadius: 12);
|
||||
private int _nearRadius
|
||||
{
|
||||
get => _renderRange.NearRadius;
|
||||
set => _renderRange.NearRadius = value;
|
||||
}
|
||||
private int _farRadius
|
||||
{
|
||||
get => _renderRange.FarRadius;
|
||||
set => _renderRange.FarRadius = value;
|
||||
}
|
||||
// A.5 T22.5: resolved quality settings (preset + env-var overrides).
|
||||
// Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on
|
||||
// ReapplyQualityPreset(). Default matches QualityPreset.High so the field
|
||||
|
|
@ -226,12 +237,8 @@ public sealed class GameWindow : IDisposable
|
|||
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
|
||||
private ClipFrame? _clipFrame;
|
||||
|
||||
// Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor
|
||||
// world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt
|
||||
// each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not
|
||||
// yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step).
|
||||
private LoadedCell? _outdoorNode;
|
||||
private readonly List<LoadedCell> _outdoorNodeBuildingCells = new();
|
||||
// PView pass-local particle classifications move with the pass executor in
|
||||
// Checkpoint E; GameWindow still owns them until that cutover.
|
||||
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new();
|
||||
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
|
||||
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
|
||||
|
|
@ -248,13 +255,6 @@ public sealed class GameWindow : IDisposable
|
|||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
|
||||
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
|
||||
|
||||
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
|
||||
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
|
||||
// (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can
|
||||
// always be a live (possibly empty) HashSet instead of `new`ing one every
|
||||
// frame or passing null.
|
||||
private readonly HashSet<uint> _animatedIdsScratch = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tier 1 cache (#53): per-entity classification results for static
|
||||
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
|
||||
|
|
@ -298,35 +298,6 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
|
||||
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
|
||||
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
|
||||
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
|
||||
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
|
||||
// Keep the experimental path available for DAT archaeology only.
|
||||
// Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug.
|
||||
|
||||
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
|
||||
// Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex.
|
||||
|
||||
// Issue #47 — use retail's close-detail GfxObj selection on
|
||||
// humanoid setups. When enabled, every per-part GfxObj id (after
|
||||
// server AnimPartChanges are applied) is replaced with Degrades[0]
|
||||
// from its DIDDegrade table when present. See GfxObjDegradeResolver
|
||||
// for the full retail-decomp citation. Default-on after visual
|
||||
// confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for
|
||||
// diagnostic before/after comparisons.
|
||||
// Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades.
|
||||
|
||||
// Issue #48 diagnostic — dump per-scenery-spawn placement evidence
|
||||
// (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ,
|
||||
// mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets
|
||||
// the user identify a floating tree by its world coordinates and tell
|
||||
// whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift
|
||||
// (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md.
|
||||
// Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ.
|
||||
|
||||
/// <summary>
|
||||
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
|
||||
private readonly HashSet<SkyPesKey> _missingSkyPes = new();
|
||||
|
||||
// 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
|
||||
|
|
@ -467,21 +438,6 @@ public sealed class GameWindow : IDisposable
|
|||
AcDream.Core.World.SkyStateProvider.Default());
|
||||
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
|
||||
|
||||
// A7.L1 (2026-07-09) — last frame's rendered visible-cell set, fed into next
|
||||
// frame's Lighting.BuildPointLightSnapshot scoping. One frame of latency
|
||||
// (~16 ms) instead of a mid-DrawInside callback: DrawableCells only becomes
|
||||
// known partway through DrawInside (after the flood + look-in merge), and the
|
||||
// #176 correction already showed that rebuilding the light pool FROM a
|
||||
// freshly-recomputed frame flood (the deleted RebuildScopedLights callback,
|
||||
// c500912b) is a flicker mechanism. Reusing last frame's already-drawn set
|
||||
// (MP-Alloc: reused HashSet, no per-frame alloc) decouples the light-scoping
|
||||
// change from DrawInside's internals entirely. _lightPoolVisibleCellsValid is
|
||||
// false until the first indoor frame completes, and is cleared on any
|
||||
// outdoor-only frame so scoping fails open (unscoped) for one frame on
|
||||
// re-entry rather than filtering by a stale dungeon's cell ids.
|
||||
private readonly HashSet<uint> _lightPoolVisibleCells = new();
|
||||
private bool _lightPoolVisibleCellsValid;
|
||||
|
||||
public readonly AcDream.Core.World.WeatherSystem Weather = new();
|
||||
// Wired into the hook router in OnLoad so SetLightHook fires
|
||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||
|
|
@ -2890,6 +2846,52 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Rendering.RenderWeatherFrameController(
|
||||
WorldTime,
|
||||
Weather);
|
||||
_skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
|
||||
_scriptRunner!,
|
||||
_particleSink!,
|
||||
_effectPoses,
|
||||
_entityEffects);
|
||||
var worldFrameEnvironment =
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameEnvironmentPreparation(
|
||||
_options,
|
||||
WorldTime,
|
||||
Lighting,
|
||||
_wbDrawDispatcher,
|
||||
_envCellRenderer,
|
||||
_sceneLightingUbo,
|
||||
_renderRange,
|
||||
_skyPesFrame);
|
||||
_worldRenderFrameBuilder =
|
||||
new AcDream.App.Rendering.WorldRenderFrameBuilder(
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
|
||||
_cameraController!,
|
||||
_localPlayerTeleport),
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
|
||||
_retailSelectionScene,
|
||||
_particleVisibility,
|
||||
_terrain,
|
||||
_worldReveal,
|
||||
_envCellFrustum),
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview(
|
||||
_settingsVm,
|
||||
_audioEngine,
|
||||
_cameraController!,
|
||||
_displayFramePacing),
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameRootSource(
|
||||
_physicsEngine,
|
||||
_cellVisibility,
|
||||
_localPlayerMode,
|
||||
_chaseCameraInput,
|
||||
_playerControllerSlot,
|
||||
_liveWorldOrigin),
|
||||
worldFrameEnvironment,
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameAnimatedEntitySource(
|
||||
_animatedEntities,
|
||||
_staticAnimationScheduler,
|
||||
_equippedChildRenderer),
|
||||
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
|
||||
_landblockPresentationPipeline!,
|
||||
_cellVisibility));
|
||||
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
||||
liveObjectFrame,
|
||||
_worldState,
|
||||
|
|
@ -3268,115 +3270,8 @@ public sealed class GameWindow : IDisposable
|
|||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
private void UpdateSkyPes(
|
||||
float dayFraction,
|
||||
AcDream.Core.World.DayGroupData? dayGroup,
|
||||
System.Numerics.Vector3 cameraWorldPos,
|
||||
bool suppressSky)
|
||||
{
|
||||
if (_scriptRunner is null || _particleSink is null)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<SkyPesKey>();
|
||||
if (!suppressSky && dayGroup is not null)
|
||||
{
|
||||
for (int i = 0; i < dayGroup.SkyObjects.Count; i++)
|
||||
{
|
||||
var obj = dayGroup.SkyObjects[i];
|
||||
if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction))
|
||||
continue;
|
||||
|
||||
var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene);
|
||||
seen.Add(key);
|
||||
uint skyEntityId = SkyPesEntityId(key);
|
||||
var renderPass = obj.IsPostScene
|
||||
? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene
|
||||
: AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene;
|
||||
_particleSink.SetEntityRenderPass(skyEntityId, renderPass);
|
||||
var anchor = SkyPesAnchor(obj, cameraWorldPos);
|
||||
var rotation = SkyPesRotation(obj, dayFraction);
|
||||
_effectPoses.Publish(
|
||||
skyEntityId,
|
||||
System.Numerics.Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* System.Numerics.Matrix4x4.CreateTranslation(anchor),
|
||||
System.Array.Empty<System.Numerics.Matrix4x4>(),
|
||||
cellId: 0u);
|
||||
|
||||
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
|
||||
continue;
|
||||
|
||||
_entityEffects?.RegisterSyntheticOwner(skyEntityId);
|
||||
|
||||
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
|
||||
{
|
||||
_activeSkyPes.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_missingSkyPes.Add(key);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.ClearEntityRenderPass(skyEntityId);
|
||||
_effectPoses.Remove(skyEntityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in _activeSkyPes.ToArray())
|
||||
{
|
||||
if (seen.Contains(key))
|
||||
continue;
|
||||
|
||||
uint skyEntityId = SkyPesEntityId(key);
|
||||
_scriptRunner.StopAllForEntity(skyEntityId);
|
||||
_entityEffects?.UnregisterSyntheticOwner(skyEntityId);
|
||||
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
|
||||
_effectPoses.Remove(skyEntityId);
|
||||
_activeSkyPes.Remove(key);
|
||||
}
|
||||
|
||||
foreach (var key in _missingSkyPes.ToArray())
|
||||
{
|
||||
if (!seen.Contains(key))
|
||||
_missingSkyPes.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint SkyPesEntityId(SkyPesKey key)
|
||||
{
|
||||
// 0xF0000000 prefix marks synthetic sky-PES entityIds (no real
|
||||
// server GUID lives in the 0xFxxxxxxx space). Reserve bit
|
||||
// 0x08000000 for the pre/post-scene flag and the lower 27 bits
|
||||
// for the object index — keeps the post-scene flag from sliding
|
||||
// into the index range if a future DayGroup ever ships >65k sky
|
||||
// objects (current Dereth max is 18, but the constraint is free).
|
||||
uint postBit = key.PostScene ? 0x08000000u : 0u;
|
||||
return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu);
|
||||
}
|
||||
|
||||
internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) =>
|
||||
entity.Id;
|
||||
|
||||
|
||||
private static System.Numerics.Vector3 SkyPesAnchor(
|
||||
AcDream.Core.World.SkyObjectData obj,
|
||||
System.Numerics.Vector3 cameraWorldPos)
|
||||
{
|
||||
if (obj.IsWeather && (obj.Properties & 0x08u) == 0u)
|
||||
return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f);
|
||||
|
||||
return cameraWorldPos;
|
||||
}
|
||||
|
||||
private static System.Numerics.Quaternion SkyPesRotation(
|
||||
AcDream.Core.World.SkyObjectData obj,
|
||||
float dayFraction)
|
||||
{
|
||||
float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f);
|
||||
return System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||
System.Numerics.Vector3.UnitY,
|
||||
-rotationRad);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
|
||||
/// Routes fog presets into the weather system's sticky override
|
||||
|
|
@ -3522,280 +3417,34 @@ public sealed class GameWindow : IDisposable
|
|||
if (_cameraController is not null && !portalViewportVisible)
|
||||
{
|
||||
_retailAlphaQueue.BeginFrame();
|
||||
var activeCamera = _cameraController.Active;
|
||||
var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera)
|
||||
?? activeCamera;
|
||||
var worldProjection = camera.Projection;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
|
||||
_retailSelectionScene?.SetViewFrustum(frustum);
|
||||
|
||||
// Extract camera world position from the inverse of the view
|
||||
// matrix — needed by the scene-lighting UBO (for fog distance)
|
||||
// and by the sky renderer (for the camera-centered sky dome).
|
||||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
||||
_particleVisibility.BeginFrame(camPos);
|
||||
_terrain?.BeginVisibilityFrame();
|
||||
if (!IsLiveModeWaitingForLogin)
|
||||
{
|
||||
_particleVisibility.UseWorldView();
|
||||
_worldReveal?.ObserveWorldViewportVisible();
|
||||
_worldReveal?.Complete();
|
||||
}
|
||||
|
||||
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the
|
||||
// engine each frame, so volume sliders preview audibly while
|
||||
// the user drags. Cancel reverts the draft and the engine
|
||||
// catches up on the very next frame; Save persists to
|
||||
// settings.json without changing engine state (already
|
||||
// applied). Cheap enough to run unconditionally on every
|
||||
// tick — four float assignments.
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null)
|
||||
{
|
||||
var a = _settingsVm.AudioDraft;
|
||||
_audioEngine.MasterVolume = a.Master;
|
||||
_audioEngine.MusicVolume = a.Music;
|
||||
_audioEngine.SfxVolume = a.Sfx;
|
||||
_audioEngine.AmbientVolume = a.Ambient;
|
||||
}
|
||||
|
||||
// L.0 Display tab: push the live DisplayDraft into the
|
||||
// active rendering surfaces each frame. FOV is the live-
|
||||
// preview slider per the brainstorm — dragging it changes
|
||||
// camera FovY immediately. VSync change-detected to avoid
|
||||
// spamming the window. Resolution + Fullscreen apply on
|
||||
// Save (handled by ApplyDisplayWindowState — too jarring
|
||||
// to live-preview a resize).
|
||||
if (_settingsVm is not null && _cameraController is not null)
|
||||
{
|
||||
var d = _settingsVm.DisplayDraft;
|
||||
float fovYRad = d.FieldOfView * (MathF.PI / 180f);
|
||||
_cameraController.Orbit.FovY = fovYRad;
|
||||
_cameraController.Fly.FovY = fovYRad;
|
||||
if (_cameraController.Chase is not null)
|
||||
_cameraController.Chase.FovY = fovYRad;
|
||||
_displayFramePacing.ApplyPreference(d.VSync);
|
||||
}
|
||||
|
||||
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||
// correctly relative to where we're looking.
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||
{
|
||||
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
|
||||
var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23);
|
||||
_audioEngine.SetListener(
|
||||
camPos.X, camPos.Y, camPos.Z,
|
||||
fwd.X, fwd.Y, fwd.Z,
|
||||
up.X, up.Y, up.Z);
|
||||
}
|
||||
|
||||
// Step 4: portal visibility — compute BEFORE the UBO upload so
|
||||
// the indoor flag drives the sun's intensity to zero for
|
||||
// dungeons (r13 §13.7).
|
||||
// Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the
|
||||
// collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 →
|
||||
// DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991).
|
||||
// The viewer cell is the camera-collision sweep's swept cell
|
||||
// (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell):
|
||||
// graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source
|
||||
// (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints.
|
||||
// SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail
|
||||
// CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides
|
||||
// whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs
|
||||
// SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone.
|
||||
|
||||
// ── Lighting root: the PLAYER cell (CurrCell). ──
|
||||
LoadedCell? playerRoot = null;
|
||||
if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj
|
||||
&& _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell))
|
||||
playerRoot = playerRegCell;
|
||||
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
|
||||
|
||||
// ── Render root: the VIEWER (collided camera) cell + eye. ──
|
||||
// Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the
|
||||
// non-default legacy/debug camera paths: the player's registered cell (or none).
|
||||
uint viewerCellId =
|
||||
(_playerMode && _retailChaseCamera is not null
|
||||
&& AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera)
|
||||
? _retailChaseCamera.ViewerCellId
|
||||
: (playerRoot?.CellId ?? 0u);
|
||||
var viewerEyePos = camPos; // the collided eye drives the projection
|
||||
var playerViewPos = _playerController?.RenderPosition
|
||||
?? _playerController?.Position
|
||||
?? camPos;
|
||||
LoadedCell? viewerRoot = null;
|
||||
if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell))
|
||||
viewerRoot = viewerRegCell;
|
||||
// T4 (BR-6): the per-frame ACME BFS (ComputeVisibilityFromRoot) is
|
||||
// DELETED from the frame — it ran a full second visibility
|
||||
// computation whose only production consumer was this boolean,
|
||||
// which is exactly "the viewer root resolved to a loaded interior
|
||||
// cell" (TryGetCell above already proves cells are loaded). The
|
||||
// PView flood is the ONE visibility gate (feedback_render_one_gate).
|
||||
bool cameraInsideCell = viewerRoot is not null;
|
||||
|
||||
// Retail render routing is owned by the collided camera/viewer cell.
|
||||
// The player cell still owns lighting state, but it must not force an
|
||||
// indoor draw while the camera is outside; that drops the outdoor pass
|
||||
// and leaves clear color around a floating doorway slice.
|
||||
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true;
|
||||
|
||||
// Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified
|
||||
// gated-draw block (after envCellViewProj exists) where it can report
|
||||
// the real PortalVisibilityFrame — OutsideView polygon/plane counts and
|
||||
// per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead
|
||||
// of the old camera-state-only spike. See the U.4 ClipFrame assembly
|
||||
// below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled).
|
||||
|
||||
// Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the
|
||||
// seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0)
|
||||
// gates sun/lighting off seen_outside on the player's current cell, NOT off an
|
||||
// independent AABB containment scan. playerInsideCell = true (kill sunlight) only
|
||||
// when the player is inside a SEALED interior (seen_outside=false = dungeon).
|
||||
// Building interiors with seen_outside=true keep the sun (sky visible through door).
|
||||
// V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent
|
||||
// of the camera's viewer cell — retail kills the sun off the player's cell, not the eye.
|
||||
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
|
||||
|
||||
// Phase C.1: tick retail PhysicsScript particle hooks. Named
|
||||
// retail decomp confirms SkyObject.PesObjectId is copied by
|
||||
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
|
||||
// debug-only and disabled for normal retail rendering.
|
||||
if (_options.EnableSkyPesDebug)
|
||||
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
|
||||
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||
// the scene-lighting UBO once per frame. Every shader that
|
||||
// consumes binding=1 reads the same data for the rest of the
|
||||
// frame — terrain, static mesh, instanced mesh, sky.
|
||||
UpdateSunFromSky(kf, playerInsideCell);
|
||||
// A7 indoor lighting: position retail's viewer fill light at the player
|
||||
// (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is
|
||||
// the primary interior fill (no sun indoors) and is indoor-only via the
|
||||
// AP-43 gate. playerViewPos is the player render position (or camPos when
|
||||
// there is no player), matching retail's player/viewer branch.
|
||||
Lighting.UpdateViewerLight(playerViewPos);
|
||||
Lighting.Tick(camPos);
|
||||
|
||||
// Fix B (A7 #3): build this frame's point-light snapshot and hand it to
|
||||
// the entity dispatcher for per-OBJECT light selection
|
||||
// (minimize_object_lighting). Replaces the single global nearest-8-to-
|
||||
// camera UBO set for point/spot lights so a wall's torches stay tied to
|
||||
// the wall as the camera moves. The SUN + ambient still flow through the
|
||||
// SceneLighting UBO built below (binding=1) — terrain/sky read those.
|
||||
// #176 root cause: the pool is anchored at the PLAYER (retail
|
||||
// Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and
|
||||
// collected from ALL registered (=resident-cell) lights — it is a
|
||||
// function of player position only, so camera rotation cannot change
|
||||
// it. playerViewPos carries retail's no-player fallback (camPos).
|
||||
// A7.L1: candidacy is additionally scoped to last frame's rendered
|
||||
// visible-cell set (see _lightPoolVisibleCells) when available — the
|
||||
// Town Network starvation fix. Unscoped (null) until the first indoor
|
||||
// frame completes or after any outdoor-only frame.
|
||||
Lighting.BuildPointLightSnapshot(
|
||||
playerViewPos,
|
||||
_lightPoolVisibleCellsValid ? _lightPoolVisibleCells : null);
|
||||
_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);
|
||||
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
|
||||
|
||||
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
|
||||
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
|
||||
|
||||
// A.5 T22: override fog ramp with N₁/N₂-derived distances so the
|
||||
// horizon fog masks the N₁ scenery boundary. Sky keyframe fog is
|
||||
// retail-accurate at normal view distances but far too short for
|
||||
// the extended N₂=12 (25×25 LB) streaming window.
|
||||
// FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12).
|
||||
// FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults.
|
||||
// Multipliers exposed as env vars for fast iteration at visual gate.
|
||||
{
|
||||
const float LandblockSize = 192.0f;
|
||||
float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f);
|
||||
float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f);
|
||||
float fogStart = _nearRadius * LandblockSize * startMult;
|
||||
float fogEnd = _farRadius * LandblockSize * endMult;
|
||||
// Preserve fog color (xyz), lightning flash (z), and mode (w).
|
||||
ubo.FogParams = new System.Numerics.Vector4(
|
||||
fogStart,
|
||||
fogEnd,
|
||||
ubo.FogParams.Z, // lightning flash — unchanged
|
||||
ubo.FogParams.W); // fog mode — unchanged
|
||||
}
|
||||
|
||||
_sceneLightingUbo?.Upload(ubo);
|
||||
|
||||
// #133 A7 (2026-06-13): objective dungeon-lighting probe. One
|
||||
// rate-limited [light] line — insideCell / ambient / sun /
|
||||
// registered-point-lights / active-slot-count / player cell — so
|
||||
// the dungeon-dim question is self-verifiable from launch.log
|
||||
// without a screenshot. RegisteredCount is point/spot lights only
|
||||
// (the sun lives in LightManager.Sun, never in the _all list);
|
||||
// ubo.CellAmbient.W is the shader active-slot count, which counts
|
||||
// the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1.
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitLight(
|
||||
insideCell: playerInsideCell,
|
||||
ambientR: Lighting.CurrentAmbient.AmbientColor.X,
|
||||
ambientG: Lighting.CurrentAmbient.AmbientColor.Y,
|
||||
ambientB: Lighting.CurrentAmbient.AmbientColor.Z,
|
||||
sunIntensity: Lighting.Sun?.Intensity ?? 0f,
|
||||
registeredLights: Lighting.RegisteredCount,
|
||||
activeLights: (int)ubo.CellAmbient.W,
|
||||
playerCellId: playerRoot?.CellId ?? 0u,
|
||||
lights: Lighting);
|
||||
AcDream.App.Rendering.WorldRenderFrame worldFrame =
|
||||
_worldRenderFrameBuilder!.Build(
|
||||
in foundation,
|
||||
IsLiveModeWaitingForLogin,
|
||||
_activeDayGroup);
|
||||
AcDream.App.Rendering.WorldCameraFrame cameraFrame = worldFrame.Camera;
|
||||
AcDream.App.Rendering.WorldRootFrame rootFrame = worldFrame.Roots;
|
||||
var camera = cameraFrame.Camera;
|
||||
var worldProjection = cameraFrame.Projection;
|
||||
var frustum = cameraFrame.Frustum;
|
||||
var camPos = cameraFrame.Position;
|
||||
// The frame builder resolves retail's player-lighting root and
|
||||
// collided-viewer render root before any draw decision is made.
|
||||
LoadedCell? playerRoot = rootFrame.PlayerRoot;
|
||||
bool playerSeenOutside = rootFrame.PlayerSeenOutside;
|
||||
uint viewerCellId = rootFrame.ViewerCellId;
|
||||
var viewerEyePos = rootFrame.ViewerEyePosition;
|
||||
var playerViewPos = rootFrame.PlayerViewPosition;
|
||||
LoadedCell? viewerRoot = rootFrame.ViewerRoot;
|
||||
bool cameraInsideCell = rootFrame.CameraInsideCell;
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
uint? playerLb = rootFrame.PlayerLandblockId;
|
||||
int renderCenterLbX = rootFrame.RenderCenterLandblockX;
|
||||
int renderCenterLbY = rootFrame.RenderCenterLandblockY;
|
||||
|
||||
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
|
||||
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||||
|
||||
// Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot
|
||||
// is prepared after the portal-visible cell filter is known.
|
||||
var envCellViewProj = camera.View * worldProjection;
|
||||
_envCellFrustum?.Update(envCellViewProj);
|
||||
|
||||
// MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a
|
||||
// HashSet<uint> every frame. Downstream consumers (WbDrawDispatcher.
|
||||
// WalkEntitiesInto) treat null and an empty set identically, so an
|
||||
// always-non-null (possibly empty) set is behaviorally the same as
|
||||
// the old null-when-nothing-animated local.
|
||||
//
|
||||
// Every entity in _animatedEntities (i.e. every entity with a
|
||||
// Sequencer) is added UNCONDITIONALLY: membership here is the
|
||||
// "re-classify me every frame, don't use the Tier-1 static cache"
|
||||
// flag, and the Tier-1 cache captures an entity's REST pose +
|
||||
// opacity-1.0 exactly once. Any entity whose rendered state can
|
||||
// depart that rest — a door held at its final OPEN frame, a wall
|
||||
// held FADED-OUT by a TransparentPartHook — MUST stay on the
|
||||
// per-frame path so the live sequencer pose + TranslucencyFadeManager
|
||||
// opacity are read; otherwise it flips back to the stale cached
|
||||
// closed/opaque state the instant its animation settles.
|
||||
//
|
||||
// DO NOT re-narrow this to "only if the current cycle is multi-frame"
|
||||
// (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled
|
||||
// one-shot hold IS pose-stable frame-to-frame, but stable at the
|
||||
// HELD pose the cache does NOT contain — that is the door/fade
|
||||
// flip-back. The per-frame re-classification cost that narrowing
|
||||
// saved was a DEBUG-build artifact; Release is GPU-bound (the CPU
|
||||
// work hides under GPU time), so the unconditional add is free where
|
||||
// it matters. If a real Release CPU cost from static-prop
|
||||
// re-classification is ever measured, gate on "entity is at its
|
||||
// captured rest state" (default motion AND no active fade), never on
|
||||
// "is mid-cycle".
|
||||
_animatedEntities.CopySpatialIdsTo(_animatedIdsScratch);
|
||||
_staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch);
|
||||
if (_equippedChildRenderer is not null)
|
||||
{
|
||||
foreach (uint id in _equippedChildRenderer.AttachedEntityIds)
|
||||
_animatedIdsScratch.Add(id);
|
||||
}
|
||||
HashSet<uint>? animatedIds = _animatedIdsScratch;
|
||||
var envCellViewProj = cameraFrame.ViewProjection;
|
||||
HashSet<uint> animatedIds = worldFrame.AnimatedEntityIds;
|
||||
|
||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
||||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
|
|
@ -3814,7 +3463,7 @@ public sealed class GameWindow : IDisposable
|
|||
// Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped
|
||||
// to the doorway via the OutsideView (Stage 4, below).
|
||||
// Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky.
|
||||
bool renderSky = viewerRoot is null || rootSeenOutside;
|
||||
bool renderSky = rootFrame.RenderSky;
|
||||
// Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape
|
||||
// position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to
|
||||
// the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky
|
||||
|
|
@ -3841,72 +3490,20 @@ public sealed class GameWindow : IDisposable
|
|||
// GPU-fenced frame slot; each renderer re-binds binding=2 defensively.
|
||||
_clipFrame ??= ClipFrame.NoClip();
|
||||
|
||||
// Phase 3 (render unification, additive): build the synthetic outdoor cell node when
|
||||
// the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet
|
||||
// rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors
|
||||
// the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks);
|
||||
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
|
||||
// OutsideView terrain integration that consumes this is the next (cutover) step.
|
||||
_outdoorNode = null;
|
||||
_outdoorNodeBuildingCells.Clear();
|
||||
if (viewerRoot is not null || viewerCellId != 0u)
|
||||
{
|
||||
// T2 (BR-4): draw-driven flood gating. Retail floods a building's
|
||||
// interior exactly when its shell DRAWS and an aperture survives
|
||||
// the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck
|
||||
// → portal-BSP walk → ConstructView's GetClip; NO distance
|
||||
// constant anywhere on the chain). Port: a per-BUILDING frustum
|
||||
// pre-gate on the aperture bounds (Building.PortalBounds — the
|
||||
// tight equivalent of the shell viewconeCheck for FLOOD purposes),
|
||||
// replacing the old Chebyshev≤1 landblock cell-sweep; the 48 m
|
||||
// seed cap dies with it (RetailPViewRenderer seeds at ∞). The
|
||||
// per-portal admission stays BuildFromExterior's screen clip
|
||||
// (empty clip = no seed) — retail's GetClip-vs-view gate.
|
||||
// Per-building iteration is also the FPS fix the 2026-06-07
|
||||
// Chebyshev hack approximated: dozens of AABB tests instead of an
|
||||
// O(all loaded cells) portal sweep.
|
||||
// #124: the gather now runs for INTERIOR roots too — retail's
|
||||
// look-in executes inside LScape::draw for ANY root with a
|
||||
// non-empty outside view (DrawCells pc:432719). The renderer
|
||||
// routes interior-root look-ins to its landscape-stage sub-pass
|
||||
// (DrawBuildingLookIns); the root's own building self-excludes
|
||||
// via the seed eye-side test.
|
||||
foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries
|
||||
?? Array.Empty<AcDream.App.Rendering.Wb.BuildingRegistry>())
|
||||
{
|
||||
foreach (var b in registry.All())
|
||||
{
|
||||
if (b.HasPortalBounds
|
||||
&& !AcDream.App.Rendering.FrustumCuller.IsAabbVisible(
|
||||
frustum, b.PortalBounds.Min, b.PortalBounds.Max))
|
||||
continue;
|
||||
|
||||
foreach (uint cid in b.EnvCellIds)
|
||||
if (_cellVisibility.TryGetCell(cid, out var bc) && bc is not null)
|
||||
_outdoorNodeBuildingCells.Add(bc);
|
||||
}
|
||||
}
|
||||
if (viewerRoot is null)
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)"));
|
||||
}
|
||||
|
||||
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
|
||||
bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor(
|
||||
playerCellId,
|
||||
playerRoot is not null);
|
||||
IReadOnlyList<LoadedCell> nearbyBuildingCells =
|
||||
worldFrame.Buildings.NearbyBuildingCells;
|
||||
bool playerIndoorGate = rootFrame.PlayerIndoorGate;
|
||||
uint playerCellId = rootFrame.PlayerCellId;
|
||||
// Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the
|
||||
// VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the
|
||||
// synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The
|
||||
// frame builder's synthetic outdoor node, built from nearby building entrances. The
|
||||
// result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) ->
|
||||
// the outdoor LScape block below still runs as the safety path (and login still shows the
|
||||
// live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the
|
||||
// doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays
|
||||
// computed for the [render-sig] probe but no longer selects the path (handoff
|
||||
// docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B).
|
||||
var clipRoot = viewerRoot ?? _outdoorNode;
|
||||
var clipRoot = worldFrame.ClipRoot;
|
||||
string renderBranch = clipRoot is null
|
||||
? "OutdoorRoot"
|
||||
: "RetailPViewInside";
|
||||
|
|
@ -4026,7 +3623,7 @@ public sealed class GameWindow : IDisposable
|
|||
// R-A2: outdoor root floods each nearby building per-building (not via the root).
|
||||
// #124: interior roots get the gather too — the renderer routes them to the
|
||||
// landscape-stage look-in sub-pass instead of the merge.
|
||||
NearbyBuildingCells = _outdoorNodeBuildingCells,
|
||||
NearbyBuildingCells = nearbyBuildingCells,
|
||||
ViewerEyePos = viewerEyePos,
|
||||
ViewProjection = envCellViewProj,
|
||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||
|
|
@ -4141,9 +3738,7 @@ public sealed class GameWindow : IDisposable
|
|||
// pool scoping. DrawableCells is the renderer's own reused scratch set
|
||||
// (cleared/rebuilt every DrawInside call) — copy it, don't hold a
|
||||
// reference to it.
|
||||
_lightPoolVisibleCells.Clear();
|
||||
_lightPoolVisibleCells.UnionWith(pviewResult.DrawableCells);
|
||||
_lightPoolVisibleCellsValid = true;
|
||||
_worldRenderFrameBuilder!.ObserveDrawableCells(pviewResult.DrawableCells);
|
||||
|
||||
// Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp +
|
||||
// the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player
|
||||
|
|
@ -4212,7 +3807,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// A7.L1: no indoor draw this frame — fail open (unscoped) rather than
|
||||
// scoping next frame's light pool by a stale dungeon's cell ids.
|
||||
_lightPoolVisibleCellsValid = false;
|
||||
_worldRenderFrameBuilder!.ClearDrawableCells();
|
||||
}
|
||||
|
||||
// Phase U.3: close the world-geometry clip bracket opened above. From here down the
|
||||
|
|
@ -4993,67 +4588,6 @@ public sealed class GameWindow : IDisposable
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the current sun (directional light, slot 0 of the UBO)
|
||||
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
|
||||
/// plus the cell ambient. Indoor cells force the sun intensity to
|
||||
/// zero and substitute a flat 0.2 white ambient — exact retail
|
||||
/// behavior per <c>CellManager::ChangePosition</c> @ 0x004559B0,
|
||||
/// which calls <c>SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)</c>
|
||||
/// when the player's <c>CObjCell::seen_outside</c> flag is 0.
|
||||
/// Indoor brightness then comes from per-cell point lights
|
||||
/// (Setup.Lights on the cell's static objects, registered through
|
||||
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
|
||||
/// The trigger is the PLAYER's cell, not the camera's — third-person
|
||||
/// chase camera enters interiors before the player body does, and
|
||||
/// retail keys lighting off the player position.
|
||||
/// </summary>
|
||||
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
|
||||
{
|
||||
// Sun direction: points FROM the sun TOWARDS the world. Our
|
||||
// shader does dot(N, -forward) so a positive N·L means the
|
||||
// surface faces the sun.
|
||||
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
|
||||
|
||||
if (playerInsideCell)
|
||||
{
|
||||
// Indoor default — retail's flat 0.2 neutral ambient, sun
|
||||
// zeroed. See xref to retail decomp in the doc comment above.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = System.Numerics.Vector3.Zero,
|
||||
Intensity = 0f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f),
|
||||
SunColor: System.Numerics.Vector3.Zero,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Outdoor: full keyframe sun + ambient. The SkyKeyframe stores
|
||||
// raw DirColor + DirBright (and AmbColor + AmbBright) for
|
||||
// retail-faithful per-channel keyframe interpolation; the
|
||||
// computed `kf.SunColor` / `kf.AmbientColor` properties return
|
||||
// the post-multiplied product the shader expects.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = kf.SunColor,
|
||||
Intensity = 1f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: kf.AmbientColor,
|
||||
SunColor: kf.SunColor,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
|
||||
//
|
||||
// The ImGui DebugPanel reads through DebugVM closures that ask
|
||||
|
|
@ -5934,17 +5468,6 @@ public sealed class GameWindow : IDisposable
|
|||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||
}
|
||||
|
||||
/// <summary>A.5 T22: parse a float environment variable, returning
|
||||
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
|
||||
private static float ParseEnvFloat(string name, float defaultValue)
|
||||
{
|
||||
var s = System.Environment.GetEnvironmentVariable(name);
|
||||
if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var v))
|
||||
return v;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
=> CompleteShutdown();
|
||||
|
||||
|
|
@ -5989,6 +5512,17 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSessionController = null;
|
||||
}),
|
||||
]),
|
||||
// Frame composition borrows equipped, effect, audio, and render
|
||||
// owners. Withdraw the graph before the first borrowed owner closes;
|
||||
// the later render-frontend stage still disposes the GL owners.
|
||||
new ResourceShutdownStage("frame borrowers",
|
||||
[
|
||||
new("world frame composition", () =>
|
||||
{
|
||||
_worldRenderFrameBuilder = null;
|
||||
_skyPesFrame = null;
|
||||
}),
|
||||
]),
|
||||
new ResourceShutdownStage("session dependents",
|
||||
[
|
||||
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
|
||||
|
|
|
|||
135
src/AcDream.App/Rendering/SkyPesFrameController.cs
Normal file
135
src/AcDream.App/Rendering/SkyPesFrameController.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.Core.Vfx;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the optional DAT-archaeology sky-PES experiment. Named retail shows
|
||||
/// GameSky does not consume SkyObject.PesObjectId, so production invokes this
|
||||
/// owner only when the explicit startup diagnostic is enabled.
|
||||
/// </summary>
|
||||
internal sealed class SkyPesFrameController
|
||||
{
|
||||
private readonly record struct SkyPesKey(
|
||||
int ObjectIndex,
|
||||
uint PesObjectId,
|
||||
bool PostScene);
|
||||
|
||||
private readonly PhysicsScriptRunner _scripts;
|
||||
private readonly ParticleHookSink _particles;
|
||||
private readonly EntityEffectPoseRegistry _poses;
|
||||
private readonly EntityEffectController? _effects;
|
||||
private readonly HashSet<SkyPesKey> _active = [];
|
||||
private readonly HashSet<SkyPesKey> _missing = [];
|
||||
|
||||
public SkyPesFrameController(
|
||||
PhysicsScriptRunner scripts,
|
||||
ParticleHookSink particles,
|
||||
EntityEffectPoseRegistry poses,
|
||||
EntityEffectController? effects)
|
||||
{
|
||||
_scripts = scripts ?? throw new ArgumentNullException(nameof(scripts));
|
||||
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||||
_poses = poses ?? throw new ArgumentNullException(nameof(poses));
|
||||
_effects = effects;
|
||||
}
|
||||
|
||||
public void Update(
|
||||
float dayFraction,
|
||||
DayGroupData? dayGroup,
|
||||
Vector3 cameraWorldPosition,
|
||||
bool suppressSky)
|
||||
{
|
||||
var seen = new HashSet<SkyPesKey>();
|
||||
if (!suppressSky && dayGroup is not null)
|
||||
{
|
||||
for (int index = 0; index < dayGroup.SkyObjects.Count; index++)
|
||||
{
|
||||
SkyObjectData skyObject = dayGroup.SkyObjects[index];
|
||||
if (skyObject.PesObjectId == 0 || !skyObject.IsVisible(dayFraction))
|
||||
continue;
|
||||
|
||||
var key = new SkyPesKey(
|
||||
index,
|
||||
skyObject.PesObjectId,
|
||||
skyObject.IsPostScene);
|
||||
seen.Add(key);
|
||||
uint ownerId = EntityId(key);
|
||||
ParticleRenderPass renderPass = skyObject.IsPostScene
|
||||
? ParticleRenderPass.SkyPostScene
|
||||
: ParticleRenderPass.SkyPreScene;
|
||||
_particles.SetEntityRenderPass(ownerId, renderPass);
|
||||
Vector3 anchor = Anchor(skyObject, cameraWorldPosition);
|
||||
Quaternion rotation = Rotation(skyObject, dayFraction);
|
||||
_poses.Publish(
|
||||
ownerId,
|
||||
Matrix4x4.CreateFromQuaternion(rotation)
|
||||
* Matrix4x4.CreateTranslation(anchor),
|
||||
Array.Empty<Matrix4x4>(),
|
||||
cellId: 0u);
|
||||
|
||||
if (_active.Contains(key) || _missing.Contains(key))
|
||||
continue;
|
||||
|
||||
_effects?.RegisterSyntheticOwner(ownerId);
|
||||
if (_scripts.Play(skyObject.PesObjectId, ownerId, anchor))
|
||||
{
|
||||
_active.Add(key);
|
||||
}
|
||||
else
|
||||
{
|
||||
_missing.Add(key);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.ClearEntityRenderPass(ownerId);
|
||||
_poses.Remove(ownerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (SkyPesKey key in _active.ToArray())
|
||||
{
|
||||
if (seen.Contains(key))
|
||||
continue;
|
||||
|
||||
uint ownerId = EntityId(key);
|
||||
_scripts.StopAllForEntity(ownerId);
|
||||
_effects?.UnregisterSyntheticOwner(ownerId);
|
||||
_particles.StopAllForEntity(ownerId, fadeOut: true);
|
||||
_poses.Remove(ownerId);
|
||||
_active.Remove(key);
|
||||
}
|
||||
|
||||
foreach (SkyPesKey key in _missing.ToArray())
|
||||
{
|
||||
if (!seen.Contains(key))
|
||||
_missing.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint EntityId(SkyPesKey key)
|
||||
{
|
||||
uint postScene = key.PostScene ? 0x08000000u : 0u;
|
||||
return 0xF0000000u
|
||||
| postScene
|
||||
| ((uint)key.ObjectIndex & 0x07FFFFFFu);
|
||||
}
|
||||
|
||||
private static Vector3 Anchor(
|
||||
SkyObjectData skyObject,
|
||||
Vector3 cameraWorldPosition)
|
||||
{
|
||||
if (skyObject.IsWeather && (skyObject.Properties & 0x08u) == 0u)
|
||||
return cameraWorldPosition + new Vector3(0f, 0f, -120f);
|
||||
return cameraWorldPosition;
|
||||
}
|
||||
|
||||
private static Quaternion Rotation(
|
||||
SkyObjectData skyObject,
|
||||
float dayFraction)
|
||||
{
|
||||
float radians = skyObject.CurrentAngle(dayFraction) * (MathF.PI / 180f);
|
||||
return Quaternion.CreateFromAxisAngle(Vector3.UnitY, -radians);
|
||||
}
|
||||
}
|
||||
645
src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
Normal file
645
src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering.Selection;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Camera facts borrowed for one world render preparation.</summary>
|
||||
internal readonly record struct WorldCameraFrame(
|
||||
ICamera Camera,
|
||||
Matrix4x4 Projection,
|
||||
Matrix4x4 ViewProjection,
|
||||
FrustumPlanes Frustum,
|
||||
Matrix4x4 InverseView,
|
||||
Vector3 Position);
|
||||
|
||||
/// <summary>Player-lighting and collided-viewer roots for one frame.</summary>
|
||||
internal readonly record struct WorldRootFrame(
|
||||
LoadedCell? PlayerRoot,
|
||||
bool PlayerSeenOutside,
|
||||
uint ViewerCellId,
|
||||
Vector3 ViewerEyePosition,
|
||||
Vector3 PlayerViewPosition,
|
||||
LoadedCell? ViewerRoot,
|
||||
bool CameraInsideCell,
|
||||
bool RootSeenOutside,
|
||||
bool PlayerInsideCell,
|
||||
uint? PlayerLandblockId,
|
||||
int RenderCenterLandblockX,
|
||||
int RenderCenterLandblockY,
|
||||
uint PlayerCellId,
|
||||
bool PlayerIndoorGate)
|
||||
{
|
||||
public bool RenderSky => ViewerRoot is null || RootSeenOutside;
|
||||
}
|
||||
|
||||
/// <summary>Borrowed building scratch, valid only until the next build.</summary>
|
||||
internal readonly record struct WorldBuildingFrame(
|
||||
LoadedCell? OutdoorNode,
|
||||
IReadOnlyList<LoadedCell> NearbyBuildingCells);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable one-frame world facts. Collection members borrow reusable owner
|
||||
/// scratch and must not be retained beyond the next <see cref="WorldRenderFrameBuilder.Build"/>.
|
||||
/// </summary>
|
||||
internal readonly record struct WorldRenderFrame(
|
||||
WorldCameraFrame Camera,
|
||||
WorldRootFrame Roots,
|
||||
WorldBuildingFrame Buildings,
|
||||
HashSet<uint> AnimatedEntityIds)
|
||||
{
|
||||
public LoadedCell? ClipRoot => Roots.ViewerRoot ?? Buildings.OutdoorNode;
|
||||
}
|
||||
|
||||
internal interface IWorldFrameCameraSource
|
||||
{
|
||||
WorldCameraFrame Resolve();
|
||||
}
|
||||
|
||||
internal interface IWorldFrameRootSource
|
||||
{
|
||||
WorldRootFrame Resolve(in WorldCameraFrame camera);
|
||||
}
|
||||
|
||||
internal interface IWorldFrameVisibilityPreparation
|
||||
{
|
||||
void Begin(in WorldCameraFrame camera, bool waitingForLogin);
|
||||
|
||||
void PublishViewProjection(in WorldCameraFrame camera);
|
||||
}
|
||||
|
||||
internal interface IWorldFrameSettingsPreview
|
||||
{
|
||||
void Apply(in WorldCameraFrame camera);
|
||||
}
|
||||
|
||||
internal interface IWorldFrameEnvironmentPreparation
|
||||
{
|
||||
void Prepare(
|
||||
in WorldCameraFrame camera,
|
||||
in WorldRootFrame roots,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup);
|
||||
|
||||
void ObserveDrawableCells(IReadOnlySet<uint> drawableCells);
|
||||
|
||||
void ClearDrawableCells();
|
||||
}
|
||||
|
||||
internal interface IWorldFrameAnimatedEntitySource
|
||||
{
|
||||
HashSet<uint> Capture();
|
||||
}
|
||||
|
||||
internal interface IWorldFrameBuildingSource
|
||||
{
|
||||
WorldBuildingFrame Gather(
|
||||
LoadedCell? viewerRoot,
|
||||
uint viewerCellId,
|
||||
in FrustumPlanes frustum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders typed world-frame fact sources without owning their borrowed render
|
||||
/// resources. It makes no draw decision and retains no result from a prior call.
|
||||
/// </summary>
|
||||
internal sealed class WorldRenderFrameBuilder
|
||||
{
|
||||
private readonly IWorldFrameCameraSource _camera;
|
||||
private readonly IWorldFrameVisibilityPreparation _visibility;
|
||||
private readonly IWorldFrameSettingsPreview _settings;
|
||||
private readonly IWorldFrameRootSource _roots;
|
||||
private readonly IWorldFrameEnvironmentPreparation _environment;
|
||||
private readonly IWorldFrameAnimatedEntitySource _animated;
|
||||
private readonly IWorldFrameBuildingSource _buildings;
|
||||
|
||||
public WorldRenderFrameBuilder(
|
||||
IWorldFrameCameraSource camera,
|
||||
IWorldFrameVisibilityPreparation visibility,
|
||||
IWorldFrameSettingsPreview settings,
|
||||
IWorldFrameRootSource roots,
|
||||
IWorldFrameEnvironmentPreparation environment,
|
||||
IWorldFrameAnimatedEntitySource animated,
|
||||
IWorldFrameBuildingSource buildings)
|
||||
{
|
||||
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
|
||||
_visibility = visibility ?? throw new ArgumentNullException(nameof(visibility));
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_roots = roots ?? throw new ArgumentNullException(nameof(roots));
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_animated = animated ?? throw new ArgumentNullException(nameof(animated));
|
||||
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||||
}
|
||||
|
||||
public WorldRenderFrame Build(
|
||||
in RenderFrameFoundation foundation,
|
||||
bool waitingForLogin,
|
||||
DayGroupData? activeDayGroup)
|
||||
{
|
||||
WorldCameraFrame camera = _camera.Resolve();
|
||||
_visibility.Begin(in camera, waitingForLogin);
|
||||
_settings.Apply(in camera);
|
||||
WorldRootFrame roots = _roots.Resolve(in camera);
|
||||
_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);
|
||||
_visibility.PublishViewProjection(in camera);
|
||||
HashSet<uint> animated = _animated.Capture();
|
||||
FrustumPlanes frustum = camera.Frustum;
|
||||
WorldBuildingFrame buildings = _buildings.Gather(
|
||||
roots.ViewerRoot,
|
||||
roots.ViewerCellId,
|
||||
in frustum);
|
||||
return new WorldRenderFrame(camera, roots, buildings, animated);
|
||||
}
|
||||
|
||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
||||
_environment.ObserveDrawableCells(drawableCells);
|
||||
|
||||
public void ClearDrawableCells() => _environment.ClearDrawableCells();
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameCameraSource : IWorldFrameCameraSource
|
||||
{
|
||||
private readonly CameraController _cameras;
|
||||
private readonly LocalPlayerTeleportController _teleport;
|
||||
|
||||
public RuntimeWorldFrameCameraSource(
|
||||
CameraController cameras,
|
||||
LocalPlayerTeleportController teleport)
|
||||
{
|
||||
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
||||
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
|
||||
}
|
||||
|
||||
public WorldCameraFrame Resolve()
|
||||
{
|
||||
ICamera camera = _teleport.ApplyViewPlane(_cameras.Active);
|
||||
Matrix4x4 projection = camera.Projection;
|
||||
Matrix4x4 viewProjection = camera.View * projection;
|
||||
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(viewProjection);
|
||||
Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView);
|
||||
var position = new Vector3(inverseView.M41, inverseView.M42, inverseView.M43);
|
||||
return new WorldCameraFrame(
|
||||
camera,
|
||||
projection,
|
||||
viewProjection,
|
||||
frustum,
|
||||
inverseView,
|
||||
position);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameRootSource : IWorldFrameRootSource
|
||||
{
|
||||
private const float LandblockSize = 192f;
|
||||
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly CellVisibility _cells;
|
||||
private readonly ILocalPlayerModeSource _mode;
|
||||
private readonly IChaseCameraSource _chase;
|
||||
private readonly ILocalPlayerControllerSource _player;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
|
||||
public RuntimeWorldFrameRootSource(
|
||||
PhysicsEngine physics,
|
||||
CellVisibility cells,
|
||||
ILocalPlayerModeSource mode,
|
||||
IChaseCameraSource chase,
|
||||
ILocalPlayerControllerSource player,
|
||||
LiveWorldOriginState origin)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
|
||||
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
}
|
||||
|
||||
public WorldRootFrame Resolve(in WorldCameraFrame camera)
|
||||
{
|
||||
// Retail keeps these as two related but distinct positions:
|
||||
// CellManager::ChangePosition @ 0x004559B0 uses the player's current
|
||||
// cell for seen_outside/lighting, while RenderNormalMode @ 0x00453AA0
|
||||
// passes the collided camera's viewer_cell to DrawInside.
|
||||
LoadedCell? playerRoot = null;
|
||||
if (_physics.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCell
|
||||
&& _cells.TryGetCell(playerCell.Id, out LoadedCell? registeredPlayer))
|
||||
{
|
||||
playerRoot = registeredPlayer;
|
||||
}
|
||||
|
||||
bool playerSeenOutside = playerRoot?.SeenOutside ?? true;
|
||||
uint viewerCellId = _mode.IsPlayerMode
|
||||
&& _chase.Retail is { } retailChase
|
||||
&& CameraDiagnostics.UseRetailChaseCamera
|
||||
? retailChase.ViewerCellId
|
||||
: playerRoot?.CellId ?? 0u;
|
||||
LoadedCell? viewerRoot = null;
|
||||
if (viewerCellId != 0u
|
||||
&& _cells.TryGetCell(viewerCellId, out LoadedCell? registeredViewer))
|
||||
{
|
||||
viewerRoot = registeredViewer;
|
||||
}
|
||||
|
||||
var player = _player.Controller;
|
||||
Vector3 playerViewPosition = player?.RenderPosition
|
||||
?? player?.Position
|
||||
?? camera.Position;
|
||||
bool cameraInsideCell = viewerRoot is not null;
|
||||
bool rootSeenOutside = viewerRoot?.SeenOutside ?? true;
|
||||
bool playerInsideCell = playerRoot is not null && !playerSeenOutside;
|
||||
|
||||
uint? playerLandblockId = null;
|
||||
if (_mode.IsPlayerMode && player is not null)
|
||||
{
|
||||
int playerX = _origin.CenterX + (int)Math.Floor(player.Position.X / LandblockSize);
|
||||
int playerY = _origin.CenterY + (int)Math.Floor(player.Position.Y / LandblockSize);
|
||||
playerLandblockId = (uint)((playerX << 24) | (playerY << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
int renderCenterX = _origin.CenterX
|
||||
+ (int)Math.Floor(camera.Position.X / LandblockSize);
|
||||
int renderCenterY = _origin.CenterY
|
||||
+ (int)Math.Floor(camera.Position.Y / LandblockSize);
|
||||
uint playerCellId = _physics.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
|
||||
bool playerIndoorGate = RenderingDiagnostics.ShouldRenderIndoor(
|
||||
playerCellId,
|
||||
playerRoot is not null);
|
||||
|
||||
return new WorldRootFrame(
|
||||
playerRoot,
|
||||
playerSeenOutside,
|
||||
viewerCellId,
|
||||
camera.Position,
|
||||
playerViewPosition,
|
||||
viewerRoot,
|
||||
cameraInsideCell,
|
||||
rootSeenOutside,
|
||||
playerInsideCell,
|
||||
playerLandblockId,
|
||||
renderCenterX,
|
||||
renderCenterY,
|
||||
playerCellId,
|
||||
playerIndoorGate);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameVisibilityPreparation
|
||||
: IWorldFrameVisibilityPreparation
|
||||
{
|
||||
private readonly RetailSelectionScene? _selection;
|
||||
private readonly ParticleVisibilityController _particles;
|
||||
private readonly TerrainModernRenderer? _terrain;
|
||||
private readonly WorldRevealCoordinator? _reveal;
|
||||
private readonly WbFrustum? _environmentFrustum;
|
||||
|
||||
public RuntimeWorldFrameVisibilityPreparation(
|
||||
RetailSelectionScene? selection,
|
||||
ParticleVisibilityController particles,
|
||||
TerrainModernRenderer? terrain,
|
||||
WorldRevealCoordinator? reveal,
|
||||
WbFrustum? environmentFrustum)
|
||||
{
|
||||
_selection = selection;
|
||||
_particles = particles ?? throw new ArgumentNullException(nameof(particles));
|
||||
_terrain = terrain;
|
||||
_reveal = reveal;
|
||||
_environmentFrustum = environmentFrustum;
|
||||
}
|
||||
|
||||
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
|
||||
{
|
||||
_selection?.SetViewFrustum(camera.Frustum);
|
||||
_particles.BeginFrame(camera.Position);
|
||||
_terrain?.BeginVisibilityFrame();
|
||||
if (waitingForLogin)
|
||||
return;
|
||||
|
||||
_particles.UseWorldView();
|
||||
_reveal?.ObserveWorldViewportVisible();
|
||||
_reveal?.Complete();
|
||||
}
|
||||
|
||||
public void PublishViewProjection(in WorldCameraFrame camera) =>
|
||||
_environmentFrustum?.Update(camera.ViewProjection);
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameSettingsPreview : IWorldFrameSettingsPreview
|
||||
{
|
||||
private readonly SettingsVM? _settings;
|
||||
private readonly OpenAlAudioEngine? _audio;
|
||||
private readonly CameraController _cameras;
|
||||
private readonly DisplayFramePacingController _pacing;
|
||||
|
||||
public RuntimeWorldFrameSettingsPreview(
|
||||
SettingsVM? settings,
|
||||
OpenAlAudioEngine? audio,
|
||||
CameraController cameras,
|
||||
DisplayFramePacingController pacing)
|
||||
{
|
||||
_settings = settings;
|
||||
_audio = audio;
|
||||
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
||||
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
|
||||
}
|
||||
|
||||
public void Apply(in WorldCameraFrame camera)
|
||||
{
|
||||
if (_audio is { IsAvailable: true } && _settings is not null)
|
||||
{
|
||||
var audio = _settings.AudioDraft;
|
||||
_audio.MasterVolume = audio.Master;
|
||||
_audio.MusicVolume = audio.Music;
|
||||
_audio.SfxVolume = audio.Sfx;
|
||||
_audio.AmbientVolume = audio.Ambient;
|
||||
}
|
||||
|
||||
if (_settings is not null)
|
||||
{
|
||||
var display = _settings.DisplayDraft;
|
||||
float fieldOfView = display.FieldOfView * (MathF.PI / 180f);
|
||||
_cameras.Orbit.FovY = fieldOfView;
|
||||
_cameras.Fly.FovY = fieldOfView;
|
||||
if (_cameras.Chase is not null)
|
||||
_cameras.Chase.FovY = fieldOfView;
|
||||
_pacing.ApplyPreference(display.VSync);
|
||||
}
|
||||
|
||||
if (_audio is not { IsAvailable: true })
|
||||
return;
|
||||
|
||||
Matrix4x4 inverse = camera.InverseView;
|
||||
var forward = new Vector3(-inverse.M31, -inverse.M32, -inverse.M33);
|
||||
var up = new Vector3(inverse.M21, inverse.M22, inverse.M23);
|
||||
Vector3 position = camera.Position;
|
||||
_audio.SetListener(
|
||||
position.X, position.Y, position.Z,
|
||||
forward.X, forward.Y, forward.Z,
|
||||
up.X, up.Y, up.Z);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IWorldRenderRangeSource
|
||||
{
|
||||
int NearRadius { get; }
|
||||
|
||||
int FarRadius { get; }
|
||||
}
|
||||
|
||||
internal sealed class WorldRenderRangeState : IWorldRenderRangeSource
|
||||
{
|
||||
public WorldRenderRangeState(int nearRadius, int farRadius)
|
||||
{
|
||||
NearRadius = nearRadius;
|
||||
FarRadius = farRadius;
|
||||
}
|
||||
|
||||
public int NearRadius { get; set; }
|
||||
|
||||
public int FarRadius { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameEnvironmentPreparation
|
||||
: IWorldFrameEnvironmentPreparation
|
||||
{
|
||||
private const float LandblockSize = 192f;
|
||||
|
||||
private readonly RuntimeOptions _options;
|
||||
private readonly WorldTimeService _worldTime;
|
||||
private readonly LightManager _lighting;
|
||||
private readonly WbDrawDispatcher? _dispatcher;
|
||||
private readonly EnvCellRenderer? _environmentCells;
|
||||
private readonly SceneLightingUboBinding? _lightingUbo;
|
||||
private readonly IWorldRenderRangeSource _ranges;
|
||||
private readonly SkyPesFrameController? _skyPes;
|
||||
private readonly HashSet<uint> _visibleCells = [];
|
||||
private bool _visibleCellsValid;
|
||||
|
||||
public RuntimeWorldFrameEnvironmentPreparation(
|
||||
RuntimeOptions options,
|
||||
WorldTimeService worldTime,
|
||||
LightManager lighting,
|
||||
WbDrawDispatcher? dispatcher,
|
||||
EnvCellRenderer? environmentCells,
|
||||
SceneLightingUboBinding? lightingUbo,
|
||||
IWorldRenderRangeSource ranges,
|
||||
SkyPesFrameController? skyPes)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||
_lighting = lighting ?? throw new ArgumentNullException(nameof(lighting));
|
||||
_dispatcher = dispatcher;
|
||||
_environmentCells = environmentCells;
|
||||
_lightingUbo = lightingUbo;
|
||||
_ranges = ranges ?? throw new ArgumentNullException(nameof(ranges));
|
||||
_skyPes = skyPes;
|
||||
}
|
||||
|
||||
public void Prepare(
|
||||
in WorldCameraFrame camera,
|
||||
in WorldRootFrame roots,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup)
|
||||
{
|
||||
if (_options.EnableSkyPesDebug)
|
||||
{
|
||||
_skyPes?.Update(
|
||||
(float)_worldTime.DayFraction,
|
||||
activeDayGroup,
|
||||
camera.Position,
|
||||
roots.CameraInsideCell);
|
||||
}
|
||||
|
||||
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
|
||||
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
|
||||
_lighting.Tick(camera.Position);
|
||||
_lighting.BuildPointLightSnapshot(
|
||||
roots.PlayerViewPosition,
|
||||
_visibleCellsValid ? _visibleCells : null);
|
||||
_dispatcher?.SetSceneLights(_lighting.PointSnapshot);
|
||||
_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);
|
||||
|
||||
AtmosphereSnapshot atmosphere = foundation.Atmosphere;
|
||||
SceneLightingUbo ubo = SceneLightingUbo.Build(
|
||||
_lighting,
|
||||
in atmosphere,
|
||||
camera.Position,
|
||||
(float)_worldTime.DayFraction);
|
||||
float fogStart = _ranges.NearRadius
|
||||
* LandblockSize
|
||||
* _options.FogStartMultiplier;
|
||||
float fogEnd = _ranges.FarRadius
|
||||
* LandblockSize
|
||||
* _options.FogEndMultiplier;
|
||||
ubo.FogParams = new Vector4(
|
||||
fogStart,
|
||||
fogEnd,
|
||||
ubo.FogParams.Z,
|
||||
ubo.FogParams.W);
|
||||
_lightingUbo?.Upload(ubo);
|
||||
|
||||
RenderingDiagnostics.EmitLight(
|
||||
insideCell: roots.PlayerInsideCell,
|
||||
ambientR: _lighting.CurrentAmbient.AmbientColor.X,
|
||||
ambientG: _lighting.CurrentAmbient.AmbientColor.Y,
|
||||
ambientB: _lighting.CurrentAmbient.AmbientColor.Z,
|
||||
sunIntensity: _lighting.Sun?.Intensity ?? 0f,
|
||||
registeredLights: _lighting.RegisteredCount,
|
||||
activeLights: (int)ubo.CellAmbient.W,
|
||||
playerCellId: roots.PlayerRoot?.CellId ?? 0u,
|
||||
lights: _lighting);
|
||||
}
|
||||
|
||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(drawableCells);
|
||||
_visibleCells.Clear();
|
||||
_visibleCells.UnionWith(drawableCells);
|
||||
_visibleCellsValid = true;
|
||||
}
|
||||
|
||||
public void ClearDrawableCells() => _visibleCellsValid = false;
|
||||
|
||||
private void UpdateSunFromSky(SkyKeyframe keyframe, bool playerInsideCell)
|
||||
{
|
||||
// CellManager::ChangePosition @ 0x004559B0 keys this on the player
|
||||
// cell's seen_outside flag and installs neutral 0.2 ambient indoors.
|
||||
Vector3 sunToWorld = -SkyStateProvider.SunDirectionFromKeyframe(keyframe);
|
||||
if (playerInsideCell)
|
||||
{
|
||||
_lighting.Sun = new LightSource
|
||||
{
|
||||
Kind = LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = Vector3.Zero,
|
||||
Intensity = 0f,
|
||||
Range = 1f,
|
||||
};
|
||||
_lighting.CurrentAmbient = new CellAmbientState(
|
||||
new Vector3(0.20f, 0.20f, 0.20f),
|
||||
Vector3.Zero,
|
||||
sunToWorld);
|
||||
return;
|
||||
}
|
||||
|
||||
_lighting.Sun = new LightSource
|
||||
{
|
||||
Kind = LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = keyframe.SunColor,
|
||||
Intensity = 1f,
|
||||
Range = 1f,
|
||||
};
|
||||
_lighting.CurrentAmbient = new CellAmbientState(
|
||||
keyframe.AmbientColor,
|
||||
keyframe.SunColor,
|
||||
sunToWorld);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameAnimatedEntitySource
|
||||
: IWorldFrameAnimatedEntitySource
|
||||
{
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _live;
|
||||
private readonly RetailStaticAnimatingObjectScheduler? _statics;
|
||||
private readonly EquippedChildRenderController? _equipped;
|
||||
private readonly HashSet<uint> _scratch = [];
|
||||
|
||||
public RuntimeWorldFrameAnimatedEntitySource(
|
||||
LiveEntityAnimationRuntimeView<LiveEntityAnimationState> live,
|
||||
RetailStaticAnimatingObjectScheduler? statics,
|
||||
EquippedChildRenderController? equipped)
|
||||
{
|
||||
_live = live ?? throw new ArgumentNullException(nameof(live));
|
||||
_statics = statics;
|
||||
_equipped = equipped;
|
||||
}
|
||||
|
||||
public HashSet<uint> Capture()
|
||||
{
|
||||
// Membership means "classify from the current pose this frame," not
|
||||
// merely "animation is advancing." A settled door or translucency
|
||||
// hook can differ permanently from its cached rest pose, so every
|
||||
// sequenced/attached entity remains on the live path.
|
||||
_live.CopySpatialIdsTo(_scratch);
|
||||
_statics?.CopyAnimatedEntityIdsTo(_scratch);
|
||||
if (_equipped is not null)
|
||||
{
|
||||
foreach (uint entityId in _equipped.AttachedEntityIds)
|
||||
_scratch.Add(entityId);
|
||||
}
|
||||
|
||||
return _scratch;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeWorldFrameBuildingSource : IWorldFrameBuildingSource
|
||||
{
|
||||
private readonly LandblockPresentationPipeline _presentation;
|
||||
private readonly CellVisibility _cells;
|
||||
private readonly List<LoadedCell> _scratch = [];
|
||||
|
||||
public RuntimeWorldFrameBuildingSource(
|
||||
LandblockPresentationPipeline presentation,
|
||||
CellVisibility cells)
|
||||
{
|
||||
_presentation = presentation
|
||||
?? throw new ArgumentNullException(nameof(presentation));
|
||||
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
|
||||
}
|
||||
|
||||
public WorldBuildingFrame Gather(
|
||||
LoadedCell? viewerRoot,
|
||||
uint viewerCellId,
|
||||
in FrustumPlanes frustum)
|
||||
{
|
||||
_scratch.Clear();
|
||||
LoadedCell? outdoorNode = null;
|
||||
if (viewerRoot is null && viewerCellId == 0u)
|
||||
return new WorldBuildingFrame(null, _scratch);
|
||||
|
||||
// DrawBuilding @ 0x0059F2A0 gates each building by its visible aperture
|
||||
// before walking its portal BSP. The portal-bounds frustum test is the
|
||||
// corresponding coarse admission gate; there is no distance cutoff.
|
||||
foreach (BuildingRegistry registry in _presentation.BuildingRegistries)
|
||||
{
|
||||
foreach (Building building in registry.All())
|
||||
{
|
||||
if (building.HasPortalBounds
|
||||
&& !FrustumCuller.IsAabbVisible(
|
||||
frustum,
|
||||
building.PortalBounds.Min,
|
||||
building.PortalBounds.Max))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (uint cellId in building.EnvCellIds)
|
||||
{
|
||||
if (_cells.TryGetCell(cellId, out LoadedCell? cell) && cell is not null)
|
||||
_scratch.Add(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewerRoot is null)
|
||||
outdoorNode = OutdoorCellNode.Build(viewerCellId);
|
||||
if (RenderingDiagnostics.ProbeFlapEnabled)
|
||||
{
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_scratch.Count} (T2 frustum-gated per-building floods)"));
|
||||
}
|
||||
|
||||
return new WorldBuildingFrame(outdoorNode, _scratch);
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,9 @@ public sealed record RuntimeOptions(
|
|||
string? AcDir,
|
||||
bool UiProbeDump,
|
||||
string? UiProbeScript,
|
||||
string? AutomationArtifactDirectory)
|
||||
string? AutomationArtifactDirectory,
|
||||
float FogStartMultiplier,
|
||||
float FogEndMultiplier)
|
||||
{
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
|
|
@ -99,7 +101,9 @@ public sealed record RuntimeOptions(
|
|||
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
||||
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
|
||||
AutomationArtifactDirectory:
|
||||
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")));
|
||||
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")),
|
||||
FogStartMultiplier: TryParseFloat(env("ACDREAM_FOG_START_MULT")) ?? 0.7f,
|
||||
FogEndMultiplier: TryParseFloat(env("ACDREAM_FOG_END_MULT")) ?? 0.95f);
|
||||
}
|
||||
|
||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||
|
|
@ -120,4 +124,9 @@ public sealed record RuntimeOptions(
|
|||
|
||||
private static int? TryParseNonNegativeInt(string? s)
|
||||
=> TryParseInt(s) is { } v && v >= 0 ? v : null;
|
||||
|
||||
private static float? TryParseFloat(string? s)
|
||||
=> float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out float value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,522 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Lighting;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Core.World.Cells;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class WorldRenderFrameBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_orders_world_preparation_and_combines_borrowed_results()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var camera = new FlyCamera { Position = new Vector3(11f, 22f, 33f) };
|
||||
var cameraFrame = new WorldCameraFrame(
|
||||
camera,
|
||||
camera.Projection,
|
||||
camera.View * camera.Projection,
|
||||
default,
|
||||
Matrix4x4.Identity,
|
||||
camera.Position);
|
||||
WorldRootFrame roots = default;
|
||||
var animated = new HashSet<uint> { 41u, 42u };
|
||||
var buildings = new List<LoadedCell>();
|
||||
var foundation = new RenderFrameFoundation(
|
||||
PortalViewportVisible: false,
|
||||
Sky: default,
|
||||
Atmosphere: default);
|
||||
var visibility = new RecordingVisibility(calls);
|
||||
var environment = new RecordingEnvironment(calls);
|
||||
var builder = new WorldRenderFrameBuilder(
|
||||
new RecordingCamera(calls, cameraFrame),
|
||||
visibility,
|
||||
new RecordingSettings(calls),
|
||||
new RecordingRoots(calls, roots),
|
||||
environment,
|
||||
new RecordingAnimated(calls, animated),
|
||||
new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings)));
|
||||
|
||||
WorldRenderFrame result = builder.Build(
|
||||
in foundation,
|
||||
waitingForLogin: true,
|
||||
activeDayGroup: null);
|
||||
var drawableCells = new HashSet<uint> { 0x01010001u };
|
||||
builder.ObserveDrawableCells(drawableCells);
|
||||
builder.ClearDrawableCells();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"camera",
|
||||
"visibility:begin",
|
||||
"settings",
|
||||
"roots",
|
||||
"environment",
|
||||
"visibility:projection",
|
||||
"animated",
|
||||
"buildings",
|
||||
],
|
||||
calls);
|
||||
Assert.Same(camera, result.Camera.Camera);
|
||||
Assert.Same(animated, result.AnimatedEntityIds);
|
||||
Assert.Same(buildings, result.Buildings.NearbyBuildingCells);
|
||||
Assert.Null(result.ClipRoot);
|
||||
Assert.True(visibility.WaitingForLogin);
|
||||
Assert.Equal(foundation, environment.Foundation);
|
||||
Assert.Same(drawableCells, environment.DrawableCells);
|
||||
Assert.Equal(1, environment.ClearCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Required_sources_fail_fast()
|
||||
{
|
||||
var camera = new RecordingCamera([], default);
|
||||
var visibility = new RecordingVisibility([]);
|
||||
var settings = new RecordingSettings([]);
|
||||
var roots = new RecordingRoots([], default);
|
||||
var environment = new RecordingEnvironment([]);
|
||||
var animated = new RecordingAnimated([], []);
|
||||
var buildings = new RecordingBuildings([], default);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_range_state_updates_both_tiers_without_replacing_the_source()
|
||||
{
|
||||
IWorldRenderRangeSource source = new WorldRenderRangeState(4, 12);
|
||||
var mutable = Assert.IsType<WorldRenderRangeState>(source);
|
||||
|
||||
mutable.NearRadius = 7;
|
||||
mutable.FarRadius = 19;
|
||||
|
||||
Assert.Equal(7, source.NearRadius);
|
||||
Assert.Equal(19, source.FarRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_animated_source_reuses_scratch_and_removes_stale_ids()
|
||||
{
|
||||
var slot = new LiveEntityRuntimeSlot();
|
||||
var live = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(slot);
|
||||
var source = new RuntimeWorldFrameAnimatedEntitySource(
|
||||
live,
|
||||
statics: null,
|
||||
equipped: null);
|
||||
|
||||
HashSet<uint> first = source.Capture();
|
||||
first.Add(0xDEADBEEFu);
|
||||
HashSet<uint> second = source.Capture();
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Empty(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_building_source_reuses_scratch_and_rebuilds_outdoor_root()
|
||||
{
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => { },
|
||||
state: new GpuWorldState());
|
||||
var source = new RuntimeWorldFrameBuildingSource(
|
||||
pipeline,
|
||||
new CellVisibility());
|
||||
FrustumPlanes frustum = default;
|
||||
|
||||
WorldBuildingFrame first = source.Gather(
|
||||
viewerRoot: null,
|
||||
viewerCellId: 0x01010001u,
|
||||
in frustum);
|
||||
var scratch = Assert.IsType<List<LoadedCell>>(first.NearbyBuildingCells);
|
||||
scratch.Add(new LoadedCell { CellId = 0x01010100u });
|
||||
WorldBuildingFrame second = source.Gather(
|
||||
viewerRoot: null,
|
||||
viewerCellId: 0x02020001u,
|
||||
in frustum);
|
||||
|
||||
Assert.Same(scratch, second.NearbyBuildingCells);
|
||||
Assert.Empty(second.NearbyBuildingCells);
|
||||
Assert.Equal(0x01010001u, first.OutdoorNode?.CellId);
|
||||
Assert.Equal(0x02020001u, second.OutdoorNode?.CellId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_root_source_keeps_player_lighting_and_viewer_render_cells_distinct()
|
||||
{
|
||||
const uint playerCellId = 0x01010100u;
|
||||
const uint viewerCellId = 0x01010101u;
|
||||
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
physics.DataCache.CellGraph.Add(CoreCell(playerCellId, seenOutside: false));
|
||||
physics.UpdatePlayerCurrCell(playerCellId);
|
||||
var cells = new CellVisibility();
|
||||
var playerCell = new LoadedCell { CellId = playerCellId, SeenOutside = false };
|
||||
var viewerCell = new LoadedCell { CellId = viewerCellId, SeenOutside = true };
|
||||
cells.AddCell(playerCell);
|
||||
cells.AddCell(viewerCell);
|
||||
var retailChase = new RetailChaseCamera();
|
||||
retailChase.Update(
|
||||
playerPosition: Vector3.Zero,
|
||||
playerYaw: 0f,
|
||||
playerVelocity: Vector3.Zero,
|
||||
isOnGround: true,
|
||||
contactPlaneNormal: Vector3.UnitZ,
|
||||
dt: 1f / 60f,
|
||||
cellId: viewerCellId);
|
||||
var mode = new LocalPlayerModeState { IsPlayerMode = true };
|
||||
var chase = new ChaseCameraInputState { Retail = retailChase };
|
||||
var origin = new LiveWorldOriginState();
|
||||
origin.SetPlaceholder(0x01, 0x01);
|
||||
var source = new RuntimeWorldFrameRootSource(
|
||||
physics,
|
||||
cells,
|
||||
mode,
|
||||
chase,
|
||||
new LocalPlayerControllerSlot(),
|
||||
origin);
|
||||
var camera = new FlyCamera { Position = new Vector3(5f, 6f, 7f) };
|
||||
WorldCameraFrame cameraFrame = CameraFrame(camera);
|
||||
bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera;
|
||||
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = true;
|
||||
WorldRootFrame result = source.Resolve(in cameraFrame);
|
||||
|
||||
Assert.Same(playerCell, result.PlayerRoot);
|
||||
Assert.Same(viewerCell, result.ViewerRoot);
|
||||
Assert.True(result.PlayerInsideCell);
|
||||
Assert.True(result.CameraInsideCell);
|
||||
Assert.False(result.PlayerSeenOutside);
|
||||
Assert.True(result.RootSeenOutside);
|
||||
Assert.True(result.RenderSky);
|
||||
Assert.Equal(playerCellId, result.PlayerCellId);
|
||||
Assert.Equal(viewerCellId, result.ViewerCellId);
|
||||
Assert.Equal(camera.Position, result.ViewerEyePosition);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_root_source_preserves_null_root_fallback_before_player_membership()
|
||||
{
|
||||
var physics = new PhysicsEngine();
|
||||
var source = new RuntimeWorldFrameRootSource(
|
||||
physics,
|
||||
new CellVisibility(),
|
||||
new LocalPlayerModeState(),
|
||||
new ChaseCameraInputState(),
|
||||
new LocalPlayerControllerSlot(),
|
||||
new LiveWorldOriginState());
|
||||
WorldCameraFrame camera = CameraFrame(new FlyCamera());
|
||||
|
||||
WorldRootFrame result = source.Resolve(in camera);
|
||||
|
||||
Assert.Null(result.PlayerRoot);
|
||||
Assert.Null(result.ViewerRoot);
|
||||
Assert.Equal(0u, result.PlayerCellId);
|
||||
Assert.Equal(0u, result.ViewerCellId);
|
||||
Assert.False(result.PlayerInsideCell);
|
||||
Assert.False(result.CameraInsideCell);
|
||||
Assert.True(result.RenderSky);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Runtime_environment_copies_visible_cells_and_clear_restores_unscoped_lights()
|
||||
{
|
||||
const uint visibleCell = 0x01010100u;
|
||||
const uint hiddenCell = 0x01010101u;
|
||||
var lighting = new LightManager();
|
||||
var visibleLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
CellId = visibleCell,
|
||||
};
|
||||
var hiddenLight = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
CellId = hiddenCell,
|
||||
};
|
||||
lighting.Register(visibleLight);
|
||||
lighting.Register(hiddenLight);
|
||||
var environment = new RuntimeWorldFrameEnvironmentPreparation(
|
||||
RuntimeOptions.Parse("test-dat", _ => null),
|
||||
new WorldTimeService(SkyStateProvider.Default()),
|
||||
lighting,
|
||||
dispatcher: null,
|
||||
environmentCells: null,
|
||||
lightingUbo: null,
|
||||
new WorldRenderRangeState(4, 12),
|
||||
skyPes: null);
|
||||
var borrowed = new HashSet<uint> { visibleCell };
|
||||
environment.ObserveDrawableCells(borrowed);
|
||||
borrowed.Clear();
|
||||
borrowed.Add(hiddenCell);
|
||||
WorldCameraFrame camera = CameraFrame(new FlyCamera());
|
||||
WorldRootFrame roots = default;
|
||||
RenderFrameFoundation foundation = default;
|
||||
|
||||
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);
|
||||
|
||||
Assert.Contains(visibleLight, lighting.PointSnapshot);
|
||||
Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot);
|
||||
|
||||
environment.ClearDrawableCells();
|
||||
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);
|
||||
|
||||
Assert.Contains(visibleLight, lighting.PointSnapshot);
|
||||
Assert.Contains(hiddenLight, lighting.PointSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_builder_preserves_the_frame_preparation_order()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"WorldCameraFrame camera = _camera.Resolve();",
|
||||
"_visibility.Begin(in camera, waitingForLogin);",
|
||||
"_settings.Apply(in camera);",
|
||||
"WorldRootFrame roots = _roots.Resolve(in camera);",
|
||||
"_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);",
|
||||
"_visibility.PublishViewProjection(in camera);",
|
||||
"HashSet<uint> animated = _animated.Capture();",
|
||||
"WorldBuildingFrame buildings = _buildings.Gather(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);",
|
||||
"_lighting.UpdateViewerLight(roots.PlayerViewPosition);",
|
||||
"_lighting.Tick(camera.Position);",
|
||||
"_lighting.BuildPointLightSnapshot(",
|
||||
"_dispatcher?.SetSceneLights(_lighting.PointSnapshot);",
|
||||
"_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);",
|
||||
"SceneLightingUbo ubo = SceneLightingUbo.Build(",
|
||||
"_lightingUbo?.Upload(ubo);",
|
||||
"RenderingDiagnostics.EmitLight(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
CountOccurrences(source, "_worldRenderFrameBuilder!.Build("));
|
||||
Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if (_cameraController is not null && !portalViewportVisible)",
|
||||
"_retailAlphaQueue.BeginFrame();",
|
||||
"_worldRenderFrameBuilder!.Build(",
|
||||
"if (IsLiveModeWaitingForLogin)",
|
||||
"normalWorldDrawn = true;");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_worldRenderFrameBuilder = null;",
|
||||
"_skyPesFrame = null;",
|
||||
"new(\"equipped children\"",
|
||||
"new(\"effect network state\"",
|
||||
"new(\"audio\"",
|
||||
"new(\"mesh draw dispatcher\"",
|
||||
"new(\"environment cells\"",
|
||||
"new(\"scene lighting\"");
|
||||
}
|
||||
|
||||
private static string BuilderSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"WorldRenderFrameBuilder.cs"));
|
||||
|
||||
private static WorldCameraFrame CameraFrame(FlyCamera camera) => new(
|
||||
camera,
|
||||
camera.Projection,
|
||||
camera.View * camera.Projection,
|
||||
default,
|
||||
Matrix4x4.Identity,
|
||||
camera.Position);
|
||||
|
||||
private static AcDream.Core.World.Cells.EnvCell CoreCell(
|
||||
uint id,
|
||||
bool seenOutside) => new(
|
||||
id,
|
||||
Matrix4x4.Identity,
|
||||
Matrix4x4.Identity,
|
||||
Vector3.Zero,
|
||||
Vector3.One,
|
||||
Array.Empty<CellPortal>(),
|
||||
Array.Empty<uint>(),
|
||||
seenOutside,
|
||||
containmentBsp: null);
|
||||
|
||||
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
private sealed class RecordingCamera(
|
||||
List<string> calls,
|
||||
WorldCameraFrame result) : IWorldFrameCameraSource
|
||||
{
|
||||
public WorldCameraFrame Resolve()
|
||||
{
|
||||
calls.Add("camera");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingVisibility(List<string> calls)
|
||||
: IWorldFrameVisibilityPreparation
|
||||
{
|
||||
public bool WaitingForLogin { get; private set; }
|
||||
|
||||
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
|
||||
{
|
||||
calls.Add("visibility:begin");
|
||||
WaitingForLogin = waitingForLogin;
|
||||
}
|
||||
|
||||
public void PublishViewProjection(in WorldCameraFrame camera) =>
|
||||
calls.Add("visibility:projection");
|
||||
}
|
||||
|
||||
private sealed class RecordingSettings(List<string> calls)
|
||||
: IWorldFrameSettingsPreview
|
||||
{
|
||||
public void Apply(in WorldCameraFrame camera) => calls.Add("settings");
|
||||
}
|
||||
|
||||
private sealed class RecordingRoots(
|
||||
List<string> calls,
|
||||
WorldRootFrame result) : IWorldFrameRootSource
|
||||
{
|
||||
public WorldRootFrame Resolve(in WorldCameraFrame camera)
|
||||
{
|
||||
calls.Add("roots");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingEnvironment(List<string> calls)
|
||||
: IWorldFrameEnvironmentPreparation
|
||||
{
|
||||
public RenderFrameFoundation Foundation { get; private set; }
|
||||
|
||||
public IReadOnlySet<uint>? DrawableCells { get; private set; }
|
||||
|
||||
public int ClearCount { get; private set; }
|
||||
|
||||
public void Prepare(
|
||||
in WorldCameraFrame camera,
|
||||
in WorldRootFrame roots,
|
||||
in RenderFrameFoundation foundation,
|
||||
DayGroupData? activeDayGroup)
|
||||
{
|
||||
calls.Add("environment");
|
||||
Foundation = foundation;
|
||||
}
|
||||
|
||||
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
||||
DrawableCells = drawableCells;
|
||||
|
||||
public void ClearDrawableCells() => ClearCount++;
|
||||
}
|
||||
|
||||
private sealed class RecordingAnimated(
|
||||
List<string> calls,
|
||||
HashSet<uint> result) : IWorldFrameAnimatedEntitySource
|
||||
{
|
||||
public HashSet<uint> Capture()
|
||||
{
|
||||
calls.Add("animated");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingBuildings(
|
||||
List<string> calls,
|
||||
WorldBuildingFrame result) : IWorldFrameBuildingSource
|
||||
{
|
||||
public WorldBuildingFrame Gather(
|
||||
LoadedCell? viewerRoot,
|
||||
uint viewerCellId,
|
||||
in FrustumPlanes frustum)
|
||||
{
|
||||
calls.Add("buildings");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,8 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.False(opts.UiProbeDump);
|
||||
Assert.Null(opts.UiProbeScript);
|
||||
Assert.Null(opts.AutomationArtifactDirectory);
|
||||
Assert.Equal(0.7f, opts.FogStartMultiplier);
|
||||
Assert.Equal(0.95f, opts.FogEndMultiplier);
|
||||
Assert.False(opts.UiProbeEnabled);
|
||||
Assert.False(opts.HasLiveCredentials);
|
||||
}
|
||||
|
|
@ -180,6 +182,26 @@ public sealed class RuntimeOptionsTests
|
|||
Assert.Equal(12, RuntimeOptions.Parse(AnyDatDir, Env(new() { ["ACDREAM_STREAM_RADIUS"] = "12" })).LegacyStreamRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FogMultipliers_ParseInvariantFloats_AndFallBackIndependently()
|
||||
{
|
||||
var parsed = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_FOG_START_MULT"] = "0.625",
|
||||
["ACDREAM_FOG_END_MULT"] = "1.125",
|
||||
}));
|
||||
Assert.Equal(0.625f, parsed.FogStartMultiplier);
|
||||
Assert.Equal(1.125f, parsed.FogEndMultiplier);
|
||||
|
||||
var invalid = RuntimeOptions.Parse(AnyDatDir, Env(new()
|
||||
{
|
||||
["ACDREAM_FOG_START_MULT"] = "not-a-number",
|
||||
["ACDREAM_FOG_END_MULT"] = "",
|
||||
}));
|
||||
Assert.Equal(0.7f, invalid.FogStartMultiplier);
|
||||
Assert.Equal(0.95f, invalid.FogEndMultiplier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiagnosticFlags_RespectExactValueOne()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue