feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)

The C# analog of CreatureMode::Render: draws one re-dressed player clone
into a private FBO (RGBA8 + depth24-stencil8) with the fixed DollCamera +
one distant light (retail 0.3,1.9,0.65 @ 2.0), sealed in a GLStateScope so
it can't disturb world/UI GL state. frustum:null ⇒ the doll's synthetic
landblock is always visible ⇒ walked from entry.Entities and drawn from its
current MeshRefs (static pose; idle animation is a later slice).

Manages the FBO directly via GL (ManagedGLFramebuffer is unused/bitrotted
WB infra). UiViewport blit flips V (FBO bottom-left origin vs UI top-left)
so the doll isn't upside-down. Not yet wired — GameWindow hook is next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 09:14:20 +02:00
parent 362d41aacf
commit 3cdecb536b
2 changed files with 191 additions and 2 deletions

View file

@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Core.Lighting;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
/// <c>CreatureMode::Render</c>). Draws ONE re-dressed player clone (a <see cref="WorldEntity"/>,
/// built by <see cref="DollEntityBuilder"/> + wired by GameWindow) into a private off-screen
/// framebuffer with a fixed <see cref="DollCamera"/> + one distant light, then hands the color
/// texture to the <see cref="UiViewport"/> widget which blits it as a normal 2-D sprite.
///
/// <para>The whole 3-D pass is sealed in a <see cref="GLStateScope"/> so it can't disturb the
/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the
/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's
/// 2-D OnDraw.</para>
///
/// <para>Reuses the existing <see cref="WbDrawDispatcher"/> + the player's already-resolved,
/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
/// needed. With <c>frustum: null</c> the doll's synthetic landblock is always "visible" and the
/// entity is walked from <c>entry.Entities</c> and drawn from its current MeshRefs — a STATIC pose
/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.</para>
/// </summary>
public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable
{
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly DollCamera _camera = new();
// Off-screen target (lazily (re)created on size change).
private uint _fbo;
private uint _colorTex;
private uint _depthRbo;
private int _fbW;
private int _fbH;
// The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its
// MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw.
private WorldEntity? _doll;
// Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as
// neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path.
private const uint DollLandblockId = 0u;
public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo));
}
/// <summary>Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.</summary>
public void SetDoll(WorldEntity? doll) => _doll = doll;
/// <inheritdoc/>
public uint Render(int width, int height)
{
var doll = _doll;
if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u;
EnsureFramebuffer(width, height);
if (_fbo == 0) return 0u;
_camera.Aspect = width / (float)height;
// Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/
// FBO bindings on dispose, so the surrounding world + 2-D UI passes are 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(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll
_gl.ClearDepth(1.0);
_gl.DepthMask(true); // depth clears honor glDepthMask
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw
_gl.Disable(EnableCap.Blend);
UploadDollLight();
// One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒
// walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190).
var entities = new WorldEntity[] { doll };
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: DollLandblockId,
visibleCellIds: null,
animatedEntityIds: null);
return _colorTex;
}
/// <summary>Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant
/// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the
/// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65)
/// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533).</summary>
private void UploadDollLight()
{
var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f));
var ubo = new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional
DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.40f, 0.40f, 0.40f, 1f), // 1 active light; ambient tunable at the gate
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye)
};
_lightUbo.Upload(ubo);
}
/// <summary>(Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
/// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.</summary>
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($"[paperdoll] framebuffer 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();
}

View file

@ -19,7 +19,9 @@ public sealed class UiViewport : UiElement
{
if (!Visible || TextureHandle == 0) return;
// Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren).
// Draw the full-texture sprite with UV 0..1 and white tint (no color modulation).
ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
// V is FLIPPED (v0=1, v1=0): TextureHandle is an off-screen FBO color texture, whose origin is
// bottom-left (GL), while the UI sprite convention is top-left. Without the flip the doll renders
// upside-down. (If the doll appears upside-down at the visual gate, this is the line to revisit.)
ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One);
}
}