feat(render): implement Campaign AR and terrain fidelity
This commit is contained in:
parent
99cf26e00c
commit
7a5f96ede5
368 changed files with 50611 additions and 950 deletions
|
|
@ -1,4 +1,6 @@
|
|||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Rendering;
|
||||
using Arch.Core;
|
||||
using ArchWorld = Arch.Core.World;
|
||||
|
|
@ -37,6 +39,12 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
private RenderProjectionCounts _counts;
|
||||
private ulong _lastAppliedJournalSequence;
|
||||
private ulong _indexRevision = 1;
|
||||
private ulong _directionalShadowTopologyRevision = 1;
|
||||
private DirectionalShadowTransformChange[]? _directionalShadowTransformChanges;
|
||||
private Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
|
||||
_directionalShadowPartPoses;
|
||||
private ulong _directionalShadowTransformRevision;
|
||||
private int _directionalShadowTransformChangeCount;
|
||||
private bool _disposed;
|
||||
|
||||
public ArchRenderScene(RenderSceneGeneration initialGeneration)
|
||||
|
|
@ -76,6 +84,23 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
long lookupBytes =
|
||||
(long)lookupCapacity * Unsafe.SizeOf<ProjectionLookupSlotEstimate>();
|
||||
long indexBytes = EstimateIndexBytes();
|
||||
long directionalShadowJournalBytes =
|
||||
_directionalShadowTransformChanges is null
|
||||
? 0
|
||||
: checked((long)_directionalShadowTransformChanges.Length
|
||||
* Unsafe.SizeOf<DirectionalShadowTransformChange>());
|
||||
if (_directionalShadowPartPoses is not null)
|
||||
{
|
||||
directionalShadowJournalBytes = checked(
|
||||
directionalShadowJournalBytes
|
||||
+ (long)_directionalShadowPartPoses.EnsureCapacity(0)
|
||||
* (sizeof(int)
|
||||
+ Unsafe.SizeOf<KeyValuePair<
|
||||
RenderProjectionId,
|
||||
DirectionalShadowPartPoseSnapshot>>())
|
||||
+ _directionalShadowPartPoses.Values.Sum(static pose =>
|
||||
(long)pose.Count * Unsafe.SizeOf<Matrix4x4>()));
|
||||
}
|
||||
|
||||
return new RenderSceneMemoryAccounting(
|
||||
EntityCount: _world.Size,
|
||||
|
|
@ -86,7 +111,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
ProjectionLookupCapacity: lookupCapacity,
|
||||
EstimatedProjectionLookupBytes: lookupBytes,
|
||||
EstimatedIndexBytes: indexBytes,
|
||||
EstimatedJournalBufferBytes: 0,
|
||||
EstimatedJournalBufferBytes: directionalShadowJournalBytes,
|
||||
EstimatedSynchronizationSourceBytes: 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -163,7 +188,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
ref _world.Get<RenderTransform>(entry.Entity);
|
||||
ref RenderWorldBounds bounds =
|
||||
ref _world.Get<RenderWorldBounds>(entry.Entity);
|
||||
if (current == update.Transform && bounds == update.Bounds)
|
||||
bool transformChanged = !TransformBitsEqual(current, update.Transform);
|
||||
if (!transformChanged && bounds == update.Bounds)
|
||||
continue;
|
||||
|
||||
_world.Set(
|
||||
|
|
@ -171,6 +197,15 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
new PreviousRenderTransform(current.LocalToWorld));
|
||||
_world.Set(entry.Entity, update.Transform);
|
||||
_world.Set(entry.Entity, update.Bounds);
|
||||
if (transformChanged
|
||||
&& HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass)
|
||||
&& _directionalShadowTransformChanges is not null)
|
||||
{
|
||||
RenderProjectionRecord currentRecord = ReadRecord(in entry);
|
||||
PublishDirectionalShadowTransformChange(
|
||||
in currentRecord,
|
||||
DirectionalShadowTransformChangeKind.DynamicSynchronization);
|
||||
}
|
||||
|
||||
ref RenderDirtyMask dirty =
|
||||
ref _world.Get<RenderDirtyMask>(entry.Entity);
|
||||
|
|
@ -226,8 +261,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
ClearIndices();
|
||||
_counts = default;
|
||||
_lastAppliedJournalSequence = 0;
|
||||
ResetDirectionalShadowTransformChanges();
|
||||
Generation = replacementGeneration;
|
||||
AdvanceIndexRevision();
|
||||
AdvanceDirectionalShadowTopologyRevision();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
@ -239,6 +276,10 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
ArchWorld.Destroy(_world);
|
||||
_entries.Clear();
|
||||
ClearIndices();
|
||||
_directionalShadowTransformChanges = null;
|
||||
_directionalShadowPartPoses = null;
|
||||
_directionalShadowTransformRevision = 0;
|
||||
_directionalShadowTransformChangeCount = 0;
|
||||
_counts = default;
|
||||
_disposed = true;
|
||||
}
|
||||
|
|
@ -284,6 +325,99 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
return _indexRevision;
|
||||
}
|
||||
|
||||
ulong IRenderSceneQuerySource.GetDirectionalShadowTopologyRevision(
|
||||
RenderSceneGeneration generation)
|
||||
{
|
||||
EnsureQueryGeneration(generation);
|
||||
return _directionalShadowTopologyRevision;
|
||||
}
|
||||
|
||||
ulong IRenderSceneQuerySource.GetDirectionalShadowTransformRevision(
|
||||
RenderSceneGeneration generation)
|
||||
{
|
||||
EnsureQueryGeneration(generation);
|
||||
EnsureDirectionalShadowTransformJournal();
|
||||
return _directionalShadowTransformRevision;
|
||||
}
|
||||
|
||||
DirectionalShadowTransformChanges
|
||||
IRenderSceneQuerySource.CopyDirectionalShadowTransformChanges(
|
||||
RenderSceneGeneration generation,
|
||||
ulong afterRevision,
|
||||
Span<DirectionalShadowTransformSnapshot> destination)
|
||||
{
|
||||
EnsureQueryGeneration(generation);
|
||||
EnsureDirectionalShadowTransformJournal();
|
||||
ulong latest = _directionalShadowTransformRevision;
|
||||
if (afterRevision == latest)
|
||||
return new DirectionalShadowTransformChanges(latest, 0, false);
|
||||
if (afterRevision == 0
|
||||
|| afterRevision > latest
|
||||
|| latest - afterRevision
|
||||
> checked((ulong)_directionalShadowTransformChangeCount))
|
||||
{
|
||||
return new DirectionalShadowTransformChanges(latest, 0, true);
|
||||
}
|
||||
|
||||
int count = checked((int)(latest - afterRevision));
|
||||
if (destination.Length < count)
|
||||
return new DirectionalShadowTransformChanges(latest, 0, true);
|
||||
DirectionalShadowTransformChange[] journal =
|
||||
_directionalShadowTransformChanges!;
|
||||
int updateTransformCount = 0;
|
||||
int updateAppearanceCount = 0;
|
||||
int dynamicSynchronizationCount = 0;
|
||||
int activeAnimatedStaticCount = 0;
|
||||
int liveDynamicRootCount = 0;
|
||||
int equippedChildCount = 0;
|
||||
for (int index = 0; index < count; index++)
|
||||
{
|
||||
ulong revision = checked(afterRevision + (ulong)index + 1UL);
|
||||
DirectionalShadowTransformChange change =
|
||||
journal[(int)(revision % (ulong)journal.Length)];
|
||||
if (change.Revision != revision)
|
||||
return new DirectionalShadowTransformChanges(latest, 0, true);
|
||||
destination[index] = change.Projection;
|
||||
switch (change.Kind)
|
||||
{
|
||||
case DirectionalShadowTransformChangeKind.UpdateTransform:
|
||||
updateTransformCount++;
|
||||
break;
|
||||
case DirectionalShadowTransformChangeKind.UpdateAppearance:
|
||||
updateAppearanceCount++;
|
||||
break;
|
||||
case DirectionalShadowTransformChangeKind.DynamicSynchronization:
|
||||
dynamicSynchronizationCount++;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown directional-shadow change kind {change.Kind}.");
|
||||
}
|
||||
switch (change.Projection.ProjectionClass)
|
||||
{
|
||||
case RenderProjectionClass.ActiveAnimatedStatic:
|
||||
activeAnimatedStaticCount++;
|
||||
break;
|
||||
case RenderProjectionClass.LiveDynamicRoot:
|
||||
liveDynamicRootCount++;
|
||||
break;
|
||||
case RenderProjectionClass.EquippedChild:
|
||||
equippedChildCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new DirectionalShadowTransformChanges(
|
||||
latest,
|
||||
count,
|
||||
false,
|
||||
updateTransformCount,
|
||||
updateAppearanceCount,
|
||||
dynamicSynchronizationCount,
|
||||
activeAnimatedStaticCount,
|
||||
liveDynamicRootCount,
|
||||
equippedChildCount);
|
||||
}
|
||||
|
||||
bool IRenderSceneQuerySource.TryGet(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionId id,
|
||||
|
|
@ -300,6 +434,30 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
return false;
|
||||
}
|
||||
|
||||
int IRenderSceneQuerySource.CopyById(
|
||||
RenderSceneGeneration generation,
|
||||
ReadOnlySpan<RenderProjectionId> ids,
|
||||
Span<RenderProjectionRecord> destination)
|
||||
{
|
||||
EnsureQueryGeneration(generation);
|
||||
if (destination.Length < ids.Length)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The render-scene ID-copy destination is too small.",
|
||||
nameof(destination));
|
||||
}
|
||||
for (int index = 0; index < ids.Length; index++)
|
||||
{
|
||||
if (!_entries.TryGetValue(ids[index], out SceneEntry entry))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Render-scene projection {ids[index]} disappeared during a batched copy.");
|
||||
}
|
||||
destination[index] = ReadRecord(in entry);
|
||||
}
|
||||
return ids.Length;
|
||||
}
|
||||
|
||||
int IRenderSceneQuerySource.CopyTo(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionClass? projectionClass,
|
||||
|
|
@ -391,6 +549,16 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
RenderProjectionRecord prior = ReadRecord(in existing);
|
||||
WriteRecord(existing.Entity, in record);
|
||||
UpdateIndices(in prior, in record);
|
||||
if (HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
|
||||
{
|
||||
if (!TransformBitsEqual(prior.Transform, record.Transform))
|
||||
{
|
||||
PublishDirectionalShadowTransformChange(
|
||||
in record,
|
||||
DirectionalShadowTransformChangeKind.UpdateTransform);
|
||||
}
|
||||
PublishDirectionalShadowPartPoseChangeIfNeeded(in record);
|
||||
}
|
||||
result.Applied++;
|
||||
result.Updated++;
|
||||
return;
|
||||
|
|
@ -407,6 +575,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
record.ProjectionClass);
|
||||
IncrementCount(record.ProjectionClass);
|
||||
AddToIndices(in record);
|
||||
SynchronizeDirectionalShadowPartPose(in record);
|
||||
result.Applied++;
|
||||
result.Registered++;
|
||||
}
|
||||
|
|
@ -465,6 +634,20 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
|
||||
RenderProjectionRecord current = ReadRecord(in entry);
|
||||
UpdateIndices(in prior, in current);
|
||||
if (HasRefreshableDirectionalShadowTransforms(current.ProjectionClass))
|
||||
{
|
||||
if (kind is RenderProjectionDeltaKind.UpdateTransform
|
||||
&& !TransformBitsEqual(prior.Transform, current.Transform))
|
||||
{
|
||||
PublishDirectionalShadowTransformChange(
|
||||
in current,
|
||||
DirectionalShadowTransformChangeKind.UpdateTransform);
|
||||
}
|
||||
else if (kind is RenderProjectionDeltaKind.UpdateAppearance)
|
||||
{
|
||||
PublishDirectionalShadowPartPoseChangeIfNeeded(in current);
|
||||
}
|
||||
}
|
||||
result.Applied++;
|
||||
result.Updated++;
|
||||
}
|
||||
|
|
@ -582,6 +765,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
private void Destroy(in SceneEntry entry)
|
||||
{
|
||||
RenderProjectionRecord record = ReadRecord(in entry);
|
||||
_directionalShadowPartPoses?.Remove(record.Id);
|
||||
RemoveFromIndices(in record);
|
||||
_world.Destroy(entry.Entity);
|
||||
DecrementCount(entry.ProjectionClass);
|
||||
|
|
@ -603,6 +787,8 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
{
|
||||
if (IndexMembershipEquals(in prior, in current))
|
||||
{
|
||||
if (!DirectionalShadowTopologyEquals(in prior, in current))
|
||||
AdvanceDirectionalShadowTopologyRevision();
|
||||
SynchronizeDirtyIndex(in current);
|
||||
return;
|
||||
}
|
||||
|
|
@ -652,6 +838,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
if (record.DirtyMask != RenderDirtyMask.None)
|
||||
_dirty.Add(record.Id);
|
||||
AdvanceIndexRevision();
|
||||
AdvanceDirectionalShadowTopologyRevision();
|
||||
}
|
||||
|
||||
private void RemoveFromIndices(in RenderProjectionRecord record)
|
||||
|
|
@ -668,6 +855,7 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
RemoveCell(_cellStatics, record.Residency.FullCellId, record.Id);
|
||||
RemoveCell(_cellDynamics, record.Residency.FullCellId, record.Id);
|
||||
AdvanceIndexRevision();
|
||||
AdvanceDirectionalShadowTopologyRevision();
|
||||
}
|
||||
|
||||
private static bool IndexMembershipEquals(
|
||||
|
|
@ -689,6 +877,235 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
&& left.SortKey == right.SortKey;
|
||||
}
|
||||
|
||||
private static bool DirectionalShadowTopologyEquals(
|
||||
in RenderProjectionRecord left,
|
||||
in RenderProjectionRecord right)
|
||||
{
|
||||
const RenderProjectionFlags eligibilityFlags =
|
||||
RenderProjectionFlags.Draw
|
||||
| RenderProjectionFlags.SpatiallyResident
|
||||
| RenderProjectionFlags.Translucent;
|
||||
|
||||
if (left.ProjectionClass != right.ProjectionClass
|
||||
|| left.OwnerIncarnation != right.OwnerIncarnation
|
||||
|| left.Source.ParentCellId != right.Source.ParentCellId
|
||||
|| (left.Flags & eligibilityFlags) != (right.Flags & eligibilityFlags)
|
||||
|| left.SortKey != right.SortKey
|
||||
|| left.MeshSet.MeshCount != right.MeshSet.MeshCount
|
||||
|| left.Material != right.Material
|
||||
|| left.DegradeState != right.DegradeState
|
||||
|| left.Source.AppearanceFingerprint
|
||||
!= right.Source.AppearanceFingerprint
|
||||
|| left.Source.DirectionalShadowTopologyFingerprint
|
||||
!= right.Source.DirectionalShadowTopologyFingerprint
|
||||
|| left.EntityPayload.IsBuildingShell
|
||||
!= right.EntityPayload.IsBuildingShell
|
||||
|| left.EntityPayload.CasterIdentity
|
||||
!= right.EntityPayload.CasterIdentity
|
||||
|| !PaletteEquals(
|
||||
left.EntityPayload.PaletteOverride,
|
||||
right.EntityPayload.PaletteOverride))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool refreshableTransforms =
|
||||
HasRefreshableDirectionalShadowTransforms(left.ProjectionClass);
|
||||
if (!refreshableTransforms
|
||||
&& (left.Transform != right.Transform
|
||||
|| left.MeshSet != right.MeshSet
|
||||
|| left.Source.GeometryFingerprint
|
||||
!= right.Source.GeometryFingerprint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IReadOnlyList<AcDream.Core.World.MeshRef>? leftMeshes =
|
||||
left.EntityPayload.MeshRefs;
|
||||
IReadOnlyList<AcDream.Core.World.MeshRef>? rightMeshes =
|
||||
right.EntityPayload.MeshRefs;
|
||||
if (ReferenceEquals(leftMeshes, rightMeshes))
|
||||
return true;
|
||||
if (leftMeshes is null
|
||||
|| rightMeshes is null
|
||||
|| leftMeshes.Count != rightMeshes.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int meshIndex = 0; meshIndex < leftMeshes.Count; meshIndex++)
|
||||
{
|
||||
AcDream.Core.World.MeshRef leftMesh = leftMeshes[meshIndex];
|
||||
AcDream.Core.World.MeshRef rightMesh = rightMeshes[meshIndex];
|
||||
if (leftMesh.GfxObjId != rightMesh.GfxObjId
|
||||
|| !SurfaceOverridesEqual(
|
||||
leftMesh.SurfaceOverrides,
|
||||
rightMesh.SurfaceOverrides)
|
||||
|| (!refreshableTransforms
|
||||
&& leftMesh.PartTransform != rightMesh.PartTransform))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasRefreshableDirectionalShadowTransforms(
|
||||
RenderProjectionClass projectionClass) =>
|
||||
projectionClass is RenderProjectionClass.ActiveAnimatedStatic
|
||||
or RenderProjectionClass.LiveDynamicRoot
|
||||
or RenderProjectionClass.EquippedChild;
|
||||
|
||||
private static bool TransformBitsEqual(
|
||||
in RenderTransform left,
|
||||
in RenderTransform right)
|
||||
{
|
||||
Matrix4x4 leftMatrix = left.LocalToWorld;
|
||||
Matrix4x4 rightMatrix = right.LocalToWorld;
|
||||
ReadOnlySpan<Matrix4x4> leftSpan = MemoryMarshal.CreateReadOnlySpan(
|
||||
in leftMatrix,
|
||||
1);
|
||||
ReadOnlySpan<Matrix4x4> rightSpan = MemoryMarshal.CreateReadOnlySpan(
|
||||
in rightMatrix,
|
||||
1);
|
||||
return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
|
||||
MemoryMarshal.AsBytes(rightSpan));
|
||||
}
|
||||
|
||||
private void EnsureDirectionalShadowTransformJournal()
|
||||
{
|
||||
if (_directionalShadowTransformChanges is not null)
|
||||
return;
|
||||
_directionalShadowTransformChanges = new DirectionalShadowTransformChange[
|
||||
DirectionalShadowTransformChangeJournal.Capacity];
|
||||
_directionalShadowTransformRevision = 1;
|
||||
_directionalShadowTransformChangeCount = 0;
|
||||
_directionalShadowPartPoses = new Dictionary<
|
||||
RenderProjectionId,
|
||||
DirectionalShadowPartPoseSnapshot>();
|
||||
foreach (SceneEntry entry in _entries.Values)
|
||||
{
|
||||
if (!HasRefreshableDirectionalShadowTransforms(entry.ProjectionClass))
|
||||
continue;
|
||||
RenderProjectionRecord record = ReadRecord(in entry);
|
||||
SynchronizeDirectionalShadowPartPose(in record);
|
||||
}
|
||||
}
|
||||
|
||||
private void PublishDirectionalShadowTransformChange(
|
||||
in RenderProjectionRecord projection,
|
||||
DirectionalShadowTransformChangeKind kind)
|
||||
{
|
||||
DirectionalShadowTransformChange[]? journal =
|
||||
_directionalShadowTransformChanges;
|
||||
if (journal is null)
|
||||
return;
|
||||
if (_directionalShadowTransformRevision == ulong.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Directional-shadow transform revision space was exhausted.");
|
||||
}
|
||||
ulong revision = ++_directionalShadowTransformRevision;
|
||||
journal[(int)(revision % (ulong)journal.Length)] =
|
||||
new DirectionalShadowTransformChange(
|
||||
revision,
|
||||
DirectionalShadowTransformSnapshot.Capture(in projection),
|
||||
kind);
|
||||
if (_directionalShadowTransformChangeCount < journal.Length)
|
||||
_directionalShadowTransformChangeCount++;
|
||||
}
|
||||
|
||||
private void ResetDirectionalShadowTransformChanges()
|
||||
{
|
||||
if (_directionalShadowTransformChanges is null)
|
||||
return;
|
||||
_directionalShadowTransformRevision = 1;
|
||||
_directionalShadowTransformChangeCount = 0;
|
||||
_directionalShadowPartPoses!.Clear();
|
||||
}
|
||||
|
||||
private void SynchronizeDirectionalShadowPartPose(
|
||||
in RenderProjectionRecord record)
|
||||
{
|
||||
Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
|
||||
poses = _directionalShadowPartPoses;
|
||||
if (poses is null
|
||||
|| !HasRefreshableDirectionalShadowTransforms(record.ProjectionClass))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
|
||||
{
|
||||
poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
|
||||
return;
|
||||
}
|
||||
pose.CaptureCurrent(in record);
|
||||
}
|
||||
|
||||
private void PublishDirectionalShadowPartPoseChangeIfNeeded(
|
||||
in RenderProjectionRecord record)
|
||||
{
|
||||
Dictionary<RenderProjectionId, DirectionalShadowPartPoseSnapshot>?
|
||||
poses = _directionalShadowPartPoses;
|
||||
if (poses is null)
|
||||
return;
|
||||
if (!poses.TryGetValue(record.Id, out DirectionalShadowPartPoseSnapshot? pose))
|
||||
{
|
||||
poses.Add(record.Id, DirectionalShadowPartPoseSnapshot.Capture(in record));
|
||||
return;
|
||||
}
|
||||
if (!pose.CaptureCurrent(in record))
|
||||
return;
|
||||
PublishDirectionalShadowTransformChange(
|
||||
in record,
|
||||
DirectionalShadowTransformChangeKind.UpdateAppearance);
|
||||
}
|
||||
|
||||
private static bool PaletteEquals(
|
||||
AcDream.Core.World.PaletteOverride? left,
|
||||
AcDream.Core.World.PaletteOverride? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
return true;
|
||||
if (left is null
|
||||
|| right is null
|
||||
|| left.BasePaletteId != right.BasePaletteId
|
||||
|| left.SubPalettes.Count != right.SubPalettes.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int index = 0; index < left.SubPalettes.Count; index++)
|
||||
{
|
||||
if (left.SubPalettes[index] != right.SubPalettes[index])
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool SurfaceOverridesEqual(
|
||||
IReadOnlyDictionary<uint, uint>? left,
|
||||
IReadOnlyDictionary<uint, uint>? right)
|
||||
{
|
||||
if (ReferenceEquals(left, right))
|
||||
return true;
|
||||
if (left is null || right is null || left.Count != right.Count)
|
||||
return false;
|
||||
|
||||
foreach ((uint surfaceId, uint textureId) in left)
|
||||
{
|
||||
if (!right.TryGetValue(surfaceId, out uint candidate)
|
||||
|| candidate != textureId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SynchronizeDirtyIndex(
|
||||
in RenderProjectionRecord record)
|
||||
{
|
||||
|
|
@ -709,6 +1126,17 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
_indexRevision++;
|
||||
}
|
||||
|
||||
private void AdvanceDirectionalShadowTopologyRevision()
|
||||
{
|
||||
if (_directionalShadowTopologyRevision == ulong.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Directional-shadow topology revision space was exhausted.");
|
||||
}
|
||||
|
||||
_directionalShadowTopologyRevision++;
|
||||
}
|
||||
|
||||
private static bool IsDynamic(RenderProjectionClass projectionClass) =>
|
||||
projectionClass is RenderProjectionClass.LiveDynamicRoot
|
||||
or RenderProjectionClass.EquippedChild;
|
||||
|
|
@ -925,10 +1353,71 @@ internal sealed class ArchRenderScene : IRenderScene, IRenderSceneQuerySource
|
|||
hash.Add(record.Source.AppearanceFingerprint.Low);
|
||||
hash.Add(record.Source.AppearanceFingerprint.High);
|
||||
hash.Add(record.Source.CurrentProjectionFlags);
|
||||
hash.Add((byte)record.EntityPayload.CasterIdentity);
|
||||
}
|
||||
|
||||
private readonly record struct ProjectionIdentity(RenderProjectionId Id);
|
||||
|
||||
private readonly record struct DirectionalShadowTransformChange(
|
||||
ulong Revision,
|
||||
DirectionalShadowTransformSnapshot Projection,
|
||||
DirectionalShadowTransformChangeKind Kind);
|
||||
|
||||
private sealed class DirectionalShadowPartPoseSnapshot
|
||||
{
|
||||
private Matrix4x4[] _parts;
|
||||
|
||||
private DirectionalShadowPartPoseSnapshot(Matrix4x4[] parts) =>
|
||||
_parts = parts;
|
||||
|
||||
internal int Count => _parts.Length;
|
||||
|
||||
internal static DirectionalShadowPartPoseSnapshot Capture(
|
||||
in RenderProjectionRecord record)
|
||||
{
|
||||
IReadOnlyList<AcDream.Core.World.MeshRef>? meshes =
|
||||
record.EntityPayload.MeshRefs;
|
||||
var parts = new Matrix4x4[meshes?.Count ?? 0];
|
||||
for (int index = 0; index < parts.Length; index++)
|
||||
parts[index] = meshes![index].PartTransform;
|
||||
return new DirectionalShadowPartPoseSnapshot(parts);
|
||||
}
|
||||
|
||||
internal bool CaptureCurrent(in RenderProjectionRecord record)
|
||||
{
|
||||
IReadOnlyList<AcDream.Core.World.MeshRef>? meshes =
|
||||
record.EntityPayload.MeshRefs;
|
||||
int count = meshes?.Count ?? 0;
|
||||
bool changed = _parts.Length != count;
|
||||
if (changed)
|
||||
_parts = new Matrix4x4[count];
|
||||
for (int index = 0; index < count; index++)
|
||||
{
|
||||
Matrix4x4 current = meshes![index].PartTransform;
|
||||
if (!MatrixBitsEqual(in _parts[index], in current))
|
||||
{
|
||||
_parts[index] = current;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static bool MatrixBitsEqual(
|
||||
in Matrix4x4 left,
|
||||
in Matrix4x4 right)
|
||||
{
|
||||
ReadOnlySpan<Matrix4x4> leftSpan = MemoryMarshal.CreateReadOnlySpan(
|
||||
in left,
|
||||
1);
|
||||
ReadOnlySpan<Matrix4x4> rightSpan = MemoryMarshal.CreateReadOnlySpan(
|
||||
in right,
|
||||
1);
|
||||
return MemoryMarshal.AsBytes(leftSpan).SequenceEqual(
|
||||
MemoryMarshal.AsBytes(rightSpan));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct OutdoorStaticTag;
|
||||
|
||||
private readonly record struct IndoorCellStaticTag;
|
||||
|
|
|
|||
|
|
@ -834,6 +834,22 @@ internal sealed class CurrentRenderSceneOracle :
|
|||
geometry.Add(fingerprint.High);
|
||||
}
|
||||
|
||||
internal static RenderSceneHash128
|
||||
CreateDirectionalShadowTopologyFingerprint(WorldEntity entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
StableRenderHash128 topology = StableRenderHash128.Create();
|
||||
topology.Add(entity.MeshRefs.Count);
|
||||
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
|
||||
{
|
||||
MeshRef mesh = entity.MeshRefs[meshIndex];
|
||||
topology.Add(mesh.GfxObjId);
|
||||
AddSurfaceOverrides(ref topology, mesh.SurfaceOverrides);
|
||||
}
|
||||
|
||||
return topology.Finish();
|
||||
}
|
||||
|
||||
internal static RenderSceneHash128 CreateSurfaceOverrideFingerprint(
|
||||
IReadOnlyDictionary<uint, uint>? overrides)
|
||||
{
|
||||
|
|
|
|||
542
src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
Normal file
542
src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
namespace AcDream.App.Rendering.Scene;
|
||||
|
||||
/// <summary>
|
||||
/// Projection-level membership. Opaque versus alpha-cutout remains an exact
|
||||
/// mesh-batch decision in the dispatcher; this product deliberately does not
|
||||
/// guess from an entity's texture set.
|
||||
/// </summary>
|
||||
internal enum DirectionalShadowCasterKind : byte
|
||||
{
|
||||
OutdoorStatic,
|
||||
Building,
|
||||
AnimatedStatic,
|
||||
LiveDynamic,
|
||||
EquippedChild,
|
||||
}
|
||||
|
||||
internal readonly record struct DirectionalShadowCaster(
|
||||
RenderProjectionRecord Projection,
|
||||
DirectionalShadowCasterKind Kind)
|
||||
{
|
||||
public bool UsesCurrentAnimatedTransforms =>
|
||||
Projection.ProjectionClass
|
||||
is RenderProjectionClass.ActiveAnimatedStatic
|
||||
or RenderProjectionClass.LiveDynamicRoot
|
||||
or RenderProjectionClass.EquippedChild;
|
||||
}
|
||||
|
||||
internal readonly struct DirectionalShadowChangedPose
|
||||
{
|
||||
internal DirectionalShadowChangedPose(
|
||||
int casterIndex,
|
||||
in DirectionalShadowTransformSnapshot snapshot)
|
||||
{
|
||||
CasterIndex = casterIndex;
|
||||
Snapshot = snapshot;
|
||||
}
|
||||
|
||||
internal readonly int CasterIndex;
|
||||
internal readonly DirectionalShadowTransformSnapshot Snapshot;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepted caster counts by the strongest class proven at render publication.
|
||||
/// TerrainCommands is populated by the terrain command producer. OutdoorStatics
|
||||
/// includes trees and all other outdoor DAT scenery; NonPlayerCreatures includes
|
||||
/// hostile monsters and non-hostile NPC creatures because neither source carries
|
||||
/// a narrower authoritative render-only discriminator.
|
||||
/// </summary>
|
||||
internal readonly record struct DirectionalShadowCasterClassDiagnostics(
|
||||
int TerrainCommands,
|
||||
int OutdoorStatics,
|
||||
int Buildings,
|
||||
int AnimatedStatics,
|
||||
int LocalPlayers,
|
||||
int RemotePlayers,
|
||||
int NonPlayerCreatures,
|
||||
int OtherLiveDynamics,
|
||||
int EquippedChildren);
|
||||
|
||||
internal readonly record struct DirectionalShadowCasterBuildStats(
|
||||
int SourceOutdoorStatics,
|
||||
int SourceOutdoorDynamics,
|
||||
int Accepted,
|
||||
int RejectedNotDrawable,
|
||||
int RejectedNotResident,
|
||||
int RejectedTransparent,
|
||||
int RejectedIndoor,
|
||||
int RejectedMissingMesh,
|
||||
int IndexCopies,
|
||||
int Classifications,
|
||||
int DynamicTransformRefreshes,
|
||||
bool TopologyRebuilt,
|
||||
int CopiedTransformChanges = 0,
|
||||
int DedupedChangedCasterSlots = 0,
|
||||
bool TransformJournalFullRefresh = false,
|
||||
int UpdateTransformChanges = 0,
|
||||
int UpdateAppearanceChanges = 0,
|
||||
int DynamicSynchronizationChanges = 0,
|
||||
int ActiveAnimatedStaticChanges = 0,
|
||||
int LiveDynamicRootChanges = 0,
|
||||
int EquippedChildChanges = 0,
|
||||
bool DensityBulkRefresh = false,
|
||||
int BatchedProjectionCopyCalls = 0)
|
||||
{
|
||||
public DirectionalShadowCasterClassDiagnostics CasterClasses { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reusable, streaming-bounded caster product. It copies and classifies the
|
||||
/// render scene's two resident outdoor indices only when the scene's shadow
|
||||
/// topology revision changes. Stable frames retain those topology records and
|
||||
/// emit only deduplicated slim root/part pose changes for prepared matrix slots.
|
||||
/// </summary>
|
||||
internal sealed class DirectionalShadowCasterFrame
|
||||
{
|
||||
private RenderProjectionRecord[] _outdoorStaticScratch = [];
|
||||
private RenderProjectionRecord[] _outdoorDynamicScratch = [];
|
||||
private DirectionalShadowCaster[] _casters = [];
|
||||
private int[] _refreshCasterSlots = [];
|
||||
private DirectionalShadowChangedPose[] _changedCasterPoses = [];
|
||||
private bool[] _changedCasterFlags = [];
|
||||
private RenderProjectionId[] _casterIds = [];
|
||||
private RenderProjectionClass[] _casterClasses = [];
|
||||
private RenderProjectionId[] _denseIdScratch = [];
|
||||
private RenderProjectionRecord[] _denseRecordScratch = [];
|
||||
private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
|
||||
new DirectionalShadowTransformSnapshot[
|
||||
DirectionalShadowTransformChangeJournal.Capacity];
|
||||
private readonly Dictionary<RenderProjectionId, int> _refreshCasterSlotById = [];
|
||||
private int _casterCount;
|
||||
private int _refreshCasterSlotCount;
|
||||
private int _changedCasterPoseCount;
|
||||
private ulong _topologyRevision;
|
||||
private ulong _transformRevision;
|
||||
private DirectionalShadowTransformChanges _lastTransformChanges;
|
||||
private bool _lastDensityBulkRefresh;
|
||||
private int _lastBatchedProjectionCopyCalls;
|
||||
|
||||
public RenderSceneGeneration Generation { get; private set; }
|
||||
|
||||
public ulong BuildSequence { get; private set; }
|
||||
|
||||
public ReadOnlySpan<DirectionalShadowCaster> Casters =>
|
||||
_casters.AsSpan(0, _casterCount);
|
||||
|
||||
internal ReadOnlySpan<int> RefreshCasterSlots =>
|
||||
_refreshCasterSlots.AsSpan(0, _refreshCasterSlotCount);
|
||||
|
||||
internal ReadOnlySpan<DirectionalShadowChangedPose> ChangedCasterPoses =>
|
||||
_changedCasterPoses.AsSpan(0, _changedCasterPoseCount);
|
||||
|
||||
internal ulong TransformRevision => _transformRevision;
|
||||
|
||||
public DirectionalShadowCasterBuildStats Stats { get; private set; }
|
||||
|
||||
public long RetainedScratchBytes =>
|
||||
checked(
|
||||
(long)_outdoorStaticScratch.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
|
||||
+ (long)_outdoorDynamicScratch.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
|
||||
+ (long)_casters.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<DirectionalShadowCaster>()
|
||||
+ (long)_refreshCasterSlots.Length * sizeof(int)
|
||||
+ (long)_changedCasterPoses.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
DirectionalShadowChangedPose>()
|
||||
+ _changedCasterFlags.Length
|
||||
+ (long)_casterIds.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
RenderProjectionId>()
|
||||
+ (long)_casterClasses.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
RenderProjectionClass>()
|
||||
+ (long)_denseIdScratch.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionId>()
|
||||
+ (long)_denseRecordScratch.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<RenderProjectionRecord>()
|
||||
+ (long)_transformChangeScratch.Length
|
||||
* System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
DirectionalShadowTransformSnapshot>()
|
||||
+ (long)_refreshCasterSlotById.EnsureCapacity(0)
|
||||
* (sizeof(int)
|
||||
+ System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
KeyValuePair<RenderProjectionId, int>>()));
|
||||
|
||||
public void Build(in RenderSceneQuery query)
|
||||
{
|
||||
ulong topologyRevision = query.DirectionalShadowTopologyRevision;
|
||||
if (BuildSequence != 0
|
||||
&& Generation == query.Generation
|
||||
&& _topologyRevision == topologyRevision)
|
||||
{
|
||||
int refreshes = RefreshChangedTransforms(in query);
|
||||
Stats = Stats with
|
||||
{
|
||||
IndexCopies = 0,
|
||||
Classifications = 0,
|
||||
DynamicTransformRefreshes = refreshes,
|
||||
TopologyRebuilt = false,
|
||||
CopiedTransformChanges = _lastTransformChanges.Count,
|
||||
DedupedChangedCasterSlots = _changedCasterPoseCount,
|
||||
TransformJournalFullRefresh =
|
||||
_lastTransformChanges.RequiresFullRefresh,
|
||||
DensityBulkRefresh = _lastDensityBulkRefresh,
|
||||
BatchedProjectionCopyCalls = _lastBatchedProjectionCopyCalls,
|
||||
UpdateTransformChanges =
|
||||
_lastTransformChanges.UpdateTransformCount,
|
||||
UpdateAppearanceChanges =
|
||||
_lastTransformChanges.UpdateAppearanceCount,
|
||||
DynamicSynchronizationChanges =
|
||||
_lastTransformChanges.DynamicSynchronizationCount,
|
||||
ActiveAnimatedStaticChanges =
|
||||
_lastTransformChanges.ActiveAnimatedStaticCount,
|
||||
LiveDynamicRootChanges =
|
||||
_lastTransformChanges.LiveDynamicRootCount,
|
||||
EquippedChildChanges =
|
||||
_lastTransformChanges.EquippedChildCount,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
RenderSceneIndexCounts counts = query.IndexCounts;
|
||||
EnsureCapacity(ref _outdoorStaticScratch, counts.OutdoorStatic);
|
||||
EnsureCapacity(ref _outdoorDynamicScratch, counts.OutdoorDynamic);
|
||||
int staticCount = query.CopyIndexTo(
|
||||
RenderSceneIndex.OutdoorStatic,
|
||||
_outdoorStaticScratch.AsSpan(0, counts.OutdoorStatic));
|
||||
int dynamicCount = query.CopyIndexTo(
|
||||
RenderSceneIndex.OutdoorDynamic,
|
||||
_outdoorDynamicScratch.AsSpan(0, counts.OutdoorDynamic));
|
||||
EnsureCapacity(ref _casters, checked(staticCount + dynamicCount));
|
||||
_casterCount = 0;
|
||||
|
||||
int rejectedNotDrawable = 0;
|
||||
int rejectedNotResident = 0;
|
||||
int rejectedTransparent = 0;
|
||||
int rejectedIndoor = 0;
|
||||
int rejectedMissingMesh = 0;
|
||||
int outdoorStatics = 0;
|
||||
int buildings = 0;
|
||||
int animatedStatics = 0;
|
||||
int localPlayers = 0;
|
||||
int remotePlayers = 0;
|
||||
int nonPlayerCreatures = 0;
|
||||
int otherLiveDynamics = 0;
|
||||
int equippedChildren = 0;
|
||||
for (int i = 0; i < staticCount; i++)
|
||||
Add(_outdoorStaticScratch[i]);
|
||||
for (int i = 0; i < dynamicCount; i++)
|
||||
Add(_outdoorDynamicScratch[i]);
|
||||
|
||||
Array.Sort(
|
||||
_casters,
|
||||
0,
|
||||
_casterCount,
|
||||
DirectionalShadowCasterComparer.Instance);
|
||||
int refreshCasterCount = 0;
|
||||
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
|
||||
{
|
||||
if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
|
||||
refreshCasterCount++;
|
||||
}
|
||||
EnsureCapacity(ref _refreshCasterSlots, refreshCasterCount);
|
||||
EnsureCapacity(ref _changedCasterPoses, refreshCasterCount);
|
||||
EnsureCapacity(ref _changedCasterFlags, _casterCount);
|
||||
EnsureCapacity(ref _casterIds, _casterCount);
|
||||
EnsureCapacity(ref _casterClasses, _casterCount);
|
||||
EnsureCapacity(ref _denseIdScratch, refreshCasterCount);
|
||||
EnsureCapacity(ref _denseRecordScratch, refreshCasterCount);
|
||||
_refreshCasterSlotCount = 0;
|
||||
_changedCasterPoseCount = 0;
|
||||
_refreshCasterSlotById.Clear();
|
||||
_refreshCasterSlotById.EnsureCapacity(refreshCasterCount);
|
||||
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
|
||||
{
|
||||
_casterIds[casterIndex] = _casters[casterIndex].Projection.Id;
|
||||
_casterClasses[casterIndex] =
|
||||
_casters[casterIndex].Projection.ProjectionClass;
|
||||
if (_casters[casterIndex].UsesCurrentAnimatedTransforms)
|
||||
{
|
||||
_refreshCasterSlots[_refreshCasterSlotCount++] = casterIndex;
|
||||
_refreshCasterSlotById.Add(
|
||||
_casterIds[casterIndex],
|
||||
casterIndex);
|
||||
}
|
||||
}
|
||||
Generation = query.Generation;
|
||||
_topologyRevision = topologyRevision;
|
||||
_transformRevision = query.DirectionalShadowTransformRevision;
|
||||
_lastTransformChanges = default;
|
||||
_lastDensityBulkRefresh = false;
|
||||
_lastBatchedProjectionCopyCalls = 0;
|
||||
BuildSequence = checked(BuildSequence + 1);
|
||||
Stats = new DirectionalShadowCasterBuildStats(
|
||||
staticCount,
|
||||
dynamicCount,
|
||||
_casterCount,
|
||||
rejectedNotDrawable,
|
||||
rejectedNotResident,
|
||||
rejectedTransparent,
|
||||
rejectedIndoor,
|
||||
rejectedMissingMesh,
|
||||
IndexCopies: 2,
|
||||
Classifications: _casterCount,
|
||||
DynamicTransformRefreshes: 0,
|
||||
TopologyRebuilt: true)
|
||||
{
|
||||
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
|
||||
TerrainCommands: 0,
|
||||
outdoorStatics,
|
||||
buildings,
|
||||
animatedStatics,
|
||||
localPlayers,
|
||||
remotePlayers,
|
||||
nonPlayerCreatures,
|
||||
otherLiveDynamics,
|
||||
equippedChildren),
|
||||
};
|
||||
return;
|
||||
|
||||
void Add(in RenderProjectionRecord projection)
|
||||
{
|
||||
if ((projection.Flags & RenderProjectionFlags.Draw) == 0)
|
||||
{
|
||||
rejectedNotDrawable++;
|
||||
return;
|
||||
}
|
||||
if ((projection.Flags & RenderProjectionFlags.SpatiallyResident) == 0)
|
||||
{
|
||||
rejectedNotResident++;
|
||||
return;
|
||||
}
|
||||
// Transparent means a true blended projection. ClipMap/foliage is
|
||||
// retained here and separated from opaque batches later.
|
||||
if ((projection.Flags & RenderProjectionFlags.Translucent) != 0)
|
||||
{
|
||||
rejectedTransparent++;
|
||||
return;
|
||||
}
|
||||
if (projection.Source.ParentCellId != 0
|
||||
&& InteriorEntityPartition.IsIndoorCellId(
|
||||
projection.Source.ParentCellId))
|
||||
{
|
||||
rejectedIndoor++;
|
||||
return;
|
||||
}
|
||||
if (projection.MeshSet.MeshCount <= 0
|
||||
|| projection.EntityPayload.MeshRefs is null
|
||||
|| projection.EntityPayload.MeshRefs.Count == 0)
|
||||
{
|
||||
rejectedMissingMesh++;
|
||||
return;
|
||||
}
|
||||
|
||||
DirectionalShadowCasterKind kind = Classify(in projection);
|
||||
_casters[_casterCount++] = new DirectionalShadowCaster(
|
||||
projection,
|
||||
kind);
|
||||
switch (kind)
|
||||
{
|
||||
case DirectionalShadowCasterKind.OutdoorStatic:
|
||||
outdoorStatics++;
|
||||
break;
|
||||
case DirectionalShadowCasterKind.Building:
|
||||
buildings++;
|
||||
break;
|
||||
case DirectionalShadowCasterKind.AnimatedStatic:
|
||||
animatedStatics++;
|
||||
break;
|
||||
case DirectionalShadowCasterKind.EquippedChild:
|
||||
equippedChildren++;
|
||||
break;
|
||||
case DirectionalShadowCasterKind.LiveDynamic:
|
||||
switch (projection.EntityPayload.CasterIdentity)
|
||||
{
|
||||
case RenderCasterIdentityKind.LocalPlayer:
|
||||
localPlayers++;
|
||||
break;
|
||||
case RenderCasterIdentityKind.RemotePlayer:
|
||||
remotePlayers++;
|
||||
break;
|
||||
case RenderCasterIdentityKind.NonPlayerCreature:
|
||||
nonPlayerCreatures++;
|
||||
break;
|
||||
default:
|
||||
otherLiveDynamics++;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(kind), kind, "Unknown shadow caster kind.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int RefreshChangedTransforms(in RenderSceneQuery query)
|
||||
{
|
||||
_changedCasterPoseCount = 0;
|
||||
ulong latest = query.DirectionalShadowTransformRevision;
|
||||
if (latest == _transformRevision)
|
||||
{
|
||||
_lastTransformChanges = new DirectionalShadowTransformChanges(
|
||||
latest,
|
||||
0,
|
||||
false);
|
||||
_lastDensityBulkRefresh = false;
|
||||
_lastBatchedProjectionCopyCalls = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
DirectionalShadowTransformChanges changes =
|
||||
query.CopyDirectionalShadowTransformChanges(
|
||||
_transformRevision,
|
||||
_transformChangeScratch);
|
||||
_lastTransformChanges = changes;
|
||||
if (changes.RequiresFullRefresh)
|
||||
{
|
||||
_lastBatchedProjectionCopyCalls = 1;
|
||||
for (int index = 0; index < _refreshCasterSlotCount; index++)
|
||||
{
|
||||
int casterIndex = _refreshCasterSlots[index];
|
||||
_denseIdScratch[index] = _casters[casterIndex].Projection.Id;
|
||||
}
|
||||
int copied = query.CopyById(
|
||||
_denseIdScratch.AsSpan(0, _refreshCasterSlotCount),
|
||||
_denseRecordScratch.AsSpan(0, _refreshCasterSlotCount));
|
||||
if (copied != _refreshCasterSlotCount)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Dense directional-shadow refresh returned an incomplete record batch.");
|
||||
}
|
||||
for (int index = 0; index < _refreshCasterSlotCount; index++)
|
||||
{
|
||||
int casterIndex = _refreshCasterSlots[index];
|
||||
RefreshOne(in _denseRecordScratch[index], casterIndex);
|
||||
DirectionalShadowTransformSnapshot snapshot =
|
||||
DirectionalShadowTransformSnapshot.Capture(
|
||||
in _denseRecordScratch[index]);
|
||||
_changedCasterPoses[_changedCasterPoseCount++] =
|
||||
new DirectionalShadowChangedPose(casterIndex, in snapshot);
|
||||
}
|
||||
_lastDensityBulkRefresh = false;
|
||||
_transformRevision = changes.LatestRevision;
|
||||
return _changedCasterPoseCount;
|
||||
}
|
||||
_lastBatchedProjectionCopyCalls = 0;
|
||||
|
||||
try
|
||||
{
|
||||
ReadOnlySpan<DirectionalShadowTransformSnapshot> records =
|
||||
_transformChangeScratch.AsSpan(0, changes.Count);
|
||||
// Newest-first makes repeated publications of the same projection
|
||||
// resolve to the latest exact root/part payload without an ECS read.
|
||||
for (int index = records.Length - 1; index >= 0; index--)
|
||||
{
|
||||
if (!_refreshCasterSlotById.TryGetValue(
|
||||
records[index].Id,
|
||||
out int casterIndex)
|
||||
|| _changedCasterFlags[casterIndex])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
_changedCasterFlags[casterIndex] = true;
|
||||
ValidateStablePose(in records[index], casterIndex);
|
||||
_changedCasterPoses[_changedCasterPoseCount++] =
|
||||
new DirectionalShadowChangedPose(
|
||||
casterIndex,
|
||||
in records[index]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (int index = 0; index < _changedCasterPoseCount; index++)
|
||||
{
|
||||
_changedCasterFlags[
|
||||
_changedCasterPoses[index].CasterIndex] = false;
|
||||
}
|
||||
}
|
||||
_lastDensityBulkRefresh = _refreshCasterSlotCount >= 64
|
||||
&& _changedCasterPoseCount
|
||||
>= checked((_refreshCasterSlotCount * 3) / 4);
|
||||
_transformRevision = changes.LatestRevision;
|
||||
return _changedCasterPoseCount;
|
||||
}
|
||||
|
||||
private void ValidateStablePose(
|
||||
in DirectionalShadowTransformSnapshot current,
|
||||
int casterIndex)
|
||||
{
|
||||
if (current.Id != _casterIds[casterIndex]
|
||||
|| current.ProjectionClass != _casterClasses[casterIndex])
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Stable directional-shadow topology changed caster "
|
||||
+ $"{_casterIds[casterIndex]} identity or class.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshOne(
|
||||
in RenderProjectionRecord current,
|
||||
int casterIndex)
|
||||
{
|
||||
DirectionalShadowCaster retained = _casters[casterIndex];
|
||||
if (current.Id != retained.Projection.Id
|
||||
|| current.ProjectionClass != retained.Projection.ProjectionClass)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Stable directional-shadow topology changed caster "
|
||||
+ $"{retained.Projection.Id} identity or class.");
|
||||
}
|
||||
_casters[casterIndex] = retained with { Projection = current };
|
||||
}
|
||||
|
||||
private static DirectionalShadowCasterKind Classify(
|
||||
in RenderProjectionRecord projection)
|
||||
{
|
||||
if (projection.EntityPayload.IsBuildingShell)
|
||||
return DirectionalShadowCasterKind.Building;
|
||||
return projection.ProjectionClass switch
|
||||
{
|
||||
RenderProjectionClass.OutdoorStatic =>
|
||||
DirectionalShadowCasterKind.OutdoorStatic,
|
||||
RenderProjectionClass.ActiveAnimatedStatic =>
|
||||
DirectionalShadowCasterKind.AnimatedStatic,
|
||||
RenderProjectionClass.LiveDynamicRoot =>
|
||||
DirectionalShadowCasterKind.LiveDynamic,
|
||||
RenderProjectionClass.EquippedChild =>
|
||||
DirectionalShadowCasterKind.EquippedChild,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Outdoor shadow index carried unsupported {projection.ProjectionClass}."),
|
||||
};
|
||||
}
|
||||
|
||||
private static void EnsureCapacity<T>(ref T[] values, int required)
|
||||
{
|
||||
if (required < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(required));
|
||||
if (values.Length >= required)
|
||||
return;
|
||||
int capacity = values.Length == 0 ? 4 : values.Length;
|
||||
while (capacity < required)
|
||||
capacity = checked(capacity * 2);
|
||||
Array.Resize(ref values, capacity);
|
||||
}
|
||||
|
||||
private sealed class DirectionalShadowCasterComparer
|
||||
: IComparer<DirectionalShadowCaster>
|
||||
{
|
||||
public static DirectionalShadowCasterComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(DirectionalShadowCaster left, DirectionalShadowCaster right)
|
||||
{
|
||||
int order = left.Projection.SortKey.Value.CompareTo(
|
||||
right.Projection.SortKey.Value);
|
||||
return order != 0
|
||||
? order
|
||||
: left.Projection.Id.CompareTo(right.Projection.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
using AcDream.App.Input;
|
||||
using AcDream.App.Update;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
|
|
@ -26,6 +28,7 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
|
|||
private readonly LiveEntityRuntime _runtime;
|
||||
private readonly RenderProjectionJournal _journal;
|
||||
private readonly IRenderTraversalOrderSource _traversalOrder;
|
||||
private readonly ILocalPlayerIdentitySource? _localPlayer;
|
||||
private readonly Dictionary<RuntimeEntityKey, TrackedProjection> _byKey = [];
|
||||
private readonly List<LiveEntityRecord> _activeRootScratch = [];
|
||||
private readonly List<TrackedProjection> _activeScratch = [];
|
||||
|
|
@ -33,12 +36,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
|
|||
public LiveRenderProjectionJournal(
|
||||
LiveEntityRuntime runtime,
|
||||
RenderProjectionJournal journal,
|
||||
IRenderTraversalOrderSource traversalOrder)
|
||||
IRenderTraversalOrderSource traversalOrder,
|
||||
ILocalPlayerIdentitySource? localPlayer = null)
|
||||
{
|
||||
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
||||
_journal = journal ?? throw new ArgumentNullException(nameof(journal));
|
||||
_traversalOrder = traversalOrder
|
||||
?? throw new ArgumentNullException(nameof(traversalOrder));
|
||||
_localPlayer = localPlayer;
|
||||
}
|
||||
|
||||
public int ProjectionCount => _byKey.Count;
|
||||
|
|
@ -286,7 +291,14 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
|
|||
ownerLandblockId,
|
||||
fullCellId,
|
||||
entity,
|
||||
spatiallyVisible);
|
||||
spatiallyVisible,
|
||||
record.ProjectionKind is LiveEntityProjectionKind.Attached
|
||||
? RenderCasterIdentityKind.EquippedChild
|
||||
: RenderCasterIdentityClassifier.Classify(
|
||||
record.Snapshot.Guid,
|
||||
record.Snapshot.ItemType,
|
||||
record.Snapshot.ObjectDescriptionFlags,
|
||||
_localPlayer?.ServerGuid ?? 0u));
|
||||
if (_traversalOrder.TryGetTraversalSortKey(
|
||||
entity,
|
||||
out RenderSortKey sortKey))
|
||||
|
|
@ -336,6 +348,31 @@ internal sealed class LiveRenderProjectionJournal : ILiveRenderProjectionSink
|
|||
}
|
||||
}
|
||||
|
||||
internal static class RenderCasterIdentityClassifier
|
||||
{
|
||||
private const uint PlayerDescriptionFlag = 0x8u;
|
||||
private const uint PlayerGuidPrefix = 0x50000000u;
|
||||
|
||||
internal static RenderCasterIdentityKind Classify(
|
||||
uint serverGuid,
|
||||
uint? itemType,
|
||||
uint? objectDescriptionFlags,
|
||||
uint localPlayerGuid)
|
||||
{
|
||||
if (localPlayerGuid != 0 && serverGuid == localPlayerGuid)
|
||||
return RenderCasterIdentityKind.LocalPlayer;
|
||||
if ((objectDescriptionFlags.GetValueOrDefault()
|
||||
& PlayerDescriptionFlag) != 0
|
||||
|| (serverGuid & 0xFF000000u) == PlayerGuidPrefix)
|
||||
{
|
||||
return RenderCasterIdentityKind.RemotePlayer;
|
||||
}
|
||||
if ((itemType.GetValueOrDefault() & (uint)ItemType.Creature) != 0)
|
||||
return RenderCasterIdentityKind.NonPlayerCreature;
|
||||
return RenderCasterIdentityKind.OtherLiveDynamic;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LiveRenderProjectionResourceLifecycle(
|
||||
ILiveRenderProjectionSink sink) : ILiveEntityResourceLifecycle
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ internal static class RenderProjectionRecordFactory
|
|||
uint ownerLandblockId,
|
||||
uint fullCellId,
|
||||
WorldEntity entity,
|
||||
bool spatiallyVisible)
|
||||
bool spatiallyVisible,
|
||||
RenderCasterIdentityKind casterIdentity =
|
||||
RenderCasterIdentityKind.Unclassified)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
CurrentRenderProjectionFingerprint fingerprint =
|
||||
|
|
@ -80,11 +82,14 @@ internal static class RenderProjectionRecordFactory
|
|||
fingerprint.Transform,
|
||||
fingerprint.Geometry,
|
||||
fingerprint.Appearance,
|
||||
fingerprint.Flags),
|
||||
fingerprint.Flags,
|
||||
CurrentRenderSceneOracle
|
||||
.CreateDirectionalShadowTopologyFingerprint(entity)),
|
||||
new RenderEntityPayload(
|
||||
entity.MeshRefs,
|
||||
entity.PaletteOverride,
|
||||
entity.IsBuildingShell));
|
||||
entity.IsBuildingShell,
|
||||
casterIdentity));
|
||||
}
|
||||
|
||||
private static (Vector3 Minimum, Vector3 Maximum) CalculateBounds(
|
||||
|
|
|
|||
|
|
@ -222,7 +222,26 @@ internal readonly record struct RenderSourceMetadata(
|
|||
RenderSceneHash128 TransformFingerprint,
|
||||
RenderSceneHash128 GeometryFingerprint,
|
||||
RenderSceneHash128 AppearanceFingerprint,
|
||||
uint CurrentProjectionFlags = 0);
|
||||
uint CurrentProjectionFlags = 0,
|
||||
RenderSceneHash128 DirectionalShadowTopologyFingerprint = default);
|
||||
|
||||
/// <summary>
|
||||
/// Render-only identity facts retained from the authoritative publication edge.
|
||||
/// Outdoor DAT scenery has no tree discriminator, and the create-object payload
|
||||
/// does not distinguish hostile monsters from other non-player creatures, so
|
||||
/// neither narrower category is guessed here.
|
||||
/// </summary>
|
||||
internal enum RenderCasterIdentityKind : byte
|
||||
{
|
||||
Unclassified,
|
||||
OutdoorStatic,
|
||||
Building,
|
||||
LocalPlayer,
|
||||
RemotePlayer,
|
||||
NonPlayerCreature,
|
||||
OtherLiveDynamic,
|
||||
EquippedChild,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Borrowed immutable presentation payload captured at a scene publication
|
||||
|
|
@ -234,7 +253,9 @@ internal readonly record struct RenderSourceMetadata(
|
|||
internal readonly record struct RenderEntityPayload(
|
||||
IReadOnlyList<MeshRef> MeshRefs,
|
||||
PaletteOverride? PaletteOverride,
|
||||
bool IsBuildingShell);
|
||||
bool IsBuildingShell,
|
||||
RenderCasterIdentityKind CasterIdentity =
|
||||
RenderCasterIdentityKind.Unclassified);
|
||||
|
||||
internal readonly record struct RenderProjectionRecord(
|
||||
RenderProjectionId Id,
|
||||
|
|
@ -449,6 +470,66 @@ internal readonly record struct RenderSceneDigest(
|
|||
RenderProjectionCounts Counts,
|
||||
RenderSceneHash128 Hash);
|
||||
|
||||
internal static class DirectionalShadowTransformChangeJournal
|
||||
{
|
||||
// Larger than the measured 9,498-caster dense-Arwic row so one complete
|
||||
// changed-pose publication fits without truncation. Overflow is explicit
|
||||
// and makes the consumer take its exact full-refresh fallback.
|
||||
internal const int Capacity = 16_384;
|
||||
}
|
||||
|
||||
internal readonly struct DirectionalShadowTransformSnapshot
|
||||
{
|
||||
internal DirectionalShadowTransformSnapshot(
|
||||
RenderProjectionId id,
|
||||
RenderProjectionClass projectionClass,
|
||||
RenderTransform transform,
|
||||
RenderEntityPayload entityPayload)
|
||||
{
|
||||
Id = id;
|
||||
ProjectionClass = projectionClass;
|
||||
Transform = transform;
|
||||
EntityPayload = entityPayload;
|
||||
}
|
||||
|
||||
internal readonly RenderProjectionId Id;
|
||||
internal readonly RenderProjectionClass ProjectionClass;
|
||||
internal readonly RenderTransform Transform;
|
||||
internal readonly RenderEntityPayload EntityPayload;
|
||||
|
||||
internal static DirectionalShadowTransformSnapshot Capture(
|
||||
in RenderProjectionRecord projection) =>
|
||||
new(
|
||||
projection.Id,
|
||||
projection.ProjectionClass,
|
||||
projection.Transform,
|
||||
projection.EntityPayload);
|
||||
}
|
||||
|
||||
internal readonly record struct DirectionalShadowTransformChanges(
|
||||
ulong LatestRevision,
|
||||
int Count,
|
||||
bool RequiresFullRefresh,
|
||||
int UpdateTransformCount = 0,
|
||||
int UpdateAppearanceCount = 0,
|
||||
int DynamicSynchronizationCount = 0,
|
||||
int ActiveAnimatedStaticCount = 0,
|
||||
int LiveDynamicRootCount = 0,
|
||||
int EquippedChildCount = 0);
|
||||
|
||||
// Transform-journal records carry the producer's already-current projection.
|
||||
// Root matrices are values; MeshRef payloads are borrowed under the render
|
||||
// publication ordering rule: a part-pose mutation must be followed by its
|
||||
// UpdateAppearance publication before the frame opens a scene query. Consumers
|
||||
// read newest-to-oldest, so repeated IDs always select the latest publication.
|
||||
|
||||
internal enum DirectionalShadowTransformChangeKind : byte
|
||||
{
|
||||
UpdateTransform,
|
||||
UpdateAppearance,
|
||||
DynamicSynchronization,
|
||||
}
|
||||
|
||||
internal sealed class RenderSceneDigestBuffer
|
||||
{
|
||||
internal List<RenderProjectionRecord> Records { get; } = [];
|
||||
|
|
@ -461,12 +542,26 @@ internal interface IRenderSceneQuerySource
|
|||
RenderProjectionCounts GetCounts(RenderSceneGeneration generation);
|
||||
RenderSceneIndexCounts GetIndexCounts(RenderSceneGeneration generation);
|
||||
ulong GetIndexRevision(RenderSceneGeneration generation);
|
||||
ulong GetDirectionalShadowTopologyRevision(
|
||||
RenderSceneGeneration generation);
|
||||
ulong GetDirectionalShadowTransformRevision(
|
||||
RenderSceneGeneration generation);
|
||||
|
||||
DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
|
||||
RenderSceneGeneration generation,
|
||||
ulong afterRevision,
|
||||
Span<DirectionalShadowTransformSnapshot> destination);
|
||||
|
||||
bool TryGet(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionId id,
|
||||
out RenderProjectionRecord record);
|
||||
|
||||
int CopyById(
|
||||
RenderSceneGeneration generation,
|
||||
ReadOnlySpan<RenderProjectionId> ids,
|
||||
Span<RenderProjectionRecord> destination);
|
||||
|
||||
int CopyTo(
|
||||
RenderSceneGeneration generation,
|
||||
RenderProjectionClass? projectionClass,
|
||||
|
|
@ -512,11 +607,30 @@ internal readonly struct RenderSceneQuery
|
|||
public ulong IndexRevision =>
|
||||
Source.GetIndexRevision(Generation);
|
||||
|
||||
public ulong DirectionalShadowTopologyRevision =>
|
||||
Source.GetDirectionalShadowTopologyRevision(Generation);
|
||||
|
||||
public ulong DirectionalShadowTransformRevision =>
|
||||
Source.GetDirectionalShadowTransformRevision(Generation);
|
||||
|
||||
public DirectionalShadowTransformChanges CopyDirectionalShadowTransformChanges(
|
||||
ulong afterRevision,
|
||||
Span<DirectionalShadowTransformSnapshot> destination) =>
|
||||
Source.CopyDirectionalShadowTransformChanges(
|
||||
Generation,
|
||||
afterRevision,
|
||||
destination);
|
||||
|
||||
public bool TryGet(
|
||||
RenderProjectionId id,
|
||||
out RenderProjectionRecord record) =>
|
||||
Source.TryGet(Generation, id, out record);
|
||||
|
||||
public int CopyById(
|
||||
ReadOnlySpan<RenderProjectionId> ids,
|
||||
Span<RenderProjectionRecord> destination) =>
|
||||
Source.CopyById(Generation, ids, destination);
|
||||
|
||||
public int CopyTo(Span<RenderProjectionRecord> destination) =>
|
||||
Source.CopyTo(Generation, null, destination);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering.Scene.Arch;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.World;
|
||||
|
|
@ -95,7 +96,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
|
|||
|
||||
public LiveRenderProjectionJournal BindLiveRuntime(
|
||||
LiveEntityRuntime runtime,
|
||||
IRenderTraversalOrderSource traversalOrder)
|
||||
IRenderTraversalOrderSource traversalOrder,
|
||||
ILocalPlayerIdentitySource? localPlayer = null)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
|
@ -109,7 +111,8 @@ internal sealed class RenderSceneShadowRuntime : IDisposable
|
|||
_live = new LiveRenderProjectionJournal(
|
||||
runtime,
|
||||
_journal,
|
||||
traversalOrder);
|
||||
traversalOrder,
|
||||
localPlayer);
|
||||
return _live;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ internal sealed class StaticRenderProjectionJournal :
|
|||
&& accepted.Source.AppearanceFingerprint
|
||||
== retained.Record.Source.AppearanceFingerprint
|
||||
&& accepted.EntityPayload.IsBuildingShell
|
||||
== retained.Record.EntityPayload.IsBuildingShell)
|
||||
== retained.Record.EntityPayload.IsBuildingShell
|
||||
&& accepted.EntityPayload.CasterIdentity
|
||||
== retained.Record.EntityPayload.CasterIdentity)
|
||||
{
|
||||
accepted = accepted with
|
||||
{
|
||||
|
|
@ -245,7 +247,10 @@ internal sealed class StaticRenderProjectionJournal :
|
|||
tracked.Record.Residency.OwnerLandblockId,
|
||||
tracked.Record.Residency.FullCellId,
|
||||
entity,
|
||||
spatiallyVisible: true) with
|
||||
spatiallyVisible: true,
|
||||
casterIdentity: entity.IsBuildingShell
|
||||
? RenderCasterIdentityKind.Building
|
||||
: RenderCasterIdentityKind.OutdoorStatic) with
|
||||
{
|
||||
PreviousTransform = new PreviousRenderTransform(
|
||||
tracked.Record.Transform.LocalToWorld),
|
||||
|
|
@ -317,7 +322,10 @@ internal sealed class StaticRenderProjectionJournal :
|
|||
landblockId,
|
||||
fullCellId,
|
||||
entity,
|
||||
spatiallyVisible: true) with
|
||||
spatiallyVisible: true,
|
||||
casterIdentity: entity.IsBuildingShell
|
||||
? RenderCasterIdentityKind.Building
|
||||
: RenderCasterIdentityKind.OutdoorStatic) with
|
||||
{
|
||||
SortKey = sortKey,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue