Skip to content

Latest commit

 

History

History
955 lines (723 loc) · 29.2 KB

File metadata and controls

955 lines (723 loc) · 29.2 KB

3D Hex Map Implementation Guide for Godot 4

A comprehensive technical guide for implementing 3D hexagonal maps in Godot 4, based on the Nyika project. This covers coordinate systems, terrain rendering, entity placement, camera controls, and integrating external 3D assets.

Table of Contents

  1. Hex Coordinate System
  2. Terrain Rendering
  3. Heightmap Loading
  4. Entity Placement
  5. Camera System
  6. Mini-Map
  7. Rivers and Water
  8. Territory Borders
  9. External Asset Integration (Meshy AI)
  10. Shaders
  11. Common Pitfalls

Hex Coordinate System

Orientation: Pointy-Top Hexagons

We use pointy-top hexagons with odd-row offset coordinates (axial system).

Row 0:  ⬡  ⬡  ⬡  ⬡     ← Even row: no offset
Row 1:   ⬡  ⬡  ⬡  ⬡    ← Odd row: offset right by half hex width
Row 2:  ⬡  ⬡  ⬡  ⬡

Core Constants

# HexUtils.gd
const OUTER_RADIUS: float = 1.0      # Center to corner (vertex)
const INNER_RADIUS: float = 0.866025404  # sqrt(3)/2 - center to edge midpoint
const X_OFFSET: float = 1.732050808  # sqrt(3) - horizontal spacing between centers
const Z_OFFSET: float = 1.5          # Vertical spacing between row centers

Grid to World Conversion

static func grid_to_world(grid_pos: Vector2i) -> Vector3:
    var x: float
    var z: float = grid_pos.y * Z_OFFSET

    # Odd rows are offset by half a hex width
    if grid_pos.y % 2 == 1:
        x = (grid_pos.x + 0.5) * X_OFFSET
    else:
        x = grid_pos.x * X_OFFSET

    return Vector3(x, 0.0, z)

World to Grid Conversion

This is trickier - use cube coordinate rounding for accuracy:

static func world_to_grid(world_pos: Vector3) -> Vector2i:
    # Convert to fractional axial coordinates
    var q: float = (sqrt(3.0) / 3.0 * world_pos.x - 1.0 / 3.0 * world_pos.z) / OUTER_RADIUS
    var r: float = (2.0 / 3.0 * world_pos.z) / OUTER_RADIUS

    # Convert to cube coordinates and round
    var cube = _axial_to_cube(Vector2(q, r))
    var rounded = _cube_round(cube)
    var axial = _cube_to_axial(rounded)

    return Vector2i(int(axial.x), int(axial.y))

static func _axial_to_cube(axial: Vector2) -> Vector3:
    var x = axial.x
    var z = axial.y
    var y = -x - z
    return Vector3(x, y, z)

static func _cube_round(cube: Vector3) -> Vector3:
    var rx = round(cube.x)
    var ry = round(cube.y)
    var rz = round(cube.z)

    var x_diff = abs(rx - cube.x)
    var y_diff = abs(ry - cube.y)
    var z_diff = abs(rz - cube.z)

    if x_diff > y_diff and x_diff > z_diff:
        rx = -ry - rz
    elif y_diff > z_diff:
        ry = -rx - rz
    else:
        rz = -rx - ry

    return Vector3(rx, ry, rz)

static func _cube_to_axial(cube: Vector3) -> Vector2:
    return Vector2(cube.x, cube.z)

Neighbor Calculations

Critical: Odd and even rows have different neighbor offsets!

# Neighbor offsets for even rows (y % 2 == 0)
const EVEN_ROW_OFFSETS: Array[Vector2i] = [
    Vector2i(1, 0),   # East
    Vector2i(0, -1),  # Southeast
    Vector2i(-1, -1), # Southwest
    Vector2i(-1, 0),  # West
    Vector2i(-1, 1),  # Northwest
    Vector2i(0, 1),   # Northeast
]

# Neighbor offsets for odd rows (y % 2 == 1)
const ODD_ROW_OFFSETS: Array[Vector2i] = [
    Vector2i(1, 0),   # East
    Vector2i(1, -1),  # Southeast
    Vector2i(0, -1),  # Southwest
    Vector2i(-1, 0),  # West
    Vector2i(0, 1),   # Northwest
    Vector2i(1, 1),   # Northeast
]

static func get_neighbors(pos: Vector2i) -> Array[Vector2i]:
    var offsets = ODD_ROW_OFFSETS if pos.y % 2 == 1 else EVEN_ROW_OFFSETS
    var neighbors: Array[Vector2i] = []
    for offset in offsets:
        neighbors.append(pos + offset)
    return neighbors

Hex Distance

Convert to cube coordinates for accurate distance:

static func hex_distance(a: Vector2i, b: Vector2i) -> int:
    var ac = _offset_to_cube(a)
    var bc = _offset_to_cube(b)
    return int((abs(ac.x - bc.x) + abs(ac.y - bc.y) + abs(ac.z - bc.z)) / 2)

static func _offset_to_cube(pos: Vector2i) -> Vector3i:
    var x = pos.x - (pos.y - (pos.y & 1)) / 2
    var z = pos.y
    var y = -x - z
    return Vector3i(x, y, z)

Terrain Rendering

Discrete Elevation Tiers (Civ 6 Style)

Instead of per-tile noise, use discrete elevation tiers for a clean "board game" aesthetic:

# GameConstants.gd
const TERRAIN_TIER_HEIGHT: float = 0.25
const TERRAIN_BASE_THICKNESS: float = 0.15

const TERRAIN_ELEVATION_TIERS: Dictionary = {
    "terrain_water_deep": 0,
    "terrain_water_shallow": 1,
    "terrain_coastal": 2,
    "terrain_wetland": 2,
    "terrain_plains": 3,
    "terrain_forest": 3,
    "terrain_desert": 3,
    "terrain_urban": 3,
    "terrain_hills": 4,
    "terrain_mountains": 6,
}

static func get_terrain_elevation_at(grid_pos: Vector2i) -> float:
    var terrain_type = WorldState.get_terrain_at(grid_pos)
    var tier = TERRAIN_ELEVATION_TIERS.get(terrain_type, 3)
    return TERRAIN_BASE_THICKNESS + (tier * TERRAIN_TIER_HEIGHT)

3D Hex Prism Mesh Generation

Generate hexagonal prisms with two surfaces (top for terrain texture, sides for cliffs):

static func create_hex_prism_mesh(height: float, radius: float = OUTER_RADIUS) -> ArrayMesh:
    var st = SurfaceTool.new()
    var mesh = ArrayMesh.new()

    # Calculate hex corners
    var corners: Array[Vector3] = []
    for i in range(6):
        var angle = PI / 3.0 * i - PI / 6.0  # Start at top-right for pointy-top
        corners.append(Vector3(
            cos(angle) * radius,
            0.0,
            sin(angle) * radius
        ))

    # SURFACE 0: Top face (terrain texture)
    st.begin(Mesh.PRIMITIVE_TRIANGLES)
    var top_center = Vector3(0, height, 0)
    for i in range(6):
        var next = (i + 1) % 6
        st.set_normal(Vector3.UP)
        st.set_uv(_calculate_hex_uv(top_center))
        st.add_vertex(top_center)
        st.set_uv(_calculate_hex_uv(corners[i] + Vector3(0, height, 0)))
        st.add_vertex(corners[i] + Vector3(0, height, 0))
        st.set_uv(_calculate_hex_uv(corners[next] + Vector3(0, height, 0)))
        st.add_vertex(corners[next] + Vector3(0, height, 0))
    st.commit(mesh)

    # SURFACE 1: Side walls (cliff texture)
    st.begin(Mesh.PRIMITIVE_TRIANGLES)
    for i in range(6):
        var next = (i + 1) % 6
        var top1 = corners[i] + Vector3(0, height, 0)
        var top2 = corners[next] + Vector3(0, height, 0)
        var bot1 = corners[i]
        var bot2 = corners[next]

        # Calculate outward normal
        var edge = top2 - top1
        var normal = edge.cross(Vector3.UP).normalized()

        st.set_normal(normal)
        # Triangle 1
        st.add_vertex(top1)
        st.add_vertex(bot1)
        st.add_vertex(top2)
        # Triangle 2
        st.add_vertex(top2)
        st.add_vertex(bot1)
        st.add_vertex(bot2)
    st.commit(mesh)

    return mesh

Mesh Caching Strategy

Pre-generate one mesh per elevation tier, then reuse via MultiMeshInstance3D or individual instances:

var _prism_cache: Dictionary = {}  # tier -> ArrayMesh

func _generate_prism_cache() -> void:
    for tier in range(7):
        var height = TERRAIN_BASE_THICKNESS + (tier * TERRAIN_TIER_HEIGHT)
        _prism_cache[tier] = HexUtils.create_hex_prism_mesh(height)

func _get_mesh_for_terrain(terrain_type: String) -> ArrayMesh:
    var tier = TERRAIN_ELEVATION_TIERS.get(terrain_type, 3)
    return _prism_cache[tier]

Dual Material System

Apply different materials to top surface vs side walls:

func _create_hex_instance(grid_pos: Vector2i, terrain_type: String) -> MeshInstance3D:
    var mesh_instance = MeshInstance3D.new()
    mesh_instance.mesh = _get_mesh_for_terrain(terrain_type)
    mesh_instance.position = HexUtils.grid_to_world(grid_pos)

    # Surface 0: Top - terrain material (grass, sand, etc.)
    mesh_instance.set_surface_override_material(0, _get_terrain_material(terrain_type))

    # Surface 1: Sides - cliff/dirt material
    mesh_instance.set_surface_override_material(1, _cliff_material)

    return mesh_instance

Heightmap Loading

Auto-Discovery System

Maps are automatically discovered from assets/maps/ by scanning for image files:

# HeightmapLoader.gd
const MAPS_DIRECTORY: String = "res://assets/maps/"
const SUPPORTED_EXTENSIONS: Array[String] = [".jpg", ".jpeg", ".png"]

static func get_available_maps() -> Array[MapInfo]:
    var maps: Array[MapInfo] = []
    var dir = DirAccess.open(MAPS_DIRECTORY)
    dir.list_dir_begin()
    var filename = dir.get_next()
    while filename != "":
        if not dir.current_is_dir():
            var ext = filename.get_extension().to_lower()
            if ("." + ext) in SUPPORTED_EXTENSIONS:
                maps.append(_create_map_info_from_file(filename))
        filename = dir.get_next()
    return maps

Optional Sidecar Descriptions

Each heightmap can have an optional .txt sidecar file with the same base name:

assets/maps/
├── nyika.jpg           # Heightmap image
├── nyika.txt           # Optional description: "The continent of Nyika..."
├── river_canyons.jpg
└── river_canyons.txt

Grayscale Thresholds

The heightmap loader converts grayscale values to terrain types:

# Tuned for heightmaps where rivers/water are dark gray (not pure black)
const WATER_DEEP_THRESHOLD: int = 50      # 0-50 = deep water
const WATER_SHALLOW_THRESHOLD: int = 85   # 51-85 = shallow water (rivers, lakes)
const PLAINS_THRESHOLD: int = 120         # 86-120 = lowlands
const HILLS_THRESHOLD: int = 170          # 121-170 = hills
const MOUNTAIN_THRESHOLD: int = 220       # 171-220 = mountains
# 221-255 = mountain peaks

Important: These thresholds are tuned for heightmaps where water features are drawn in dark gray (~60-80) rather than pure black. If your heightmap uses pure black for water, you may need lower thresholds.

Square Maps Recommended

The game uses a square grid (default 200×200) to match most heightmap images. Rectangular heightmaps will be stretched to fit.


Entity Placement

Automatic Terrain Height

All entities must be placed at the correct terrain elevation:

func place_entity_on_hex(entity: Node3D, grid_pos: Vector2i) -> void:
    var world_pos = HexUtils.grid_to_world(grid_pos)
    var terrain_height = GameConstants.get_terrain_elevation_at(grid_pos)
    entity.position = Vector3(world_pos.x, terrain_height, world_pos.z)

AABB-Based Y-Offset for 3D Models

Many 3D models (especially from Meshy AI) have their origin at the center rather than the bottom. Calculate and apply Y-offset:

var _y_offset_cache: Dictionary = {}

func get_y_offset(model_key: String, instance: Node3D) -> float:
    if _y_offset_cache.has(model_key):
        return _y_offset_cache[model_key]

    var aabb = _get_combined_aabb(instance)
    var y_offset = -aabb.position.y  # Lift so bottom touches ground
    _y_offset_cache[model_key] = y_offset
    return y_offset

func _get_combined_aabb(node: Node3D) -> AABB:
    var combined = AABB()
    var found_mesh = false

    if node is MeshInstance3D:
        combined = node.get_aabb()
        found_mesh = true

    for child in node.get_children():
        if child is Node3D:
            var child_aabb = _get_combined_aabb(child)
            if child_aabb.size != Vector3.ZERO:
                if not found_mesh:
                    combined = child_aabb
                    found_mesh = true
                else:
                    combined = combined.merge(child_aabb)

    return combined

# Usage when instantiating:
var instance = model_scene.instantiate()
var scale = get_model_scale(model_key)
instance.scale = scale
var y_offset = get_y_offset(model_key, instance) * scale.y
instance.position.y = terrain_height + y_offset

Smart Building Placement

For cities with multiple buildings, use geometry-aware ring placement:

func calculate_building_placement(index: int, building_geometry: Dictionary, total_count: int) -> Dictionary:
    var width = building_geometry.width
    var depth = building_geometry.depth

    # Adaptive radius based on building size
    var max_dim = maxf(width, depth)
    var base_radius = 0.3 + max_dim * 0.4

    # Distribute evenly around center
    var angle = (float(index) / float(total_count)) * TAU

    var offset = Vector3(
        cos(angle) * base_radius,
        0,  # Y-offset handled separately
        sin(angle) * base_radius
    )

    # Rotate building so long axis is tangent to circle
    var rotation = angle + PI / 2.0
    if depth > width:  # Building is deeper than wide
        rotation += PI / 2.0

    return {"offset": offset, "rotation": rotation}

Camera System

Orthographic Top-Down with Tilt

For hex maps, orthographic projection prevents perspective distortion:

@onready var camera: Camera3D = $Camera3D

var current_zoom: float = 15.0
var camera_target: Vector3 = Vector3.ZERO

const ZOOM_MIN: float = 5.0
const ZOOM_MAX: float = 25.0
const PAN_SPEED: float = 30.0

func _ready() -> void:
    camera.projection = Camera3D.PROJECTION_ORTHOGONAL
    camera.near = 0.1
    camera.far = 500.0
    _update_camera()

func _update_camera() -> void:
    # 45-degree angle for isometric-like view
    var offset = Vector3(0, current_zoom * 0.7, current_zoom * 0.7)
    camera.position = camera_target + offset
    camera.look_at(camera_target, Vector3.UP)
    camera.size = current_zoom  # Orthographic size

func _input(event: InputEvent) -> void:
    # Zoom with mouse wheel
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_WHEEL_UP:
            current_zoom = max(ZOOM_MIN, current_zoom - 2.0)
            _update_camera()
        elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
            current_zoom = min(ZOOM_MAX, current_zoom + 2.0)
            _update_camera()

func _process(delta: float) -> void:
    # WASD panning (scale speed with zoom for consistent feel)
    var pan_input = Vector2.ZERO
    if Input.is_action_pressed("ui_up"): pan_input.y -= 1
    if Input.is_action_pressed("ui_down"): pan_input.y += 1
    if Input.is_action_pressed("ui_left"): pan_input.x -= 1
    if Input.is_action_pressed("ui_right"): pan_input.x += 1

    if pan_input != Vector2.ZERO:
        var zoom_factor = current_zoom / 15.0
        camera_target.x += pan_input.x * PAN_SPEED * zoom_factor * delta
        camera_target.z += pan_input.y * PAN_SPEED * zoom_factor * delta
        _clamp_to_bounds()
        _update_camera()

Click Detection

Use a ground plane collision shape for hex picking:

# In GameMap scene, add:
# - StaticBody3D (ground_plane)
#   - CollisionShape3D with BoxShape3D covering map extent

func _on_ground_plane_input_event(_camera: Node, event: InputEvent, position: Vector3, _normal: Vector3, _shape_idx: int) -> void:
    if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
        var grid_pos = HexUtils.world_to_grid(position)
        emit_signal("hex_clicked", grid_pos)

Mini-Map

Fixed-Size Strategic Overview

The mini-map displays the entire map in a fixed-size panel (192×160 pixels), automatically scaling to fit any map dimensions:

# MiniMap.gd
@export var target_width: float = 192.0
@export var target_height: float = 160.0

func _rebuild_map_texture() -> void:
    _map_width = WorldState.map_dimensions.x
    _map_height = WorldState.map_dimensions.y

    # Calculate hex_size to fit map within target dimensions
    var hex_size_for_width = target_width / ((_map_width + 0.5) * 1.732)
    var hex_size_for_height = target_height / (_map_height * 1.5 + 0.5)
    hex_size = min(hex_size_for_width, hex_size_for_height)

    # Generate texture at computed size
    _pixel_width = int((_map_width + 0.5) * hex_size * 1.732)
    _pixel_height = int(_map_height * hex_size * 1.5 + hex_size * 0.5)

Terrain Color Mapping

Each terrain type maps to a distinct color for quick identification:

const TERRAIN_COLORS: Dictionary = {
    "plains": Color(0.45, 0.65, 0.35),       # Green
    "forest": Color(0.2, 0.45, 0.2),         # Dark green
    "hills": Color(0.55, 0.5, 0.35),         # Brown-green
    "mountains": Color(0.5, 0.45, 0.4),      # Gray-brown
    "mountain_peak": Color(0.85, 0.85, 0.9), # Snow white
    "desert": Color(0.85, 0.75, 0.5),        # Sand
    "water_shallow": Color(0.3, 0.5, 0.7),   # Light blue
    "water_deep": Color(0.15, 0.3, 0.55),    # Dark blue
}

Entity Markers

Cities and units are drawn as simple shapes on top of the terrain:

func _draw_cities() -> void:
    for city in WorldState.cities.values():
        var pixel_pos = _grid_to_pixel(city.position)
        draw_rect(Rect2(pixel_pos - Vector2(marker_size, marker_size) * 0.5,
            Vector2(marker_size, marker_size)), CITY_COLOR)

func _draw_units() -> void:
    for unit in WorldState.units.values():
        var pixel_pos = _grid_to_pixel(unit.position)
        var color = PLAYER_UNIT_COLOR if unit.owner_faction_id == player_faction_id else UNIT_COLOR
        draw_circle(pixel_pos, marker_radius, color)

Camera Viewport Rectangle

A white rectangle shows the current camera viewport area:

func _draw_viewport_rect() -> void:
    var center = _world_to_pixel(game_map.camera_target)
    var half_width = game_map.current_zoom * 0.8
    var half_height = game_map.current_zoom * 0.5

    var scale_x = _pixel_width / (_map_bounds_max.x - _map_bounds_min.x)
    var scale_z = _pixel_height / (_map_bounds_max.z - _map_bounds_min.z)

    var rect = Rect2(
        center.x - half_width * scale_x,
        center.y - half_height * scale_z,
        half_width * scale_x * 2,
        half_height * scale_z * 2
    )
    draw_rect(rect, viewport_rect_color, false, viewport_rect_width)

Click-to-Pan

Clicking on the mini-map pans the main camera to that position:

func _gui_input(event: InputEvent) -> void:
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
            var world_pos = _pixel_to_world(event.position)
            var grid_pos = HexUtils.world_to_grid(world_pos)
            game_map.center_on_position(grid_pos)

Rivers and Water

Smooth Ribbon Mesh Generation

Generate rivers as continuous ribbon meshes following hex paths:

func generate_river_mesh(hex_path: Array[Vector2i]) -> MeshInstance3D:
    if hex_path.size() < 2:
        return null

    var st = SurfaceTool.new()
    st.begin(Mesh.PRIMITIVE_TRIANGLES)

    # Build path with smooth curves
    var curve = Curve3D.new()
    for i in range(hex_path.size()):
        var world_pos = HexUtils.grid_to_world(hex_path[i])
        var height = GameConstants.get_terrain_elevation_at(hex_path[i]) + RIVER_HEIGHT_OFFSET
        curve.add_point(Vector3(world_pos.x, height, world_pos.z))

    # Sample curve at regular intervals
    var samples = curve.get_baked_points()
    var prev_left: Vector3
    var prev_right: Vector3
    var uv_distance: float = 0.0

    for i in range(samples.size()):
        var pos = samples[i]
        var tangent = _get_curve_tangent(curve, float(i) / samples.size())
        var perpendicular = tangent.cross(Vector3.UP).normalized()

        var left = pos - perpendicular * RIVER_WIDTH * 0.5
        var right = pos + perpendicular * RIVER_WIDTH * 0.5

        if i > 0:
            # Add quad between previous and current cross-section
            _add_river_quad(st, prev_left, prev_right, left, right, uv_distance)
            uv_distance += pos.distance_to(samples[i - 1])

        prev_left = left
        prev_right = right

    var mesh = ArrayMesh.new()
    st.commit(mesh)

    var mesh_instance = MeshInstance3D.new()
    mesh_instance.mesh = mesh
    mesh_instance.material_override = river_material
    return mesh_instance

Water Shader with Flow Animation

// river_water.gdshader
shader_type spatial;
render_mode blend_mix, unshaded;

uniform vec4 shallow_color: source_color = vec4(0.2, 0.6, 0.8, 0.9);
uniform vec4 deep_color: source_color = vec4(0.1, 0.3, 0.5, 0.95);
uniform float flow_speed: hint_range(0.0, 2.0) = 0.5;
uniform sampler2D foam_texture;

void fragment() {
    // Scroll UVs for flow animation
    vec2 flow_uv = UV + vec2(0.0, TIME * flow_speed);

    // Depth-based color (darker in center)
    float depth = smoothstep(0.0, 0.5, abs(UV.x - 0.5));
    vec4 water_color = mix(deep_color, shallow_color, depth);

    // Foam from vertex color (encoded by mesh generator)
    float foam_amount = COLOR.r;
    vec4 foam = texture(foam_texture, flow_uv * 2.0);

    ALBEDO = mix(water_color.rgb, foam.rgb, foam_amount * foam.a);
    ALPHA = water_color.a;
}

Territory Borders

Civ-Style Border Mesh Generation

Draw borders only on edges between owned and unowned hexes:

func create_territory_border(territory_hexes: Array[Vector2i], color: Color) -> MeshInstance3D:
    var st = SurfaceTool.new()
    st.begin(Mesh.PRIMITIVE_TRIANGLES)

    for hex_pos in territory_hexes:
        var neighbors = HexUtils.get_neighbors(hex_pos)
        for i in range(6):
            var neighbor = neighbors[i]
            # Only draw edge if neighbor is NOT in territory
            if neighbor not in territory_hexes:
                var edge_points = _get_hex_edge(hex_pos, i)
                _add_border_segment(st, edge_points[0], edge_points[1], color)

    var mesh = ArrayMesh.new()
    st.commit(mesh)

    var instance = MeshInstance3D.new()
    instance.mesh = mesh
    return instance

func _get_hex_edge(hex_pos: Vector2i, edge_index: int) -> Array[Vector3]:
    var center = HexUtils.grid_to_world(hex_pos)
    var height = GameConstants.get_terrain_elevation_at(hex_pos) + BORDER_HEIGHT_OFFSET
    var corners = HexUtils.get_hex_corners(center, HexUtils.OUTER_RADIUS)

    var start = corners[edge_index] + Vector3(0, height, 0)
    var end = corners[(edge_index + 1) % 6] + Vector3(0, height, 0)
    return [start, end]

External Asset Integration (Meshy AI)

GLB Import Settings

Meshy AI models require specific import settings to avoid color corruption:

Critical setting: Disable light baking to prevent vertex color modification:

# In .glb.import file
[params]
meshes/light_baking=0  # MUST be 0, not 1

To fix existing imports in bulk:

for f in assets/textures/buildings/*.glb.import; do
    sed -i 's/meshes\/light_baking=1/meshes\/light_baking=0/' "$f"
done

Material Extraction for Meshy Models

Meshy GLB materials don't import cleanly. Extract textures and recreate materials:

func fix_meshy_materials(instance: Node3D, model_key: String) -> void:
    var meshes = _find_mesh_instances(instance)
    var mat = _get_or_create_material(model_key)
    if mat == null:
        return

    for mesh in meshes:
        for i in range(mesh.get_surface_override_material_count()):
            mesh.set_surface_override_material(i, mat)

func _get_or_create_material(model_key: String) -> StandardMaterial3D:
    if _material_cache.has(model_key):
        return _material_cache[model_key]

    # Try multiple texture naming patterns
    var albedo_paths = [
        ASSET_PATH + model_key + "_Image_0.jpg",
        ASSET_PATH + model_key + "_0.jpg",
    ]

    var albedo_tex: Texture2D = null
    for path in albedo_paths:
        if ResourceLoader.exists(path):
            albedo_tex = load(path)
            break

    if albedo_tex == null:
        return null

    var mat = StandardMaterial3D.new()
    mat.albedo_texture = albedo_tex

    # Try to find normal map
    var normal_paths = [
        ASSET_PATH + model_key + "_Image_1.jpg",
        ASSET_PATH + model_key + "_1.jpg",
    ]
    for path in normal_paths:
        if ResourceLoader.exists(path):
            mat.normal_enabled = true
            mat.normal_texture = load(path)
            break

    _material_cache[model_key] = mat
    return mat

Per-Model Material Overrides

Some models need special treatment (e.g., gold with emission to fix crushed shadows):

const MATERIAL_OVERRIDES: Dictionary = {
    "res_gold": {
        "emission_enabled": true,
        "emission": Color(1.0, 0.85, 0.4),
        "emission_energy_multiplier": 0.3,
    },
}

func apply_material_overrides(model_key: String, instance: Node3D) -> void:
    if not MATERIAL_OVERRIDES.has(model_key):
        return

    var overrides = MATERIAL_OVERRIDES[model_key]
    var meshes = _find_mesh_instances(instance)

    for mesh in meshes:
        for i in range(mesh.get_surface_override_material_count()):
            var mat = StandardMaterial3D.new()
            # Copy original texture
            var original = mesh.mesh.surface_get_material(i)
            if original is StandardMaterial3D:
                mat.albedo_texture = original.albedo_texture
            # Apply overrides
            for prop in overrides:
                mat.set(prop, overrides[prop])
            mesh.set_surface_override_material(i, mat)

Shaders

Hex Overlay Shader (Selection/Movement Highlights)

// hex_overlay.gdshader
shader_type spatial;
render_mode unshaded, depth_draw_opaque;

uniform vec4 overlay_color: source_color = vec4(1.0, 1.0, 0.0, 0.5);
uniform vec4 border_color: source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float opacity: hint_range(0.0, 1.0) = 0.5;
uniform float border_width: hint_range(0.0, 0.3) = 0.1;
uniform bool fill_interior = true;
uniform bool animate = false;
uniform float pulse_speed: hint_range(0.5, 4.0) = 2.0;

void fragment() {
    // Calculate distance from hex center (approximate)
    float dist_from_center = length(UV - vec2(0.5));
    float hex_edge = 0.45;  // Approximate hex boundary in UV space

    float edge_factor = smoothstep(hex_edge - border_width, hex_edge, dist_from_center);

    float alpha = opacity;
    if (animate) {
        alpha *= 0.7 + 0.3 * sin(TIME * pulse_speed);
    }

    if (fill_interior) {
        vec4 color = mix(overlay_color, border_color, edge_factor);
        ALBEDO = color.rgb;
        ALPHA = color.a * alpha;
    } else {
        // Border only
        float border_factor = smoothstep(hex_edge - border_width, hex_edge - border_width * 0.5, dist_from_center)
                            * (1.0 - smoothstep(hex_edge, hex_edge + 0.01, dist_from_center));
        ALBEDO = border_color.rgb;
        ALPHA = border_color.a * border_factor * alpha;
    }
}

Common Pitfalls

1. Neighbor Offset Mismatch

Problem: Manually calculating neighbor offsets without accounting for odd/even rows.

Solution: Always use HexUtils.get_neighbors() which handles row parity automatically.

2. World-Grid Round-Trip Errors

Problem: Converting grid→world→grid doesn't return the original position due to floating point errors.

Solution: Use cube coordinate rounding in world_to_grid() for accuracy.

3. Meshy AI Color Corruption

Problem: Imported Meshy GLB models appear with wrong colors (washed out, black shadows).

Solution:

  • Set meshes/light_baking=0 in GLB import settings
  • Extract and recreate materials manually

4. Models Sinking Into Ground

Problem: 3D models placed at terrain height appear partially underground.

Solution: Calculate AABB-based Y-offset to account for models with origin at center.

5. Camera Bounds Calculation

Problem: Camera pan limits don't account for odd-row hex offsets.

Solution: Calculate bounds using:

var max_x = (map_width - 0.5) * HexUtils.X_OFFSET  # Account for odd row shift
var max_z = (map_height - 1) * HexUtils.Z_OFFSET

6. Inconsistent Resource Sizes

Problem: Same resource type appears at different sizes across map.

Solution: Use fixed scale factors per resource type, don't vary by deposit size.

7. Z-Fighting on Overlays

Problem: Selection overlays flicker with terrain surface.

Solution: Add small Y offset (0.01-0.05) to overlay meshes above terrain.


File Reference

File Purpose
scripts/map/HexUtils.gd Core hex math (autoload)
scripts/map/HexMetrics.gd River/elevation calculations
scripts/map/HexBorder.gd Territory border generation
scripts/map/TerrainGrid.gd 3D hex prism terrain renderer
scripts/map/TerrainGenerator.gd Procedural terrain generation
scripts/map/HeightmapLoader.gd Heightmap auto-discovery and loading
scripts/map/RiverRenderer.gd Smooth ribbon river meshes
scripts/map/GameMap.gd Camera controls, click detection
scripts/ui/minimap/MiniMap.gd Strategic overview minimap
scripts/autoload/FactionBuildings.gd Building model management
scripts/autoload/ResourceModels.gd Resource deposit models
scripts/autoload/InfrastructureModels.gd Infrastructure models
scripts/autoload/GameConstants.gd Elevation tiers, terrain types
assets/shaders/hex_overlay.gdshader Selection highlighting
assets/shaders/river_water.gdshader Animated water

Resources