acdream/src/AcDream.App/Studio/PanelFbo.cs
Erik f778b100b7 feat(studio): headless --screenshot mode (render panel to PNG + exit)
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>
2026-06-25 16:36:10 +02:00

156 lines
6.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}