refactor(render): extract frame resource preparation
Move ordered resource begin, clear/state setup, live upload/reveal, weather timing, and display pacing behind focused owners while preserving the accepted frame order and retryable shutdown ownership. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
733126a272
commit
bc6f09f987
8 changed files with 1068 additions and 205 deletions
|
|
@ -377,6 +377,20 @@ corrected-diff reviews are clean.
|
|||
- prove `WbMeshAdapter.Tick` remains before reveal evaluation and drawing;
|
||||
- prove dispatcher begin occurs once for the whole world/paperdoll frame.
|
||||
|
||||
Completed 2026-07-22. `RenderFrameResourceController` now owns the exact
|
||||
resource-begin → atmosphere/GL-clear → live-upload/reveal/particle transaction,
|
||||
with a single captured GPU slot shared by every phase. Focused sources own the
|
||||
login reveal cell, teleport render state, upload timing, and render-time weather
|
||||
clock. `DisplayFramePacingController` owns requested VSync, the event-refreshed
|
||||
monitor-rate cache, software pacing, and the post-render callback without adding
|
||||
a monitor query to the frame hot path. Borrowed frame owners are withdrawn before
|
||||
render teardown; portal fallback ownership transfers before the throwable sink
|
||||
bind; pacing releases its borrowed surface/profiler before their owners close.
|
||||
`GameWindow.cs` is 6,181 raw lines, 60.7% below the 15,723-line campaign
|
||||
baseline. Twenty-one focused tests, the Release build, and the complete suite
|
||||
pass (7,269 passed / 5 skipped). Retail, architecture, and adversarial
|
||||
corrected-diff reviews are clean.
|
||||
|
||||
### D — world frame builder
|
||||
|
||||
- move animated/equipped classification, camera/root selection, outdoor-node
|
||||
|
|
|
|||
141
src/AcDream.App/Rendering/DisplayFramePacingController.cs
Normal file
141
src/AcDream.App/Rendering/DisplayFramePacingController.cs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using Silk.NET.Maths;
|
||||
using Silk.NET.Windowing;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal interface IDisplayFramePacingSurface
|
||||
{
|
||||
bool VSync { get; set; }
|
||||
|
||||
int? ActiveMonitorRefreshHz { get; }
|
||||
}
|
||||
|
||||
/// <summary>Narrow Silk window adapter for display pacing.</summary>
|
||||
internal sealed class SilkDisplayFramePacingSurface : IDisplayFramePacingSurface
|
||||
{
|
||||
private readonly IWindow _window;
|
||||
|
||||
public SilkDisplayFramePacingSurface(IWindow window)
|
||||
{
|
||||
_window = window ?? throw new ArgumentNullException(nameof(window));
|
||||
}
|
||||
|
||||
public bool VSync
|
||||
{
|
||||
get => _window.VSync;
|
||||
set => _window.VSync = value;
|
||||
}
|
||||
|
||||
public int? ActiveMonitorRefreshHz
|
||||
{
|
||||
get
|
||||
{
|
||||
int? refreshHz = _window.Monitor?.VideoMode.RefreshRate;
|
||||
return refreshHz is > 0 ? refreshHz : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns requested VSync, monitor-aware policy resolution, the software pacer,
|
||||
/// and the post-render pacing event. Window lifecycle and frame preparation
|
||||
/// call this typed owner rather than duplicating pacing state.
|
||||
/// </summary>
|
||||
internal sealed class DisplayFramePacingController : IDisposable
|
||||
{
|
||||
private readonly bool _uncappedRendering;
|
||||
private FrameProfiler? _profiler;
|
||||
private readonly FramePacingController _pacing;
|
||||
private IDisplayFramePacingSurface? _surface;
|
||||
private int? _activeMonitorRefreshHz;
|
||||
private bool _requestedVSync =
|
||||
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
|
||||
|
||||
public DisplayFramePacingController(
|
||||
bool uncappedRendering,
|
||||
FrameProfiler profiler)
|
||||
: this(uncappedRendering, profiler, new FramePacingController())
|
||||
{
|
||||
}
|
||||
|
||||
internal DisplayFramePacingController(
|
||||
bool uncappedRendering,
|
||||
FrameProfiler profiler,
|
||||
FramePacingController pacing)
|
||||
{
|
||||
_uncappedRendering = uncappedRendering;
|
||||
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
|
||||
_pacing = pacing ?? throw new ArgumentNullException(nameof(pacing));
|
||||
}
|
||||
|
||||
internal bool RequestedVSync => _requestedVSync;
|
||||
|
||||
internal FramePacingPolicy Policy => _pacing.Policy;
|
||||
|
||||
internal bool IsSurfaceBound => _surface is not null;
|
||||
|
||||
public FramePacingPolicy InitializeStartup(bool requestedVSync)
|
||||
{
|
||||
_requestedVSync = requestedVSync;
|
||||
FramePacingPolicy policy = Resolve(monitorRefreshHz: null);
|
||||
_pacing.Apply(policy);
|
||||
return policy;
|
||||
}
|
||||
|
||||
public void BindSurface(IDisplayFramePacingSurface surface)
|
||||
{
|
||||
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
|
||||
}
|
||||
|
||||
public void ApplyPreference(bool requestedVSync)
|
||||
{
|
||||
_requestedVSync = requestedVSync;
|
||||
ApplyResolvedPolicy();
|
||||
}
|
||||
|
||||
public void RefreshActiveMonitor()
|
||||
{
|
||||
_activeMonitorRefreshHz = _surface?.ActiveMonitorRefreshHz;
|
||||
ApplyResolvedPolicy();
|
||||
}
|
||||
|
||||
public void OnWindowMoved(Vector2D<int> _) => RefreshActiveMonitor();
|
||||
|
||||
public void OnWindowStateChanged(WindowState _) => RefreshActiveMonitor();
|
||||
|
||||
public void OnFrameRendered(double _)
|
||||
{
|
||||
FrameProfiler profiler = _profiler
|
||||
?? throw new ObjectDisposedException(nameof(DisplayFramePacingController));
|
||||
using var pacingStage = profiler.BeginStage(FrameStage.Pacing);
|
||||
_pacing.CompleteFrame();
|
||||
}
|
||||
|
||||
private FramePacingPolicy Resolve(int? monitorRefreshHz) =>
|
||||
FramePacingPolicy.Resolve(
|
||||
_requestedVSync,
|
||||
_uncappedRendering,
|
||||
monitorRefreshHz);
|
||||
|
||||
private void ApplyResolvedPolicy()
|
||||
{
|
||||
FramePacingPolicy policy = Resolve(_activeMonitorRefreshHz);
|
||||
_pacing.Apply(policy);
|
||||
if (_surface is not null && _surface.VSync != policy.UseVSync)
|
||||
_surface.VSync = policy.UseVSync;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pacing.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_surface = null;
|
||||
_profiler = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,8 +85,6 @@ public sealed class GameWindow : IDisposable
|
|||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
|
||||
"1",
|
||||
System.StringComparison.Ordinal);
|
||||
private readonly AcDream.App.Rendering.RollingTimingSampleWindow
|
||||
_entityUploadTiming = new(256);
|
||||
private long _frameDiagLastPublicationMilliseconds;
|
||||
|
||||
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
|
||||
|
|
@ -104,11 +102,14 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
|
||||
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
|
||||
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
||||
private AcDream.App.Rendering.RenderFrameResourceController?
|
||||
_renderFrameResources;
|
||||
private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation?
|
||||
_renderFrameLivePreparation;
|
||||
private AcDream.App.Rendering.RenderWeatherFrameController?
|
||||
_renderWeatherFrame;
|
||||
private ResourceShutdownTransaction? _shutdown;
|
||||
private readonly FramePacingController _framePacing = new();
|
||||
private bool _requestedVSync =
|
||||
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
|
||||
private int? _activeMonitorRefreshHz;
|
||||
private readonly DisplayFramePacingController _displayFramePacing;
|
||||
|
||||
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||
|
|
@ -513,8 +514,6 @@ public sealed class GameWindow : IDisposable
|
|||
private long _loadedSkyDayIndex = long.MinValue;
|
||||
private AcDream.Core.World.DayGroupData? _activeDayGroup;
|
||||
|
||||
private double _weatherAccum;
|
||||
|
||||
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
|
||||
// range of the real values so the first press hits index 0 of the
|
||||
// cycle table cleanly.
|
||||
|
|
@ -720,6 +719,9 @@ public sealed class GameWindow : IDisposable
|
|||
_datDir = options.DatDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
_displayFramePacing = new DisplayFramePacingController(
|
||||
options.UncappedRendering,
|
||||
_frameProfiler);
|
||||
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
|
||||
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
|
||||
_uiRegistry = uiRegistry;
|
||||
|
|
@ -760,12 +762,8 @@ public sealed class GameWindow : IDisposable
|
|||
var startupDisplay = startupStore.LoadDisplay();
|
||||
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
|
||||
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
|
||||
_requestedVSync = startupDisplay.VSync;
|
||||
FramePacingPolicy startupPacing = FramePacingPolicy.Resolve(
|
||||
startupDisplay.VSync,
|
||||
_options.UncappedRendering,
|
||||
monitorRefreshHz: null);
|
||||
_framePacing.Apply(startupPacing);
|
||||
FramePacingPolicy startupPacing =
|
||||
_displayFramePacing.InitializeStartup(startupDisplay.VSync);
|
||||
var options = WindowOptions.Default with
|
||||
{
|
||||
Size = new Vector2D<int>(1280, 720),
|
||||
|
|
@ -789,16 +787,18 @@ public sealed class GameWindow : IDisposable
|
|||
};
|
||||
|
||||
_window = Window.Create(options);
|
||||
_displayFramePacing.BindSurface(
|
||||
new SilkDisplayFramePacingSurface(_window));
|
||||
_window.Load += OnLoad;
|
||||
_window.Update += OnUpdate;
|
||||
_window.Render += OnRender;
|
||||
// Registered after OnRender so software pacing waits after all frame
|
||||
// work and before Silk.NET performs its automatic buffer swap.
|
||||
_window.Render += OnFrameRendered;
|
||||
_window.Render += _displayFramePacing.OnFrameRendered;
|
||||
_window.Closing += OnClosing;
|
||||
_window.FocusChanged += OnFocusChanged;
|
||||
_window.Move += OnWindowMoved;
|
||||
_window.StateChanged += OnWindowStateChanged;
|
||||
_window.Move += _displayFramePacing.OnWindowMoved;
|
||||
_window.StateChanged += _displayFramePacing.OnWindowStateChanged;
|
||||
// L.0 Display tab: keep the GL viewport + camera aspect in sync
|
||||
// with the window framebuffer. Without this handler, resizing
|
||||
// the window (or applying a Display-tab Resolution change at
|
||||
|
|
@ -2840,11 +2840,56 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.App.Streaming.LocalPlayerTeleportSession(
|
||||
_liveSessionController),
|
||||
localTeleportPresentation);
|
||||
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
||||
// Ownership transferred to LocalPlayerTeleportController. This field
|
||||
// remains only as the staged-startup fallback used by shutdown when
|
||||
// composition fails before the transfer.
|
||||
_portalTunnel = null;
|
||||
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
|
||||
var teleportRenderState =
|
||||
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
|
||||
_localPlayerTeleport);
|
||||
_renderFrameLivePreparation =
|
||||
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
|
||||
_textureCache,
|
||||
_wbMeshAdapter,
|
||||
_worldReveal,
|
||||
teleportRenderState,
|
||||
new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
_options.LiveMode,
|
||||
_localPlayerMode),
|
||||
new AcDream.App.Rendering.LiveLoginRevealCellSource(
|
||||
_liveEntities!,
|
||||
_localPlayerIdentity),
|
||||
_particleRenderer,
|
||||
_frameProfiler,
|
||||
_frameDiag);
|
||||
_renderFrameResources =
|
||||
new AcDream.App.Rendering.RenderFrameResourceController(
|
||||
_gpuFrameFlights!,
|
||||
new AcDream.App.Rendering.RuntimeRenderFrameBeginResources(
|
||||
_textureCache,
|
||||
_wbDrawDispatcher,
|
||||
_envCellRenderer,
|
||||
_portalDepthMask,
|
||||
_textRenderer,
|
||||
_uiHost?.TextRenderer,
|
||||
_clipFrame,
|
||||
_terrain,
|
||||
_sceneLightingUbo,
|
||||
_frameProfiler,
|
||||
_gl!),
|
||||
new AcDream.App.Rendering.RuntimeRenderFrameClearPhase(
|
||||
_gl!,
|
||||
WorldTime,
|
||||
Weather,
|
||||
teleportRenderState,
|
||||
_particleVisibility,
|
||||
_worldRenderDiagnostics!),
|
||||
_renderFrameLivePreparation);
|
||||
_renderWeatherFrame =
|
||||
new AcDream.App.Rendering.RenderWeatherFrameController(
|
||||
WorldTime,
|
||||
Weather);
|
||||
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
||||
liveObjectFrame,
|
||||
_worldState,
|
||||
|
|
@ -3223,18 +3268,6 @@ public sealed class GameWindow : IDisposable
|
|||
session.SendTurbineChatTo(
|
||||
roomId, chatType, dispatchType, senderGuid, text, cookie),
|
||||
Log: Console.WriteLine);
|
||||
private bool TryGetLoginWorldCell(out uint cell)
|
||||
{
|
||||
cell = 0;
|
||||
if (_liveEntities is null
|
||||
|| !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
|
||||
|| playerSpawn.Position is not { } position)
|
||||
return false;
|
||||
|
||||
cell = position.LandblockId;
|
||||
return cell != 0;
|
||||
}
|
||||
|
||||
private void UpdateSkyPes(
|
||||
float dayFraction,
|
||||
AcDream.Core.World.DayGroupData? dayGroup,
|
||||
|
|
@ -3440,112 +3473,23 @@ public sealed class GameWindow : IDisposable
|
|||
Exception? renderFailure = null;
|
||||
try
|
||||
{
|
||||
_textureCache?.BeginCompositeTextureFrame();
|
||||
_textureCache?.TickCompositeTextureCache();
|
||||
_wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
_frameProfiler.FrameBoundary(_gl!);
|
||||
GL gl = _gl!;
|
||||
var frameInput = new AcDream.App.Rendering.RenderFrameInput(
|
||||
deltaSeconds,
|
||||
_window!.Size.X,
|
||||
_window.Size.Y);
|
||||
_renderFrameResources!.Prepare(frameInput);
|
||||
AcDream.App.Rendering.RenderFrameFoundation foundation =
|
||||
_renderFrameResources.Foundation;
|
||||
|
||||
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
|
||||
// normal SmartBox viewport and UIElement_Viewport's portal
|
||||
// CreatureMode; it does not redraw the world behind portal space.
|
||||
bool portalViewportVisible =
|
||||
_localPlayerTeleport?.IsPortalViewportVisible == true;
|
||||
if (portalViewportVisible)
|
||||
_particleVisibility.Reset();
|
||||
bool portalViewportVisible = foundation.PortalViewportVisible;
|
||||
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
// so there's no visible seam. Updated each frame from the
|
||||
// interpolated keyframe.
|
||||
var kf = WorldTime.CurrentSky;
|
||||
var atmo = Weather.Snapshot(in kf);
|
||||
bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None;
|
||||
var fogColor = atmo.FogColor;
|
||||
// Clear to fog color (horizon haze) so if sky meshes have alpha
|
||||
// gaps or don't cover the full view, the "missing" area reads as
|
||||
// distant haze, not as pitch-black. Fog color is clamped to 0..1
|
||||
// since keyframes may pre-multiply DirBright and produce over-1
|
||||
// values that some drivers interpret as "bright clamp" (pink/green
|
||||
// frames).
|
||||
if (portalViewportVisible)
|
||||
{
|
||||
// SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
|
||||
// with RenderDevice.Clear(7, black, 1). The portal viewport later
|
||||
// clears depth only, preserving this frame's black target—not a
|
||||
// previous swap-chain image or the hidden destination world.
|
||||
_gl!.ClearColor(0f, 0f, 0f, 1f);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl!.ClearColor(
|
||||
System.Math.Clamp(fogColor.X, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Y, 0f, 1f),
|
||||
System.Math.Clamp(fogColor.Z, 0f, 1f),
|
||||
1f);
|
||||
}
|
||||
|
||||
// §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
|
||||
// write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
|
||||
// A pass that leaked DepthMask(false) at frame end (EnvCellRenderer's empty
|
||||
// Transparent pass, fixed same-day) turned this clear into a no-op and the whole
|
||||
// world failed GL_LESS against its own previous-frame depth ghost. Per
|
||||
// feedback_render_self_contained_gl_state: the clear site asserts the state it
|
||||
// depends on rather than inheriting it. The [gl-state] tripwire still detects
|
||||
// any future leak.
|
||||
_gl!.DepthMask(true);
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
|
||||
|
||||
// WB GameScene.cs:830-843 establishes CW as the frame-global
|
||||
// front-face convention; per-batch CullMode changes only the culled
|
||||
// side. A8 indoor/env-cell geometry and setup meshes share that
|
||||
// convention, so keep the GL state aligned before any scene pass.
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
|
||||
// §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the
|
||||
// state entering the world passes differs from the previous frame.
|
||||
_worldRenderDiagnostics?.EmitGlStateTripwireIfChanged(
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
||||
|
||||
// Phase N.6 slice 1: one-shot surface-format histogram dump under
|
||||
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
|
||||
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
|
||||
|
||||
// Phase N.4: drain WB pipeline queues (staged mesh data +
|
||||
// GL thread queue). Must happen before any draw work so that
|
||||
// resources uploaded this frame are available immediately.
|
||||
// No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
|
||||
// [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit
|
||||
// the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply.
|
||||
long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
|
||||
{
|
||||
_wbMeshAdapter?.Tick();
|
||||
uint revealCell = _localPlayerTeleport?.ActiveDestinationCell is uint teleportCell
|
||||
&& teleportCell != 0u
|
||||
? teleportCell
|
||||
: IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
|
||||
? loginCell
|
||||
: 0u;
|
||||
if (revealCell != 0)
|
||||
{
|
||||
_worldReveal?.PrepareAndEvaluate(revealCell);
|
||||
}
|
||||
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
|
||||
}
|
||||
if (_frameDiag)
|
||||
{
|
||||
_entityUploadTiming.PushStopwatchTicks(
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() - fdE0);
|
||||
}
|
||||
AcDream.Core.World.SkyKeyframe kf = foundation.Sky;
|
||||
AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere;
|
||||
bool environOverrideActive = foundation.EnvironOverrideActive;
|
||||
|
||||
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
||||
// after the scene draws (below). ImGuiController.Update()
|
||||
|
|
@ -3554,13 +3498,7 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
var cal = WorldTime.CurrentCalendar;
|
||||
int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
|
||||
AcDream.Core.World.DerethDateTime.MonthsInAYear)
|
||||
+ (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
|
||||
+ (cal.Day - 1);
|
||||
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
|
||||
_weatherAccum += deltaSeconds;
|
||||
_renderWeatherFrame!.Tick(deltaSeconds);
|
||||
|
||||
// (Pre-Bug-A code spawned camera-attached rain/snow particle
|
||||
// emitters here as a workaround for missing weather-mesh
|
||||
|
|
@ -3636,7 +3574,7 @@ public sealed class GameWindow : IDisposable
|
|||
_cameraController.Fly.FovY = fovYRad;
|
||||
if (_cameraController.Chase is not null)
|
||||
_cameraController.Chase.FovY = fovYRad;
|
||||
ApplyFramePacingPreference(d.VSync);
|
||||
_displayFramePacing.ApplyPreference(d.VSync);
|
||||
}
|
||||
|
||||
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||
|
|
@ -4015,9 +3953,9 @@ public sealed class GameWindow : IDisposable
|
|||
// Outdoor frames have one no-clip terrain state. Indoor frames
|
||||
// defer both allocations to RetailPViewRenderer, which knows the
|
||||
// exact slice count and packs every terrain state into one arena.
|
||||
_clipFrame.ReserveTerrainUploads(_gl, 1);
|
||||
_clipFrame.UploadRegions(_gl);
|
||||
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(_gl);
|
||||
_clipFrame.ReserveTerrainUploads(gl, 1);
|
||||
_clipFrame.UploadRegions(gl);
|
||||
TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl);
|
||||
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||||
_terrain?.SetClipUbo(terrainBinding);
|
||||
|
|
@ -4035,13 +3973,13 @@ public sealed class GameWindow : IDisposable
|
|||
sigSkyDrawn = drawSkyThisFrame;
|
||||
if (drawSkyThisFrame)
|
||||
{
|
||||
_clipFrame.BindTerrainClip(_gl);
|
||||
_clipFrame.BindTerrainClip(gl);
|
||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
||||
_gl.Enable(EnableCap.ClipDistance0 + _cp);
|
||||
gl.Enable(EnableCap.ClipDistance0 + _cp);
|
||||
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_activeDayGroup, kf, environOverrideActive);
|
||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
||||
_gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
|
||||
if (_particleSystem is not null && _particleRenderer is not null)
|
||||
_particleRenderer.Draw(camera, camPos,
|
||||
|
|
@ -4147,9 +4085,9 @@ public sealed class GameWindow : IDisposable
|
|||
? null
|
||||
: () =>
|
||||
{
|
||||
_gl.Disable(EnableCap.ScissorTest);
|
||||
_gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
|
||||
_gl.Clear(ClearBufferMask.DepthBufferBit);
|
||||
gl.Disable(EnableCap.ScissorTest);
|
||||
gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
|
||||
gl.Clear(ClearBufferMask.DepthBufferBit);
|
||||
},
|
||||
DrawExitPortalMasks = sliceCtx =>
|
||||
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
|
||||
|
|
@ -4283,7 +4221,7 @@ public sealed class GameWindow : IDisposable
|
|||
// below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky
|
||||
// pre-scene pass above already did the same. Both are scissored to the doorway too.)
|
||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
||||
_gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
|
||||
// Phase G.1 / E.3: draw all live particles after opaque
|
||||
// scene geometry so alpha blending composites correctly.
|
||||
|
|
@ -4342,13 +4280,13 @@ public sealed class GameWindow : IDisposable
|
|||
if (clipRoot is null && drawSkyThisFrame)
|
||||
{
|
||||
sigSkyDrawn = true;
|
||||
_clipFrame.BindTerrainClip(_gl);
|
||||
_clipFrame.BindTerrainClip(gl);
|
||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
||||
_gl.Enable(EnableCap.ClipDistance0 + _cp);
|
||||
gl.Enable(EnableCap.ClipDistance0 + _cp);
|
||||
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_activeDayGroup, kf, environOverrideActive);
|
||||
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
|
||||
_gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
gl.Disable(EnableCap.ClipDistance0 + _cp);
|
||||
|
||||
if (_particleSystem is not null && _particleRenderer is not null)
|
||||
_particleRenderer.Draw(camera, camPos,
|
||||
|
|
@ -5390,8 +5328,8 @@ public sealed class GameWindow : IDisposable
|
|||
// resolution + fullscreen use the on-Save path below.
|
||||
if (_window is not null)
|
||||
{
|
||||
RefreshActiveMonitorFramePacing();
|
||||
ApplyFramePacingPreference(_persistedDisplay.VSync);
|
||||
_displayFramePacing.RefreshActiveMonitor();
|
||||
_displayFramePacing.ApplyPreference(_persistedDisplay.VSync);
|
||||
ApplyDisplayWindowState(_persistedDisplay);
|
||||
}
|
||||
|
||||
|
|
@ -5532,39 +5470,6 @@ public sealed class GameWindow : IDisposable
|
|||
_window.WindowState = desiredState;
|
||||
}
|
||||
|
||||
private void ApplyFramePacingPreference(bool requestedVSync)
|
||||
{
|
||||
_requestedVSync = requestedVSync;
|
||||
FramePacingPolicy policy = FramePacingPolicy.Resolve(
|
||||
requestedVSync,
|
||||
_options.UncappedRendering,
|
||||
_activeMonitorRefreshHz);
|
||||
_framePacing.Apply(policy);
|
||||
|
||||
if (_window is not null && _window.VSync != policy.UseVSync)
|
||||
_window.VSync = policy.UseVSync;
|
||||
}
|
||||
|
||||
private void RefreshActiveMonitorFramePacing()
|
||||
{
|
||||
int? refreshHz = _window?.Monitor?.VideoMode.RefreshRate;
|
||||
_activeMonitorRefreshHz = refreshHz is > 0 ? refreshHz : null;
|
||||
ApplyFramePacingPreference(_requestedVSync);
|
||||
}
|
||||
|
||||
private void OnFrameRendered(double _)
|
||||
{
|
||||
using var pacingStage = _frameProfiler.BeginStage(
|
||||
AcDream.App.Diagnostics.FrameStage.Pacing);
|
||||
_framePacing.CompleteFrame();
|
||||
}
|
||||
|
||||
private void OnWindowMoved(Silk.NET.Maths.Vector2D<int> _)
|
||||
=> RefreshActiveMonitorFramePacing();
|
||||
|
||||
private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _)
|
||||
=> RefreshActiveMonitorFramePacing();
|
||||
|
||||
private static bool TryParseResolution(string spec, out int width, out int height)
|
||||
{
|
||||
width = height = 0;
|
||||
|
|
@ -6007,7 +5912,7 @@ public sealed class GameWindow : IDisposable
|
|||
double ticksToMicros =
|
||||
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
|
||||
AcDream.App.Rendering.RollingTimingPercentiles upload =
|
||||
_entityUploadTiming.Snapshot();
|
||||
_renderFrameLivePreparation?.UploadTiming ?? default;
|
||||
double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0;
|
||||
double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0;
|
||||
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
|
||||
|
|
@ -6154,6 +6059,12 @@ public sealed class GameWindow : IDisposable
|
|||
]),
|
||||
new ResourceShutdownStage("render frontends",
|
||||
[
|
||||
new("frame composition", () =>
|
||||
{
|
||||
_renderFrameResources = null;
|
||||
_renderFrameLivePreparation = null;
|
||||
_renderWeatherFrame = null;
|
||||
}),
|
||||
new("portal tunnel", () =>
|
||||
{
|
||||
_localPlayerTeleport?.Dispose();
|
||||
|
|
@ -6200,8 +6111,8 @@ public sealed class GameWindow : IDisposable
|
|||
new("debug lines", () => _debugLines?.Dispose()),
|
||||
new("text renderer", () => _textRenderer?.Dispose()),
|
||||
new("debug font", () => _debugFont?.Dispose()),
|
||||
new("frame pacing", _displayFramePacing.Dispose),
|
||||
new("frame profiler", _frameProfiler.Dispose),
|
||||
new("frame pacing", _framePacing.Dispose),
|
||||
]),
|
||||
new ResourceShutdownStage("frame flight owner",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ internal sealed class ImmediateGpuResourceRetirementQueue : IGpuResourceRetireme
|
|||
internal sealed class GpuFrameFlightController :
|
||||
IGpuResourceRetirementQueue,
|
||||
IRenderFrameLifetime,
|
||||
IRenderFrameSlotSource,
|
||||
IDisposable
|
||||
{
|
||||
internal const int DefaultMaximumFramesInFlight = 3;
|
||||
|
|
|
|||
373
src/AcDream.App/Rendering/RenderFrameResourceController.cs
Normal file
373
src/AcDream.App/Rendering/RenderFrameResourceController.cs
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering.Vfx;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.World;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
internal readonly record struct RenderFrameFoundation(
|
||||
bool PortalViewportVisible,
|
||||
SkyKeyframe Sky,
|
||||
AtmosphereSnapshot Atmosphere)
|
||||
{
|
||||
public bool EnvironOverrideActive =>
|
||||
Atmosphere.Override != EnvironOverride.None;
|
||||
}
|
||||
|
||||
internal interface IRenderFrameBeginResources
|
||||
{
|
||||
void Begin(int gpuSlot);
|
||||
}
|
||||
|
||||
internal interface IRenderFrameSlotSource
|
||||
{
|
||||
int CurrentSlot { get; }
|
||||
}
|
||||
|
||||
internal interface IRenderFrameClearPhase
|
||||
{
|
||||
RenderFrameFoundation Clear();
|
||||
}
|
||||
|
||||
internal interface IRenderFrameLivePreparation
|
||||
{
|
||||
void Prepare(int gpuSlot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the pre-world resource transaction after GPU-flight begin. The three
|
||||
/// typed phases preserve resource begin, clear/state, then live publication
|
||||
/// order without exposing a renderer service bag.
|
||||
/// </summary>
|
||||
internal sealed class RenderFrameResourceController : IRenderFrameResourcePhase
|
||||
{
|
||||
private readonly IRenderFrameSlotSource _frameSlots;
|
||||
private readonly IRenderFrameBeginResources _resources;
|
||||
private readonly IRenderFrameClearPhase _clear;
|
||||
private readonly IRenderFrameLivePreparation _live;
|
||||
|
||||
public RenderFrameResourceController(
|
||||
IRenderFrameSlotSource frameSlots,
|
||||
IRenderFrameBeginResources resources,
|
||||
IRenderFrameClearPhase clear,
|
||||
IRenderFrameLivePreparation live)
|
||||
{
|
||||
_frameSlots = frameSlots
|
||||
?? throw new ArgumentNullException(nameof(frameSlots));
|
||||
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
||||
_clear = clear ?? throw new ArgumentNullException(nameof(clear));
|
||||
_live = live ?? throw new ArgumentNullException(nameof(live));
|
||||
}
|
||||
|
||||
public RenderFrameFoundation Foundation { get; private set; }
|
||||
|
||||
public void Prepare(RenderFrameInput input)
|
||||
{
|
||||
int gpuSlot = _frameSlots.CurrentSlot;
|
||||
_resources.Begin(gpuSlot);
|
||||
Foundation = _clear.Clear();
|
||||
_live.Prepare(gpuSlot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Exact ordered begin calls for resources shared by the frame.</summary>
|
||||
internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResources
|
||||
{
|
||||
private readonly TextureCache? _textures;
|
||||
private readonly WbDrawDispatcher? _dispatcher;
|
||||
private readonly EnvCellRenderer? _environmentCells;
|
||||
private readonly PortalDepthMaskRenderer? _portalDepth;
|
||||
private readonly TextRenderer? _worldText;
|
||||
private readonly TextRenderer? _uiText;
|
||||
private readonly ClipFrame? _clip;
|
||||
private readonly TerrainModernRenderer? _terrain;
|
||||
private readonly SceneLightingUboBinding? _lighting;
|
||||
private readonly FrameProfiler _profiler;
|
||||
private readonly GL _gl;
|
||||
|
||||
public RuntimeRenderFrameBeginResources(
|
||||
TextureCache? textures,
|
||||
WbDrawDispatcher? dispatcher,
|
||||
EnvCellRenderer? environmentCells,
|
||||
PortalDepthMaskRenderer? portalDepth,
|
||||
TextRenderer? worldText,
|
||||
TextRenderer? uiText,
|
||||
ClipFrame? clip,
|
||||
TerrainModernRenderer? terrain,
|
||||
SceneLightingUboBinding? lighting,
|
||||
FrameProfiler profiler,
|
||||
GL gl)
|
||||
{
|
||||
_textures = textures;
|
||||
_dispatcher = dispatcher;
|
||||
_environmentCells = environmentCells;
|
||||
_portalDepth = portalDepth;
|
||||
_worldText = worldText;
|
||||
_uiText = uiText;
|
||||
_clip = clip;
|
||||
_terrain = terrain;
|
||||
_lighting = lighting;
|
||||
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
}
|
||||
|
||||
public void Begin(int gpuSlot)
|
||||
{
|
||||
_textures?.BeginCompositeTextureFrame();
|
||||
_textures?.TickCompositeTextureCache();
|
||||
_dispatcher?.BeginFrame(gpuSlot);
|
||||
_environmentCells?.BeginFrame(gpuSlot);
|
||||
_portalDepth?.BeginFrame(gpuSlot);
|
||||
_worldText?.BeginFrame(gpuSlot);
|
||||
_uiText?.BeginFrame(gpuSlot);
|
||||
_clip?.BeginFrame(gpuSlot);
|
||||
_terrain?.BeginFrame(gpuSlot);
|
||||
_lighting?.BeginFrame(gpuSlot);
|
||||
_profiler.FrameBoundary(_gl);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IRenderFramePortalStateSource
|
||||
{
|
||||
bool IsPortalViewportVisible { get; }
|
||||
|
||||
uint ActiveDestinationCell { get; }
|
||||
}
|
||||
|
||||
internal sealed class LocalPlayerTeleportRenderStateSource
|
||||
: IRenderFramePortalStateSource
|
||||
{
|
||||
private readonly LocalPlayerTeleportController _teleport;
|
||||
|
||||
public LocalPlayerTeleportRenderStateSource(LocalPlayerTeleportController teleport)
|
||||
{
|
||||
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
|
||||
}
|
||||
|
||||
public bool IsPortalViewportVisible => _teleport.IsPortalViewportVisible;
|
||||
|
||||
public uint ActiveDestinationCell => _teleport.ActiveDestinationCell;
|
||||
}
|
||||
|
||||
/// <summary>Atmosphere clear and frame-global GL state establishment.</summary>
|
||||
internal sealed class RuntimeRenderFrameClearPhase : IRenderFrameClearPhase
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly WorldTimeService _worldTime;
|
||||
private readonly WeatherSystem _weather;
|
||||
private readonly IRenderFramePortalStateSource _portal;
|
||||
private readonly ParticleVisibilityController _particleVisibility;
|
||||
private readonly WorldRenderDiagnostics _diagnostics;
|
||||
|
||||
public RuntimeRenderFrameClearPhase(
|
||||
GL gl,
|
||||
WorldTimeService worldTime,
|
||||
WeatherSystem weather,
|
||||
IRenderFramePortalStateSource portal,
|
||||
ParticleVisibilityController particleVisibility,
|
||||
WorldRenderDiagnostics diagnostics)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
||||
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
|
||||
_particleVisibility = particleVisibility
|
||||
?? throw new ArgumentNullException(nameof(particleVisibility));
|
||||
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
|
||||
}
|
||||
|
||||
public RenderFrameFoundation Clear()
|
||||
{
|
||||
bool portalViewportVisible = _portal.IsPortalViewportVisible;
|
||||
if (portalViewportVisible)
|
||||
_particleVisibility.Reset();
|
||||
|
||||
SkyKeyframe sky = _worldTime.CurrentSky;
|
||||
AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);
|
||||
if (portalViewportVisible)
|
||||
{
|
||||
// SceneTool::BeginScene @ 0x0043DAD0 starts the replacement
|
||||
// CreatureMode frame with an opaque black target.
|
||||
_gl.ClearColor(0f, 0f, 0f, 1f);
|
||||
}
|
||||
else
|
||||
{
|
||||
_gl.ClearColor(
|
||||
Math.Clamp(atmosphere.FogColor.X, 0f, 1f),
|
||||
Math.Clamp(atmosphere.FogColor.Y, 0f, 1f),
|
||||
Math.Clamp(atmosphere.FogColor.Z, 0f, 1f),
|
||||
1f);
|
||||
}
|
||||
|
||||
_gl.DepthMask(true);
|
||||
_gl.Clear(
|
||||
ClearBufferMask.ColorBufferBit
|
||||
| ClearBufferMask.DepthBufferBit
|
||||
| ClearBufferMask.StencilBufferBit);
|
||||
_gl.CullFace(TriangleFace.Back);
|
||||
_gl.FrontFace(FrontFaceDirection.CW);
|
||||
_diagnostics.EmitGlStateTripwireIfChanged(
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled);
|
||||
|
||||
return new RenderFrameFoundation(
|
||||
portalViewportVisible,
|
||||
sky,
|
||||
atmosphere);
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IRenderLoginStateSource
|
||||
{
|
||||
bool IsWaitingForLogin { get; }
|
||||
}
|
||||
|
||||
internal sealed class RenderLoginStateSource : IRenderLoginStateSource
|
||||
{
|
||||
private readonly bool _liveMode;
|
||||
private readonly ILocalPlayerModeSource _mode;
|
||||
|
||||
public RenderLoginStateSource(bool liveMode, ILocalPlayerModeSource mode)
|
||||
{
|
||||
_liveMode = liveMode;
|
||||
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
||||
}
|
||||
|
||||
public bool IsWaitingForLogin => _liveMode && !_mode.ChaseModeEverEntered;
|
||||
}
|
||||
|
||||
internal interface ILoginRevealCellSource
|
||||
{
|
||||
bool TryGet(out uint cellId);
|
||||
}
|
||||
|
||||
internal sealed class LiveLoginRevealCellSource : ILoginRevealCellSource
|
||||
{
|
||||
private readonly LiveEntityRuntime _entities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
|
||||
public LiveLoginRevealCellSource(
|
||||
LiveEntityRuntime entities,
|
||||
ILocalPlayerIdentitySource identity)
|
||||
{
|
||||
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
}
|
||||
|
||||
public bool TryGet(out uint cellId)
|
||||
{
|
||||
cellId = 0;
|
||||
if (!_entities.TryGetSnapshot(_identity.ServerGuid, out var playerSpawn)
|
||||
|| playerSpawn.Position is not { } position)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cellId = position.LandblockId;
|
||||
return cellId != 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Render-thread mesh publication, reveal evaluation, and VFX begin.</summary>
|
||||
internal sealed class RuntimeRenderFrameLivePreparation
|
||||
: IRenderFrameLivePreparation
|
||||
{
|
||||
private readonly TextureCache? _textures;
|
||||
private readonly WbMeshAdapter? _meshes;
|
||||
private readonly WorldRevealCoordinator? _worldReveal;
|
||||
private readonly IRenderFramePortalStateSource _portal;
|
||||
private readonly IRenderLoginStateSource _login;
|
||||
private readonly ILoginRevealCellSource _loginCell;
|
||||
private readonly ParticleRenderer? _particles;
|
||||
private readonly FrameProfiler _profiler;
|
||||
private readonly bool _diagnosticsEnabled;
|
||||
private readonly RollingTimingSampleWindow _uploadTiming = new(256);
|
||||
|
||||
public RuntimeRenderFrameLivePreparation(
|
||||
TextureCache? textures,
|
||||
WbMeshAdapter? meshes,
|
||||
WorldRevealCoordinator? worldReveal,
|
||||
IRenderFramePortalStateSource portal,
|
||||
IRenderLoginStateSource login,
|
||||
ILoginRevealCellSource loginCell,
|
||||
ParticleRenderer? particles,
|
||||
FrameProfiler profiler,
|
||||
bool diagnosticsEnabled)
|
||||
{
|
||||
_textures = textures;
|
||||
_meshes = meshes;
|
||||
_worldReveal = worldReveal;
|
||||
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
|
||||
_login = login ?? throw new ArgumentNullException(nameof(login));
|
||||
_loginCell = loginCell ?? throw new ArgumentNullException(nameof(loginCell));
|
||||
_particles = particles;
|
||||
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
|
||||
_diagnosticsEnabled = diagnosticsEnabled;
|
||||
}
|
||||
|
||||
public RollingTimingPercentiles UploadTiming => _uploadTiming.Snapshot();
|
||||
|
||||
public void Prepare(int gpuSlot)
|
||||
{
|
||||
_textures?.TickSurfaceHistogramDumpIfEnabled();
|
||||
long start = _diagnosticsEnabled
|
||||
? System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
: 0L;
|
||||
using (var uploadStage = _profiler.BeginStage(FrameStage.Upload))
|
||||
{
|
||||
_meshes?.Tick();
|
||||
uint revealCell = _portal.ActiveDestinationCell;
|
||||
if (revealCell == 0
|
||||
&& _login.IsWaitingForLogin
|
||||
&& _loginCell.TryGet(out uint loginCell))
|
||||
{
|
||||
revealCell = loginCell;
|
||||
}
|
||||
|
||||
if (revealCell != 0)
|
||||
_worldReveal?.PrepareAndEvaluate(revealCell);
|
||||
_particles?.BeginFrame(gpuSlot);
|
||||
}
|
||||
|
||||
if (_diagnosticsEnabled)
|
||||
{
|
||||
_uploadTiming.PushStopwatchTicks(
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() - start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owns the render-time weather clock and deterministic day index.</summary>
|
||||
internal sealed class RenderWeatherFrameController
|
||||
{
|
||||
private readonly WorldTimeService _worldTime;
|
||||
private readonly WeatherSystem _weather;
|
||||
private double _elapsedSeconds;
|
||||
|
||||
public RenderWeatherFrameController(
|
||||
WorldTimeService worldTime,
|
||||
WeatherSystem weather)
|
||||
{
|
||||
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
||||
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
||||
}
|
||||
|
||||
internal double ElapsedSeconds => _elapsedSeconds;
|
||||
|
||||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
DerethDateTime.Calendar calendar = _worldTime.CurrentCalendar;
|
||||
int dayIndex = calendar.Year
|
||||
* (DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear)
|
||||
+ (int)calendar.Month * DerethDateTime.DaysInAMonth
|
||||
+ (calendar.Day - 1);
|
||||
_weather.Tick(
|
||||
nowSeconds: _elapsedSeconds,
|
||||
dayIndex,
|
||||
dtSeconds: (float)deltaSeconds);
|
||||
_elapsedSeconds += deltaSeconds;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
using AcDream.App.Diagnostics;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class DisplayFramePacingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Binding_surface_preserves_startup_fallback_until_monitor_refresh()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = true,
|
||||
ActiveMonitorRefreshHz = 144,
|
||||
};
|
||||
|
||||
FramePacingPolicy startup = controller.InitializeStartup(
|
||||
requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
|
||||
Assert.Equal(FramePacingPolicy.FallbackRefreshHz, startup.SoftwareLimitHz);
|
||||
Assert.Equal(startup, controller.Policy);
|
||||
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preference_and_monitor_changes_resolve_one_owned_policy()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = false,
|
||||
ActiveMonitorRefreshHz = 120,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
controller.ApplyPreference(requestedVSync: true);
|
||||
Assert.Equal(new FramePacingPolicy(true, null), controller.Policy);
|
||||
Assert.True(surface.VSync);
|
||||
|
||||
surface.ActiveMonitorRefreshHz = 165;
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
Assert.Equal(new FramePacingPolicy(false, 120d), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
|
||||
controller.OnWindowStateChanged(default);
|
||||
Assert.Equal(new FramePacingPolicy(false, 165d), controller.Policy);
|
||||
|
||||
surface.ActiveMonitorRefreshHz = 75;
|
||||
controller.OnWindowMoved(default);
|
||||
Assert.Equal(new FramePacingPolicy(false, 75d), controller.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Repeated_preference_preview_uses_the_event_refreshed_monitor_cache()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = false,
|
||||
ActiveMonitorRefreshHz = 144,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
controller.BindSurface(surface);
|
||||
controller.RefreshActiveMonitor();
|
||||
Assert.Equal(1, surface.RefreshReadCount);
|
||||
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
controller.ApplyPreference(requestedVSync: false);
|
||||
|
||||
Assert.Equal(1, surface.RefreshReadCount);
|
||||
Assert.Equal(new FramePacingPolicy(false, 144d), controller.Policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_uncapped_mode_disables_both_pacing_mechanisms()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: true,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface
|
||||
{
|
||||
VSync = true,
|
||||
ActiveMonitorRefreshHz = 240,
|
||||
};
|
||||
controller.InitializeStartup(requestedVSync: true);
|
||||
controller.BindSurface(surface);
|
||||
|
||||
controller.RefreshActiveMonitor();
|
||||
|
||||
Assert.Equal(new FramePacingPolicy(false, null), controller.Policy);
|
||||
Assert.False(surface.VSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_completion_delegates_to_the_owned_software_pacer()
|
||||
{
|
||||
var clock = new FakeClock(1_000);
|
||||
var waiter = new RecordingWaiter(clock);
|
||||
using var profiler = new FrameProfiler();
|
||||
using var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(clock, waiter));
|
||||
controller.InitializeStartup(requestedVSync: false);
|
||||
|
||||
clock.Timestamp = 10;
|
||||
controller.OnFrameRendered(0d);
|
||||
|
||||
Assert.Equal([7L], waiter.Durations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_releases_borrowed_surface_and_profiler_without_disposing_surface()
|
||||
{
|
||||
using var profiler = new FrameProfiler();
|
||||
var controller = new DisplayFramePacingController(
|
||||
uncappedRendering: false,
|
||||
profiler,
|
||||
new FramePacingController(
|
||||
new FakeClock(1_000),
|
||||
new RecordingWaiter()));
|
||||
var surface = new FakeSurface();
|
||||
controller.BindSurface(surface);
|
||||
Assert.True(controller.IsSurfaceBound);
|
||||
|
||||
controller.Dispose();
|
||||
controller.Dispose();
|
||||
|
||||
Assert.False(controller.IsSurfaceBound);
|
||||
Assert.Throws<ObjectDisposedException>(() => controller.OnFrameRendered(0d));
|
||||
Assert.False(surface.Disposed);
|
||||
}
|
||||
|
||||
private sealed class FakeSurface : IDisplayFramePacingSurface, IDisposable
|
||||
{
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public bool VSync { get; set; }
|
||||
|
||||
private int? _activeMonitorRefreshHz;
|
||||
|
||||
public int RefreshReadCount { get; private set; }
|
||||
|
||||
public int? ActiveMonitorRefreshHz
|
||||
{
|
||||
get
|
||||
{
|
||||
RefreshReadCount++;
|
||||
return _activeMonitorRefreshHz;
|
||||
}
|
||||
set => _activeMonitorRefreshHz = value;
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class FakeClock(long frequency) : IFramePacingClock
|
||||
{
|
||||
public long Frequency { get; } = frequency;
|
||||
|
||||
public long Timestamp { get; set; }
|
||||
|
||||
public long GetTimestamp() => Timestamp;
|
||||
}
|
||||
|
||||
private sealed class RecordingWaiter(FakeClock? clock = null) : IFramePacingWaiter
|
||||
{
|
||||
public List<long> Durations { get; } = [];
|
||||
|
||||
public void Wait(long durationTicks, long clockFrequency)
|
||||
{
|
||||
Durations.Add(durationTicks);
|
||||
if (clock is not null)
|
||||
clock.Timestamp += durationTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,16 +20,33 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionRender_BeginsDevToolsBeforeWorldAndBeginsDispatcherOnce()
|
||||
public void ProductionRender_Prepares_resources_then_devtools_weather_and_world()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_renderFrameResources!.Prepare(frameInput);",
|
||||
"_devToolsFramePresenter?.BeginFrame((float)deltaSeconds);",
|
||||
"Weather.Tick(nowSeconds: _weatherAccum",
|
||||
"_renderWeatherFrame!.Tick(deltaSeconds);",
|
||||
"_retailSelectionScene?.BeginFrame();");
|
||||
Assert.Equal(1, CountOccurrences(source, "_wbDrawDispatcher?.BeginFrame("));
|
||||
Assert.DoesNotContain("_wbDrawDispatcher?.BeginFrame(", source);
|
||||
Assert.DoesNotContain("_wbMeshAdapter?.Tick();", source);
|
||||
Assert.DoesNotContain("_particleRenderer?.BeginFrame(", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Composition_transfers_portal_tunnel_before_constructing_frame_borrowers()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new AcDream.App.Streaming.LocalPlayerTeleportController(",
|
||||
"_portalTunnel = null;",
|
||||
"_localPlayerTeleportSink.Bind(_localPlayerTeleport);",
|
||||
"new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(",
|
||||
"new AcDream.App.Rendering.RenderFrameResourceController(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -55,12 +72,21 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"EmitClipRouteScissorProbe(scissor",
|
||||
"_lastVisibleLandblocks",
|
||||
"_perfAccum",
|
||||
"_entityUploadTiming",
|
||||
"_weatherAccum",
|
||||
"TryGetLoginWorldCell",
|
||||
"ApplyFramePacingPreference",
|
||||
"RefreshActiveMonitorFramePacing",
|
||||
"private void OnFrameRendered(",
|
||||
];
|
||||
foreach (string identifier in removed)
|
||||
Assert.DoesNotContain(identifier, source, StringComparison.Ordinal);
|
||||
|
||||
Assert.Contains("new AcDream.App.Rendering.PaperdollFramePresenter(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.DevToolsFramePresenter(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderFrameResourceController(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RenderWeatherFrameController(", source);
|
||||
Assert.Contains("new DisplayFramePacingController(", source);
|
||||
Assert.Contains("_inputCapture.WantCaptureMouse", source);
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +99,17 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
source,
|
||||
"new ResourceShutdownStage(\"submitted GPU work\"",
|
||||
"new ResourceShutdownStage(\"render frontends\"",
|
||||
"new(\"frame composition\"",
|
||||
"new(\"portal tunnel\"",
|
||||
"new(\"paperdoll viewport\"",
|
||||
"new ResourceShutdownStage(\"OpenGL context\"");
|
||||
Assert.Contains("_renderFrameResources = null;", source);
|
||||
Assert.Contains("_renderFrameLivePreparation = null;", source);
|
||||
Assert.Contains("_renderWeatherFrame = null;", source);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new(\"frame pacing\", _displayFramePacing.Dispose)",
|
||||
"new(\"frame profiler\", _frameProfiler.Dispose)");
|
||||
Assert.DoesNotContain("_devToolsBackend?.Dispose()", source);
|
||||
}
|
||||
|
||||
|
|
@ -139,19 +173,6 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
}
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RenderFrameResourceControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prepare_runs_begin_clear_and_live_in_order_for_one_gpu_slot()
|
||||
{
|
||||
List<string> calls = [];
|
||||
var clearResult = new RenderFrameFoundation(
|
||||
PortalViewportVisible: true,
|
||||
Sky: default,
|
||||
Atmosphere: default);
|
||||
var slots = new AdvancingSlotSource(2);
|
||||
var controller = new RenderFrameResourceController(
|
||||
slots,
|
||||
new RecordingBegin(calls),
|
||||
new RecordingClear(calls, clearResult),
|
||||
new RecordingLive(calls));
|
||||
|
||||
controller.Prepare(new RenderFrameInput(1d / 60d, 1280, 720));
|
||||
|
||||
Assert.Equal(["begin:2", "clear", "live:2"], calls);
|
||||
Assert.Equal(1, slots.ReadCount);
|
||||
Assert.Equal(clearResult, controller.Foundation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Required_phase_dependencies_fail_fast()
|
||||
{
|
||||
var slots = new AdvancingSlotSource(0);
|
||||
var begin = new RecordingBegin([]);
|
||||
var clear = new RecordingClear([], default);
|
||||
var live = new RecordingLive([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(null!, begin, clear, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, null!, clear, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, begin, null!, live));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new RenderFrameResourceController(slots, begin, clear, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_begin_resources_preserve_the_exact_frame_order()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_textures?.BeginCompositeTextureFrame();",
|
||||
"_textures?.TickCompositeTextureCache();",
|
||||
"_dispatcher?.BeginFrame(gpuSlot);",
|
||||
"_environmentCells?.BeginFrame(gpuSlot);",
|
||||
"_portalDepth?.BeginFrame(gpuSlot);",
|
||||
"_worldText?.BeginFrame(gpuSlot);",
|
||||
"_uiText?.BeginFrame(gpuSlot);",
|
||||
"_clip?.BeginFrame(gpuSlot);",
|
||||
"_terrain?.BeginFrame(gpuSlot);",
|
||||
"_lighting?.BeginFrame(gpuSlot);",
|
||||
"_profiler.FrameBoundary(_gl);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_live_preparation_publishes_meshes_before_reveal_and_particles()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_meshes?.Tick();",
|
||||
"_worldReveal?.PrepareAndEvaluate(revealCell);",
|
||||
"_particles?.BeginFrame(gpuSlot);");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Production_clear_phase_preserves_atmosphere_and_gl_state_order()
|
||||
{
|
||||
string source = ResourceSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"bool portalViewportVisible = _portal.IsPortalViewportVisible;",
|
||||
"_particleVisibility.Reset();",
|
||||
"SkyKeyframe sky = _worldTime.CurrentSky;",
|
||||
"AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky);",
|
||||
"_gl.DepthMask(true);",
|
||||
"_gl.Clear(",
|
||||
"_gl.CullFace(TriangleFace.Back);",
|
||||
"_gl.FrontFace(FrontFaceDirection.CW);",
|
||||
"_diagnostics.EmitGlStateTripwireIfChanged(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weather_frame_clock_advances_only_after_the_weather_tick()
|
||||
{
|
||||
var controller = new RenderWeatherFrameController(
|
||||
new AcDream.Core.World.WorldTimeService(
|
||||
AcDream.Core.World.SkyStateProvider.Default()),
|
||||
new AcDream.Core.World.WeatherSystem());
|
||||
|
||||
controller.Tick(0.25d);
|
||||
controller.Tick(0.5d);
|
||||
|
||||
Assert.Equal(0.75d, controller.ElapsedSeconds);
|
||||
AssertAppearsInOrder(
|
||||
ResourceSource(),
|
||||
"_weather.Tick(",
|
||||
"nowSeconds: _elapsedSeconds,",
|
||||
"_elapsedSeconds += deltaSeconds;");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Login_state_is_latched_by_the_shared_player_mode_source()
|
||||
{
|
||||
var mode = new AcDream.App.Input.LocalPlayerModeState();
|
||||
var live = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
liveMode: true,
|
||||
mode);
|
||||
var offline = new AcDream.App.Rendering.RenderLoginStateSource(
|
||||
liveMode: false,
|
||||
mode);
|
||||
|
||||
Assert.True(live.IsWaitingForLogin);
|
||||
Assert.False(offline.IsWaitingForLogin);
|
||||
|
||||
mode.ChaseModeEverEntered = true;
|
||||
|
||||
Assert.False(live.IsWaitingForLogin);
|
||||
}
|
||||
|
||||
private static string ResourceSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RenderFrameResourceController.cs"));
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
private sealed class AdvancingSlotSource(int firstSlot) : IRenderFrameSlotSource
|
||||
{
|
||||
public int ReadCount { get; private set; }
|
||||
|
||||
public int CurrentSlot => firstSlot + ReadCount++;
|
||||
}
|
||||
|
||||
private sealed class RecordingBegin(List<string> calls) : IRenderFrameBeginResources
|
||||
{
|
||||
public void Begin(int gpuSlot) => calls.Add($"begin:{gpuSlot}");
|
||||
}
|
||||
|
||||
private sealed class RecordingClear(
|
||||
List<string> calls,
|
||||
RenderFrameFoundation result) : IRenderFrameClearPhase
|
||||
{
|
||||
public RenderFrameFoundation Clear()
|
||||
{
|
||||
calls.Add("clear");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingLive(List<string> calls) : IRenderFrameLivePreparation
|
||||
{
|
||||
public void Prepare(int gpuSlot) => calls.Add($"live:{gpuSlot}");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue