acdream/src/AcDream.App/Studio/PanelFbo.cs
Erik 101a35cc2d fix(studio): Task 3 review — UiRoot.Pick, RenderStack IDisposable, dt cleanup
Code-review follow-ups to the ImGui inspector:
- Add public UiRoot.Pick(x,y) over the private HitTestTopDown (honors
  Z-order + modal exclusivity); StudioWindow uses it instead of a manual
  UiElement.HitTest with subtracted ScreenPosition.
- RenderStack : IDisposable — disposes the GL pieces it owns in one place;
  StudioWindow OnClosing + Dispose both call _stack?.Dispose(), closing the
  error-path leak (only UiHost was disposed on the Dispose-without-OnClosing
  path).
- Drop the stale _dt field; OnRender passes its own dt to Tick + BeginFrame.
- Fix a stale PanelFbo comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:47 +02:00

128 lines
5.3 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;
}
// ── 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();
}