Move the complete update/render construction graph into a typed Phase-8 owner, explicitly carry the content dependencies it consumes, and publish both roots through one exact lease. Extract lifecycle resource sampling and frame-owned late bindings so partial startup and shutdown withdraw the same generation without window callbacks. Co-authored-by: Codex <codex@openai.com>
543 lines
20 KiB
C#
543 lines
20 KiB
C#
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class RetailPViewPassExecutorTests
|
|
{
|
|
[Fact]
|
|
public void DrawInside_outdoor_executes_the_real_typed_sequence_without_interior_clear()
|
|
{
|
|
var renderer = new RetailPViewRenderer();
|
|
using var executor = new RecordingExecutor();
|
|
LoadedCell root = OutdoorCellNode.Build(0xA9B40000u);
|
|
|
|
renderer.DrawInside(Frame(root), executor);
|
|
|
|
Assert.Equal(
|
|
[
|
|
"begin",
|
|
"assemble",
|
|
"prepare-clip:3",
|
|
"indoor-routing",
|
|
"prepare-cells",
|
|
"diagnostics",
|
|
"terrain-clip",
|
|
"clear-routing",
|
|
"landscape-early",
|
|
"terrain-clip",
|
|
"clear-routing",
|
|
"outstage-routing",
|
|
"landscape-late",
|
|
"landscape-alpha",
|
|
"indoor-routing",
|
|
"indoor-routing",
|
|
"exit-mask",
|
|
"indoor-routing",
|
|
"opaque-shells",
|
|
],
|
|
executor.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawInside_interior_exit_flushes_before_clear_and_resets_borrowed_result()
|
|
{
|
|
var renderer = new RetailPViewRenderer();
|
|
using var executor = new RecordingExecutor();
|
|
LoadedCell outdoor = OutdoorCellNode.Build(0xA9B40000u);
|
|
RetailPViewFrameResult borrowed = renderer.DrawInside(Frame(outdoor), executor);
|
|
Assert.Contains(outdoor.CellId, borrowed.DrawableCells);
|
|
|
|
executor.Operations.Clear();
|
|
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
|
RetailPViewFrameResult reused = renderer.DrawInside(Frame(interior), executor);
|
|
|
|
Assert.Same(borrowed, reused);
|
|
Assert.DoesNotContain(outdoor.CellId, reused.DrawableCells);
|
|
Assert.Contains(interior.CellId, reused.DrawableCells);
|
|
AssertAppearsInOrder(
|
|
string.Join('|', executor.Operations),
|
|
"landscape-early",
|
|
"landscape-late",
|
|
"unattached-particles",
|
|
"landscape-alpha",
|
|
"interior-depth-clear",
|
|
"indoor-routing",
|
|
"exit-mask");
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawInside_interior_without_an_outside_slice_skips_landscape_and_depth_clear()
|
|
{
|
|
var renderer = new RetailPViewRenderer();
|
|
using var executor = new RecordingExecutor();
|
|
var root = new LoadedCell
|
|
{
|
|
CellId = 0xA9B40100u,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
};
|
|
|
|
renderer.DrawInside(Frame(root), executor);
|
|
|
|
Assert.DoesNotContain("terrain-clip", executor.Operations);
|
|
Assert.DoesNotContain("landscape-early", executor.Operations);
|
|
Assert.DoesNotContain("landscape-late", executor.Operations);
|
|
Assert.DoesNotContain("landscape-alpha", executor.Operations);
|
|
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Particle_classifications_reset_before_an_empty_following_frame()
|
|
{
|
|
var classifications = new RetailPViewParticleClassifications();
|
|
classifications.ReplaceOutdoor([Entity(7u)]);
|
|
classifications.Visible.Add(8u);
|
|
classifications.Dynamics.Add(9u);
|
|
|
|
classifications.BeginFrame();
|
|
|
|
Assert.Empty(classifications.Outdoor);
|
|
Assert.Empty(classifications.Visible);
|
|
Assert.Empty(classifications.Dynamics);
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawInside_real_fixtures_reach_entity_particle_and_transparent_shell_operations()
|
|
{
|
|
var renderer = new RetailPViewRenderer();
|
|
using var executor = new RecordingExecutor { HasTransparentShells = true };
|
|
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
|
WorldEntity cellStatic = Entity(10u, parentCellId: interior.CellId);
|
|
WorldEntity dynamic = Entity(
|
|
11u,
|
|
serverGuid: 0x80000011u,
|
|
parentCellId: interior.CellId);
|
|
|
|
renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor);
|
|
|
|
Assert.Contains("opaque-shells", executor.Operations);
|
|
Assert.Contains("transparent-shells-ordered", executor.Operations);
|
|
Assert.Contains("entity-bucket", executor.Operations);
|
|
Assert.Contains("cell-particles", executor.Operations);
|
|
Assert.Contains("dynamics-particles", executor.Operations);
|
|
Assert.Contains("phantom-objects", executor.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch()
|
|
{
|
|
var renderer = new RetailPViewRenderer();
|
|
using var executor = new RecordingExecutor();
|
|
LoadedCell root = InteriorWithExit(0xA9B40100u);
|
|
root.BuildingId = 1u;
|
|
LoadedCell[] building = NearbyTwoCellBuilding();
|
|
|
|
renderer.DrawInside(
|
|
Frame(
|
|
root,
|
|
nearbyBuildingCells: building,
|
|
additionalCells: building),
|
|
executor);
|
|
|
|
Assert.Contains("look-in-punch", executor.Operations);
|
|
}
|
|
|
|
[Fact]
|
|
public void Renderer_source_preserves_retail_stage_order_through_typed_operations()
|
|
{
|
|
string source = RendererSource();
|
|
string drawInside = MethodBody(source, "public RetailPViewFrameResult DrawInside(");
|
|
AssertAppearsInOrder(
|
|
drawInside,
|
|
"passes.BeginFrame();",
|
|
"passes.EmitDiagnostics(ctx, result);",
|
|
"DrawLandscapeThroughOutsideView(ctx, passes",
|
|
"DrawExitPortalMasks(ctx, passes",
|
|
"DrawEnvCellShells(passes, pvFrame);",
|
|
"DrawCellObjectLists(ctx, passes",
|
|
"DrawDynamicsLast(ctx, passes");
|
|
|
|
string landscape = MethodBody(
|
|
source,
|
|
"private void DrawLandscapeThroughOutsideView(");
|
|
AssertAppearsInOrder(
|
|
landscape,
|
|
"passes.SetTerrainClip(slice.Planes);",
|
|
"passes.DrawLandscapeSlice(ctx",
|
|
"DrawBuildingLookIns(ctx, passes",
|
|
"passes.DrawLandscapeSliceLate(ctx",
|
|
"passes.DrawUnattachedSceneParticles(ctx);",
|
|
"passes.FlushLandscapeAlpha();",
|
|
"passes.ClearInteriorDepth();");
|
|
|
|
string lookIns = MethodBody(source, "private void DrawBuildingLookIns(");
|
|
AssertAppearsInOrder(
|
|
lookIns,
|
|
"passes.DrawLookInPortalPunch(ctx",
|
|
"passes.DrawOpaqueCellShells(_shellBatch);",
|
|
"passes.DrawCellParticles(ctx");
|
|
}
|
|
|
|
[Fact]
|
|
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
|
|
{
|
|
Assert.DoesNotContain(
|
|
typeof(RetailPViewFrameInput).GetProperties(),
|
|
property => typeof(Delegate).IsAssignableFrom(property.PropertyType));
|
|
|
|
FieldInfo[] fields = typeof(RetailPViewPassExecutor).GetFields(
|
|
BindingFlags.Instance | BindingFlags.NonPublic);
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(CellVisibility));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameInput));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameResult));
|
|
Assert.DoesNotContain(fields, field => field.FieldType == typeof(ClipFrameAssembly));
|
|
Assert.DoesNotContain(
|
|
fields,
|
|
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
|
}
|
|
|
|
[Fact]
|
|
public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics()
|
|
{
|
|
string source = ExecutorClassSource();
|
|
string begin = MethodBody(source, "public void BeginFrame()");
|
|
Assert.Contains("_particleClassifications.BeginFrame();", begin);
|
|
|
|
string landscape = MethodBody(source, "public void DrawLandscapeSlice(");
|
|
AssertAppearsInOrder(
|
|
landscape,
|
|
"_terrainDiagnostics.Begin();",
|
|
"_terrain?.Draw(",
|
|
"_terrainDiagnostics.Complete();");
|
|
|
|
Assert.DoesNotContain("Console.WriteLine", source);
|
|
Assert.DoesNotContain("Console.WriteLine", RendererSource());
|
|
}
|
|
|
|
[Fact]
|
|
public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag()
|
|
{
|
|
string source = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Composition",
|
|
"FrameRootComposition.cs"));
|
|
|
|
Assert.Contains("new RetailPViewPassExecutor(", source);
|
|
Assert.Contains("new WorldScenePViewRenderer(", source);
|
|
Assert.DoesNotContain("new AcDream.App.Rendering.RetailPViewFrameInput", source);
|
|
Assert.DoesNotContain("RetailPViewDrawContext", source);
|
|
Assert.DoesNotContain("DrawLandscapeSlice =", source);
|
|
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
|
}
|
|
|
|
private static string RendererSource() => File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"RetailPViewRenderer.cs"));
|
|
|
|
private static string ExecutorSource() => File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"RetailPViewPassExecutor.cs"));
|
|
|
|
private static string ExecutorClassSource()
|
|
{
|
|
string source = ExecutorSource();
|
|
int start = source.IndexOf(
|
|
"internal sealed class RetailPViewPassExecutor",
|
|
StringComparison.Ordinal);
|
|
Assert.True(start >= 0, "Missing concrete RetailPViewPassExecutor.");
|
|
return source[start..];
|
|
}
|
|
|
|
private static RetailPViewFrameInput Frame(
|
|
LoadedCell root,
|
|
IReadOnlyList<WorldEntity>? entities = null,
|
|
IReadOnlyList<LoadedCell>? nearbyBuildingCells = null,
|
|
IReadOnlyList<LoadedCell>? additionalCells = null)
|
|
{
|
|
var cells = new Dictionary<uint, LoadedCell> { [root.CellId] = root };
|
|
if (additionalCells is not null)
|
|
{
|
|
foreach (LoadedCell cell in additionalCells)
|
|
cells[cell.CellId] = cell;
|
|
}
|
|
|
|
var entries = new List<(
|
|
uint LandblockId,
|
|
Vector3 AabbMin,
|
|
Vector3 AabbMax,
|
|
IReadOnlyList<WorldEntity> Entities,
|
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>();
|
|
if (entities is not null)
|
|
{
|
|
entries.Add((
|
|
root.CellId & 0xFFFF0000u,
|
|
Vector3.Zero,
|
|
Vector3.Zero,
|
|
entities,
|
|
null));
|
|
}
|
|
|
|
return new RetailPViewFrameInput
|
|
{
|
|
RootCell = root,
|
|
NearbyBuildingCells = nearbyBuildingCells,
|
|
ViewerEyePos = Vector3.Zero,
|
|
ViewProjection = TestCamera.ViewProjection,
|
|
Cells = new DictionaryCellSource(cells),
|
|
Camera = new TestCamera(),
|
|
CameraWorldPosition = Vector3.Zero,
|
|
Frustum = null,
|
|
PlayerLandblockId = root.CellId & 0xFFFF0000u,
|
|
AnimatedEntityIds = null,
|
|
RenderCenterLbX = 0,
|
|
RenderCenterLbY = 0,
|
|
RenderRadius = 1,
|
|
LandblockEntries = entries,
|
|
RenderSky = true,
|
|
RenderWeather = true,
|
|
DayFraction = 0f,
|
|
ActiveDayGroup = null,
|
|
SkyKeyframe = default,
|
|
EnvironOverrideActive = false,
|
|
ViewerCellId = root.CellId,
|
|
PlayerCellId = root.CellId,
|
|
PlayerViewPosition = Vector3.Zero,
|
|
CameraView = TestCamera.ViewMatrix,
|
|
CameraCellResolution = CameraCellResolution.None,
|
|
};
|
|
}
|
|
|
|
private static LoadedCell InteriorWithExit(uint cellId)
|
|
{
|
|
var cell = new LoadedCell
|
|
{
|
|
CellId = cellId,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
Portals = [new CellPortalInfo(0xFFFF, 0, 0, 0)],
|
|
};
|
|
cell.PortalPolygons.Add(
|
|
[
|
|
new Vector3(-1f, -1f, -2f),
|
|
new Vector3(1f, -1f, -2f),
|
|
new Vector3(1f, 1f, -2f),
|
|
new Vector3(-1f, 1f, -2f),
|
|
]);
|
|
return cell;
|
|
}
|
|
|
|
private static LoadedCell[] NearbyTwoCellBuilding()
|
|
{
|
|
const uint vestibuleId = 0xA9B40170u;
|
|
const uint roomId = 0xA9B40171u;
|
|
var vestibule = new LoadedCell
|
|
{
|
|
CellId = vestibuleId,
|
|
BuildingId = 2u,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
Portals =
|
|
[
|
|
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
|
new CellPortalInfo(0x0171, 1, 0, 0),
|
|
],
|
|
ClipPlanes =
|
|
[
|
|
new PortalClipPlane
|
|
{
|
|
Normal = new Vector3(0, 0, 1),
|
|
D = 3f,
|
|
InsideSide = 1,
|
|
},
|
|
],
|
|
};
|
|
vestibule.PortalPolygons.Add(
|
|
[
|
|
new Vector3(-0.5f, -0.5f, -2f),
|
|
new Vector3(0.5f, -0.5f, -2f),
|
|
new Vector3(0.5f, 0.5f, -2f),
|
|
new Vector3(-0.5f, 0.5f, -2f),
|
|
]);
|
|
vestibule.PortalPolygons.Add(
|
|
[
|
|
new Vector3(-0.6f, -0.6f, -4f),
|
|
new Vector3(0.6f, -0.6f, -4f),
|
|
new Vector3(0.6f, 0.6f, -4f),
|
|
new Vector3(-0.6f, 0.6f, -4f),
|
|
]);
|
|
|
|
var room = new LoadedCell
|
|
{
|
|
CellId = roomId,
|
|
BuildingId = 2u,
|
|
WorldTransform = Matrix4x4.Identity,
|
|
InverseWorldTransform = Matrix4x4.Identity,
|
|
Portals = [new CellPortalInfo(0x0170, 0, 0, 1)],
|
|
};
|
|
room.PortalPolygons.Add(
|
|
[
|
|
new Vector3(-0.6f, -0.6f, -4f),
|
|
new Vector3(0.6f, -0.6f, -4f),
|
|
new Vector3(0.6f, 0.6f, -4f),
|
|
new Vector3(-0.6f, 0.6f, -4f),
|
|
]);
|
|
return [vestibule, room];
|
|
}
|
|
|
|
private static WorldEntity Entity(
|
|
uint id,
|
|
uint serverGuid = 0,
|
|
uint? parentCellId = null) => new()
|
|
{
|
|
Id = id,
|
|
ServerGuid = serverGuid,
|
|
SourceGfxObjOrSetupId = 0,
|
|
Position = Vector3.Zero,
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
|
ParentCellId = parentCellId,
|
|
};
|
|
|
|
private static string MethodBody(string source, string signature)
|
|
{
|
|
int start = source.IndexOf(signature, StringComparison.Ordinal);
|
|
Assert.True(start >= 0, $"Missing method: {signature}");
|
|
int open = source.IndexOf('{', start);
|
|
int depth = 0;
|
|
for (int index = open; index < source.Length; index++)
|
|
{
|
|
if (source[index] == '{')
|
|
depth++;
|
|
else if (source[index] == '}' && --depth == 0)
|
|
return source[start..(index + 1)];
|
|
}
|
|
|
|
throw new InvalidOperationException($"Unterminated method: {signature}");
|
|
}
|
|
|
|
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 > cursor, $"Missing or out-of-order 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 DictionaryCellSource(
|
|
IReadOnlyDictionary<uint, LoadedCell> cells) : IRetailPViewCellSource
|
|
{
|
|
public LoadedCell? Find(uint cellId) =>
|
|
cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null;
|
|
}
|
|
|
|
private sealed class TestCamera : ICamera
|
|
{
|
|
public static Matrix4x4 ViewMatrix { get; } = Matrix4x4.CreateLookAt(
|
|
Vector3.Zero,
|
|
new Vector3(0, 0, -1),
|
|
Vector3.UnitY);
|
|
|
|
public static Matrix4x4 ViewProjection { get; } = ViewMatrix
|
|
* Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
|
|
|
public Matrix4x4 View => ViewMatrix;
|
|
public Matrix4x4 Projection { get; } =
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
|
public float Aspect { get; set; } = 1f;
|
|
}
|
|
|
|
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
|
|
{
|
|
public void AbortFrame() => Operations.Add("abort");
|
|
|
|
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
|
|
|
public List<string> Operations { get; } = [];
|
|
public bool HasTransparentShells { get; init; }
|
|
|
|
public void BeginFrame() => Operations.Add("begin");
|
|
public ClipFrameAssembly AssembleClipFrame(
|
|
PortalVisibilityFrame portalFrame,
|
|
ClipFrameAssembly reuseAssembly)
|
|
{
|
|
Operations.Add("assemble");
|
|
return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
|
}
|
|
|
|
public void PrepareClipFrame(int terrainUploadCount) =>
|
|
Operations.Add($"prepare-clip:{terrainUploadCount}");
|
|
|
|
public void SetTerrainClip(ReadOnlySpan<Vector4> planes) =>
|
|
Operations.Add("terrain-clip");
|
|
|
|
public void ClearClipRouting() => Operations.Add("clear-routing");
|
|
public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing");
|
|
public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet<uint> visibleCellIds) =>
|
|
Operations.Add("prepare-cells");
|
|
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
|
|
public bool CellHasTransparentShell(uint cellId) => HasTransparentShells;
|
|
public void DrawTransparentCellShells(HashSet<uint> cellIds) => Operations.Add("transparent-shells");
|
|
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
|
|
Operations.Add("transparent-shells-ordered");
|
|
public void DrawEntityBucket(
|
|
RetailPViewFrameInput frame,
|
|
IReadOnlyList<WorldEntity> entities,
|
|
HashSet<uint>? visibleCellIds) => Operations.Add("entity-bucket");
|
|
public void EmitClipRouteProbe(
|
|
ClipFrameAssembly clipAssembly,
|
|
ClipViewSlice slice,
|
|
int sliceIndex) => Operations.Add("clip-probe");
|
|
public void EmitOutStageOwner(
|
|
WorldEntity entity,
|
|
Vector3 sphereCenter,
|
|
float sphereRadius,
|
|
int sliceIndex,
|
|
bool passed) => Operations.Add("outstage-owner");
|
|
public void EmitOutStageRouting(
|
|
int sliceIndex,
|
|
IReadOnlyList<WorldEntity> entities,
|
|
ViewconeCuller viewcone) => Operations.Add("outstage-routing");
|
|
public void EmitPhantomObjects(uint cellId, int survivorCount) =>
|
|
Operations.Add("phantom-objects");
|
|
public void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) => Operations.Add("landscape-early");
|
|
public void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context) => Operations.Add("landscape-late");
|
|
public void ClearInteriorDepth() => Operations.Add("interior-depth-clear");
|
|
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
|
|
public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch");
|
|
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles");
|
|
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
|
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
|
public void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList<WorldEntity> survivors) => Operations.Add("dynamics-particles");
|
|
public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics");
|
|
|
|
public void Dispose() => _clipFrame.Dispose();
|
|
}
|
|
}
|