Add MrGameEng.Tilemaps: code-built tile grids rendered through the batcher
CI / build-test (push) Failing after 1m4s

TileSet maps ids to texture regions with tints (0 = empty), TileGrid is a
bounds-checked dense ushort grid, and the Tilemap component places a grid
in the world per render layer. UseTilemaps() inserts TilemapRenderSystem
before the flush; it submits only the camera-visible cell range
(TilemapMath.VisibleCells), so frame cost scales with the screen, not the
grid. Demonstrated in the sample as a checkerboard floor; Tiled loading
stays on the roadmap on top of this API.
This commit is contained in:
Leonid Pershin
2026-06-11 10:24:47 +03:00
parent b318d1e795
commit b3415120c3
15 changed files with 548 additions and 6 deletions
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Tilemaps.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>Wires the tilemaps module into a <see cref="Scene"/>.</summary>
public static class SceneTilemapExtensions
{
/// <summary>
/// Adds <see cref="TilemapRenderSystem"/> to the scene's draw phase, right before the
/// renderer flush. Call from <c>OnLoad</c> after <c>UseRenderer2D()</c>.
/// </summary>
public static void UseTilemaps(this Scene scene)
{
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first.");
var systems = scene.DrawSystems.ChildSystems;
for (var i = 0; i < systems.Count; i++)
{
if (systems[i] is RenderFlushSystem)
{
scene.DrawSystems.Insert(i, new TilemapRenderSystem(renderer));
return;
}
}
throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?).");
}
}
+59
View File
@@ -0,0 +1,59 @@
namespace MrGameEng.Tilemaps;
/// <summary>
/// Dense rectangular grid of tile ids (see <see cref="TileSet"/>; 0 = empty).
/// Plain data with bounds-checked access — fill it from worldgen code, mutate at runtime.
/// </summary>
public sealed class TileGrid
{
private readonly ushort[] _cells;
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>Creates a grid filled with the empty tile.</summary>
public TileGrid(int width, int height)
{
ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
Width = width;
Height = height;
_cells = new ushort[width * height];
}
/// <summary>Tile id at the given cell.</summary>
public ushort this[int x, int y]
{
get
{
CheckBounds(x, y);
return _cells[y * Width + x];
}
set
{
CheckBounds(x, y);
_cells[y * Width + x] = value;
}
}
/// <summary>True when the cell lies inside the grid.</summary>
public bool Contains(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
/// <summary>Sets every cell to <paramref name="id"/>.</summary>
public void Fill(ushort id) => Array.Fill(_cells, id);
/// <summary>Unchecked read used by the render system after range clamping.</summary>
internal ushort UnsafeGet(int x, int y) => _cells[y * Width + x];
private void CheckBounds(int x, int y)
{
if (!Contains(x, y))
{
throw new ArgumentOutOfRangeException(
nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid.");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>One tile kind: a texture region plus a tint multiplier.</summary>
/// <param name="Region">Texture region the tile is drawn with.</param>
/// <param name="Color">Tint, multiplied with the texture. White = unmodified.</param>
public readonly record struct TileDef(Texture2DRegion Region, Color Color)
{
/// <summary>Creates an untinted tile.</summary>
public TileDef(Texture2DRegion region)
: this(region, Color.White)
{
}
}
/// <summary>
/// Maps tile ids to <see cref="TileDef"/>s. Built in code: every <see cref="Add"/> returns
/// the id to store in a <see cref="TileGrid"/>. Id 0 is reserved for "empty" (nothing drawn).
/// </summary>
public sealed class TileSet
{
private readonly List<TileDef> _tiles = [default];
/// <summary>Number of defined tiles including the reserved empty tile 0.</summary>
public int Count => _tiles.Count;
/// <summary>The tile definition for <paramref name="id"/>. Id 0 has no region.</summary>
public TileDef this[int id] => _tiles[id];
/// <summary>Defines a tile and returns its id (1, 2, …).</summary>
public ushort Add(Texture2DRegion region, Color? color = null)
{
ArgumentNullException.ThrowIfNull(region);
if (_tiles.Count > ushort.MaxValue)
{
throw new InvalidOperationException("A TileSet holds at most 65535 tiles.");
}
_tiles.Add(new TileDef(region, color ?? Color.White));
return (ushort)(_tiles.Count - 1);
}
}
+47
View File
@@ -0,0 +1,47 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>
/// Tilemap component: a <see cref="TileGrid"/> of ids drawn with a <see cref="TileSet"/>.
/// Cell (0,0) sits at <see cref="Origin"/>, cells are <see cref="TileSize"/> world units square.
/// Only the cells visible through the camera are submitted each frame, so grids can be large.
/// Create via the constructor — the struct default has no grid and draws nothing.
/// </summary>
public struct Tilemap : IComponent
{
/// <summary>Tile definitions; ids in the grid index into it.</summary>
public TileSet? TileSet;
/// <summary>The cells. Mutating ids takes effect next frame.</summary>
public TileGrid? Grid;
/// <summary>World position of the top-left corner of cell (0,0).</summary>
public Vector2 Origin;
/// <summary>Cell size in world units.</summary>
public float TileSize;
/// <summary>Render layer of the whole map.</summary>
public LayerId Layer;
/// <summary>Draw order within the layer (smaller = drawn first / behind).</summary>
public float Depth;
/// <summary>Tint multiplied into every tile on top of its <see cref="TileDef.Color"/>.</summary>
public Color Color;
/// <summary>Creates a tilemap at world origin.</summary>
public Tilemap(TileGrid grid, TileSet tileSet, float tileSize, LayerId layer = default)
{
Grid = grid;
TileSet = tileSet;
TileSize = tileSize;
Layer = layer;
Origin = Vector2.Zero;
Depth = 0f;
Color = Color.White;
}
}
+23
View File
@@ -0,0 +1,23 @@
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>Pure cell-range math for tilemap rendering. Y axis points down.</summary>
public static class TilemapMath
{
/// <summary>
/// Computes the inclusive cell range of a grid that intersects <paramref name="cullRect"/>.
/// Returns false when the map is entirely outside the rectangle.
/// </summary>
public static bool VisibleCells(
in RectF cullRect, Vector2 origin, float tileSize, int width, int height,
out int x0, out int y0, out int x1, out int y1)
{
x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize));
y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize));
x1 = Math.Min(width - 1, (int)MathF.Floor((cullRect.Right - origin.X) / tileSize));
y1 = Math.Min(height - 1, (int)MathF.Floor((cullRect.Bottom - origin.Y) / tileSize));
return x0 <= x1 && y0 <= y1;
}
}
@@ -0,0 +1,77 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
namespace MrGameEng.Tilemaps;
/// <summary>
/// Submits the camera-visible cells of every <see cref="Tilemap"/> entity to the renderer.
/// Cells outside the camera are never touched, so per-frame cost scales with the screen,
/// not with the grid. Tiles batch with sprites by the usual layer → depth → texture order.
/// </summary>
public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
{
private readonly Renderer2D _renderer;
/// <summary>Creates the system for <paramref name="renderer"/>.</summary>
public TilemapRenderSystem(Renderer2D renderer) => _renderer = renderer;
/// <inheritdoc />
protected override void OnUpdate()
{
var cullRect = _renderer.Camera.CullRect;
foreach (var (maps, _) in Query.Chunks)
{
foreach (ref readonly var map in maps.Span)
{
if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f)
{
continue;
}
var screenSpace = _renderer.Layers[map.Layer].Space == LayerSpace.Screen;
if (screenSpace)
{
// Screen-space слои не куллятся камерой — рисуем весь грид.
SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1);
}
else if (TilemapMath.VisibleCells(
in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height,
out var x0, out var y0, out var x1, out var y1))
{
SubmitRange(in map, grid, tileSet, x0, y0, x1, y1);
}
}
}
}
private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1)
{
for (var y = y0; y <= y1; y++)
{
for (var x = x0; x <= x1; x++)
{
var id = grid.UnsafeGet(x, y);
if (id == 0)
{
continue;
}
var def = tileSet[id];
var region = def.Region;
var transform = new Transform2D(
map.Origin + new Vector2(x, y) * map.TileSize,
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height));
var sprite = new Sprite(region, map.Layer)
{
Color = map.Color == Color.White
? def.Color
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
Depth = map.Depth,
};
_renderer.Submit(in transform, in sprite);
}
}
}
}