Hex Grid Layout (3D)
3D hex grids extend hexagonal positioning with a vertical axis, creating hex columns instead of flat hexagons. This is the grid for strategy games with elevation — Civilization-style maps, wargames with height advantage, or any game where hex adjacency meets terrain height.
When to Use 3D Hex vs Rect
Use Case |
Rect 3D |
Hex 3D |
|---|---|---|
Voxel worlds (Minecraft-like) |
✅ Axis-aligned |
❌ Gaps between columns |
FPS dungeon crawlers |
✅ Box rooms |
❌ Awkward walls |
Strategy with elevation |
❌ No hex adjacency |
✅ Natural fit |
Wargames with terrain |
❌ Wrong feel |
✅ Authentic |
Board game ports |
❌ Doesn't match |
✅ Matches rules |
Civilization-style maps |
❌ Square tiles |
✅ Hex tiles |
The trade-off: Hex 3D grids are hexagonal in XZ but rectangular in Y. Each "column" is a stack of hex cells. This gives you hex adjacency for ground movement while keeping vertical stacking simple.
Core Concepts
The system is built on three primitives:
HexGrid3D<'T>— Storage for hex column data (hex grid + vertical layers)HexGrid3DSection<'T>— A cursor/view into the grid for relative positioning- Stamps — Functions that transform sections (
HexGrid3DSection<'T> -> HexGrid3DSection<'T>)
Orientation and Axes
Like 2D hex grids, 3D hex supports both orientations via HexOrientation:
Orientation |
XZ Shape |
Best for |
|---|---|---|
|
Pointed at top/bottom |
Strategy maps, tactical RPGs |
|
Flat at top/bottom |
Isometric-style games, board games |
Axis Convention: - X = width (left/right) - Z = depth (forward/back) — this is the hex grid plane - Y = height / layers (up/down) — this is the vertical stacking axis
open Mibo.Layout3D
open Mibo.Layout
open System.Numerics
// Strategy map with pointy-top hex columns
let grid = HexGrid3D.create 20 10 15 32f 2f Vector3.Zero PointyTop
// Board game with flat-top hex columns
let board = HexGrid3D.create 12 5 10 48f 1.5f Vector3.Zero FlatTop
Parameters:
- width — Number of columns (X axis)
- height — Number of vertical layers (Y axis)
- depth — Number of rows (Z axis)
- hexSize — Radius of hex (center to corner)
- layerHeight — World-space height of each vertical layer
- origin — World-space origin point
- orientation — PointyTop or FlatTop
Basic Operations
open Mibo.Layout3D
open Mibo.Layout
let grid = HexGrid3D.create 20 10 15 32f 2f Vector3.Zero PointyTop
// Place content at column 5, row 3, layer 2
HexGrid3D.set 5 3 2 myCell grid
// Read content (returns voption — no heap allocation)
match HexGrid3D.get 5 3 2 grid with
| ValueSome cell -> // handle cell
| ValueNone -> // empty
// Remove content
HexGrid3D.clear 5 3 2 grid
// Get world position for rendering
let worldPos = HexGrid3D.getWorldPos 5 3 2 grid // Vector3
Iteration
All Cells
grid |> HexGrid3D.iter (fun col row layer cell ->
let pos = HexGrid3D.getWorldPos col row layer grid
spawnModel pos cell
)
Volume-Culled (Frustum Culling)
For large worlds, only process cells within a bounding volume:
let bounds = {
Min = Vector3(cameraX - viewDist, cameraY - viewHeight, cameraZ - viewDist)
Max = Vector3(cameraX + viewDist, cameraY + viewHeight, cameraZ + viewDist)
}
grid |> HexGrid3D.iterVolume bounds (fun col row layer cell ->
let pos = HexGrid3D.getWorldPos col row layer grid
spawnModel pos cell
)
Performance note: For a 50×20×50 hex grid (50,000 cells), iterVolume typically processes only 500-2000 cells. Always use it for gameplay rendering.
The DSL: Building Levels with Stamps
The HexLayout3D module provides a fluent DSL for placing content. All functions return the section for pipeline composition.
Running the DSL
let grid =
HexGrid3D.create 30 10 20 32f 2f Vector3.Zero PointyTop
|> HexLayout3D.run (fun section ->
section
|> HexLayout3D.fill 0 0 0 30 10 20 AirCell
|> HexLayout3D.floorHex 0 0 0 30 20 GrassCell
|> HexLayout3D.shell 0 0 0 30 10 20 StoneCell
)
Sections: Relative Positioning
section
|> HexLayout3D.section 5 0 3 (fun inner ->
// (0, 0, 0) here = (5, 0, 3) in parent
inner |> HexLayout3D.fill 0 0 0 4 3 4 WoodCell
)
|> HexLayout3D.section 15 0 8 (fun inner ->
// (0, 0, 0) here = (15, 0, 8) in parent
inner |> HexLayout3D.fill 0 0 0 3 2 3 StoneCell
)
Structural Helpers
// Shrink by N on all sides
section |> HexLayout3D.padding 2 (fun inner -> ...)
// Explicit padding: left, bottom, back, right, top, front
section |> HexLayout3D.paddingEx 1 1 1 1 1 1 (fun inner -> ...)
// Center a block within the section
section |> HexLayout3D.center 4 3 4 (fun inner -> ...)
// Place stamps along axes with spacing
section |> HexLayout3D.flowX 5 [tower; tower; tower] // Horizontal row
section |> HexLayout3D.flowY 3 [floor; floor; floor] // Vertical stack
section |> HexLayout3D.flowZ 4 [house; house; house] // Depth row
Primitives: Placing Content
Single Cells and Lines
// Single cell
HexLayout3D.set col row layer content section
// Lines along axes
HexLayout3D.repeatX col row layer count content section // Along X
HexLayout3D.repeatY col row layer count content section // Along Y (vertical)
HexLayout3D.repeatZ col row layer count content section // Along Z
// Alias for vertical column
HexLayout3D.column col row layer height content section
Volumes
// Fill a box volume
HexLayout3D.fill col row layer w h d content section
// Clear a volume
HexLayout3D.clear col row layer w h d section
Planes (Single-Cell Thickness)
// Horizontal floor (XZ plane)
HexLayout3D.floorHex col row layer width depth content section
// Vertical wall (XY plane) — faces forward/back
HexLayout3D.wallXY col row layer width height content section
// Vertical wall (YZ plane) — faces left/right
HexLayout3D.wallYZ col row layer height depth content section
3D Shapes
// Hollow box (6 faces)
HexLayout3D.shell col row layer w h d content section
// 12 edges only
HexLayout3D.edges col row layer w h d content section
// Sphere
HexLayout3D.sphere cx cy cz radius true content section // Filled
HexLayout3D.sphere cx cy cz radius false content section // Shell only
// Cylinder (Y-aligned)
HexLayout3D.cylinder cx cz layer radius height true content section // Filled
HexLayout3D.cylinder cx cz layer radius height false content section // Shell only
Combined Shapes
// Filled box with different border
HexLayout3D.border col row layer w h d borderContent section
// Filled box with border content
HexLayout3D.rect col row layer w h d borderContent fillContent section
// Only the 8 corners
HexLayout3D.corners col row layer w h d content section
Geometry: 3D Lines
// 3D line between two points
HexLayout3D.line x1 y1 z1 x2 y2 z2 content section
When to use: Roads, tunnels, rivers, laser beams, flight paths — anything that connects two points in 3D space.
Patterns: Decorative and Procedural
Checkerboard Patterns
// Full 3D checkerboard
HexLayout3D.checker3D odd even section
// Single hex layer checkerboard
HexLayout3D.checkerHexLayer layer odd even section
// Planar checkers
HexLayout3D.checkerXY row odd even section // Vertical plane (XY)
HexLayout3D.checkerYZ col odd even section // Vertical plane (YZ)
// Box skin checker
HexLayout3D.checkerShell col row layer w h d odd even section
Random Scatter
// Scatter in 3D volume
HexLayout3D.scatter3D count seed content section
// Scatter on single hex layer
HexLayout3D.scatterHexLayer layer count seed content section
// Scatter on vertical planes
HexLayout3D.scatterXY row count seed content section
HexLayout3D.scatterYZ col count seed content section
// Scatter on box surface
HexLayout3D.scatterShell col row layer w h d count seed content section
// Scatter on edges
HexLayout3D.scatterEdges col row layer w h d count seed content section
// Scatter on border
HexLayout3D.scatterBorder col row layer w h d count seed content section
// Scatter an entire stamp at random positions
HexLayout3D.scatterStamp count seed stamp section
Seed usage: Same seed = same pattern (deterministic). Different seeds for variety. Combine multiple scatter calls for layered randomness.
Procedural Generation
// Generate from 3D function
HexLayout3D.generate col row layer w h d (fun c r l ->
if l < 3 then StoneCell else AirCell
) section
// Generate on single hex layer
HexLayout3D.generateHexLayer layer (fun c r ->
if (c + r) % 2 = 0 then GrassTile else DirtTile
) section
// Generate on vertical planes
HexLayout3D.generateXY row (fun c l -> ...) section
HexLayout3D.generateYZ col (fun r l -> ...) section
Find and Replace
// Replace all instances
HexLayout3D.replace oldContent newContent section
// Probabilistic replace
HexLayout3D.replaceScatter oldContent newContent 0.3f seed section
Iteration and Transformation
// Read existing content
HexLayout3D.iter col row layer w h d (fun c r l cell ->
match cell with
| ValueSome c -> printfn "Found %A at (%d,%d,%d)" c c r l
| ValueNone -> ()
) section
// Transform existing content
HexLayout3D.map col row layer w h d (fun cell ->
match cell with
| Block age -> Block(age + 1)
| other -> other
) section
// Conditional set (only if empty)
HexLayout3D.setIfEmpty col row layer content section
Building Stamps: Reusable Components
Simple Stamp
/// A hex column with grass on top and dirt below
let grassColumn height (section: HexGrid3DSection<Cell>) =
section
|> HexLayout3D.column 0 0 0 (height - 1) DirtCell
|> HexLayout3D.set 0 (height - 1) 0 GrassCell
Parameterized Stamp
/// A configurable house
let house width height depth floor wall roof (section: HexGrid3DSection<Cell>) =
section
|> HexLayout3D.floorHex 0 0 0 width depth floor
|> HexLayout3D.shell 0 0 0 width height depth wall
|> HexLayout3D.floorHex 0 0 (height - 1) width depth roof
|> HexLayout3D.clear 1 0 1 (width - 2) (height - 1) (depth - 2) // Interior
Composing Stamps
let village =
house 4 3 4 GrassFloor WoodWall ThatchRoof
>> HexLayout3D.section 6 0 0 (house 3 2 3 GrassFloor StoneWall TileRoof)
>> HexLayout3D.section 0 0 6 campfire
>> HexLayout3D.scatterHexLayer 0 8 42 FlowerCell
Domain Modules
module StrategyGame =
module Terrain =
let mountain radius height = ...
let river width = ...
let forest density = ...
module Structures =
let castle size = ...
let barracks = ...
let watchtower height = ...
module Units =
let army unitType count = ...
let formation shape = ...
Elevation: The Key Feature
3D hex grids shine when elevation matters. The vertical axis lets you model terrain height, multi-level structures, and height-based gameplay.
Heightmap Terrain
/// Create terrain from a height function
let terrainFromHeightmap maxHeight heightFn surfaceContent subsurfaceContent subsurfaceDepth =
HexLayout3D.generateHexLayer 0 (fun col row ->
let height = heightFn col row
// Surface tile at the top
surfaceContent
)
>> fun section ->
// Fill subsurface layers
for layer in 0 .. maxHeight - 1 do
section |> HexLayout3D.generateHexLayer layer (fun col row ->
let surfaceHeight = heightFn col row
if layer < surfaceHeight - subsurfaceDepth then
subsurfaceContent
else
surfaceContent
) |> ignore
section
Plateaus and Depressions
/// A raised plateau
let plateau width depth height surface side (section: HexGrid3DSection<Cell>) =
section
|> HexLayout3D.fill 0 0 0 width height depth side // Sides
|> HexLayout3D.floorHex 0 0 (height - 1) width depth surface // Top
/// A pit/crater
let pit width depth drop (section: HexGrid3DSection<Cell>) =
section
|> HexLayout3D.clear 0 0 0 width drop depth // Remove cells
Ramps and Slopes
/// A ramp going up along X
let rampX length width rise (section: HexGrid3DSection<Cell>) =
for i in 0 .. length - 1 do
let layerHeight = int (float rise * float i / float length)
section |> HexLayout3D.floorHex i 0 layerHeight 1 width RampCell
section
Adjacency and Pathfinding
Hex adjacency in 3D works in the XZ plane (6 neighbors at each layer), plus vertical connections (up/down).
Ground Movement (Same Layer)
/// Get 6 hex neighbors at the same layer
let hexNeighbors col row =
let isOddRow = row % 2 = 1
if isOddRow then
[| (col, row - 1); (col + 1, row - 1)
(col - 1, row); (col + 1, row)
(col, row + 1); (col + 1, row + 1) |]
else
[| (col - 1, row - 1); (col, row - 1)
(col - 1, row); (col + 1, row)
(col - 1, row + 1); (col, row + 1) |]
/// Get vertical neighbors (up/down)
let verticalNeighbors col row layer =
[| (col, row, layer - 1); (col, row, layer + 1) |]
Height-Based Movement Cost
/// Movement cost considering elevation
let movementCost fromCol fromRow fromLayer toCol toRow toLayer grid =
let fromHeight = getTerrainHeight fromCol fromRow fromLayer grid
let toHeight = getTerrainHeight toCol toRow toLayer grid
let heightDiff = abs (toHeight - fromHeight)
// Upward movement costs more
if heightDiff > 0 then
1 + heightDiff // 2 for 1 height, 3 for 2 height, etc.
else
1 // Flat or downhill
Layered Composition
For complex maps with multiple data layers:
let level =
LayeredHexGrid3D.create 30 10 20 32f 2f Vector3.Zero PointyTop
|> LayeredHexLayout3D.layer 0 (fun section ->
// Layer 0: Terrain (ground, water, mountains)
section
|> HexLayout3D.floorHex 0 0 0 30 20 GrassCell
|> HexLayout3D.section 10 0 5 (terrain.mountain 6 8)
|> HexLayout3D.section 0 0 15 (terrain.river 3)
)
|> LayeredHexLayout3D.layer 1 (fun section ->
// Layer 1: Structures
section
|> HexLayout3D.section 5 0 3 (structures.castle 8)
|> HexLayout3D.section 20 0 10 (structures.barracks)
)
|> LayeredHexLayout3D.layer 2 (fun section ->
// Layer 2: Units
section
|> HexLayout3D.section 7 0 5 (units.army Infantry 10)
|> HexLayout3D.section 22 0 12 (units.army Cavalry 5)
)
Rendering Integration
Basic Rendering
grid |> HexGrid3D.iter (fun col row layer cell ->
let pos = HexGrid3D.getWorldPos col row layer grid
spawnModel pos cell
)
Volume-Culled Rendering
let frustumBounds = {
Min = Vector3(minX, minY, minZ)
Max = Vector3(maxX, maxY, maxZ)
}
grid |> HexGrid3D.iterVolume frustumBounds (fun col row layer cell ->
let pos = HexGrid3D.getWorldPos col row layer grid
spawnModel pos cell
)
Using the Renderer Helper
open Mibo.Layout3D
// Basic render
HexGrid3DRenderer.render grid (fun worldPos cell ->
spawnModel worldPos cell
)
// Volume-culled render
HexGrid3DRenderer.renderVolume frustumBounds grid (fun worldPos cell ->
spawnModel worldPos cell
)
// With indices (for debug or special logic)
HexGrid3DRenderer.renderWithIndices grid (fun col row layer worldPos cell ->
spawnModel worldPos cell
)
GPU Instanced Rendering
For large maps with many copies of the same mesh (trees, rocks, buildings):
// Create instanced context
let instancedCtx = InstancedRenderContext(
getKey = fun cell -> cell.MeshKey, // Group by mesh type
getMeshesAndMaterial = fun cell -> cell.Meshes, // Get GPU resources
getTransform = fun worldPos cell -> // Compute transform
Matrix4x4.CreateTranslation(worldPos)
)
// Render with instancing (one draw call per mesh type)
HexGrid3DRenderer.renderInstanced instancedCtx grid renderBuffer
// Volume-culled instancing
HexGrid3DRenderer.renderVolumeInstanced instancedCtx frustumBounds grid renderBuffer
When to use instancing: If you have 100+ copies of the same mesh (trees, rocks, identical buildings), instancing reduces draw calls from 100+ to 1-2. For unique meshes, regular rendering is fine.
Complete Example: Civilization-Style Map
type Terrain =
| Grass | Plains | Desert | Tundra | Forest | Mountain | Water
type Structure =
| City | Fort | Farm | Mine | Road
let civMap =
HexGrid3D.create 40 8 30 32f 2f Vector3.Zero FlatTop
|> HexLayout3D.run (fun section ->
section
// Base terrain
|> HexLayout3D.floorHex 0 0 0 40 30 GrassCell
// Mountain range (barrier)
|> HexLayout3D.section 15 0 10 (fun s ->
s |> HexLayout3D.fill 0 0 0 8 5 3 MountainCell
|> HexLayout3D.floorHex 0 0 4 8 3 SnowCell
)
// River (winding)
|> HexLayout3D.line 0 0 5 39 0 25 WaterCell
// Forests
|> HexLayout3D.section 2 0 2 (fun s ->
s |> HexLayout3D.scatterHexLayer 0 30 101 ForestCell
)
|> HexLayout3D.section 28 0 18 (fun s ->
s |> HexLayout3D.scatterHexLayer 0 25 202 ForestCell
)
// Desert region
|> HexLayout3D.section 30 0 2 (fun s ->
s |> HexLayout3D.floorHex 0 0 0 8 6 DesertCell
|> HexLayout3D.scatterHexLayer 0 5 301 OasisCell
)
// Tundra (north)
|> HexLayout3D.floorHex 0 0 0 40 3 TundraCell
// Roads connecting cities
|> HexLayout3D.line 5 0 5 20 0 15 RoadCell
|> HexLayout3D.line 20 0 15 35 0 25 RoadCell
// Cities
|> HexLayout3D.section 5 0 5 (fun s ->
s |> HexLayout3D.fill 0 0 0 2 2 2 CityCell
|> HexLayout3D.border 0 0 0 3 3 3 WallCell // Defensive wall
)
|> HexLayout3D.section 20 0 15 (fun s ->
s |> HexLayout3D.fill 0 0 0 3 2 3 CityCell
)
|> HexLayout3D.section 35 0 25 (fun s ->
s |> HexLayout3D.fill 0 0 0 2 2 2 CityCell
)
// Farms near cities
|> HexLayout3D.scatterHexLayer 0 10 401 FarmCell
// Resources
|> HexLayout3D.scatter3D 15 501 IronCell // Scattered iron deposits
)
Performance Considerations
- Flat array storage — O(1) cell access, cache-friendly iteration
- Struct voption — No heap allocation per cell
- Zero-copy sections — Sections don't duplicate the grid
- Inline lambdas — DSL functions compile away closures
- Use
iterVolume— Always cull for gameplay rendering - Use
generate— For procedural content, faster than individualsetcalls - Use instancing — For rendering many identical meshes
API Reference
For complete function signatures, see the API Reference.
[<Struct>] type Vector3 = new: value: Vector2 * z: float32 -> unit + 3 overloads member CopyTo: array: float32 array -> unit + 2 overloads member Equals: other: Vector3 -> bool + 2 overloads member GetHashCode: unit -> int member Length: unit -> float32 member LengthSquared: unit -> float32 member ToString: unit -> string + 2 overloads member TryCopyTo: destination: Span<float32> -> bool static member (&&&) : left: Vector3 * right: Vector3 -> Vector3 static member ( * ) : left: Vector3 * right: Vector3 -> Vector3 + 2 overloads ...
<summary>Represents a vector with three single-precision floating-point values.</summary>
--------------------
Vector3 ()
Vector3(value: float32) : Vector3
Vector3(values: System.ReadOnlySpan<float32>) : Vector3
Vector3(value: Vector2, z: float32) : Vector3
Vector3(x: float32, y: float32, z: float32) : Vector3
<summary>Gets a vector whose 3 elements are equal to zero.</summary>
<returns>A vector whose three elements are equal to zero (that is, it returns the vector <code data-dev-comment-type="c">(0,0,0)</code>).</returns>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val float: value: 'T -> float (requires member op_Explicit)
--------------------
type float = System.Double
--------------------
type float<'Measure> = float
[<Struct>] type Matrix4x4 = new: value: Matrix3x2 -> unit + 1 overload member Equals: other: Matrix4x4 -> bool + 1 overload member GetDeterminant: unit -> float32 member GetElement: row: int * column: int -> float32 member GetHashCode: unit -> int member GetRow: index: int -> Vector4 member ToString: unit -> string member WithElement: row: int * column: int * value: float32 -> Matrix4x4 member WithRow: index: int * value: Vector4 -> Matrix4x4 static member ( * ) : value1: Matrix4x4 * value2: Matrix4x4 -> Matrix4x4 + 1 overload ...
<summary>Represents a 4x4 matrix.</summary>
--------------------
Matrix4x4 ()
Matrix4x4(value: Matrix3x2) : Matrix4x4
Matrix4x4(m11: float32, m12: float32, m13: float32, m14: float32, m21: float32, m22: float32, m23: float32, m24: float32, m31: float32, m32: float32, m33: float32, m34: float32, m41: float32, m42: float32, m43: float32, m44: float32) : Matrix4x4
Matrix4x4.CreateTranslation(xPosition: float32, yPosition: float32, zPosition: float32) : Matrix4x4
Mibo.Raylib