feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect
Task 3 of the acdream UI Studio plan. The studio previously drew the panel straight to the window; it now renders the panel into an off-screen FBO (PanelFbo) and displays it in an ImGui Canvas pane alongside a Tree pane (recursive element hierarchy, click-to-select) and a Properties pane (EventId/type/rect/anchors/ZOrder/flags of the selected element). Click-to-inspect: a left-click inside the Canvas calls UiElement.HitTest on the panel root (same-assembly internal access) and selects the topmost element at the cursor, wiring the canvas directly to the tree selection. PanelFbo lifecycle mirrors PaperdollViewportRenderer (RGBA8 color + Depth24Stencil8 renderbuffer, GLStateScope-sealed, lazy resize on size change). V-flip in DrawCanvas (uv0=(0,1)/uv1=(1,0)) corrects GL's bottom-left FBO origin to ImGui's top-left convention so click Y maps directly to panel-local Y without inversion. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
df6b5b3b39
commit
d2240974ec
3 changed files with 359 additions and 4 deletions
128
src/AcDream.App/Studio/PanelFbo.cs
Normal file
128
src/AcDream.App/Studio/PanelFbo.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
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); // dark bg matching the old direct draw
|
||||
_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();
|
||||
}
|
||||
158
src/AcDream.App/Studio/StudioInspector.cs
Normal file
158
src/AcDream.App/Studio/StudioInspector.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using ImGuiNET;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Three-pane ImGui IDE for the acdream UI Studio:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Canvas</b> — shows the panel FBO texture; click-to-inspect returns the
|
||||
/// panel-local pixel coordinate of the click.</item>
|
||||
/// <item><b>Tree</b> — recursive ImGui tree of the element hierarchy; clicking a node
|
||||
/// sets <see cref="Selected"/>.</item>
|
||||
/// <item><b>Properties</b> — shows the <see cref="Selected"/> element's geometry,
|
||||
/// anchors, and z-order.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Coordinate mapping for the canvas:
|
||||
/// The FBO is rendered at the same logical size as the window, so a click at image-local
|
||||
/// pixel (ix, iy) maps directly to panel coord (ix, iy) — no scale factor needed when
|
||||
/// the image is drawn 1:1. We draw it 1:1 if it fits; if the Canvas ImGui window is
|
||||
/// smaller we let ImGui clip it (the click coordinates are still image-local pixels so
|
||||
/// no scale correction is needed in v1).</para>
|
||||
///
|
||||
/// <para>V-flip: the FBO origin is bottom-left (GL convention), but ImGui images use
|
||||
/// top-left. We pass uv0=(0,1), uv1=(1,0) to <c>ImGui.Image</c>, which flips V so
|
||||
/// the panel renders right-side-up in the canvas. Because V is flipped, image-local
|
||||
/// pixel y = 0 is the TOP of the panel, matching the UiRoot's top-left origin — so
|
||||
/// click y maps directly without further inversion.</para>
|
||||
/// </summary>
|
||||
public sealed class StudioInspector
|
||||
{
|
||||
/// <summary>Currently selected element (set by tree-click or canvas-click).</summary>
|
||||
public UiElement? Selected { get; set; }
|
||||
|
||||
// ── Canvas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draw the "Canvas" ImGui window containing the panel FBO texture.
|
||||
/// If the user left-clicks inside the image, returns the panel-local pixel
|
||||
/// coordinate of the click (origin top-left, matching UiRoot's space); else null.
|
||||
///
|
||||
/// <para>The texture is drawn with uv0=(0,1) / uv1=(1,0) to flip V, correcting
|
||||
/// the GL bottom-left FBO origin to the ImGui top-left convention.</para>
|
||||
/// </summary>
|
||||
public (int x, int y)? DrawCanvas(nint panelTex, int width, int height)
|
||||
{
|
||||
ImGui.Begin("Canvas");
|
||||
|
||||
var imageSize = new Vector2(width, height);
|
||||
// V-flip: FBO origin is bottom-left; ImGui images expect top-left.
|
||||
// uv0 = bottom-left of texture = top of the panel in screen space.
|
||||
// uv1 = top-right of texture = bottom of the panel in screen space.
|
||||
var uv0 = new Vector2(0f, 1f);
|
||||
var uv1 = new Vector2(1f, 0f);
|
||||
ImGui.Image(panelTex, imageSize, uv0, uv1);
|
||||
|
||||
(int x, int y)? result = null;
|
||||
|
||||
// Detect a left-click on the image.
|
||||
if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
|
||||
{
|
||||
// Image item rect: top-left of the drawn image in screen (display) coords.
|
||||
var rectMin = ImGui.GetItemRectMin();
|
||||
var mousePos = ImGui.GetMousePos();
|
||||
|
||||
// Panel-local pixel = mouse offset from image top-left.
|
||||
// Because we flipped V (uv0.Y=1, uv1.Y=0), image row 0 IS the top of the panel,
|
||||
// so y needs no further inversion.
|
||||
int ix = (int)(mousePos.X - rectMin.X);
|
||||
int iy = (int)(mousePos.Y - rectMin.Y);
|
||||
// Clamp to image bounds.
|
||||
if (ix >= 0 && ix < width && iy >= 0 && iy < height)
|
||||
result = (ix, iy);
|
||||
}
|
||||
|
||||
ImGui.End();
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Tree ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Draw the "Tree" ImGui window. Clicking a node sets <see cref="Selected"/>.</summary>
|
||||
public void DrawTree(UiElement root)
|
||||
{
|
||||
ImGui.Begin("Tree");
|
||||
DrawTreeNode(root);
|
||||
ImGui.End();
|
||||
}
|
||||
|
||||
private void DrawTreeNode(UiElement el)
|
||||
{
|
||||
// Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
|
||||
string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
|
||||
|
||||
bool isSelected = ReferenceEquals(el, Selected);
|
||||
bool hasChildren = el.Children.Count > 0;
|
||||
|
||||
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
|
||||
| ImGuiTreeNodeFlags.SpanAvailWidth;
|
||||
if (!hasChildren)
|
||||
flags |= ImGuiTreeNodeFlags.Leaf;
|
||||
if (isSelected)
|
||||
flags |= ImGuiTreeNodeFlags.Selected;
|
||||
|
||||
bool open = ImGui.TreeNodeEx(label, flags);
|
||||
|
||||
// Click on the node label (not the arrow) selects it.
|
||||
if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
|
||||
Selected = el;
|
||||
|
||||
if (open)
|
||||
{
|
||||
foreach (var child in el.Children)
|
||||
DrawTreeNode(child);
|
||||
ImGui.TreePop();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Properties ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Draw the "Properties" ImGui window for <see cref="Selected"/>.</summary>
|
||||
public void DrawProperties()
|
||||
{
|
||||
ImGui.Begin("Properties");
|
||||
|
||||
var el = Selected;
|
||||
if (el is null)
|
||||
{
|
||||
ImGui.TextUnformatted("(nothing selected)");
|
||||
ImGui.End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
|
||||
ImGui.TextUnformatted($"Type: {el.GetType().Name}");
|
||||
ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
|
||||
ImGui.TextUnformatted($"Anchors: {el.Anchors}");
|
||||
ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
|
||||
ImGui.Separator();
|
||||
ImGui.TextUnformatted($"Visible: {el.Visible}");
|
||||
ImGui.TextUnformatted($"Enabled: {el.Enabled}");
|
||||
ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
|
||||
ImGui.TextUnformatted($"Draggable: {el.Draggable}");
|
||||
ImGui.TextUnformatted($"Resizable: {el.Resizable}");
|
||||
ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
|
||||
ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
|
||||
ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
|
||||
ImGui.Separator();
|
||||
var sp = el.ScreenPosition;
|
||||
ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
|
||||
ImGui.TextUnformatted($"Children: {el.Children.Count}");
|
||||
|
||||
ImGui.End();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.UI;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using Silk.NET.Input;
|
||||
|
|
@ -17,6 +18,15 @@ namespace AcDream.App.Studio;
|
|||
///
|
||||
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path]
|
||||
///
|
||||
/// Task 3 adds an ImGui IDE on top of the panel FBO:
|
||||
/// <list type="bullet">
|
||||
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
|
||||
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
|
||||
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
|
||||
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
|
||||
/// element under the cursor via <see cref="UiRoot.HitTest"/>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The window is intentionally thin: no game world, no physics, no streaming —
|
||||
/// just GL + UiHost + the layout under test, identical to how the panel
|
||||
/// appears inside <c>GameWindow</c>.
|
||||
|
|
@ -31,6 +41,12 @@ public sealed class StudioWindow : IDisposable
|
|||
private RenderStack? _stack;
|
||||
private LayoutSource? _source;
|
||||
|
||||
// Task 3 additions.
|
||||
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
|
||||
private PanelFbo? _panelFbo;
|
||||
private StudioInspector? _inspector;
|
||||
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
|
||||
|
||||
public StudioWindow(StudioOptions opts)
|
||||
{
|
||||
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
|
||||
|
|
@ -99,10 +115,16 @@ public sealed class StudioWindow : IDisposable
|
|||
// Load the panel described by options and add it to the UI tree.
|
||||
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont);
|
||||
var root = _source.Load(_opts);
|
||||
_panelRoot = root;
|
||||
if (root is not null)
|
||||
_stack.UiHost.Root.AddChild(root);
|
||||
else
|
||||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||||
|
||||
// Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed.
|
||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||||
_panelFbo = new PanelFbo(gl);
|
||||
_inspector = new StudioInspector();
|
||||
}
|
||||
|
||||
private double _dt;
|
||||
|
|
@ -114,18 +136,61 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
private void OnRender(double dt)
|
||||
{
|
||||
if (_stack is null) return;
|
||||
if (_stack is null || _imgui is null || _panelFbo is null || _inspector is null)
|
||||
return;
|
||||
|
||||
var gl = _stack.Gl;
|
||||
gl.ClearColor(0.18f, 0.18f, 0.18f, 1f);
|
||||
int w = _window!.Size.X;
|
||||
int h = _window!.Size.Y;
|
||||
|
||||
// 1. Tick the UI widgets.
|
||||
_stack.UiHost.Tick(_dt);
|
||||
|
||||
// 2. Render the panel into the off-screen FBO; get the color texture.
|
||||
// The FBO is the same logical size as the window, so element rects map 1:1 to
|
||||
// FBO pixels — no scale factor needed when displaying the canvas at full size.
|
||||
uint panelTex = _panelFbo.Render(w, h, _stack.UiHost);
|
||||
|
||||
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
|
||||
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
|
||||
gl.Clear(ColorBufferBit | DepthBufferBit);
|
||||
|
||||
_stack.UiHost.Tick(_dt);
|
||||
_stack.UiHost.Draw(new Vector2(_window!.Size.X, _window!.Size.Y));
|
||||
// 4. Begin the ImGui frame.
|
||||
_imgui.BeginFrame((float)dt);
|
||||
|
||||
// 5. Canvas pane — show the FBO texture; detect clicks.
|
||||
(int x, int y)? click = null;
|
||||
if (panelTex != 0)
|
||||
click = _inspector.DrawCanvas((nint)panelTex, w, h);
|
||||
|
||||
// 6. If the user clicked inside the canvas, hit-test the panel tree and select.
|
||||
if (click is { } c && _panelRoot is not null)
|
||||
{
|
||||
// UiElement.HitTest is internal — accessible from AcDream.App.Studio (same assembly).
|
||||
// We call it on the panel root with coords relative to its top-left.
|
||||
var screenPos = _panelRoot.ScreenPosition;
|
||||
var hit = _panelRoot.HitTest(c.x - screenPos.X, c.y - screenPos.Y);
|
||||
if (hit is not null)
|
||||
_inspector.Selected = hit;
|
||||
}
|
||||
|
||||
// 7. Element tree pane.
|
||||
if (_panelRoot is not null)
|
||||
_inspector.DrawTree(_panelRoot);
|
||||
|
||||
// 8. Properties pane.
|
||||
_inspector.DrawProperties();
|
||||
|
||||
// 9. Finalise ImGui and flush draw data to the window.
|
||||
_imgui.Render();
|
||||
}
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
_imgui?.Dispose();
|
||||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_stack?.DrawDispatcher.Dispose();
|
||||
_stack?.MeshAdapter.Dispose();
|
||||
_stack?.TextureCache.Dispose();
|
||||
|
|
@ -139,6 +204,10 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
_imgui?.Dispose();
|
||||
_panelFbo?.Dispose();
|
||||
_imgui = null;
|
||||
_panelFbo = null;
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
// If OnClosing wasn't called (e.g. exception before Run()), clean up anyway.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue