feat(ui): debug overlay + refined input controls

Adds the first on-screen HUD for the dev client plus today's mouse-control
refinements. Also lands yesterday's scenery-alignment changes that were
left uncommitted in the working tree.

Overlay:
- BitmapFont rasterizes a system TTF via StbTrueTypeSharp into a 512x512
  R8 atlas at startup (Consolas on Windows, DejaVu/Menlo fallbacks)
- TextRenderer batches 2D quads in screen-space with ortho projection;
  one shader + two draw calls (rect then text) for panel backgrounds
  under glyphs
- DebugOverlay composes info / stats / compass / help panels on top of
  the 3D scene; toggles via F1/F4/F5/F6; transient toasts for key events
- DebugLineRenderer and its shaders (carried over from the scenery work)
  are properly committed in this commit

Controls:
- Per-mode mouse sensitivity (Chase 0.15, Fly 1.0, Orbit 1.0); F8/F9 to
  adjust the active mode multiplicatively (x1.2)
- Hold RMB to free-orbit the chase camera around the player; release
  stays at the new angle (no snap-back)
- Mouse-wheel zooms chase distance between 2m and 40m
- Chase pitch widened to [-0.7, 1.4] so mouse-Y tilts both ways from
  the default neutral angle

Scenery alignment (carried from yesterday's session):
- ShadowObjectRegistry AllEntriesForDebug + Scale field
- SceneryGenerator uses ACViewer's OnRoad polygon test + baseLoc +
  set_heading rotation
- BSPQuery dispatchers accept localToWorld so normals/offsets transform
  correctly per part
- TransitionTypes.CylinderCollision rewritten with wall-slide + push-out
- PhysicsDataCache caches visual-mesh AABB for scenery that lacks
  physics Setup bounds
This commit is contained in:
Erik 2026-04-17 18:45:38 +02:00
parent 6b4e7569a3
commit ff325abd7b
20 changed files with 2734 additions and 268 deletions

View file

@ -15,6 +15,7 @@
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
<PackageReference Include="Serilog" Version="4.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />

View file

@ -0,0 +1,180 @@
using System;
using System.IO;
using Silk.NET.OpenGL;
using StbTrueTypeSharp;
namespace AcDream.App.Rendering;
/// <summary>
/// A pixel-font atlas rasterized from a TTF at load time using stb_truetype.
/// Glyphs are packed into a single-channel (R8) GL texture. Call
/// <see cref="TryGetGlyph"/> to resolve an ASCII codepoint to UV + metrics.
///
/// Only printable ASCII (32..127) is supported for the debug overlay.
/// </summary>
public sealed unsafe class BitmapFont : IDisposable
{
public readonly struct Glyph
{
public readonly float UvMinX;
public readonly float UvMinY;
public readonly float UvMaxX;
public readonly float UvMaxY;
public readonly float OffsetX; // from cursor to glyph quad top-left
public readonly float OffsetY;
public readonly float Width; // pixels
public readonly float Height;
public readonly float Advance;
public Glyph(float umn, float vmn, float umx, float vmx,
float ox, float oy, float w, float h, float adv)
{
UvMinX = umn; UvMinY = vmn; UvMaxX = umx; UvMaxY = vmx;
OffsetX = ox; OffsetY = oy; Width = w; Height = h; Advance = adv;
}
}
private readonly GL _gl;
private readonly Glyph[] _glyphs;
private readonly int _firstChar;
private readonly int _numChars;
public uint TextureId { get; }
public float PixelHeight { get; }
public float LineHeight { get; }
public float Ascent { get; }
public int AtlasWidth { get; }
public int AtlasHeight { get; }
public BitmapFont(GL gl, byte[] ttfBytes, float pixelHeight,
int atlasSize = 512, int firstChar = 32, int numChars = 96)
{
_gl = gl;
PixelHeight = pixelHeight;
AtlasWidth = atlasSize;
AtlasHeight = atlasSize;
_firstChar = firstChar;
_numChars = numChars;
// Bake the glyph bitmap via stbtt_BakeFontBitmap.
var bakedChars = new StbTrueType.stbtt_bakedchar[numChars];
var pixels = new byte[AtlasWidth * AtlasHeight];
bool ok = StbTrueType.stbtt_BakeFontBitmap(
ttfBytes, 0, pixelHeight,
pixels, AtlasWidth, AtlasHeight,
firstChar, numChars, bakedChars);
if (!ok)
throw new InvalidOperationException(
$"stbtt_BakeFontBitmap failed: atlas {atlasSize}x{atlasSize} " +
$"too small for pixelHeight={pixelHeight}");
// Extract vertical metrics for line spacing.
using var info = StbTrueType.CreateFont(ttfBytes, 0)
?? throw new InvalidOperationException("stbtt_InitFont failed");
float scale = StbTrueType.stbtt_ScaleForPixelHeight(info, pixelHeight);
int ascent, descent, lineGap;
StbTrueType.stbtt_GetFontVMetrics(info, &ascent, &descent, &lineGap);
Ascent = ascent * scale;
LineHeight = (ascent - descent + lineGap) * scale;
// Convert baked-char records to our Glyph struct.
_glyphs = new Glyph[numChars];
for (int i = 0; i < numChars; i++)
{
var bc = bakedChars[i];
float w = bc.x1 - bc.x0;
float h = bc.y1 - bc.y0;
_glyphs[i] = new Glyph(
umn: bc.x0 / (float)AtlasWidth,
vmn: bc.y0 / (float)AtlasHeight,
umx: bc.x1 / (float)AtlasWidth,
vmx: bc.y1 / (float)AtlasHeight,
ox: bc.xoff,
oy: bc.yoff,
w: w, h: h,
adv: bc.xadvance);
}
// Upload atlas as a single-channel GL texture (R8).
TextureId = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, TextureId);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 1);
fixed (byte* ptr = pixels)
{
_gl.TexImage2D(TextureTarget.Texture2D, 0,
(int)InternalFormat.R8,
(uint)AtlasWidth, (uint)AtlasHeight, 0,
PixelFormat.Red, PixelType.UnsignedByte, ptr);
}
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
(int)TextureMinFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
(int)TextureMagFilter.Linear);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
(int)TextureWrapMode.ClampToEdge);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
(int)TextureWrapMode.ClampToEdge);
_gl.PixelStore(PixelStoreParameter.UnpackAlignment, 4); // restore default
_gl.BindTexture(TextureTarget.Texture2D, 0);
}
public bool TryGetGlyph(char c, out Glyph g)
{
int idx = c - _firstChar;
if ((uint)idx >= (uint)_numChars)
{
g = default;
return false;
}
g = _glyphs[idx];
return true;
}
/// <summary>Measure the pixel width of a single-line string in this font.</summary>
public float MeasureWidth(string s)
{
float w = 0;
for (int i = 0; i < s.Length; i++)
{
if (TryGetGlyph(s[i], out var g))
w += g.Advance;
}
return w;
}
public void Dispose()
{
_gl.DeleteTexture(TextureId);
}
/// <summary>
/// Try to load a monospaced system font from well-known paths on the host OS.
/// Returns null if no candidate was found.
/// </summary>
public static byte[]? TryLoadSystemMonospaceFont()
{
string[] candidates =
{
@"C:\Windows\Fonts\consola.ttf",
@"C:\Windows\Fonts\cour.ttf",
@"C:\Windows\Fonts\arial.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
"/Library/Fonts/Menlo.ttc",
"/System/Library/Fonts/Menlo.ttc",
};
foreach (var path in candidates)
{
try
{
if (File.Exists(path))
return File.ReadAllBytes(path);
}
catch
{
// try next candidate
}
}
return null;
}
}

View file

@ -14,17 +14,34 @@ public sealed class ChaseCamera : ICamera
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
/// <summary>Distance behind the player.</summary>
/// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
public float Distance { get; set; } = 8f;
public const float DistanceMin = 2f;
public const float DistanceMax = 40f;
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
public float Pitch { get; set; } = 0.35f; // ~20 degrees
/// <summary>
/// Additional yaw applied on top of the player's heading when positioning
/// the camera. Used by the hold-RMB "inspect" mode to orbit around the
/// player without rotating the character. Snap to 0 to return the camera
/// to directly behind the player.
/// </summary>
public float YawOffset { get; set; } = 0f;
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
public float EyeHeight { get; set; } = 1.5f;
private const float PitchMin = 0.05f;
private const float PitchMax = 1.4f; // ~80 degrees
// Pitch range: negative values place the camera below the player's Z
// (at distance * sin(Pitch)) so the player can be viewed from a low
// angle. Clamped to -0.7 to avoid pushing the camera deep underground;
// at -0.7 and Distance=8 the camera is ~5m below player-Z which will
// clip terrain on hills but is OK on flat ground. 1.4 ≈ looking
// straight down. Wider than the old [0.05, 1.4] so mouse-Y moves the
// camera in both directions from the neutral [~20°] default.
private const float PitchMin = -0.7f;
private const float PitchMax = 1.4f;
private float _playerYaw;
private Vector3 _lookAt;
@ -43,9 +60,11 @@ public sealed class ChaseCamera : ICamera
_playerYaw = playerYaw;
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
// Camera offset: behind the player (-forward direction) and above.
float forwardX = MathF.Cos(playerYaw);
float forwardY = MathF.Sin(playerYaw);
// Camera offset: behind the player (-forward direction) plus any
// YawOffset for the hold-RMB inspect orbit mode.
float effectiveYaw = playerYaw + YawOffset;
float forwardX = MathF.Cos(effectiveYaw);
float forwardY = MathF.Sin(effectiveYaw);
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
@ -63,4 +82,12 @@ public sealed class ChaseCamera : ICamera
{
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
/// <summary>
/// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax].
/// </summary>
public void AdjustDistance(float delta)
{
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
}
}

View file

@ -0,0 +1,172 @@
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Minimal GL debug line renderer for visualizing collision shapes,
/// bounding boxes, and other debug geometry. Collect lines each frame
/// via <see cref="AddLine"/> / <see cref="AddCylinder"/>, then call
/// <see cref="Flush"/> to upload + draw them.
///
/// Uses a single shared VBO that's respecialized each frame. Vertex
/// format is (vec3 pos, vec3 color) = 24 bytes per vertex.
/// </summary>
public sealed unsafe class DebugLineRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly List<float> _buffer = new(4096);
private int _vertexCount;
private int _capacityBytes;
public DebugLineRenderer(GL gl, string shaderDir)
{
_gl = gl;
_shader = new Shader(gl,
Path.Combine(shaderDir, "debug_line.vert"),
Path.Combine(shaderDir, "debug_line.frag"));
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// 24-byte stride: vec3 pos + vec3 color
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, 6 * sizeof(float), (void*)(3 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
}
/// <summary>Clear accumulated lines. Call at the start of each frame.</summary>
public void Begin()
{
_buffer.Clear();
_vertexCount = 0;
}
public void AddLine(Vector3 a, Vector3 b, Vector3 color)
{
_buffer.Add(a.X); _buffer.Add(a.Y); _buffer.Add(a.Z);
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
_buffer.Add(b.X); _buffer.Add(b.Y); _buffer.Add(b.Z);
_buffer.Add(color.X); _buffer.Add(color.Y); _buffer.Add(color.Z);
_vertexCount += 2;
}
/// <summary>
/// Draw a cylinder as 2 polygon rings (base + top) connected by 4
/// vertical line segments at 0/90/180/270 degrees.
/// </summary>
public void AddCylinder(Vector3 basePos, float radius, float height, Vector3 color)
{
const int segments = 16;
Vector3 top = basePos + new Vector3(0, 0, height);
// Ring vertices
var baseRing = new Vector3[segments];
var topRing = new Vector3[segments];
for (int i = 0; i < segments; i++)
{
float theta = i * (MathF.PI * 2f / segments);
float cx = MathF.Cos(theta) * radius;
float cy = MathF.Sin(theta) * radius;
baseRing[i] = new Vector3(basePos.X + cx, basePos.Y + cy, basePos.Z);
topRing[i] = new Vector3(top.X + cx, top.Y + cy, top.Z);
}
// Base ring
for (int i = 0; i < segments; i++)
AddLine(baseRing[i], baseRing[(i + 1) % segments], color);
// Top ring
for (int i = 0; i < segments; i++)
AddLine(topRing[i], topRing[(i + 1) % segments], color);
// 4 vertical connectors
for (int i = 0; i < 4; i++)
{
int idx = i * (segments / 4);
AddLine(baseRing[idx], topRing[idx], color);
}
}
/// <summary>
/// Draw an axis-aligned box as 12 edges.
/// </summary>
public void AddBox(Vector3 min, Vector3 max, Vector3 color)
{
Vector3[] c =
{
new(min.X, min.Y, min.Z),
new(max.X, min.Y, min.Z),
new(max.X, max.Y, min.Z),
new(min.X, max.Y, min.Z),
new(min.X, min.Y, max.Z),
new(max.X, min.Y, max.Z),
new(max.X, max.Y, max.Z),
new(min.X, max.Y, max.Z),
};
// Bottom
AddLine(c[0], c[1], color); AddLine(c[1], c[2], color);
AddLine(c[2], c[3], color); AddLine(c[3], c[0], color);
// Top
AddLine(c[4], c[5], color); AddLine(c[5], c[6], color);
AddLine(c[6], c[7], color); AddLine(c[7], c[4], color);
// Verticals
AddLine(c[0], c[4], color); AddLine(c[1], c[5], color);
AddLine(c[2], c[6], color); AddLine(c[3], c[7], color);
}
/// <summary>Upload + draw all accumulated lines.</summary>
public void Flush(Matrix4x4 view, Matrix4x4 projection)
{
if (_vertexCount == 0) return;
_shader.Use();
_shader.SetMatrix4("uView", view);
_shader.SetMatrix4("uProjection", projection);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
int neededBytes = _buffer.Count * sizeof(float);
if (neededBytes > _capacityBytes)
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)neededBytes, ptr, BufferUsageARB.DynamicDraw);
_capacityBytes = neededBytes;
}
else
{
fixed (float* ptr = CollectionsMarshal.AsSpan(_buffer))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)neededBytes, ptr);
}
// Depth test on so lines get occluded by geometry (but we want them
// visible through geometry — disable depth test so everything shows).
bool wasDepthEnabled = _gl.IsEnabled(EnableCap.DepthTest);
_gl.Disable(EnableCap.DepthTest);
_gl.DrawArrays(PrimitiveType.Lines, 0, (uint)_vertexCount);
if (wasDepthEnabled) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_shader.Dispose();
}
}

View file

@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Screen-space debug HUD. Composes panels on top of the 3D scene using a
/// <see cref="TextRenderer"/> + <see cref="BitmapFont"/>. Panels can be
/// toggled independently (info / stats / controls-help / compass).
///
/// The overlay is stateless w.r.t. game state — callers populate a
/// <see cref="Snapshot"/> each frame and pass it to <see cref="Draw"/>.
/// </summary>
public sealed class DebugOverlay
{
private readonly TextRenderer _text;
private readonly BitmapFont _font;
public bool ShowInfoPanel { get; set; } = true;
public bool ShowStatsPanel { get; set; } = true;
public bool ShowHelpPanel { get; set; } = false;
public bool ShowCompass { get; set; } = true;
// Toast state for transient notifications (e.g. "wireframes off").
private string? _toastText;
private Vector4 _toastColor = White;
private float _toastTimeLeft;
private static readonly Vector4 White = new(1f, 1f, 1f, 1f);
private static readonly Vector4 Green = new(0.4f, 0.95f, 0.4f, 1f);
private static readonly Vector4 Yellow = new(1f, 0.9f, 0.3f, 1f);
private static readonly Vector4 Red = new(1f, 0.4f, 0.35f, 1f);
private static readonly Vector4 Cyan = new(0.4f, 0.85f, 1f, 1f);
private static readonly Vector4 Grey = new(0.7f, 0.7f, 0.75f, 1f);
private static readonly Vector4 PanelBg = new(0f, 0f, 0f, 0.55f);
private static readonly Vector4 PanelBorder = new(0.15f, 0.15f, 0.2f, 0.8f);
/// <summary>Per-frame state snapshot from the caller. See <see cref="Draw"/>.</summary>
public readonly record struct Snapshot(
float Fps,
float FrameTimeMs,
Vector3 PlayerPos,
float HeadingDeg,
uint CellId,
bool OnGround,
bool InPlayerMode,
bool InFlyMode,
float VerticalVelocity,
int EntityCount,
int AnimatedCount,
int LandblocksVisible,
int LandblocksTotal,
int ShadowObjectCount,
float NearestObjDist,
string NearestObjLabel,
bool Colliding,
bool DebugWireframes,
int StreamingRadius,
float MouseSensitivity,
float ChaseDistance,
bool RmbOrbit);
public DebugOverlay(TextRenderer text, BitmapFont font)
{
_text = text;
_font = font;
}
/// <summary>Show a short message in the center-top for <paramref name="durationSec"/> seconds.</summary>
public void Toast(string message, float durationSec = 1.5f, Vector4? color = null)
{
_toastText = message;
_toastColor = color ?? Yellow;
_toastTimeLeft = durationSec;
}
/// <summary>Advance toast timer. Call once per frame with dt in seconds.</summary>
public void Update(float dt)
{
if (_toastTimeLeft > 0f)
{
_toastTimeLeft -= dt;
if (_toastTimeLeft <= 0f)
_toastText = null;
}
}
public void Draw(Snapshot s, Vector2 screenSize)
{
_text.Begin(screenSize);
if (ShowInfoPanel) DrawInfoPanel(s);
if (ShowStatsPanel) DrawStatsPanel(s, screenSize);
if (ShowCompass) DrawCompass(s, screenSize);
if (ShowHelpPanel) DrawHelpPanel(screenSize);
DrawHintBar(screenSize);
DrawToast(screenSize);
_text.Flush(_font);
}
// ──────────────────────────────────────────────────────────────────────
// Info panel — top-left: mode, position, heading, ground, nearest-obj.
// ──────────────────────────────────────────────────────────────────────
private void DrawInfoPanel(Snapshot s)
{
var lines = new List<(string text, Vector4 color)>();
string modeLabel = s.InPlayerMode ? "PLAYER" : (s.InFlyMode ? "FLY " : "ORBIT ");
var modeColor = s.InPlayerMode ? Yellow : (s.InFlyMode ? Cyan : Grey);
lines.Add(($"[{modeLabel}] wireframes {(s.DebugWireframes ? "ON " : "OFF")}",
modeColor));
lines.Add(($"Pos {s.PlayerPos.X,8:F1} {s.PlayerPos.Y,8:F1} {s.PlayerPos.Z,8:F2}", White));
lines.Add(($"Head {s.HeadingDeg,5:F0} deg Cell 0x{s.CellId:X8}", White));
var gColor = s.OnGround ? Green : Yellow;
string vzStr = s.VerticalVelocity >= 0
? $"+{s.VerticalVelocity,4:F2}"
: $"{s.VerticalVelocity,5:F2}";
lines.Add(($"Grnd {(s.OnGround ? "yes" : "NO ")} vZ {vzStr}", gColor));
var nColor = s.Colliding ? Red : White;
string nearDist = float.IsPositiveInfinity(s.NearestObjDist) ? " --- " : $"{s.NearestObjDist,4:F1}m";
lines.Add(($"Near {nearDist} {s.NearestObjLabel}", nColor));
var cColor = s.Colliding ? Red : Green;
lines.Add(($"Coll {(s.Colliding ? "BLOCKED" : "free ")}", cColor));
if (s.InPlayerMode)
{
string orbitTag = s.RmbOrbit ? " [RMB orbit]" : "";
lines.Add(($"Cam dist {s.ChaseDistance,4:F1}m{orbitTag}",
s.RmbOrbit ? Cyan : White));
}
lines.Add(($"Sens {s.MouseSensitivity:F3}x (F8 slower / F9 faster)", Grey));
DrawPanel(10f, 10f, lines);
}
// ──────────────────────────────────────────────────────────────────────
// Stats panel — top-right: fps, frame time, landblock/entity counters.
// ──────────────────────────────────────────────────────────────────────
private void DrawStatsPanel(Snapshot s, Vector2 screenSize)
{
var lines = new List<(string text, Vector4 color)>
{
($"{s.Fps,5:F0} fps {s.FrameTimeMs,5:F1} ms", Green),
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
};
float pad = 10f;
float panelW = MeasureMax(lines) + 2 * InnerPad;
DrawPanel(screenSize.X - panelW - pad, pad, lines, panelW);
}
// ──────────────────────────────────────────────────────────────────────
// Compass — bottom-center: arrow indicating heading, cardinal labels.
// ──────────────────────────────────────────────────────────────────────
private void DrawCompass(Snapshot s, Vector2 screenSize)
{
// Simple linear compass strip across 180° of horizon at the top-center.
const float stripW = 360f;
const float stripH = 20f;
float cx = screenSize.X * 0.5f;
float top = 8f;
float left = cx - stripW * 0.5f;
_text.DrawRect(left, top, stripW, stripH, PanelBg);
_text.DrawRectOutline(left, top, stripW, stripH, PanelBorder);
// Mark every 30° of yaw, labelled with cardinal letters at N/E/S/W.
// Heading 0 = +X (east). We show 180° wide, ±90° from current heading.
float h = NormalizeDeg(s.HeadingDeg);
for (int d = 0; d < 360; d += 15)
{
float rel = NormalizeDegSigned(d - h);
if (rel < -90 || rel > 90) continue;
float x = cx + rel / 90f * (stripW * 0.5f - 6f);
bool bold = (d % 90) == 0;
float tickH = bold ? stripH * 0.9f : stripH * 0.4f;
_text.DrawRect(x - 0.5f, top + stripH - tickH, 1f, tickH, bold ? White : Grey);
if (bold)
{
string lab = d switch
{
0 => "E",
90 => "N",
180 => "W",
270 => "S",
_ => ""
};
if (lab.Length > 0)
{
float w = _font.MeasureWidth(lab);
_text.DrawString(_font, lab, x - w * 0.5f, top - _font.LineHeight + 4f, White);
}
}
}
// Current heading indicator arrow below the strip.
_text.DrawRect(cx - 1.5f, top + stripH + 2f, 3f, 6f, Yellow);
string hText = $"{h,3:F0}°";
float hw = _font.MeasureWidth(hText);
_text.DrawString(_font, hText, cx - hw * 0.5f, top + stripH + 10f, Yellow);
}
// ──────────────────────────────────────────────────────────────────────
// Help panel — center: full keybind cheat-sheet, shown when F1 is pressed.
// ──────────────────────────────────────────────────────────────────────
private static readonly (string key, string desc)[] Keybinds =
{
("F1", "toggle this help"),
("F2", "toggle collision wireframes"),
("F3", "console dump (pos + nearby objects)"),
("F4", "toggle debug HUD info panel"),
("F5", "toggle stats panel"),
("F6", "toggle compass"),
("F", "toggle fly camera"),
("Tab", "toggle player mode (requires login)"),
("W A S D", "move (player mode) / fly"),
("Mouse", "turn character / look (fly)"),
("Hold RMB", "free orbit camera around player"),
("Wheel", "zoom chase camera in / out"),
("F8 / F9", "mouse sensitivity slower / faster"),
("Space", "jump (hold to charge)"),
("Shift", "run"),
("Escape", "exit fly / player / close window"),
};
private void DrawHelpPanel(Vector2 screenSize)
{
var lines = new List<(string text, Vector4 color)>();
lines.Add(("CONTROLS", Yellow));
lines.Add(("", White));
foreach (var (k, d) in Keybinds)
lines.Add(($" {k,-9} {d}", White));
lines.Add(("", White));
lines.Add(("Press F1 to close", Grey));
float panelW = MeasureMax(lines) + 2 * InnerPad;
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
float x = (screenSize.X - panelW) * 0.5f;
float y = (screenSize.Y - panelH) * 0.5f;
DrawPanel(x, y, lines, panelW);
}
// ──────────────────────────────────────────────────────────────────────
// Hint bar — bottom-left: always-visible "F1 for help" reminder.
// ──────────────────────────────────────────────────────────────────────
private void DrawHintBar(Vector2 screenSize)
{
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom";
float w = _font.MeasureWidth(hint);
float pad = 10f;
float y = screenSize.Y - _font.LineHeight - pad;
_text.DrawRect(pad - 4, y - 3, w + 8, _font.LineHeight + 6, PanelBg);
_text.DrawString(_font, hint, pad, y, Grey);
}
private void DrawToast(Vector2 screenSize)
{
if (_toastText is null || _toastTimeLeft <= 0f) return;
float w = _font.MeasureWidth(_toastText);
float x = (screenSize.X - w) * 0.5f;
float y = 60f;
var c = _toastColor;
float alpha = MathF.Min(1f, _toastTimeLeft / 0.5f);
c.W *= alpha;
var bg = PanelBg;
bg.W *= alpha;
_text.DrawRect(x - 10, y - 4, w + 20, _font.LineHeight + 8, bg);
_text.DrawString(_font, _toastText, x, y, c);
}
// ──────────────────────────────────────────────────────────────────────
// Panel helpers.
// ──────────────────────────────────────────────────────────────────────
private const float InnerPad = 8f;
private float MeasureMax(IReadOnlyList<(string text, Vector4 color)> lines)
{
float m = 0;
foreach (var (text, _) in lines)
m = MathF.Max(m, _font.MeasureWidth(text));
return m;
}
private void DrawPanel(float x, float y, IReadOnlyList<(string text, Vector4 color)> lines, float? widthOverride = null)
{
float maxW = MeasureMax(lines);
float panelW = widthOverride ?? (maxW + 2 * InnerPad);
float panelH = lines.Count * _font.LineHeight + 2 * InnerPad;
_text.DrawRect(x, y, panelW, panelH, PanelBg);
_text.DrawRectOutline(x, y, panelW, panelH, PanelBorder);
float cy = y + InnerPad;
foreach (var (text, color) in lines)
{
_text.DrawString(_font, text, x + InnerPad, cy, color);
cy += _font.LineHeight;
}
}
private static float NormalizeDeg(float deg)
{
deg %= 360f;
if (deg < 0) deg += 360f;
return deg;
}
private static float NormalizeDegSigned(float deg)
{
deg %= 360f;
if (deg > 180) deg -= 360f;
if (deg < -180) deg += 360f;
return deg;
}
}

File diff suppressed because it is too large Load diff

View file

@ -64,5 +64,17 @@ public sealed class Shader : IDisposable
_gl.Uniform3(loc, v.X, v.Y, v.Z);
}
public void SetVec2(string name, Vector2 v)
{
int loc = _gl.GetUniformLocation(Program, name);
_gl.Uniform2(loc, v.X, v.Y);
}
public void SetVec4(string name, Vector4 v)
{
int loc = _gl.GetUniformLocation(Program, name);
_gl.Uniform4(loc, v.X, v.Y, v.Z, v.W);
}
public void Dispose() => _gl.DeleteProgram(Program);
}

View file

@ -0,0 +1,7 @@
#version 430 core
in vec3 vColor;
out vec4 FragColor;
void main() {
FragColor = vec4(vColor, 1.0);
}

View file

@ -0,0 +1,13 @@
#version 430 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
uniform mat4 uView;
uniform mat4 uProjection;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = uProjection * uView * vec4(aPos, 1.0);
}

View file

@ -0,0 +1,18 @@
#version 430 core
in vec2 vUv;
in vec4 vColor;
out vec4 FragColor;
uniform sampler2D uTex;
uniform int uUseTexture;
void main() {
if (uUseTexture != 0) {
// Font atlas is a single-channel R8 texture; red = coverage alpha.
float coverage = texture(uTex, vUv).r;
FragColor = vec4(vColor.rgb, vColor.a * coverage);
} else {
FragColor = vColor;
}
if (FragColor.a < 0.005) discard;
}

View file

@ -0,0 +1,19 @@
#version 430 core
layout(location = 0) in vec2 aPos; // screen pixels, origin top-left
layout(location = 1) in vec2 aUv;
layout(location = 2) in vec4 aColor;
uniform vec2 uScreenSize;
out vec2 vUv;
out vec4 vColor;
void main() {
// Convert pixel coords (origin top-left, +Y down) to NDC (origin center, +Y up).
vec2 ndc = vec2(
aPos.x / uScreenSize.x * 2.0 - 1.0,
1.0 - aPos.y / uScreenSize.y * 2.0);
gl_Position = vec4(ndc, 0.0, 1.0);
vUv = aUv;
vColor = aColor;
}

View file

@ -0,0 +1,230 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Numerics;
using System.Runtime.InteropServices;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// 2D batched quad renderer for text + solid rectangles. Coordinates are in
/// screen pixels with origin top-left, +X right, +Y down. Call
/// <see cref="Begin"/> at the start of a HUD pass, queue geometry via
/// <see cref="DrawString"/> / <see cref="DrawRect"/>, then <see cref="Flush"/>.
///
/// Uses two internal vertex buffers (text and rect) flushed in two draw calls
/// to avoid a per-vertex "use texture" flag. Rects are drawn first so text
/// sits on top of background panels.
/// </summary>
public sealed unsafe class TextRenderer : IDisposable
{
private const int FloatsPerVertex = 8; // pos(2) + uv(2) + color(4)
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192);
private readonly List<float> _rectBuf = new(1024);
private int _textVerts;
private int _rectVerts;
private Vector2 _screenSize;
public TextRenderer(GL gl, string shaderDir)
{
_gl = gl;
_shader = new Shader(gl,
Path.Combine(shaderDir, "ui_text.vert"),
Path.Combine(shaderDir, "ui_text.frag"));
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
uint stride = FloatsPerVertex * sizeof(float);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, stride, (void*)(4 * sizeof(float)));
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0);
}
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
public void Begin(Vector2 screenSize)
{
_screenSize = screenSize;
_textBuf.Clear();
_rectBuf.Clear();
_textVerts = 0;
_rectVerts = 0;
}
/// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color)
{
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color);
_rectVerts += 6;
}
/// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{
// top, bottom, left, right
DrawRect(x, y, w, thickness, color);
DrawRect(x, y + h - thickness, w, thickness, color);
DrawRect(x, y, thickness, h, color);
DrawRect(x + w - thickness, y, thickness, h, color);
}
/// <summary>
/// Draw a single line of text at (x,y) where (x,y) is the top-left of the
/// typographic block. Handles '\n' as a line break.
/// </summary>
public void DrawString(BitmapFont font, string text, float x, float y, Vector4 color)
{
float cursorX = x;
// The caller provides top-y; shift to baseline for glyph offset math.
float baseline = y + font.Ascent;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (c == '\n')
{
cursorX = x;
baseline += font.LineHeight;
continue;
}
if (!font.TryGetGlyph(c, out var g))
{
// Unknown glyph — skip its advance width if '?' exists.
if (font.TryGetGlyph('?', out var q))
cursorX += q.Advance;
continue;
}
float gx = cursorX + g.OffsetX;
float gy = baseline + g.OffsetY;
float gw = g.Width;
float gh = g.Height;
if (gw > 0 && gh > 0)
{
AppendQuad(_textBuf,
gx, gy, gw, gh,
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
}
cursorX += g.Advance;
}
}
private static void AppendQuad(List<float> buf,
float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color)
{
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
// because the vertex shader flips Y, so OpenGL's default front-face
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
// (x, y) ─ (x+w, y)
// │ │
// (x, y+h) ─ (x+w, y+h)
//
// Triangle 1: (x,y) (x+w,y+h) (x+w,y)
// Triangle 2: (x,y) (x,y+h) (x+w,y+h)
void V(float px, float py, float pu, float pv)
{
buf.Add(px); buf.Add(py);
buf.Add(pu); buf.Add(pv);
buf.Add(color.X); buf.Add(color.Y); buf.Add(color.Z); buf.Add(color.W);
}
V(x, y, u0, v0);
V(x + w, y + h, u1, v1);
V(x + w, y, u1, v0);
V(x, y, u0, v0);
V(x, y + h, u0, v1);
V(x + w, y + h, u1, v1);
}
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font)
{
if (_textVerts == 0 && _rectVerts == 0) return;
_shader.Use();
_shader.SetVec2("uScreenSize", _screenSize);
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
// Save GL state.
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
_gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
// Untextured rects first — they form panel backgrounds.
if (_rectVerts > 0)
{
_shader.SetInt("uUseTexture", 0);
UploadBuffer(_rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts);
}
// Textured text glyphs.
if (_textVerts > 0 && font is not null)
{
_shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0);
UploadBuffer(_textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts);
}
// Restore GL state.
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
}
private void UploadBuffer(List<float> buf)
{
int bytes = buf.Count * sizeof(float);
if (bytes == 0) return;
if (bytes > _vboCapacityBytes)
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)bytes, p, BufferUsageARB.DynamicDraw);
_vboCapacityBytes = bytes;
}
else
{
fixed (float* p = CollectionsMarshal.AsSpan(buf))
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)bytes, p);
}
}
public void Dispose()
{
_gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao);
_shader.Dispose();
}
}