Add `--screenshot <path>` to the UI Studio: when set the window is created hidden (WindowOptions.IsVisible = false), the panel is loaded and rendered into PanelFbo on the first OnRender tick, pixels are read back with the new PanelFbo.ReadColorRgba(), rows are flipped (GL bottom-left → PNG top-left), and the result is saved via ImageSharp SaveAsPng before calling _window.Close(). Render size is derived from the loaded root's Width/Height (clamped 256–2048 px); falls back to 1280×720 when the root has no explicit size. In headless mode ImGui, the inspector, and input wiring are all skipped — only PanelFbo is created. Interactive path is unchanged. SixLabors.ImageSharp 3.1.12 was already a direct dep of AcDream.App (Phase O-T7); no new PackageReference needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
6.4 KiB
C#
156 lines
6.4 KiB
C#
using System;
|
||
using System.Numerics;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.App.UI;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Studio;
|
||
|
||
/// <summary>
|
||
/// Renders a <see cref="UiHost"/> into an off-screen FBO each frame and
|
||
/// returns the color texture handle for display in ImGui.
|
||
///
|
||
/// <para>Pattern lifted verbatim from <see cref="AcDream.App.Rendering.PaperdollViewportRenderer"/>:
|
||
/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on
|
||
/// size change. The entire 2-D UI pass is sealed in a <see cref="GLStateScope"/>
|
||
/// so it cannot disturb the surrounding ImGui GL state.</para>
|
||
///
|
||
/// <para>FBO origin is bottom-left (GL convention). The caller must flip V when
|
||
/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image)
|
||
/// so the image appears right-side-up in ImGui's top-left coordinate system.</para>
|
||
/// </summary>
|
||
public sealed unsafe class PanelFbo : IDisposable
|
||
{
|
||
private readonly GL _gl;
|
||
|
||
// Off-screen target — lazily (re)created when the requested size changes.
|
||
private uint _fbo;
|
||
private uint _colorTex;
|
||
private uint _depthRbo;
|
||
private int _fbW;
|
||
private int _fbH;
|
||
|
||
public PanelFbo(GL gl)
|
||
{
|
||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Render <paramref name="host"/> (a full <see cref="UiHost"/> draw pass) into a
|
||
/// private FBO at <paramref name="width"/> × <paramref name="height"/> pixels.
|
||
/// Returns the GL color texture handle (0 on failure). The texture is valid until
|
||
/// the next call to <see cref="Render"/> with a different size, or until <see cref="Dispose"/>.
|
||
/// </summary>
|
||
public uint Render(int width, int height, UiHost host)
|
||
{
|
||
if (width <= 0 || height <= 0 || host is null) return 0u;
|
||
|
||
EnsureFramebuffer(width, height);
|
||
if (_fbo == 0) return 0u;
|
||
|
||
// Seal the entire pass: GLStateScope saves + restores every GL state the
|
||
// UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state
|
||
// — set up by BeginFrame and expected intact by Render — is untouched.
|
||
using var scope = new GLStateScope(_gl);
|
||
|
||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||
_gl.Disable(EnableCap.ScissorTest);
|
||
_gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // opaque dark-grey canvas background (the FBO IS the canvas)
|
||
_gl.ClearDepth(1.0);
|
||
_gl.DepthMask(true);
|
||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||
|
||
host.Draw(new Vector2(width, height));
|
||
|
||
// FBO stays bound here; GLStateScope.Dispose() restores the previous binding.
|
||
return _colorTex;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read the FBO color attachment back to CPU as a flat RGBA8 byte array.
|
||
/// Must be called AFTER <see cref="Render"/> for the same <paramref name="width"/> and
|
||
/// <paramref name="height"/> (so the FBO exists and is the right size).
|
||
///
|
||
/// <para>FBO origin is bottom-left (GL convention). The caller is responsible for
|
||
/// flipping rows vertically before saving as a top-left-origin image format (PNG).</para>
|
||
///
|
||
/// <para>Returns an empty array when the FBO is not ready.</para>
|
||
/// </summary>
|
||
public unsafe byte[] ReadColorRgba(int width, int height)
|
||
{
|
||
if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty<byte>();
|
||
|
||
int byteCount = width * height * 4;
|
||
var buf = new byte[byteCount];
|
||
|
||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||
fixed (byte* p = buf)
|
||
{
|
||
_gl.ReadPixels(0, 0, (uint)width, (uint)height,
|
||
PixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
}
|
||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
|
||
|
||
return buf;
|
||
}
|
||
|
||
// ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
|
||
|
||
private void EnsureFramebuffer(int width, int height)
|
||
{
|
||
if (_fbo != 0 && width == _fbW && height == _fbH) return;
|
||
DeleteFramebuffer();
|
||
|
||
_fbW = width;
|
||
_fbH = height;
|
||
|
||
_colorTex = _gl.GenTexture();
|
||
_gl.BindTexture(TextureTarget.Texture2D, _colorTex);
|
||
_gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8,
|
||
(uint)width, (uint)height, 0,
|
||
PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
|
||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
|
||
(int)TextureMinFilter.Linear);
|
||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
|
||
(int)TextureMinFilter.Linear);
|
||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
|
||
(int)TextureWrapMode.ClampToEdge);
|
||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
|
||
(int)TextureWrapMode.ClampToEdge);
|
||
_gl.BindTexture(TextureTarget.Texture2D, 0);
|
||
|
||
_depthRbo = _gl.GenRenderbuffer();
|
||
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
|
||
_gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer,
|
||
InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
|
||
_gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
|
||
|
||
_fbo = _gl.GenFramebuffer();
|
||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
|
||
_gl.FramebufferTexture2D(FramebufferTarget.Framebuffer,
|
||
FramebufferAttachment.ColorAttachment0,
|
||
TextureTarget.Texture2D, _colorTex, 0);
|
||
_gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer,
|
||
FramebufferAttachment.DepthStencilAttachment,
|
||
RenderbufferTarget.Renderbuffer, _depthRbo);
|
||
|
||
var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
|
||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
|
||
if (status != GLEnum.FramebufferComplete)
|
||
{
|
||
Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})");
|
||
DeleteFramebuffer();
|
||
}
|
||
}
|
||
|
||
private void DeleteFramebuffer()
|
||
{
|
||
if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
|
||
if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
|
||
if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
|
||
_fbW = _fbH = 0;
|
||
}
|
||
|
||
public void Dispose() => DeleteFramebuffer();
|
||
}
|