perf(streaming): cursor publication across frame budgets
This commit is contained in:
parent
bb16f74fd4
commit
98f1ac8934
32 changed files with 2215 additions and 823 deletions
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
|
|
@ -32,6 +33,30 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <c>docs/research/named-retail/acclient.h:32035</c> (<c>BuildInfo</c>) and
|
||||
/// <c>:32094</c> (<c>CBldPortal</c>).</para>
|
||||
/// </summary>
|
||||
internal sealed class BuildingRegistryPublication
|
||||
{
|
||||
internal BuildingRegistryPublication(
|
||||
BuildingInfo[] buildings,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
Buildings = buildings;
|
||||
LandblockId = landblockId;
|
||||
CellsByCellId = cellsByCellId;
|
||||
PreparationCommitted = buildings.Length == 0;
|
||||
}
|
||||
|
||||
internal BuildingInfo[] Buildings { get; }
|
||||
internal uint LandblockId { get; }
|
||||
internal IReadOnlyDictionary<uint, LoadedCell> CellsByCellId { get; }
|
||||
internal BuildingRegistry Registry { get; } = new();
|
||||
internal List<(LoadedCell Cell, uint BuildingId)> CellStamps { get; } = new();
|
||||
internal int BuildingCursor { get; set; }
|
||||
internal uint NextBuildingId { get; set; } = 1;
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal bool PublicationCommitted { get; set; }
|
||||
}
|
||||
|
||||
public static class BuildingLoader
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -39,122 +64,167 @@ public static class BuildingLoader
|
|||
/// Building IDs are allocated sequentially starting at 1 (0 is reserved for
|
||||
/// "no building" semantics used by <c>LoadedCell.BuildingId</c> in RR4).
|
||||
/// </summary>
|
||||
/// <param name="info">The dat-loaded <see cref="LandBlockInfo"/> for this landblock.</param>
|
||||
/// <param name="landblockId">The 32-bit landblock id (e.g. <c>0xA9B40000</c>).
|
||||
/// The high 16 bits are ORed with each 16-bit <c>OtherCellId</c> to produce
|
||||
/// the full cell id.</param>
|
||||
/// <param name="cellsByCellId">Pre-loaded cells keyed by full 32-bit cell id.
|
||||
/// Used for BFS extension (Step B) and exit-polygon collection (Step C).
|
||||
/// An empty dict is valid for unit tests; Step B and C are skipped per cell.</param>
|
||||
/// <returns>A fully populated <see cref="BuildingRegistry"/>; never null.</returns>
|
||||
public static BuildingRegistry Build(
|
||||
LandBlockInfo info,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
var reg = new BuildingRegistry();
|
||||
if (info.Buildings is null || info.Buildings.Count == 0)
|
||||
return reg;
|
||||
|
||||
uint lbMask = landblockId & 0xFFFF0000u;
|
||||
uint nextId = 1;
|
||||
|
||||
foreach (var bInfo in info.Buildings)
|
||||
BuildingRegistryPublication publication = PreparePublication(
|
||||
info,
|
||||
landblockId,
|
||||
cellsByCellId);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
var envCellIds = new HashSet<uint>();
|
||||
var exitPortalPolys = new List<Vector3[]>();
|
||||
}
|
||||
CommitPublication(publication);
|
||||
return publication.Registry;
|
||||
}
|
||||
|
||||
// Step A: seed the cell set from BuildingInfo.Portals (entry portals).
|
||||
// Each BuildingPortal.OtherCellId is a 16-bit cell-local id; OR with
|
||||
// the landblock prefix for the full id.
|
||||
// Defensive: skip OtherCellId == 0xFFFF (exit-portal sentinel in
|
||||
// BuildingInfo — rare but WB guards against it too at PortalService.cs:58).
|
||||
if (bInfo.Portals is not null)
|
||||
internal static BuildingRegistryPublication PreparePublication(
|
||||
LandBlockInfo info,
|
||||
uint landblockId,
|
||||
IReadOnlyDictionary<uint, LoadedCell> cellsByCellId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
ArgumentNullException.ThrowIfNull(cellsByCellId);
|
||||
|
||||
int buildingCount = info.Buildings?.Count ?? 0;
|
||||
var buildings = new BuildingInfo[buildingCount];
|
||||
for (int index = 0; index < buildingCount; index++)
|
||||
buildings[index] = info.Buildings![index];
|
||||
return new BuildingRegistryPublication(
|
||||
buildings,
|
||||
landblockId,
|
||||
cellsByCellId);
|
||||
}
|
||||
|
||||
internal static bool AdvancePreparationOne(
|
||||
BuildingRegistryPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
if (publication.BuildingCursor >= publication.Buildings.Length)
|
||||
{
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
AddBuilding(
|
||||
publication,
|
||||
publication.Buildings[publication.BuildingCursor]);
|
||||
publication.BuildingCursor++;
|
||||
if (publication.BuildingCursor >= publication.Buildings.Length)
|
||||
publication.PreparationCommitted = true;
|
||||
return publication.PreparationCommitted;
|
||||
}
|
||||
|
||||
internal static void CommitPublication(
|
||||
BuildingRegistryPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!publication.PreparationCommitted)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A building registry cannot publish before preparation completes.");
|
||||
}
|
||||
if (publication.PublicationCommitted)
|
||||
return;
|
||||
|
||||
foreach ((LoadedCell cell, uint buildingId) in publication.CellStamps)
|
||||
cell.BuildingId = buildingId;
|
||||
publication.PublicationCommitted = true;
|
||||
}
|
||||
|
||||
private static void AddBuilding(
|
||||
BuildingRegistryPublication publication,
|
||||
BuildingInfo bInfo)
|
||||
{
|
||||
uint lbMask = publication.LandblockId & 0xFFFF0000u;
|
||||
var envCellIds = new HashSet<uint>();
|
||||
var exitPortalPolys = new List<Vector3[]>();
|
||||
|
||||
// Step A: seed the cell set from BuildingInfo.Portals.
|
||||
if (bInfo.Portals is not null)
|
||||
{
|
||||
foreach (var portal in bInfo.Portals)
|
||||
{
|
||||
foreach (var portal in bInfo.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
envCellIds.Add(lbMask | portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step B: BFS through interior CellPortals to find the full cell set.
|
||||
// Uses pre-loaded LoadedCell.Portals (avoids a duplicate dat fetch per
|
||||
// BFS step). Mirrors WB PortalService.cs:67-79.
|
||||
// When cellsByCellId is empty (unit-test path), BFS immediately exits.
|
||||
var queue = new Queue<uint>(envCellIds);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
if (!cellsByCellId.TryGetValue(current, out var cell)) continue;
|
||||
foreach (var p in cell.Portals)
|
||||
{
|
||||
if (p.OtherCellId == 0xFFFF) continue; // exit portal — stop BFS here
|
||||
uint neighbourId = lbMask | p.OtherCellId;
|
||||
if (envCellIds.Add(neighbourId))
|
||||
queue.Enqueue(neighbourId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step C: collect exit portal polygons in world space.
|
||||
// For each interior cell, iterate its portals; for each exit portal
|
||||
// (OtherCellId == 0xFFFF), transform the portal polygon vertices from
|
||||
// cell-local space to world space via WorldTransform.
|
||||
// Mirrors WB PortalService.cs:81-86 (GetPortalsForCell return path).
|
||||
foreach (var cellId in envCellIds)
|
||||
{
|
||||
if (!cellsByCellId.TryGetValue(cellId, out var cell)) continue;
|
||||
for (int pi = 0; pi < cell.Portals.Count; pi++)
|
||||
{
|
||||
if (cell.Portals[pi].OtherCellId != 0xFFFF) continue;
|
||||
if (pi >= cell.PortalPolygons.Count) continue;
|
||||
var localPoly = cell.PortalPolygons[pi];
|
||||
if (localPoly.Length < 3) continue;
|
||||
var worldPoly = new Vector3[localPoly.Length];
|
||||
for (int v = 0; v < localPoly.Length; v++)
|
||||
worldPoly[v] = Vector3.Transform(localPoly[v], cell.WorldTransform);
|
||||
exitPortalPolys.Add(worldPoly);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (var poly in exitPortalPolys)
|
||||
{
|
||||
foreach (var v in poly)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, v);
|
||||
portalMax = Vector3.Max(portalMax, v);
|
||||
}
|
||||
}
|
||||
|
||||
// WB PortalService.cs:89: skip buildings with no interior cells.
|
||||
if (envCellIds.Count == 0) continue;
|
||||
|
||||
var building = new Building
|
||||
{
|
||||
BuildingId = nextId++,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
};
|
||||
reg.Add(building);
|
||||
|
||||
// Step 4: stamp BuildingId on each cell (Option C — both directions
|
||||
// O(1)). The internal setter on LoadedCell.BuildingId is accessible
|
||||
// because this class lives in the same assembly (AcDream.App).
|
||||
foreach (var cellId in envCellIds)
|
||||
{
|
||||
if (cellsByCellId.TryGetValue(cellId, out var cell))
|
||||
cell.BuildingId = building.BuildingId;
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
envCellIds.Add(lbMask | portal.OtherCellId);
|
||||
}
|
||||
}
|
||||
|
||||
return reg;
|
||||
// Step B: BFS through interior portals.
|
||||
var queue = new Queue<uint>(envCellIds);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
uint current = queue.Dequeue();
|
||||
if (!publication.CellsByCellId.TryGetValue(current, out var cell))
|
||||
continue;
|
||||
foreach (var portal in cell.Portals)
|
||||
{
|
||||
if (portal.OtherCellId == 0xFFFF) continue;
|
||||
uint neighbourId = lbMask | portal.OtherCellId;
|
||||
if (envCellIds.Add(neighbourId))
|
||||
queue.Enqueue(neighbourId);
|
||||
}
|
||||
}
|
||||
|
||||
// Step C: collect exit portal polygons in world space.
|
||||
foreach (uint cellId in envCellIds)
|
||||
{
|
||||
if (!publication.CellsByCellId.TryGetValue(cellId, out var cell))
|
||||
continue;
|
||||
for (int portalIndex = 0; portalIndex < cell.Portals.Count; portalIndex++)
|
||||
{
|
||||
if (cell.Portals[portalIndex].OtherCellId != 0xFFFF) continue;
|
||||
if (portalIndex >= cell.PortalPolygons.Count) continue;
|
||||
Vector3[] localPolygon = cell.PortalPolygons[portalIndex];
|
||||
if (localPolygon.Length < 3) continue;
|
||||
var worldPolygon = new Vector3[localPolygon.Length];
|
||||
for (int vertexIndex = 0; vertexIndex < localPolygon.Length; vertexIndex++)
|
||||
{
|
||||
worldPolygon[vertexIndex] = Vector3.Transform(
|
||||
localPolygon[vertexIndex],
|
||||
cell.WorldTransform);
|
||||
}
|
||||
exitPortalPolys.Add(worldPolygon);
|
||||
}
|
||||
}
|
||||
|
||||
bool hasPortalBounds = false;
|
||||
var portalMin = new Vector3(float.MaxValue);
|
||||
var portalMax = new Vector3(float.MinValue);
|
||||
foreach (Vector3[] polygon in exitPortalPolys)
|
||||
{
|
||||
foreach (Vector3 vertex in polygon)
|
||||
{
|
||||
hasPortalBounds = true;
|
||||
portalMin = Vector3.Min(portalMin, vertex);
|
||||
portalMax = Vector3.Max(portalMax, vertex);
|
||||
}
|
||||
}
|
||||
|
||||
if (envCellIds.Count == 0)
|
||||
return;
|
||||
|
||||
uint buildingId = publication.NextBuildingId++;
|
||||
publication.Registry.Add(new Building
|
||||
{
|
||||
BuildingId = buildingId,
|
||||
EnvCellIds = envCellIds,
|
||||
ExitPortalPolygons = exitPortalPolys,
|
||||
HasPortalBounds = hasPortalBounds,
|
||||
PortalBounds = hasPortalBounds
|
||||
? new WbBoundingBox(portalMin, portalMax)
|
||||
: default,
|
||||
});
|
||||
|
||||
// Stamps remain off-side until the complete registry commits.
|
||||
foreach (uint cellId in envCellIds)
|
||||
{
|
||||
if (publication.CellsByCellId.TryGetValue(cellId, out var cell))
|
||||
publication.CellStamps.Add((cell, buildingId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs
Normal file
45
src/AcDream.App/Rendering/Wb/EnvCellLandblockPublication.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow update-thread publication seam used by streaming. Preparation builds
|
||||
/// one private shell at a time; commit publishes the completed immutable
|
||||
/// landblock snapshot with one owner dictionary replacement.
|
||||
/// </summary>
|
||||
public interface IEnvCellLandblockPublisher
|
||||
{
|
||||
EnvCellLandblockPublication PreparePublication(
|
||||
EnvCellLandblockBuild build);
|
||||
|
||||
bool AdvancePreparationOne(EnvCellLandblockPublication publication);
|
||||
|
||||
void CommitPublication(EnvCellLandblockPublication publication);
|
||||
}
|
||||
|
||||
public sealed class EnvCellLandblockPublication
|
||||
{
|
||||
internal EnvCellLandblockPublication(
|
||||
object owner,
|
||||
EnvCellLandblockBuild build)
|
||||
{
|
||||
Owner = owner;
|
||||
Build = build;
|
||||
Replacement = new EnvCellLandblock
|
||||
{
|
||||
GridX = (int)((build.LandblockId >> 24) & 0xFFu),
|
||||
GridY = (int)((build.LandblockId >> 16) & 0xFFu),
|
||||
};
|
||||
TotalBounds = new WbBoundingBox(
|
||||
new Vector3(float.MaxValue),
|
||||
new Vector3(float.MinValue));
|
||||
}
|
||||
|
||||
internal object Owner { get; }
|
||||
internal EnvCellLandblockBuild Build { get; }
|
||||
internal EnvCellLandblock Replacement { get; }
|
||||
internal WbBoundingBox TotalBounds { get; set; }
|
||||
internal int ShellCursor { get; set; }
|
||||
internal bool PreparationCommitted { get; set; }
|
||||
internal bool PublicationCommitted { get; set; }
|
||||
}
|
||||
|
|
@ -29,8 +29,11 @@ using Silk.NET.OpenGL;
|
|||
|
||||
namespace AcDream.App.Rendering.Wb;
|
||||
|
||||
public sealed unsafe class EnvCellRenderer : IDisposable
|
||||
public sealed unsafe class EnvCellRenderer :
|
||||
IDisposable,
|
||||
IEnvCellLandblockPublisher
|
||||
{
|
||||
private readonly object _publicationOwner = new();
|
||||
private readonly GL _gl;
|
||||
private readonly ObjectMeshManager _meshManager;
|
||||
private readonly WbFrustum _frustum;
|
||||
|
|
@ -381,8 +384,80 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
/// </summary>
|
||||
public void CommitLandblock(EnvCellLandblockBuild build)
|
||||
{
|
||||
_landblocks[build.LandblockId] = CreateCommittedSnapshot(build);
|
||||
EnvCellLandblockPublication publication = PreparePublication(build);
|
||||
while (!AdvancePreparationOne(publication))
|
||||
{
|
||||
}
|
||||
CommitPublication(publication);
|
||||
}
|
||||
|
||||
EnvCellLandblockPublication IEnvCellLandblockPublisher.PreparePublication(
|
||||
EnvCellLandblockBuild build) =>
|
||||
PreparePublication(build);
|
||||
|
||||
bool IEnvCellLandblockPublisher.AdvancePreparationOne(
|
||||
EnvCellLandblockPublication publication) =>
|
||||
AdvancePreparationOne(publication);
|
||||
|
||||
void IEnvCellLandblockPublisher.CommitPublication(
|
||||
EnvCellLandblockPublication publication) =>
|
||||
CommitPublication(publication);
|
||||
|
||||
internal EnvCellLandblockPublication PreparePublication(
|
||||
EnvCellLandblockBuild build)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(build);
|
||||
return new EnvCellLandblockPublication(_publicationOwner, build);
|
||||
}
|
||||
|
||||
internal bool AdvancePreparationOne(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ValidatePublication(publication);
|
||||
if (publication.PreparationCommitted)
|
||||
return true;
|
||||
|
||||
if (publication.ShellCursor < publication.Build.Shells.Length)
|
||||
{
|
||||
AddShell(
|
||||
publication.Replacement,
|
||||
publication.Build.Shells[publication.ShellCursor]);
|
||||
WbBoundingBox bounds =
|
||||
publication.Build.Shells[publication.ShellCursor].WorldBounds;
|
||||
publication.TotalBounds =
|
||||
publication.ShellCursor == 0
|
||||
? bounds
|
||||
: WbBoundingBox.Union(
|
||||
publication.TotalBounds,
|
||||
bounds);
|
||||
publication.ShellCursor++;
|
||||
return false;
|
||||
}
|
||||
|
||||
publication.Replacement.TotalEnvCellBounds =
|
||||
publication.Replacement.EnvCellBounds.Count == 0
|
||||
? new WbBoundingBox(Vector3.Zero, Vector3.Zero)
|
||||
: publication.TotalBounds;
|
||||
publication.Replacement.InstancesReady = true;
|
||||
publication.Replacement.MeshDataReady = true;
|
||||
publication.Replacement.GpuReady = true;
|
||||
publication.PreparationCommitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void CommitPublication(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ValidatePublication(publication);
|
||||
if (!publication.PreparationCommitted)
|
||||
throw new InvalidOperationException(
|
||||
"EnvCell landblock publication cannot commit before preparation.");
|
||||
if (publication.PublicationCommitted)
|
||||
return;
|
||||
|
||||
_landblocks[publication.Build.LandblockId] = publication.Replacement;
|
||||
NeedsPrepare = true;
|
||||
publication.PublicationCommitted = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -398,35 +473,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
};
|
||||
|
||||
foreach (var shell in build.Shells)
|
||||
{
|
||||
replacement.Instances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
ObjectId = shell.GeometryId,
|
||||
InstanceId = shell.CellId,
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false,
|
||||
WorldPosition = shell.WorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = shell.Rotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = shell.Transform,
|
||||
LocalBoundingBox = shell.LocalBounds,
|
||||
BoundingBox = shell.WorldBounds,
|
||||
});
|
||||
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
|
||||
|
||||
if (!replacement.BuildingPartGroups.TryGetValue(shell.GeometryId, out var instances))
|
||||
{
|
||||
instances = new List<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
AddShell(replacement, shell);
|
||||
|
||||
var total = new WbBoundingBox(new Vector3(float.MaxValue), new Vector3(float.MinValue));
|
||||
foreach (var bounds in replacement.EnvCellBounds.Values)
|
||||
|
|
@ -441,6 +488,53 @@ public sealed unsafe class EnvCellRenderer : IDisposable
|
|||
return replacement;
|
||||
}
|
||||
|
||||
private static void AddShell(
|
||||
EnvCellLandblock replacement,
|
||||
EnvCellShellPlacement shell)
|
||||
{
|
||||
replacement.Instances.Add(new EnvCellSceneryInstance
|
||||
{
|
||||
ObjectId = shell.GeometryId,
|
||||
InstanceId = shell.CellId,
|
||||
IsBuilding = true,
|
||||
IsEntryCell = false,
|
||||
WorldPosition = shell.WorldPosition,
|
||||
LocalPosition = Vector3.Zero,
|
||||
Rotation = shell.Rotation,
|
||||
Scale = Vector3.One,
|
||||
Transform = shell.Transform,
|
||||
LocalBoundingBox = shell.LocalBounds,
|
||||
BoundingBox = shell.WorldBounds,
|
||||
});
|
||||
replacement.EnvCellBounds[shell.CellId] = shell.WorldBounds;
|
||||
|
||||
if (!replacement.BuildingPartGroups.TryGetValue(
|
||||
shell.GeometryId,
|
||||
out var instances))
|
||||
{
|
||||
instances = new List<InstanceData>();
|
||||
replacement.BuildingPartGroups[shell.GeometryId] = instances;
|
||||
}
|
||||
instances.Add(new InstanceData
|
||||
{
|
||||
Transform = shell.Transform,
|
||||
CellId = shell.CellId,
|
||||
Flags = 0,
|
||||
});
|
||||
}
|
||||
|
||||
private void ValidatePublication(
|
||||
EnvCellLandblockPublication publication)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(publication);
|
||||
if (!ReferenceEquals(publication.Owner, _publicationOwner))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The EnvCell publication receipt belongs to another renderer.",
|
||||
nameof(publication));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a landblock from the renderer. Future PrepareRenderBatches will exclude it.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue