diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
new file mode 100644
index 00000000..ba293f5a
--- /dev/null
+++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
@@ -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;
+
+///
+/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail
+/// CreatureMode::Render). Draws ONE re-dressed player clone (a ,
+/// built by + wired by GameWindow) into a private off-screen
+/// framebuffer with a fixed + one distant light, then hands the color
+/// texture to the widget which blits it as a normal 2-D sprite.
+///
+/// The whole 3-D pass is sealed in a 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.
+///
+/// Reuses the existing + the player's already-resolved,
+/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is
+/// needed. With frustum: null the doll's synthetic landblock is always "visible" and the
+/// entity is walked from entry.Entities and drawn from its current MeshRefs — a STATIC pose
+/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice.
+///
+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));
+ }
+
+ /// Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player.
+ public void SetDoll(WorldEntity? doll) => _doll = doll;
+
+ ///
+ 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, IReadOnlyDictionary?)[]
+ {
+ (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;
+ }
+
+ /// 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).
+ 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);
+ }
+
+ /// (Re)create the FBO + color texture + depth-stencil renderbuffer when the requested
+ /// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8.
+ 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();
+}
diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs
index 22ceabf5..8a8c8ffa 100644
--- a/src/AcDream.App/UI/UiViewport.cs
+++ b/src/AcDream.App/UI/UiViewport.cs
@@ -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);
}
}