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