diff --git a/src/AcDream.App/Rendering/InteriorRenderer.cs b/src/AcDream.App/Rendering/InteriorRenderer.cs
new file mode 100644
index 0000000..ec0dd26
--- /dev/null
+++ b/src/AcDream.App/Rendering/InteriorRenderer.cs
@@ -0,0 +1,102 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+/// Per-frame inputs for one flood.
+public sealed class InteriorRenderContext
+{
+ /// Visible cells, closest-first (retail cell_draw_list). From PortalVisibilityFrame.
+ public required IReadOnlyList OrderedVisibleCells { get; init; }
+
+ /// The 3-bucket entity split (). Only ByCell +
+ /// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
+ /// step (clipped to OutsideView).
+ public required InteriorEntityPartition.Result Partition { get; init; }
+
+ public required ICamera Camera { get; init; }
+ public required FrustumPlanes? Frustum { get; init; }
+
+ /// The full FFFF-suffixed landblock id of the player. Used as BOTH the synthetic
+ /// per-cell entry id AND neverCullLandblockId so the degenerate (zero) synthetic AABB is never
+ /// landblock-culled — per-entity frustum culling inside Draw still applies.
+ public required uint? PlayerLandblockId { get; init; }
+
+ public required HashSet? AnimatedEntityIds { get; init; }
+}
+
+///
+/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
+/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
+/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
+/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
+/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
+/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
+///
+public sealed class InteriorRenderer
+{
+ private readonly EnvCellRenderer _envCells;
+ private readonly WbDrawDispatcher _entities;
+
+ // Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
+ private readonly HashSet _oneCell = new(1);
+
+ public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
+ {
+ _envCells = envCells;
+ _entities = entities;
+ }
+
+ public void DrawInside(InteriorRenderContext ctx)
+ {
+ // Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
+ foreach (uint cellId in ctx.OrderedVisibleCells)
+ {
+ _oneCell.Clear();
+ _oneCell.Add(cellId);
+ _envCells.Render(WbRenderPass.Opaque, _oneCell);
+
+ if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
+ DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
+ }
+
+ // Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
+ // Drawn AFTER opaque shells so wall depth occludes them correctly.
+ if (ctx.Partition.LiveDynamic.Count > 0)
+ DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
+
+ // Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
+ foreach (uint cellId in ctx.OrderedVisibleCells)
+ {
+ _oneCell.Clear();
+ _oneCell.Add(cellId);
+ _envCells.Render(WbRenderPass.Transparent, _oneCell);
+ }
+ }
+
+ // Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
+ // landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
+ // set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
+ // The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
+ // outdoorSlot + outdoorVisible) via ResolveEntitySlot.
+ private void DrawEntityBucket(
+ InteriorRenderContext ctx, IReadOnlyList bucket, HashSet? visibleCellIds)
+ {
+ // LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
+ // never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
+ uint lbId = ctx.PlayerLandblockId ?? 0u;
+ var entry = (lbId, Vector3.Zero, Vector3.Zero,
+ (IReadOnlyList)bucket,
+ (IReadOnlyDictionary?)null);
+
+ _entities.Draw(
+ ctx.Camera,
+ new[] { entry },
+ ctx.Frustum,
+ neverCullLandblockId: ctx.PlayerLandblockId,
+ visibleCellIds: visibleCellIds,
+ animatedEntityIds: ctx.AnimatedEntityIds);
+ }
+}