feat(linux): gate graphical backends and capabilities
This commit is contained in:
parent
07bb1c5a74
commit
11501d52ca
13 changed files with 1924 additions and 32 deletions
30
src/AcDream.App/Studio/StudioFrameCloseGate.cs
Normal file
30
src/AcDream.App/Studio/StudioFrameCloseGate.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Defers a close requested by render work until the caller has completed the
|
||||
/// active render-frame transaction. Silk dispatches <c>Closing</c>
|
||||
/// synchronously, so closing directly from a render callback would otherwise
|
||||
/// dispose GPU resources while submitted work is still owned by that frame.
|
||||
/// </summary>
|
||||
internal sealed class StudioFrameCloseGate
|
||||
{
|
||||
private bool _requested;
|
||||
|
||||
public bool IsRequested => _requested;
|
||||
|
||||
public void Request()
|
||||
{
|
||||
_requested = true;
|
||||
}
|
||||
|
||||
public void CompleteFrame(Action close)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(close);
|
||||
|
||||
if (!_requested)
|
||||
return;
|
||||
|
||||
_requested = false;
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,9 @@ public sealed record StudioOptions(
|
|||
string? DumpSlug = null,
|
||||
string? DumpFile = null,
|
||||
string? ScreenshotPath = null,
|
||||
bool Mockup = false)
|
||||
bool Mockup = false,
|
||||
string? CapabilityReportPath = null,
|
||||
bool AudioSmoke = false)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
|
||||
|
|
@ -40,7 +42,9 @@ public sealed record StudioOptions(
|
|||
string? dumpSlug = null;
|
||||
string? dumpFile = null;
|
||||
string? screenshotPath = null;
|
||||
string? capabilityReportPath = null;
|
||||
bool mockup = false;
|
||||
bool audioSmoke = false;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
|
|
@ -69,6 +73,14 @@ public sealed record StudioOptions(
|
|||
{
|
||||
screenshotPath = args[++i];
|
||||
}
|
||||
else if (args[i] == "--capability-report" && i + 1 < args.Length)
|
||||
{
|
||||
capabilityReportPath = args[++i];
|
||||
}
|
||||
else if (args[i] == "--audio-smoke")
|
||||
{
|
||||
audioSmoke = true;
|
||||
}
|
||||
else if (args[i] == "--mockup")
|
||||
{
|
||||
mockup = true;
|
||||
|
|
@ -90,7 +102,16 @@ public sealed record StudioOptions(
|
|||
if (!mockup && layoutId is null && markupPath is null && dumpSlug is null)
|
||||
layoutId = 0x2100006Cu;
|
||||
|
||||
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath, mockup);
|
||||
return new StudioOptions(
|
||||
datDir,
|
||||
layoutId,
|
||||
markupPath,
|
||||
dumpSlug,
|
||||
dumpFile,
|
||||
screenshotPath,
|
||||
mockup,
|
||||
capabilityReportPath,
|
||||
audioSmoke);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.App.Platform;
|
||||
using AcDream.App.Audio;
|
||||
using AcDream.Core.Audio;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.Runtime.Platform;
|
||||
|
|
@ -38,9 +41,13 @@ public sealed class StudioWindow : IDisposable
|
|||
{
|
||||
private readonly StudioOptions _opts;
|
||||
private readonly ApplicationPathSet _applicationPaths;
|
||||
private readonly GraphicalHostPlatformServices _platformServices;
|
||||
|
||||
// Created in OnLoad, released in OnClosing.
|
||||
private IWindow? _window;
|
||||
private GL? _gl;
|
||||
private IInputContext? _input;
|
||||
private OpenAlAudioEngine? _audio;
|
||||
private IDatReaderWriter? _dats;
|
||||
private RenderStack? _stack;
|
||||
private LayoutSource? _source;
|
||||
|
|
@ -64,19 +71,39 @@ public sealed class StudioWindow : IDisposable
|
|||
// Headless screenshot mode: set when --screenshot was passed.
|
||||
// True after the first OnRender fires the screenshot (guard against repeat).
|
||||
private bool _screenshotDone;
|
||||
private readonly StudioFrameCloseGate _frameCloseGate = new();
|
||||
private GraphicalCapabilityRecord? _capabilities;
|
||||
private string? _capabilityReportPath;
|
||||
|
||||
public StudioWindow(StudioOptions opts)
|
||||
: this(opts, ApplicationPathSet.Resolve())
|
||||
: this(
|
||||
opts,
|
||||
GraphicalHostPlatformServices.Resolve())
|
||||
{
|
||||
}
|
||||
|
||||
internal StudioWindow(
|
||||
StudioOptions opts,
|
||||
ApplicationPathSet applicationPaths)
|
||||
: this(
|
||||
opts,
|
||||
GraphicalHostPlatformServices.Resolve() with
|
||||
{
|
||||
Paths = applicationPaths
|
||||
?? throw new ArgumentNullException(
|
||||
nameof(applicationPaths)),
|
||||
})
|
||||
{
|
||||
}
|
||||
|
||||
internal StudioWindow(
|
||||
StudioOptions opts,
|
||||
GraphicalHostPlatformServices platformServices)
|
||||
{
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
_applicationPaths = applicationPaths
|
||||
?? throw new ArgumentNullException(nameof(applicationPaths));
|
||||
_platformServices = platformServices
|
||||
?? throw new ArgumentNullException(nameof(platformServices));
|
||||
_applicationPaths = _platformServices.Paths;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -85,6 +112,7 @@ public sealed class StudioWindow : IDisposable
|
|||
/// </summary>
|
||||
public void Run()
|
||||
{
|
||||
_platformServices.ConfigureWindowBackend();
|
||||
// Resolve quality settings the same way GameWindow.Run() does
|
||||
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
|
||||
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
|
||||
|
|
@ -122,7 +150,48 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
private void OnLoad()
|
||||
{
|
||||
var gl = GL.GetApi(_window!);
|
||||
_gl = GL.GetApi(_window!);
|
||||
_input = _window!.CreateInput();
|
||||
_capabilityReportPath = Path.GetFullPath(
|
||||
_opts.CapabilityReportPath
|
||||
?? Path.Combine(
|
||||
_applicationPaths.DiagnosticsDirectory,
|
||||
"graphical-capabilities.json"));
|
||||
_capabilities = GraphicalCapabilityGuard.CaptureVerifyAndWrite(
|
||||
_gl,
|
||||
_window!,
|
||||
_input,
|
||||
_platformServices,
|
||||
_capabilityReportPath);
|
||||
GraphicalCapabilityGuard.ThrowIfUnsupported(
|
||||
_capabilities,
|
||||
_capabilityReportPath);
|
||||
|
||||
if (_opts.AudioSmoke)
|
||||
{
|
||||
_audio = new OpenAlAudioEngine();
|
||||
bool playback = _audio.IsAvailable
|
||||
&& _audio.PlayUiWave(
|
||||
0xFFFF_FF01u,
|
||||
CreateAudioSmokeWave(),
|
||||
volume: 0.05f);
|
||||
_capabilities = _capabilities with
|
||||
{
|
||||
Audio = new GraphicalAudioCapabilities(
|
||||
Requested: true,
|
||||
Available: _audio.IsAvailable,
|
||||
PlaybackSubmitted: playback,
|
||||
DisposalComplete: _audio.IsDisposalComplete,
|
||||
Backend: "OpenAL Soft default device"),
|
||||
Lifecycle = _capabilities.Lifecycle with
|
||||
{
|
||||
OwnedAudioEngineCount = 1,
|
||||
},
|
||||
};
|
||||
GraphicalCapabilityReportWriter.Write(
|
||||
_capabilityReportPath,
|
||||
_capabilities);
|
||||
}
|
||||
|
||||
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
|
||||
|
||||
|
|
@ -135,7 +204,7 @@ public sealed class StudioWindow : IDisposable
|
|||
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
|
||||
|
||||
_stack = RenderBootstrap.Create(
|
||||
gl,
|
||||
_gl,
|
||||
_dats,
|
||||
new RenderBootstrapOptions(
|
||||
quality,
|
||||
|
|
@ -208,34 +277,63 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
// Task 3: ImGui IDE — interactive mode only.
|
||||
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
|
||||
_panelFbo = new PanelFbo(gl);
|
||||
_panelFbo = new PanelFbo(_gl);
|
||||
if (_opts.ScreenshotPath is null)
|
||||
{
|
||||
var input = _window!.CreateInput();
|
||||
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
|
||||
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
|
||||
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
|
||||
if (_opts.Mockup)
|
||||
foreach (var mouse in input.Mice)
|
||||
foreach (var mouse in _input.Mice)
|
||||
_stack.UiHost.WireMouse(mouse);
|
||||
foreach (var kb in input.Keyboards)
|
||||
foreach (var kb in _input.Keyboards)
|
||||
_stack.UiHost.WireKeyboard(kb);
|
||||
|
||||
if (_opts.Mockup)
|
||||
return;
|
||||
|
||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(
|
||||
_gl,
|
||||
_window!,
|
||||
_input);
|
||||
_inspector = new StudioInspector();
|
||||
}
|
||||
}
|
||||
|
||||
private static WaveData CreateAudioSmokeWave()
|
||||
{
|
||||
const int sampleRate = 8_000;
|
||||
const int sampleCount = 400;
|
||||
var pcm = new byte[sampleCount * sizeof(short)];
|
||||
for (int i = 0; i < sampleCount; i++)
|
||||
{
|
||||
double phase = i * (2.0 * Math.PI * 440.0 / sampleRate);
|
||||
short sample = (short)(Math.Sin(phase) * short.MaxValue * 0.08);
|
||||
BitConverter.TryWriteBytes(
|
||||
pcm.AsSpan(i * sizeof(short), sizeof(short)),
|
||||
sample);
|
||||
}
|
||||
|
||||
return new WaveData
|
||||
{
|
||||
ChannelCount = 1,
|
||||
SampleRate = sampleRate,
|
||||
BitsPerSample = 16,
|
||||
PcmBytes = pcm,
|
||||
Duration = TimeSpan.FromSeconds(
|
||||
sampleCount / (double)sampleRate),
|
||||
};
|
||||
}
|
||||
|
||||
private void OnUpdate(double dt) { }
|
||||
|
||||
private void OnRender(double dt)
|
||||
{
|
||||
if (_stack is null || _panelFbo is null) return;
|
||||
_stack.BeginFrame();
|
||||
RenderStack frameStack = _stack;
|
||||
frameStack.BeginFrame();
|
||||
Exception? renderFailure = null;
|
||||
bool frameClosed = false;
|
||||
try
|
||||
{
|
||||
|
||||
|
|
@ -270,7 +368,7 @@ public sealed class StudioWindow : IDisposable
|
|||
if (pixels.Length == 0)
|
||||
{
|
||||
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
|
||||
_window?.Close();
|
||||
_frameCloseGate.Request();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -292,7 +390,7 @@ public sealed class StudioWindow : IDisposable
|
|||
img.SaveAsPng(path);
|
||||
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
|
||||
|
||||
_window?.Close();
|
||||
_frameCloseGate.Request();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -445,7 +543,8 @@ public sealed class StudioWindow : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
_stack.EndFrame();
|
||||
frameStack.EndFrame();
|
||||
frameClosed = true;
|
||||
}
|
||||
catch (Exception closeFailure) when (renderFailure is not null)
|
||||
{
|
||||
|
|
@ -454,6 +553,9 @@ public sealed class StudioWindow : IDisposable
|
|||
renderFailure,
|
||||
closeFailure);
|
||||
}
|
||||
|
||||
if (frameClosed)
|
||||
_frameCloseGate.CompleteFrame(() => _window?.Close());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -512,19 +614,18 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
private void OnClosing()
|
||||
{
|
||||
_fixtureController?.Dispose();
|
||||
_fixtureController = null;
|
||||
_imgui?.Dispose();
|
||||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
|
||||
_dats?.Dispose();
|
||||
_dats = null;
|
||||
_stack = null;
|
||||
ReleaseContextResources();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ReleaseContextResources();
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
UpdateFinalCapabilityReport();
|
||||
}
|
||||
|
||||
private void ReleaseContextResources()
|
||||
{
|
||||
_fixtureController?.Dispose();
|
||||
_fixtureController = null;
|
||||
|
|
@ -532,13 +633,43 @@ public sealed class StudioWindow : IDisposable
|
|||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
|
||||
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
|
||||
_stack?.Dispose();
|
||||
_dats?.Dispose();
|
||||
_audio?.Dispose();
|
||||
_input?.Dispose();
|
||||
_gl?.Dispose();
|
||||
_dats = null;
|
||||
_stack = null;
|
||||
_audio = null;
|
||||
_input = null;
|
||||
_gl = null;
|
||||
}
|
||||
|
||||
private void UpdateFinalCapabilityReport()
|
||||
{
|
||||
if (_capabilities is null
|
||||
|| string.IsNullOrWhiteSpace(_capabilityReportPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_capabilities = _capabilities with
|
||||
{
|
||||
Audio = _capabilities.Audio with
|
||||
{
|
||||
DisposalComplete = true,
|
||||
},
|
||||
Lifecycle = new GraphicalSmokeLifecycleCapabilities(
|
||||
OwnedWindowCount: 0,
|
||||
OwnedGlApiCount: 0,
|
||||
OwnedInputContextCount: 0,
|
||||
OwnedAudioEngineCount: 0,
|
||||
ShutdownComplete: true),
|
||||
};
|
||||
GraphicalCapabilityReportWriter.Write(
|
||||
_capabilityReportPath,
|
||||
_capabilities);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue