| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099 |
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using Microsoft.Xna.Framework;
- using Microsoft.Xna.Framework.Graphics;
- namespace MonoGame.Extended.Tilemaps.Rendering;
- /// <summary>
- /// High-performance tilemap renderer using GraphicsDevice directly.
- /// </summary>
- /// <remarks>
- /// <para>
- /// This renderer provides optimal performance by rendering tiles using vertex and index buffers.
- /// Supports layer grouping to merge multiple layers into a single draw call, significantly
- /// reducing draw call overhead for maps with many layers.
- /// </para>
- /// <para>
- /// Layer groups can be defined dynamically and updated at runtime. Groups are rebuilt
- /// automatically when modified. For best performance with static maps, define groups
- /// once during initialization.
- /// </para>
- /// <para>
- /// Use <see cref="TilemapSpriteBatchRenderer"/> for simpler integration with SpriteBatch-based
- /// code at the cost of some performance.
- /// </para>
- /// </remarks>
- public sealed class TilemapRenderer : IDisposable
- {
- private readonly GraphicsDevice _graphicsDevice;
- private BasicEffect _effect;
- private Tilemap _tilemap;
- private RenderMode _defaultRenderMode;
- private bool _isDisposed;
- // Multi-tilemap world support (for LDtk GridVania layouts)
- private readonly List<WorldTilemap> _worldTilemaps;
- private bool _isWorldMode;
- // State management for SpriteBatch mixing
- private BlendState _savedBlendState;
- private SamplerState _savedSamplerState;
- private RasterizerState _savedRasterizerState;
- private DepthStencilState _savedDepthStencilState;
- // Layer groups and models
- private readonly Dictionary<string, LayerGroup> _layerGroups;
- private readonly Dictionary<TilemapLayer, string> _layerToGroup;
- private readonly List<LayerModel> _layerModels;
- // Drawing state
- private bool _isDrawing;
- private Matrix _viewMatrix;
- private Matrix _projectionMatrix;
- /// <summary>
- /// Initializes a new instance of the <see cref="TilemapRenderer"/> class.
- /// </summary>
- /// <param name="graphicsDevice">The graphics device.</param>
- /// <exception cref="ArgumentNullException">Thrown if graphicsDevice is null.</exception>
- public TilemapRenderer(GraphicsDevice graphicsDevice)
- {
- _graphicsDevice = graphicsDevice ?? throw new ArgumentNullException(nameof(graphicsDevice));
- _effect = new BasicEffect(_graphicsDevice)
- {
- TextureEnabled = true,
- VertexColorEnabled = false
- };
- _layerGroups = new Dictionary<string, LayerGroup>();
- _layerToGroup = new Dictionary<TilemapLayer, string>();
- _layerModels = new List<LayerModel>();
- _worldTilemaps = new List<WorldTilemap>();
- // Default to Merged mode (will be confirmed by benchmarks in Week 3)
- _defaultRenderMode = RenderMode.Merged;
- }
- /// <summary>
- /// Gets the current default rendering mode.
- /// </summary>
- public RenderMode DefaultRenderMode
- {
- get
- {
- ThrowIfDisposed();
- return _defaultRenderMode;
- }
- }
- /// <summary>
- /// Gets the names of all defined layer groups.
- /// </summary>
- public IReadOnlyList<string> LayerGroups => new List<string>(_layerGroups.Keys);
- /// <summary>
- /// Loads a tilemap for rendering.
- /// </summary>
- /// <param name="tilemap">The tilemap to load.</param>
- /// <exception cref="ArgumentNullException">Thrown if tilemap is null.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void LoadTilemap(Tilemap tilemap)
- {
- ThrowIfDisposed();
- if (tilemap == null)
- throw new ArgumentNullException(nameof(tilemap));
- // Unload previous tilemap if any
- UnloadTilemap();
- _tilemap = tilemap;
- _isWorldMode = false;
- // Build vertex/index buffers for all tile layers
- BuildLayerModels();
- }
- /// <summary>
- /// Loads multiple tilemaps as a seamless world (for LDtk GridVania layouts).
- /// </summary>
- /// <param name="tilemaps">The collection of tilemaps with world position data.</param>
- /// <remarks>
- /// <para>
- /// This method is designed for LDtk GridVania worlds where each level has world coordinates
- /// stored in properties (LDtk_WorldX, LDtk_WorldY, LDtk_WorldDepth).
- /// </para>
- /// <para>
- /// Use <see cref="DrawWorld(OrthographicCamera, int)"/> to render specific WorldDepth layers.
- /// The game controls which depth is visible based on gameplay (e.g., WorldDepth 0 for overworld,
- /// WorldDepth 1 for building interiors).
- /// </para>
- /// <para>
- /// World coordinates are read from tilemap properties. If properties are not found, the
- /// tilemap is placed at (0,0) with depth 0.
- /// </para>
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if tilemaps is null.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void LoadWorld(IEnumerable<Tilemap> tilemaps)
- {
- ThrowIfDisposed();
- if (tilemaps == null)
- throw new ArgumentNullException(nameof(tilemaps));
- // Unload any previous data
- UnloadTilemap();
- _isWorldMode = true;
- // Build layer models for each tilemap with world position
- foreach (Tilemap tilemap in tilemaps)
- {
- WorldTilemap worldTilemap = new WorldTilemap
- {
- Tilemap = tilemap,
- WorldPosition = GetWorldPosition(tilemap),
- WorldDepth = GetWorldDepth(tilemap)
- };
- // Build models for this tilemap's layers
- foreach (TilemapLayer layer in tilemap.Layers)
- {
- if (layer is TilemapTileLayer tileLayer)
- {
- LayerModel model = BuildLayerModelForWorld(tileLayer, tilemap, worldTilemap.WorldPosition);
- if (model != null)
- {
- worldTilemap.LayerModels.Add(model);
- }
- }
- }
- _worldTilemaps.Add(worldTilemap);
- }
- }
- /// <summary>
- /// Gets the world position for a tilemap from its properties.
- /// </summary>
- /// <param name="tilemap">The tilemap to get world position from.</param>
- /// <returns>The world position in pixels, or (0,0) if not found.</returns>
- private Vector2 GetWorldPosition(Tilemap tilemap)
- {
- Vector2 position = Vector2.Zero;
- if (tilemap.Properties.TryGetValue("LDtk_WorldX", out TilemapPropertyValue worldX))
- {
- position.X = worldX.AsInt();
- }
- if (tilemap.Properties.TryGetValue("LDtk_WorldY", out TilemapPropertyValue worldY))
- {
- position.Y = worldY.AsInt();
- }
- return position;
- }
- /// <summary>
- /// Gets the world depth for a tilemap from its properties.
- /// </summary>
- /// <param name="tilemap">The tilemap to get world depth from.</param>
- /// <returns>The world depth (z-order), or 0 if not found.</returns>
- private int GetWorldDepth(Tilemap tilemap)
- {
- if (tilemap.Properties.TryGetValue("LDtk_WorldDepth", out TilemapPropertyValue worldDepth))
- {
- return worldDepth.AsInt();
- }
- return 0; // Default depth
- }
- /// <summary>
- /// Builds a layer model with world position offset applied.
- /// </summary>
- /// <param name="tileLayer">The tile layer to build buffers for.</param>
- /// <param name="tilemap">The parent tilemap containing this layer.</param>
- /// <param name="worldPosition">The world position offset for this tilemap.</param>
- /// <returns>A LayerModel containing the vertex/index buffers, or null if the layer is empty.</returns>
- private LayerModel BuildLayerModelForWorld(TilemapTileLayer tileLayer, Tilemap tilemap, Vector2 worldPosition)
- {
- List<VertexPositionTexture> vertices = new List<VertexPositionTexture>();
- List<ushort> indices = new List<ushort>();
- Texture2D currentTexture = null;
- foreach ((int x, int y, TilemapTile tile) in tileLayer.GetTiles())
- {
- // Check if tileset exists for this tile's global ID
- TilemapTileset tileset = tilemap.Tilesets.GetTilesetForGid(tile.GlobalId);
- if (tileset == null)
- continue; // Skip tiles with no tileset (can happen in world mode)
- int localId = tile.GlobalId - tileset.FirstGlobalId;
- if (currentTexture == null)
- currentTexture = tileset.Texture;
- Rectangle sourceRect = tileset.GetTileRegion(localId);
- Point tilePos = tilemap.TileToWorldPosition(x, y);
- // Apply world position offset + layer offset + tileset offset
- Vector2 position = new Vector2(tilePos.X, tilePos.Y) + worldPosition + tileLayer.Offset + tileset.TileOffset;
- AddTileQuad(vertices, indices, position, tileLayer.TileWidth, tileLayer.TileHeight,
- sourceRect, tile.FlipFlags, tileset.Texture);
- }
- if (vertices.Count == 0)
- return null;
- return CreateLayerModel(vertices.ToArray(), indices.ToArray(), currentTexture);
- }
- /// <summary>
- /// Builds vertex and index buffers for all tile layers in the tilemap.
- /// </summary>
- private void BuildLayerModels()
- {
- foreach (TilemapLayer layer in _tilemap.Layers)
- {
- // Only build models for tile layers (not object/image/group layers)
- if (layer is TilemapTileLayer tileLayer)
- {
- LayerModel model = BuildLayerModel(tileLayer);
- if (model != null)
- {
- _layerModels.Add(model);
- }
- }
- }
- }
- /// <summary>
- /// Builds a vertex and index buffer for a single tile layer.
- /// </summary>
- /// <param name="tileLayer">The tile layer to build buffers for.</param>
- /// <returns>A LayerModel containing the vertex/index buffers, or null if the layer is empty.</returns>
- private LayerModel BuildLayerModel(TilemapTileLayer tileLayer)
- {
- // Collect vertices and indices for all tiles in this layer
- List<VertexPositionTexture> vertices = new List<VertexPositionTexture>();
- List<ushort> indices = new List<ushort>();
- // Group tiles by texture to minimize state changes
- // For now, we'll build a single model per layer and assume single texture
- // Multi-texture support will be added in Week 2 with layer groups
- Texture2D currentTexture = null;
- foreach ((int x, int y, TilemapTile tile) in tileLayer.GetTiles())
- {
- // Get tileset and local ID for this tile
- int localId = tile.GetLocalId(_tilemap.Tilesets, out TilemapTileset tileset);
- if (tileset == null)
- continue; // Skip tiles with no tileset
- // Track the texture (for now we assume all tiles in a layer use the same texture)
- if (currentTexture == null)
- currentTexture = tileset.Texture;
- // Get source rectangle from tileset
- Rectangle sourceRect = tileset.GetTileRegion(localId);
- // Calculate world position for this tile
- Point worldPos = _tilemap.TileToWorldPosition(x, y);
- // Apply layer offset
- Vector2 position = new Vector2(worldPos.X, worldPos.Y) + tileLayer.Offset;
- // Apply tileset tile offset
- position += tileset.TileOffset;
- // Add the tile quad (4 vertices, 6 indices)
- AddTileQuad(vertices, indices, position, tileLayer.TileWidth, tileLayer.TileHeight,
- sourceRect, tile.FlipFlags, tileset.Texture);
- }
- // If no tiles were added, return null
- if (vertices.Count == 0)
- return null;
- // Create GPU buffers
- return CreateLayerModel(vertices.ToArray(), indices.ToArray(), currentTexture);
- }
- /// <summary>
- /// Adds a tile quad to the vertex and index lists.
- /// </summary>
- /// <param name="vertices">The vertex list to append to.</param>
- /// <param name="indices">The index list to append to.</param>
- /// <param name="position">The world position of the tile's top-left corner.</param>
- /// <param name="width">The width of the tile in pixels.</param>
- /// <param name="height">The height of the tile in pixels.</param>
- /// <param name="sourceRect">The source rectangle in the texture.</param>
- /// <param name="flipFlags">The flip transformation flags.</param>
- /// <param name="texture">The texture to sample UVs from.</param>
- private void AddTileQuad(List<VertexPositionTexture> vertices, List<ushort> indices,
- Vector2 position, int width, int height,
- Rectangle sourceRect, TilemapTileFlipFlags flipFlags, Texture2D texture)
- {
- // Calculate the four corners of the quad
- Vector3 topLeft = new Vector3(position.X, position.Y, 0);
- Vector3 topRight = new Vector3(position.X + width, position.Y, 0);
- Vector3 bottomLeft = new Vector3(position.X, position.Y + height, 0);
- Vector3 bottomRight = new Vector3(position.X + width, position.Y + height, 0);
- // Calculate texture coordinates with flip flags
- Vector2[] uvs = CalculateTextureCoordinates(sourceRect, flipFlags, texture);
- // Add vertices (top-left, top-right, bottom-left, bottom-right)
- ushort vertexOffset = (ushort)vertices.Count;
- vertices.Add(new VertexPositionTexture(topLeft, uvs[0]));
- vertices.Add(new VertexPositionTexture(topRight, uvs[1]));
- vertices.Add(new VertexPositionTexture(bottomLeft, uvs[2]));
- vertices.Add(new VertexPositionTexture(bottomRight, uvs[3]));
- // Add indices for two triangles (counter-clockwise winding)
- // Triangle 1: top-left, top-right, bottom-left
- indices.Add(vertexOffset);
- indices.Add((ushort)(vertexOffset + 1));
- indices.Add((ushort)(vertexOffset + 2));
- // Triangle 2: top-right, bottom-right, bottom-left
- indices.Add((ushort)(vertexOffset + 1));
- indices.Add((ushort)(vertexOffset + 3));
- indices.Add((ushort)(vertexOffset + 2));
- }
- /// <summary>
- /// Calculates texture coordinates for a tile quad, applying flip transformations.
- /// </summary>
- /// <param name="sourceRect">The source rectangle in the texture.</param>
- /// <param name="flipFlags">The flip transformation flags.</param>
- /// <param name="texture">The texture to calculate UVs from.</param>
- /// <returns>An array of 4 UV coordinates: [top-left, top-right, bottom-left, bottom-right].</returns>
- /// <remarks>
- /// UV coordinates are inset by 0.5 texels to prevent texture bleeding and visible seams
- /// between tiles. This is a common technique to avoid sampling pixels outside the intended
- /// tile boundary when texture filtering is applied.
- /// </remarks>
- private Vector2[] CalculateTextureCoordinates(Rectangle sourceRect, TilemapTileFlipFlags flipFlags, Texture2D texture)
- {
- // Calculate texel size for UV inset
- float texelWidth = 1.0f / texture.Width;
- float texelHeight = 1.0f / texture.Height;
- // Inset by 0.5 texels to prevent sampling outside tile boundaries
- // This eliminates visible seams between tiles
- float insetU = texelWidth * 0.5f;
- float insetV = texelHeight * 0.5f;
- // Normalize coordinates to 0-1 range with inset applied
- float left = (sourceRect.Left + 0.5f) / texture.Width;
- float right = (sourceRect.Right - 0.5f) / texture.Width;
- float top = (sourceRect.Top + 0.5f) / texture.Height;
- float bottom = (sourceRect.Bottom - 0.5f) / texture.Height;
- // Apply horizontal flip
- if ((flipFlags & TilemapTileFlipFlags.FlipHorizontally) != 0)
- {
- (left, right) = (right, left);
- }
- // Apply vertical flip
- if ((flipFlags & TilemapTileFlipFlags.FlipVertically) != 0)
- {
- (top, bottom) = (bottom, top);
- }
- // Apply diagonal flip (90° rotation + horizontal flip)
- // This is the tricky one: it swaps axes and requires rearranging UVs
- if ((flipFlags & TilemapTileFlipFlags.FlipDiagonally) != 0)
- {
- // Diagonal flip means: rotate 90° clockwise, then flip horizontally
- // This results in swapping x and y coordinates
- // UV mapping becomes: [bottom-left, top-left, bottom-right, top-right]
- return new Vector2[]
- {
- new Vector2(left, bottom), // top-left → bottom-left
- new Vector2(left, top), // top-right → top-left
- new Vector2(right, bottom), // bottom-left → bottom-right
- new Vector2(right, top) // bottom-right → top-right
- };
- }
- // Standard UV mapping: [top-left, top-right, bottom-left, bottom-right]
- return new Vector2[]
- {
- new Vector2(left, top),
- new Vector2(right, top),
- new Vector2(left, bottom),
- new Vector2(right, bottom)
- };
- }
- /// <summary>
- /// Creates GPU vertex and index buffers from vertex/index arrays.
- /// </summary>
- /// <param name="vertices">The vertex array.</param>
- /// <param name="indices">The index array.</param>
- /// <param name="texture">The texture for this layer.</param>
- /// <returns>A LayerModel containing the GPU resources.</returns>
- private LayerModel CreateLayerModel(VertexPositionTexture[] vertices, ushort[] indices, Texture2D texture)
- {
- // Create vertex buffer
- VertexBuffer vertexBuffer = new VertexBuffer(
- _graphicsDevice,
- typeof(VertexPositionTexture),
- vertices.Length,
- BufferUsage.WriteOnly);
- vertexBuffer.SetData(vertices);
- // Create index buffer
- IndexBuffer indexBuffer = new IndexBuffer(
- _graphicsDevice,
- IndexElementSize.SixteenBits,
- indices.Length,
- BufferUsage.WriteOnly);
- indexBuffer.SetData(indices);
- return new LayerModel
- {
- VertexBuffer = vertexBuffer,
- IndexBuffer = indexBuffer,
- Texture = texture,
- PrimitiveCount = indices.Length / 3
- };
- }
- /// <summary>
- /// Unloads the current tilemap and disposes all GPU resources.
- /// </summary>
- public void UnloadTilemap()
- {
- ThrowIfDisposed();
- // Dispose all layer models
- foreach (LayerModel model in _layerModels)
- {
- model.Dispose();
- }
- _layerModels.Clear();
- // Clear groups
- _layerGroups.Clear();
- _layerToGroup.Clear();
- _tilemap = null;
- }
- /// <summary>
- /// Sets the default rendering mode for ungrouped layers.
- /// </summary>
- /// <param name="mode">The render mode.</param>
- /// <remarks>
- /// <para>
- /// This affects the behavior of <see cref="Draw(OrthographicCamera)"/> for layers that are not
- /// in any group.
- /// </para>
- /// <para>
- /// <see cref="RenderMode.Merged"/>: All ungrouped layers are merged into a single
- /// draw call (default).
- /// </para>
- /// <para>
- /// <see cref="RenderMode.Separate"/>: Each ungrouped layer is drawn individually.
- /// </para>
- /// </remarks>
- public void SetDefaultRenderMode(RenderMode mode)
- {
- ThrowIfDisposed();
- _defaultRenderMode = mode;
- }
- /// <summary>
- /// Defines a layer group for merged rendering.
- /// </summary>
- /// <param name="groupName">The name of the group.</param>
- /// <param name="layerNames">The names of the layers to include in the group.</param>
- /// <remarks>
- /// <para>
- /// Layers in a group are merged into a single draw call for optimal performance.
- /// Layers are rendered in the order specified, back to front.
- /// </para>
- /// <para>
- /// Groups can be redefined at any time. The merged vertex/index buffers are rebuilt
- /// when the group is next drawn.
- /// </para>
- /// <para>
- /// Each layer can only belong to one group. If a layer is already in another group,
- /// it will be removed from the previous group and added to the new one.
- /// </para>
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if groupName or layerNames is null.</exception>
- /// <exception cref="ArgumentException">Thrown if any layer name is not found in the tilemap.</exception>
- /// <exception cref="InvalidOperationException">Thrown if no tilemap is loaded.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DefineLayerGroup(string groupName, params string[] layerNames)
- {
- ThrowIfDisposed();
- ThrowIfNoTilemap();
- if (groupName == null)
- throw new ArgumentNullException(nameof(groupName));
- if (layerNames == null)
- throw new ArgumentNullException(nameof(layerNames));
- // Will be fully implemented in Week 2
- // For now, just validate layer names exist
- List<TilemapLayer> layers = new List<TilemapLayer>();
- foreach (string layerName in layerNames)
- {
- TilemapLayer layer = _tilemap.Layers[layerName];
- if (layer == null)
- throw new ArgumentException($"Layer '{layerName}' not found in tilemap.", nameof(layerNames));
- layers.Add(layer);
- }
- }
- /// <summary>
- /// Defines a layer group using layer indices.
- /// </summary>
- /// <param name="groupName">The name of the group.</param>
- /// <param name="startIndex">The index of the first layer.</param>
- /// <param name="count">The number of layers to include.</param>
- /// <remarks>
- /// Each layer can only belong to one group. If a layer is already in another group,
- /// it will be removed from the previous group and added to the new one.
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if groupName is null.</exception>
- /// <exception cref="ArgumentOutOfRangeException">Thrown if layer indices are out of range.</exception>
- /// <exception cref="InvalidOperationException">Thrown if no tilemap is loaded.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DefineLayerGroup(string groupName, int startIndex, int count)
- {
- ThrowIfDisposed();
- ThrowIfNoTilemap();
- if (groupName == null)
- throw new ArgumentNullException(nameof(groupName));
- if (startIndex < 0 || startIndex >= _tilemap.Layers.Count)
- throw new ArgumentOutOfRangeException(nameof(startIndex));
- if (count <= 0 || startIndex + count > _tilemap.Layers.Count)
- throw new ArgumentOutOfRangeException(nameof(count));
- // Will be fully implemented in Week 2
- }
- /// <summary>
- /// Removes a layer group.
- /// </summary>
- /// <param name="groupName">The name of the group to remove.</param>
- /// <remarks>
- /// Layers in the removed group become ungrouped and will be drawn individually
- /// if <see cref="Draw(OrthographicCamera)"/> is called.
- /// </remarks>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void RemoveLayerGroup(string groupName)
- {
- ThrowIfDisposed();
- if (_layerGroups.ContainsKey(groupName))
- {
- LayerGroup group = _layerGroups[groupName];
- // Remove layer→group mappings
- foreach (TilemapLayer layer in group.Layers)
- {
- _layerToGroup.Remove(layer);
- }
- // Dispose group's merged model
- group.MergedModel?.Dispose();
- _layerGroups.Remove(groupName);
- }
- }
- /// <summary>
- /// Gets whether a layer group is defined.
- /// </summary>
- /// <param name="groupName">The name of the group.</param>
- /// <returns>True if the group exists; otherwise, false.</returns>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public bool HasLayerGroup(string groupName)
- {
- ThrowIfDisposed();
- return _layerGroups.ContainsKey(groupName);
- }
- /// <summary>
- /// Marks a layer group as needing rebuild.
- /// </summary>
- /// <param name="groupName">The name of the group.</param>
- /// <remarks>
- /// Call this after modifying tiles in layers that belong to a group.
- /// The group will be rebuilt on the next draw call.
- /// </remarks>
- /// <exception cref="ArgumentException">Thrown if the group does not exist.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void MarkGroupDirty(string groupName)
- {
- ThrowIfDisposed();
- if (!_layerGroups.TryGetValue(groupName, out LayerGroup group))
- throw new ArgumentException($"Layer group '{groupName}' does not exist.", nameof(groupName));
- group.IsDirty = true;
- }
- /// <summary>
- /// Rebuilds the merged vertex/index buffers for a layer group.
- /// </summary>
- /// <param name="groupName">The name of the group.</param>
- /// <remarks>
- /// This is called automatically when drawing a dirty group, but can be called
- /// manually to control when the rebuild happens (e.g., during loading).
- /// </remarks>
- /// <exception cref="ArgumentException">Thrown if the group does not exist.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void RebuildLayerGroup(string groupName)
- {
- ThrowIfDisposed();
- if (!_layerGroups.TryGetValue(groupName, out LayerGroup group))
- throw new ArgumentException($"Layer group '{groupName}' does not exist.", nameof(groupName));
- // Will be fully implemented in Week 2
- // RebuildLayerGroupInternal(group);
- }
- /// <summary>
- /// Begins a manual rendering sequence.
- /// </summary>
- /// <param name="camera">The camera for view and projection matrices.</param>
- /// <remarks>
- /// Call this before <see cref="DrawLayerGroup"/> or <see cref="DrawLayer(string)"/>.
- /// Must be paired with <see cref="EndDraw"/>.
- /// Automatically saves GraphicsDevice state for restoration after SpriteBatch usage.
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if camera is null.</exception>
- /// <exception cref="InvalidOperationException">Thrown if no tilemap is loaded or already drawing.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void BeginDraw(OrthographicCamera camera)
- {
- ThrowIfDisposed();
- ThrowIfNoTilemap();
- if (camera == null)
- throw new ArgumentNullException(nameof(camera));
- if (_isDrawing)
- throw new InvalidOperationException("BeginDraw already called. Call EndDraw first.");
- _isDrawing = true;
- // Save GraphicsDevice state for SpriteBatch mixing
- SaveGraphicsDeviceState();
- // Configure sampler state to prevent tile seams
- _graphicsDevice.SamplerStates[0] = SamplerState.PointClamp;
- // Set up matrices
- _viewMatrix = camera.GetViewMatrix();
- _projectionMatrix = Matrix.CreateOrthographicOffCenter(
- 0, _graphicsDevice.Viewport.Width,
- _graphicsDevice.Viewport.Height, 0,
- 0, 1);
- _effect.View = _viewMatrix;
- _effect.Projection = _projectionMatrix;
- _effect.World = Matrix.Identity;
- }
- /// <summary>
- /// Draws a layer group.
- /// </summary>
- /// <param name="groupName">The name of the layer group.</param>
- /// <remarks>
- /// All layers in the group are rendered in a single draw call.
- /// Must be called between <see cref="BeginDraw"/> and <see cref="EndDraw"/>.
- /// If the group is marked dirty, it will be rebuilt before drawing.
- /// </remarks>
- /// <exception cref="InvalidOperationException">Thrown if called outside of BeginDraw/EndDraw.</exception>
- /// <exception cref="ArgumentException">Thrown if the group does not exist.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DrawLayerGroup(string groupName)
- {
- ThrowIfDisposed();
- ThrowIfNotDrawing();
- if (!_layerGroups.TryGetValue(groupName, out LayerGroup group))
- throw new ArgumentException($"Layer group '{groupName}' does not exist.", nameof(groupName));
- // Will be fully implemented in Week 2
- // if (group.IsDirty)
- // RebuildLayerGroupInternal(group);
- //
- // DrawLayerModel(group.MergedModel);
- }
- /// <summary>
- /// Draws a layer model to the screen.
- /// </summary>
- /// <param name="model">The layer model to draw.</param>
- private void DrawLayerModel(LayerModel model)
- {
- if (model == null)
- return;
- // Set the texture
- _effect.Texture = model.Texture;
- // Bind vertex and index buffers
- _graphicsDevice.SetVertexBuffer(model.VertexBuffer);
- _graphicsDevice.Indices = model.IndexBuffer;
- // Apply the effect and draw
- foreach (EffectPass pass in _effect.CurrentTechnique.Passes)
- {
- pass.Apply();
- _graphicsDevice.DrawIndexedPrimitives(
- PrimitiveType.TriangleList,
- baseVertex: 0,
- startIndex: 0,
- primitiveCount: model.PrimitiveCount);
- }
- }
- /// <summary>
- /// Draws a single layer by name.
- /// </summary>
- /// <param name="layerName">The name of the layer.</param>
- /// <remarks>
- /// If the layer is part of a group, only this layer is drawn (not the entire group).
- /// Must be called between <see cref="BeginDraw"/> and <see cref="EndDraw"/>.
- /// </remarks>
- /// <exception cref="InvalidOperationException">Thrown if called outside of BeginDraw/EndDraw.</exception>
- /// <exception cref="ArgumentException">Thrown if the layer does not exist.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DrawLayer(string layerName)
- {
- ThrowIfDisposed();
- ThrowIfNotDrawing();
- TilemapLayer layer = _tilemap.Layers[layerName];
- if (layer == null)
- throw new ArgumentException($"Layer '{layerName}' not found in tilemap.", nameof(layerName));
- DrawLayerInternal(layer);
- }
- /// <summary>
- /// Draws a single layer by index.
- /// </summary>
- /// <param name="layerIndex">The index of the layer.</param>
- /// <remarks>
- /// If the layer is part of a group, only this layer is drawn (not the entire group).
- /// Must be called between <see cref="BeginDraw"/> and <see cref="EndDraw"/>.
- /// </remarks>
- /// <exception cref="InvalidOperationException">Thrown if called outside of BeginDraw/EndDraw.</exception>
- /// <exception cref="ArgumentOutOfRangeException">Thrown if the layer index is out of range.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DrawLayer(int layerIndex)
- {
- ThrowIfDisposed();
- ThrowIfNotDrawing();
- if (layerIndex < 0 || layerIndex >= _tilemap.Layers.Count)
- throw new ArgumentOutOfRangeException(nameof(layerIndex));
- DrawLayerInternal(_tilemap.Layers[layerIndex]);
- }
- /// <summary>
- /// Internal method to draw a single layer.
- /// </summary>
- /// <param name="layer">The layer to draw.</param>
- private void DrawLayerInternal(TilemapLayer layer)
- {
- // Skip invisible layers
- if (!layer.IsVisible)
- return;
- // Only tile layers are supported in Week 1
- // Object/Image/Group layers will be added in Week 4
- if (layer is not TilemapTileLayer)
- return;
- // Find the corresponding layer model
- // For now, we match by layer index since models are built in order
- int layerIndex = _tilemap.Layers.IndexOf(layer);
- if (layerIndex < 0 || layerIndex >= _layerModels.Count)
- return;
- LayerModel model = _layerModels[layerIndex];
- // Apply layer opacity by modifying effect alpha
- // (This is a simplified approach; full implementation in Week 4)
- _effect.Alpha = layer.Opacity;
- // Draw the layer model
- DrawLayerModel(model);
- // Restore alpha
- _effect.Alpha = 1.0f;
- }
- /// <summary>
- /// Ends a manual rendering sequence.
- /// </summary>
- /// <exception cref="InvalidOperationException">Thrown if BeginDraw was not called.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void EndDraw()
- {
- ThrowIfDisposed();
- ThrowIfNotDrawing();
- _isDrawing = false;
- }
- /// <summary>
- /// Draws all layers automatically.
- /// </summary>
- /// <param name="camera">The camera for view and projection matrices.</param>
- /// <remarks>
- /// <para>
- /// Draws all visible layers. Grouped layers are drawn as a group (single draw call).
- /// Ungrouped layers are drawn according to <see cref="DefaultRenderMode"/>.
- /// </para>
- /// <para>
- /// For fine-grained control over layer rendering order (e.g., to inject sprites
- /// between layers), use manual rendering with <see cref="BeginDraw(OrthographicCamera)"/>,
- /// <see cref="DrawLayerGroup"/>, and <see cref="EndDraw"/>.
- /// </para>
- /// <para>
- /// <strong>Note:</strong> This method is for single tilemap mode only.
- /// For world mode (LDtk GridVania), use <see cref="DrawWorld(OrthographicCamera, int)"/>
- /// to specify which WorldDepth to render.
- /// </para>
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if camera is null.</exception>
- /// <exception cref="InvalidOperationException">Thrown if no tilemap is loaded or if in world mode.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void Draw(OrthographicCamera camera)
- {
- ThrowIfDisposed();
- ThrowIfNoTilemap();
- if (camera == null)
- throw new ArgumentNullException(nameof(camera));
- if (_isWorldMode)
- throw new InvalidOperationException("Cannot use Draw() in world mode. Use DrawWorld(camera, worldDepth) instead.");
- // Single tilemap mode
- BeginDraw(camera);
- // Week 1: Simple implementation - draw all layers in order
- // Week 2: Will add layer grouping support
- // Week 3: Will add RenderMode support for ungrouped layers
- for (int i = 0; i < _tilemap.Layers.Count; i++)
- {
- DrawLayer(i);
- }
- EndDraw();
- }
- /// <summary>
- /// Draws all tilemaps at the specified WorldDepth.
- /// </summary>
- /// <param name="camera">The camera for view and projection matrices.</param>
- /// <param name="worldDepth">The WorldDepth to render. Only tilemaps with this exact depth will be drawn.</param>
- /// <remarks>
- /// <para>
- /// This method is designed for LDtk GridVania worlds where levels are organized by WorldDepth.
- /// The game typically renders one depth at a time based on gameplay context:
- /// - WorldDepth 0: Main overworld
- /// - WorldDepth 1: Building interiors, second floors
- /// - WorldDepth -1: Underground, basements
- /// </para>
- /// <para>
- /// This matches how the LDtk editor displays levels - showing one depth layer at a time.
- /// </para>
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if camera is null.</exception>
- /// <exception cref="InvalidOperationException">Thrown if not in world mode.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void DrawWorld(OrthographicCamera camera, int worldDepth)
- {
- ThrowIfDisposed();
- if (camera == null)
- throw new ArgumentNullException(nameof(camera));
- if (!_isWorldMode)
- throw new InvalidOperationException("Not in world mode. Use LoadWorld() first, or use Draw() for single tilemap.");
- // Configure sampler state to prevent tile seams
- _graphicsDevice.SamplerStates[0] = SamplerState.PointClamp;
- // Set up matrices
- _viewMatrix = camera.GetViewMatrix();
- _projectionMatrix = Matrix.CreateOrthographicOffCenter(
- 0, _graphicsDevice.Viewport.Width,
- _graphicsDevice.Viewport.Height, 0,
- 0, 1);
- _effect.View = _viewMatrix;
- _effect.Projection = _projectionMatrix;
- _effect.World = Matrix.Identity;
- // Draw only tilemaps at the specified WorldDepth
- foreach (WorldTilemap worldTilemap in _worldTilemaps)
- {
- if (worldTilemap.WorldDepth != worldDepth)
- continue; // Skip levels not at this depth
- // Draw all layer models for this tilemap
- foreach (LayerModel model in worldTilemap.LayerModels)
- {
- DrawLayerModel(model);
- }
- }
- }
- /// <summary>
- /// Updates animated tiles.
- /// </summary>
- /// <param name="gameTime">The game time.</param>
- /// <remarks>
- /// Must be called each frame to update tile animations.
- /// Automatically marks groups containing animated tiles as dirty when frames change.
- /// </remarks>
- /// <exception cref="ArgumentNullException">Thrown if gameTime is null.</exception>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void Update(GameTime gameTime)
- {
- ThrowIfDisposed();
- if (gameTime == null)
- throw new ArgumentNullException(nameof(gameTime));
- // Will be fully implemented in Week 4
- // UpdateAnimations(gameTime);
- }
- /// <summary>
- /// Saves the current GraphicsDevice state.
- /// </summary>
- /// <remarks>
- /// Call this before using SpriteBatch between <see cref="BeginDraw"/> and <see cref="EndDraw"/>.
- /// Paired with <see cref="RestoreGraphicsDeviceState"/>.
- /// Note: <see cref="BeginDraw"/> automatically saves state, so this is only needed for
- /// additional state changes.
- /// </remarks>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void SaveGraphicsDeviceState()
- {
- ThrowIfDisposed();
- _savedBlendState = _graphicsDevice.BlendState;
- _savedSamplerState = _graphicsDevice.SamplerStates[0];
- _savedRasterizerState = _graphicsDevice.RasterizerState;
- _savedDepthStencilState = _graphicsDevice.DepthStencilState;
- }
- /// <summary>
- /// Restores previously saved GraphicsDevice state.
- /// </summary>
- /// <remarks>
- /// Call this after using SpriteBatch between <see cref="BeginDraw"/> and <see cref="EndDraw"/>.
- /// Paired with <see cref="SaveGraphicsDeviceState"/>.
- /// </remarks>
- /// <exception cref="ObjectDisposedException">Thrown if this renderer has been disposed.</exception>
- public void RestoreGraphicsDeviceState()
- {
- ThrowIfDisposed();
- _graphicsDevice.BlendState = _savedBlendState;
- _graphicsDevice.SamplerStates[0] = _savedSamplerState;
- _graphicsDevice.RasterizerState = _savedRasterizerState;
- _graphicsDevice.DepthStencilState = _savedDepthStencilState;
- }
- /// <summary>
- /// Disposes all GPU resources used by this renderer.
- /// </summary>
- public void Dispose()
- {
- if (_isDisposed)
- return;
- UnloadTilemap();
- _effect?.Dispose();
- _effect = null;
- _isDisposed = true;
- }
- private void ThrowIfDisposed()
- {
- if (_isDisposed)
- throw new ObjectDisposedException(nameof(TilemapRenderer));
- }
- private void ThrowIfNoTilemap()
- {
- if (_tilemap == null && !_isWorldMode)
- throw new InvalidOperationException("No tilemap loaded. Call LoadTilemap or LoadWorld first.");
- }
- private void ThrowIfNotDrawing()
- {
- if (!_isDrawing)
- throw new InvalidOperationException("Not in drawing state. Call BeginDraw first.");
- }
- // Internal classes for layer management
- private sealed class LayerGroup
- {
- public List<TilemapLayer> Layers { get; set; }
- public LayerModel MergedModel { get; set; }
- public bool IsDirty { get; set; }
- }
- private sealed class LayerModel : IDisposable
- {
- public VertexBuffer VertexBuffer { get; set; }
- public IndexBuffer IndexBuffer { get; set; }
- public Texture2D Texture { get; set; }
- public int PrimitiveCount { get; set; }
- public void Dispose()
- {
- VertexBuffer?.Dispose();
- IndexBuffer?.Dispose();
- }
- }
- private sealed class WorldTilemap
- {
- public Tilemap Tilemap { get; set; }
- public Vector2 WorldPosition { get; set; }
- public int WorldDepth { get; set; }
- public List<LayerModel> LayerModels { get; set; }
- public WorldTilemap()
- {
- LayerModels = new List<LayerModel>();
- }
- }
- }
|