feat(rendering): on-screen debug overlay showing current biome and coordinates

Asset-free bitmap-text overlay: a 5x7 font (glyphs defined as verifiable ASCII-art) drawn as solid quads through a tiny 2D shader, white text with a drop shadow for legibility over any background. Renders 'BIOME: <name>' and 'XYZ: x, y, z' top-left, updating live. Adds Shader.SetVector2 and WorldGenerator.BiomeNameAt support; text scale and spawn-height offset read from Configuration.
This commit is contained in:
MDietermann
2026-06-03 16:27:15 +02:00
parent e034d10339
commit 689536348b
5 changed files with 206 additions and 4 deletions

View File

@@ -0,0 +1,10 @@
#version 330 core
out vec4 FragColor;
uniform vec3 u_color;
void main()
{
FragColor = vec4(u_color, 1.0);
}

View File

@@ -0,0 +1,12 @@
#version 330 core
// Positions are already in normalized device coordinates; u_offset nudges the
// whole batch by a few pixels (in NDC) for the drop-shadow pass.
layout (location = 0) in vec2 aPos;
uniform vec2 u_offset;
void main()
{
gl_Position = vec4(aPos + u_offset, 0.0, 1.0);
}

View File

@@ -24,6 +24,7 @@ public sealed class Game : GameWindow
private Shader _shader = null!;
private Texture _texture = null!;
private LoadingScreen _loading = null!;
private DebugOverlay _debugOverlay = null!;
private World.World _world = null!;
private InMemoryChunkModificationStore _modStore = null!;
private WorldGenerator _generator = null!;
@@ -51,6 +52,7 @@ public sealed class Game : GameWindow
this._shader = new Shader("Assets/shaders/chunk.vert", "Assets/shaders/chunk.frag");
this._texture = new Texture("Assets/textures/VoxelShadeAtlas.png");
this._loading = new LoadingScreen();
this._debugOverlay = new DebugOverlay();
this._modStore = new InMemoryChunkModificationStore();
this._world = new World.World(this._registry, this._modStore);
@@ -60,11 +62,11 @@ public sealed class Game : GameWindow
// Spawn at terrain surface above (0, 0).
int surfaceY = this._generator.SurfaceHeightAt(0, 0);
Vector3 spawnPosition = new(0.5f, surfaceY + 2f, 0.5f);
Vector3 spawnPosition = new(0.5f, surfaceY + this._cfg.SpawnHeightOffset, 0.5f);
this._player = new Player(this._cfg, spawnPosition, this.ClientSize.X / (float)this.ClientSize.Y);
var mesher = new ChunkMesher(this._texture.Width, this._texture.Height);
ChunkMesher mesher = new(this._texture.Width, this._texture.Height);
this._streamer = new ChunkStreamer(
this._cfg,
this._world,
@@ -145,6 +147,16 @@ public sealed class Game : GameWindow
renderer.Draw();
});
Vector3 playerPos = this._player.Position;
int px = (int)System.Math.Floor(playerPos.X);
int py = (int)System.Math.Floor(playerPos.Y);
int pz = (int)System.Math.Floor(playerPos.Z);
this._debugOverlay.Draw(new[]
{
$"BIOME: {this._generator.BiomeNameAt(px, pz)}",
$"XYZ: {px}, {py}, {pz}",
}, this.ClientSize.X, this.ClientSize.Y, this._cfg.DebugOverlayTextScale);
this.SwapBuffers();
}
@@ -159,6 +171,7 @@ public sealed class Game : GameWindow
{
Log.Information("Game shutting down");
this._streamer?.Dispose();
this._debugOverlay?.Dispose();
this._loading?.Dispose();
this._texture?.Dispose();
this._shader?.Dispose();

164
Rendering/DebugOverlay.cs Normal file
View File

@@ -0,0 +1,164 @@
using OpenTK.Graphics.OpenGL4;
using OpenTK.Mathematics;
namespace VoxelEngine.Rendering;
// Minimal, asset-free debug text. A 5x7 bitmap font is rendered as solid quads
// (one quad per lit font pixel) through a trivial 2D shader. Glyphs are defined
// as visual ASCII-art rows so they can be eyeballed for correctness. Meant for
// low-volume debug HUDs, not general text.
public sealed class DebugOverlay : IDisposable
{
private const int GlyphW = 5;
private const int GlyphH = 7;
private readonly int _vao;
private readonly int _vbo;
private readonly Shader _shader;
private readonly List<float> _verts = new();
public DebugOverlay()
{
this._shader = new Shader("Assets/shaders/debugtext.vert", "Assets/shaders/debugtext.frag");
this._vao = GL.GenVertexArray();
GL.BindVertexArray(this._vao);
this._vbo = GL.GenBuffer();
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), 0);
GL.EnableVertexAttribArray(0);
GL.BindVertexArray(0);
}
// Draws `lines` stacked top-down in the top-left corner. `scale` is pixels
// per font pixel; fbWidth/fbHeight are the current framebuffer size, used to
// map pixel coordinates to NDC. Drawn over the scene (no depth, no culling).
public void Draw(IReadOnlyList<string> lines, int fbWidth, int fbHeight, int scale = 3)
{
if (fbWidth <= 0 || fbHeight <= 0 || lines.Count == 0) return;
this._verts.Clear();
const int marginX = 10;
const int marginY = 10;
int lineStride = (GlyphH + 2) * scale;
for (int i = 0; i < lines.Count; i++)
this.AddString(lines[i], marginX, marginY + i * lineStride, scale, fbWidth, fbHeight);
if (this._verts.Count == 0) return;
GL.BindVertexArray(this._vao);
GL.BindBuffer(BufferTarget.ArrayBuffer, this._vbo);
GL.BufferData(BufferTarget.ArrayBuffer, this._verts.Count * sizeof(float),
this._verts.ToArray(), BufferUsageHint.DynamicDraw);
bool depthWasOn = GL.IsEnabled(EnableCap.DepthTest);
bool cullWasOn = GL.IsEnabled(EnableCap.CullFace);
GL.Disable(EnableCap.DepthTest);
GL.Disable(EnableCap.CullFace);
this._shader.Use();
int vertexCount = this._verts.Count / 2;
// Drop shadow (offset down-right by ~2px) then white text, so it stays
// legible over sky, grass or stone alike.
this._shader.SetVector2("u_offset", new Vector2(4f / fbWidth, -4f / fbHeight));
this._shader.SetVector3("u_color", new Vector3(0f, 0f, 0f));
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
this._shader.SetVector2("u_offset", Vector2.Zero);
this._shader.SetVector3("u_color", new Vector3(1f, 1f, 1f));
GL.DrawArrays(PrimitiveType.Triangles, 0, vertexCount);
GL.BindVertexArray(0);
if (depthWasOn) GL.Enable(EnableCap.DepthTest);
if (cullWasOn) GL.Enable(EnableCap.CullFace);
}
private void AddString(string text, int pxX, int pxY, int scale, int fbWidth, int fbHeight)
{
int cursorX = pxX;
foreach (char ch in text)
{
if (DebugOverlay.Glyphs.TryGetValue(char.ToUpperInvariant(ch), out string[]? rows))
{
for (int r = 0; r < GlyphH; r++)
for (int c = 0; c < GlyphW; c++)
{
if (rows[r][c] != ' ')
this.AddQuad(cursorX + c * scale, pxY + r * scale, scale, fbWidth, fbHeight);
}
}
cursorX += (GlyphW + 1) * scale; // one font-pixel gap between glyphs
}
}
// Append two triangles for the pixel-space square at (px,py) of side `s`.
// Pixel space is top-left origin, y down; converted to NDC here.
private void AddQuad(int px, int py, int s, int fbWidth, int fbHeight)
{
float x0 = px / (float)fbWidth * 2f - 1f;
float x1 = (px + s) / (float)fbWidth * 2f - 1f;
float y0 = 1f - py / (float)fbHeight * 2f;
float y1 = 1f - (py + s) / (float)fbHeight * 2f;
this._verts.AddRange(new[]
{
x0, y0, x1, y0, x1, y1,
x0, y0, x1, y1, x0, y1,
});
}
public void Dispose()
{
GL.DeleteBuffer(this._vbo);
GL.DeleteVertexArray(this._vao);
this._shader.Dispose();
}
// 5x7 font. Each glyph is 7 rows of 5 columns; non-space = lit pixel.
private static readonly Dictionary<char, string[]> Glyphs = new()
{
['A'] = new[] { " ### ", "# #", "# #", "#####", "# #", "# #", "# #" },
['B'] = new[] { "#### ", "# #", "# #", "#### ", "# #", "# #", "#### " },
['C'] = new[] { " ####", "# ", "# ", "# ", "# ", "# ", " ####" },
['D'] = new[] { "#### ", "# #", "# #", "# #", "# #", "# #", "#### " },
['E'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "#####" },
['F'] = new[] { "#####", "# ", "# ", "#### ", "# ", "# ", "# " },
['G'] = new[] { " ####", "# ", "# ", "# ##", "# #", "# #", " ####" },
['H'] = new[] { "# #", "# #", "# #", "#####", "# #", "# #", "# #" },
['I'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", "#####" },
['J'] = new[] { "#####", " # ", " # ", " # ", "# # ", "# # ", " ## " },
['K'] = new[] { "# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #" },
['L'] = new[] { "# ", "# ", "# ", "# ", "# ", "# ", "#####" },
['M'] = new[] { "# #", "## ##", "# # #", "# #", "# #", "# #", "# #" },
['N'] = new[] { "# #", "## #", "# # #", "# ##", "# #", "# #", "# #" },
['O'] = new[] { " ### ", "# #", "# #", "# #", "# #", "# #", " ### " },
['P'] = new[] { "#### ", "# #", "# #", "#### ", "# ", "# ", "# " },
['Q'] = new[] { " ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #" },
['R'] = new[] { "#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #" },
['S'] = new[] { " ####", "# ", "# ", " ### ", " #", " #", "#### " },
['T'] = new[] { "#####", " # ", " # ", " # ", " # ", " # ", " # " },
['U'] = new[] { "# #", "# #", "# #", "# #", "# #", "# #", " ### " },
['V'] = new[] { "# #", "# #", "# #", "# #", "# #", " # # ", " # " },
['W'] = new[] { "# #", "# #", "# #", "# # #", "# # #", "## ##", "# #" },
['X'] = new[] { "# #", "# #", " # # ", " # ", " # # ", "# #", "# #" },
['Y'] = new[] { "# #", "# #", " # # ", " # ", " # ", " # ", " # " },
['Z'] = new[] { "#####", " #", " # ", " # ", " # ", "# ", "#####" },
['0'] = new[] { " ### ", "# #", "# ##", "# # #", "## #", "# #", " ### " },
['1'] = new[] { " # ", " ## ", " # ", " # ", " # ", " # ", "#####" },
['2'] = new[] { " ### ", "# #", " #", " # ", " # ", " # ", "#####" },
['3'] = new[] { "#####", " # ", " # ", " # ", " #", "# #", " ### " },
['4'] = new[] { " # ", " ## ", " # # ", "# # ", "#####", " # ", " # " },
['5'] = new[] { "#####", "# ", "#### ", " #", " #", "# #", " ### " },
['6'] = new[] { " ### ", "# ", "# ", "#### ", "# #", "# #", " ### " },
['7'] = new[] { "#####", " #", " # ", " # ", " # ", " # ", " # " },
['8'] = new[] { " ### ", "# #", "# #", " ### ", "# #", "# #", " ### " },
['9'] = new[] { " ### ", "# #", "# #", " ####", " #", " #", " ### " },
[' '] = new[] { " ", " ", " ", " ", " ", " ", " " },
[':'] = new[] { " ", " # ", " # ", " ", " # ", " # ", " " },
[','] = new[] { " ", " ", " ", " ", " # ", " # ", " # " },
['-'] = new[] { " ", " ", " ", "#####", " ", " ", " " },
['.'] = new[] { " ", " ", " ", " ", " ", " ## ", " ## " },
['/'] = new[] { " #", " #", " # ", " # ", " # ", "# ", "# " },
};
}

View File

@@ -10,8 +10,8 @@ public sealed class Shader : IDisposable
public Shader(string vertexPath, string fragmentPath)
{
int vertex = Shader.CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = Shader.CompileFromFile(ShaderType.FragmentShader, fragmentPath);
int vertex = CompileFromFile(ShaderType.VertexShader, vertexPath);
int fragment = CompileFromFile(ShaderType.FragmentShader, fragmentPath);
this._handle = GL.CreateProgram();
GL.AttachShader(this._handle, vertex);
@@ -42,6 +42,9 @@ public sealed class Shader : IDisposable
public void SetVector3(string name, Vector3 value) =>
GL.Uniform3(this.Location(name), value.X, value.Y, value.Z);
public void SetVector2(string name, Vector2 value) =>
GL.Uniform2(this.Location(name), value.X, value.Y);
private int Location(string name)
{
if (this._uniforms.TryGetValue(name, out int location)) return location;