Bitmask Tilemap Godot 3d

| Mar 18, 2025 min read

Bitmask Tilemap using Blender and Godot 3d

In this tutorial, we will create a 3d tilemap using Blender and Godot. We will use a bitmask technique to create a tilemap that can be used in Godot. This technique is useful for creating tilemaps that can be used in 3d games.

gridmap hero shot

The Lay of the Land

I found it very difficult to get ahold of resources to help me in this process. The one definitive shout-out would be to Sonny Bone via Tutsplus for his venerated tutorial on 2d bitmasking. I’ll be using most of all his techniques and adapting them to fit my needs. My hope is that you find this useful in generating your own maps for cool games; and maybe you’ll let me know about what you’ve got going on.

The Process

Since the principles of using bitmasking are pretty well documented for 2d uses (and my own use is actually to create a simple 2d plane of tiles in 3d) I’ll focus instead on the production process and the code I used to generate my map. This starts with a good template file.

blender template

I created this by duplicating a default plane 8 times while using Snap to Increment. Then I dropped in text objects for each number on the perimeter of the resulting grid. The numbers are useful when orienting yourself while building tiles. Unfortunately there’s no way to really automate the production of tiles so far as I can tell but the whole process only took about 30 minutes to put together very basic tiles. Additional tile types can then be created in 15 minutes by simply replacing what’s in each individual object.

Once the grid is laid out, I selected the center plane and subdivided it a couple of times so that I’d have “pixels” to work with to make corners for each tile as it was required.

example subdivided tile showing corners

For your convenience I’ve also laid out a separate sheet that shows all 47 tiles that I created for my map. This is a good starting point for your own map.

47 tiles.

When you assemble yours you will want to ensure that everything is aligned on world 0,0,0 (in case you’re using a version of Godot that doesn’t support resetting transforms during meshlib creation.)

Assembly in Godot

Over in Godot you’ll create a new instance of the .blend file you’ve created. This will allow you to select all your mesh instances (named from 0 to 255) and wrap them with a collision shape under a rigidbody.

add collision dialog

Then you can go to the File menu and “Export” to a .meshlib for use in a gridmap.

The method that I decided to use for my current game project is to use a proxy GridMap. This means that I have two GridMap objects in my scene. One map is the container for the painted tiles themselves (in my case represented by a simple red dot.) This GridMap also has a script attached to it that exports a reference to a second GridMap which is the “Drawable” surface that we’re going to bitmask on top of. While I’m limited to a single layer this code could very easily be expanded to handle the Y axis as well.

You’ll create one meshlib that has a simple placeholder mesh in it and assign that to your “Control” Gridmap.

Then you’ll create a second meshlib that has all of your tiles in it and assign that to your “Drawable” Gridmap. Assigning the Drawable to the Control’s “Drawable” exported member variable.

The Code

I had deliberated on whether to include the entire class here or not. I decided to include it because it’s a very simple class and it’s not very long. I’ve also included a few comments to help you understand what’s going on. (At least in so far as I understand what’s going on. Hah!)

using Godot;
using System;
using System.Collections.Generic;

// your namespace will vary
namespace URBANFORT.Map;

public enum Directions
{
    NorthWest = 1,
    North = 2,
    NorthEast = 4,
    East = 16,
    SouthEast = 128,
    South = 64,
    SouthWest = 32,
    West = 8
}

/// <summary>
/// A grid map that uses a bitmask to determine the asset to place on the grid for each mask value.
/// </summary>
[Tool]
public partial class BitmaskMap : GridMap
{

    public static readonly List<Directions> Corners = [Directions.NorthWest, Directions.NorthEast, Directions.SouthEast, Directions.SouthWest];

    [ExportToolButton("Paint")]
    public Callable Cleanup => Callable.From(() => Repaint());

    // DEBUG: Annotate the grid with the bitmask value
    [Export]
    public bool Annotate { get; set; } = false;

    // The drawable grid map that will be painted
    [Export]
    public GridMap Drawable { get; set; }

    // I named my tiles simply by their bitmask value; but you might want to include SM_ or something
    [Export]
    public string MeshLibraryItemPrefix { get; set; } = "";

    // Same reasoning as the prefix, just wanted a little future-proofing
    [Export]
    public string MeshLibraryItemSuffix { get; set; } = "";

    public void Repaint ()
    {
        // Unset all cells in drawable
        Drawable.Clear();

        // This has a bug that makes labels appear in all open scenes that I have not worked on - Reloading Godot clears them and they are debug annotations in any case
        Node3D annotationsContainer = GetTree().Root.GetNode<Node3D>("Annotations");
        if (annotationsContainer == null)
        {
            annotationsContainer = new Node3D();
            annotationsContainer.Name = "Annotations";
            GetTree().Root.AddChild(annotationsContainer);
        } else {
            foreach (Node child in annotationsContainer.GetChildren())
            {
                child.QueueFree();
            }
        }

        var cells = GetUsedCells();

        foreach (Vector3I cell in cells)
        {
            int filledTotal = 0;

            foreach (Directions dir in Directions.GetValues(typeof(Directions)))
            {
                var neighbor = GetNeighborCoordinate(cell, dir);
                if (GetCellItem(neighbor) == InvalidCellItem)
                {
                    continue;
                }

                if (Corners.Contains(dir))
                {
                    var northNeighbor = GetNeighborCoordinate(cell, Directions.North);
                    var eastNeighbor = GetNeighborCoordinate(cell, Directions.East);
                    var southNeighbor = GetNeighborCoordinate(cell, Directions.South);
                    var westNeighbor = GetNeighborCoordinate(cell, Directions.West);

                    // If dir is northwest and either north or west of that are empty skip
                    if (dir == Directions.NorthWest && (GetCellItem(northNeighbor) == InvalidCellItem || GetCellItem(westNeighbor) == InvalidCellItem))
                    {
                        continue;
                    }

                    // If dir is northeast and either north or east of that are empty skip
                    if (dir == Directions.NorthEast && (GetCellItem(northNeighbor) == InvalidCellItem || GetCellItem(eastNeighbor) == InvalidCellItem))
                    {
                        continue;
                    }

                    // If dir is southeast and either south or east of that are empty skip
                    if (dir == Directions.SouthEast && (GetCellItem(southNeighbor) == InvalidCellItem || GetCellItem(eastNeighbor) == InvalidCellItem))
                    {
                        continue;
                    }

                    // If dir is southwest and either south or west of that are empty skip
                    if (dir == Directions.SouthWest && (GetCellItem(southNeighbor) == InvalidCellItem || GetCellItem(westNeighbor) == InvalidCellItem))
                    {
                        continue;
                    }
                }

                filledTotal += (int)dir;
            }

            if (Drawable != null) {
                try
                {
                    int meshItemIndex = Drawable.MeshLibrary.FindItemByName($"{MeshLibraryItemPrefix}{filledTotal}{MeshLibraryItemSuffix}");

                    if (meshItemIndex != -1)
                    {
                        Drawable.SetCellItem(cell, meshItemIndex);
                    }
                    else
                    {
                        GD.Print($"No item found for cell {cell} {MeshLibraryItemPrefix}{filledTotal}{MeshLibraryItemSuffix}");
                    }
                    if (Annotate)
                    {
                        var label = new Label3D
                        {
                            Text = filledTotal.ToString(),
                            FontSize = 48
                        };
                        Vector3 labelPosition = Drawable.ToGlobal(Drawable.ToLocal(cell)) * Drawable.CellSize;
                        labelPosition.X += 0.25f;
                        labelPosition.Z += 0.25f;
                        labelPosition.Y += 3;
                        label.Transform = new Transform3D(Basis.Identity.Rotated(Vector3.Right, -90f), labelPosition);
                        if (meshItemIndex == -1) {
                            label.Modulate = new Color(1, 0, 0);
                        } else {
                            label.Modulate = new Color(0, 0.5f, 0);
                        }
                        annotationsContainer.AddChild(label);
                        label.SetOwner(annotationsContainer);
                    }
                }
                catch (Exception e)
                {
                    GD.PrintErr($"Error setting cell at {cell}, filledTotal({filledTotal}): {e.Message}");
                    foreach (int index in Drawable.MeshLibrary.GetItemList())
                    {
                        GD.PrintErr($"Item: {index} - {Drawable.MeshLibrary.GetItemName(index)}");
                    }
                }
            }
        }
    }

    // In Godot, Z forward (down) is negative
    public static Vector3I GetNeighborCoordinate (Vector3I coordinate, Directions direction)
    {
        var neighbor = coordinate;
        switch (direction)
        {
            case Directions.NorthWest:
                neighbor.X -= 1;
                neighbor.Z -= 1;
                break;
            case Directions.North:
                neighbor.Z -= 1;
                break;
            case Directions.NorthEast:
                neighbor.X += 1;
                neighbor.Z -= 1;
                break;
            case Directions.East:
                neighbor.X += 1;
                break;
            case Directions.SouthEast:
                neighbor.X += 1;
                neighbor.Z += 1;
                break;
            case Directions.South:
                neighbor.Z += 1;
                break;
            case Directions.SouthWest:
                neighbor.X -= 1;
                neighbor.Z += 1;
                break;
            case Directions.West:
                neighbor.X -= 1;
                break;
        }
        return neighbor;
    }
}

I’ve also included my very basic set of template tiles in the blend file which is available for download