UV Mapping for CylinderMesh

Godot Version

4.5

Question

I am currently working on a hexagonal tile that is able to change height by specific increments when given a number. The issue I am having is that I am not able to map the UV coordinates of the vertices so that the texture for the sides uses the same texture for each segment.

I want to use the CylinderMesh to create the tile. However, the CylinderMesh does not have built-in methods for adjusting the UVs of the vertices. To that end, I create an ArrayMesh based on the desired CylinderMesh shape and then adjust the UVs using MeshDataTool.

@tool
class_name ScalingHexMesh
extends MeshInstance3D
## Enables a MeshInstance3D to be adjustable by outside factors.


## The UV coordinates for the top vertices of the mesh.
const UV_TOP: PackedVector2Array = [
	Vector2(0.98, 0.39),
	Vector2(0.74, 0.01),
	Vector2(0.26, 0.01),
	Vector2(0.02, 0.39),
	Vector2(0.26, 0.76),
	Vector2(0.74, 0.76),
	Vector2(0.98, 0.39),
	Vector2(0.5, 0.39), # center vertex
]
## The UV coordinates for the side of the mesh. Each side segment uses the same
## texture (or is at least supposed to).
const UV_SIDE: PackedVector2Array = [
	Vector2(0.26, 0.79), # top left
	Vector2(0.72, 0.98), # bottom right
	Vector2(0.26, 0.98), # bottom left
	Vector2(0.72, 0.79), # top right
]

## The mesh used to represent a hex tile.
var _hex_mesh: ArrayMesh:
	get:
		return mesh as ArrayMesh


## Updates the shape mesh so that it reflects the current height.
func _update_tile_shape_height(height: int) -> void:
	# Move the shape so that the bottom is always at -0.25
	mesh = _create_base_mesh(height)
	var y_translate: float = (HexUtil.HEX_TILE_UNIT_HEIGHT / 2) * (height - 1)
	set_position(Vector3(0.0, y_translate, 0.0))
	_update_uvs()


## Update the UV map of the mesh to align with the base texture:
## res://art/hex_base_texture.png
func _update_uvs() -> void:
	var mesh_data := MeshDataTool.new()
	mesh_data.create_from_surface(_hex_mesh, 0)
	var vertex_count: int = mesh_data.get_vertex_count()
	## Set the UVs for the top cap of the mesh.
	for i: int in UV_TOP.size():
		mesh_data.set_vertex_uv(vertex_count - i - 1, UV_TOP[i])
	## Set the UVs for the side segments of the mesh.
	var even_row: int
	var even_vertex: int
	for i: int in vertex_count - UV_TOP.size():
		even_row = i % 2
		even_vertex = i % 7 % 2
		mesh_data.set_vertex_uv(i, UV_SIDE[even_row * 2 + even_vertex])
	mesh_data.commit_to_surface(_hex_mesh)


## Creates an array mesh for the hex tile of the specified height.
func _create_base_mesh(height: int) -> ArrayMesh:
	var cylinder_mesh := CylinderMesh.new()
	cylinder_mesh.top_radius = HexUtil.HEX_TILE_RADIUS
	cylinder_mesh.bottom_radius = HexUtil.HEX_TILE_RADIUS
	cylinder_mesh.height = HexUtil.HEX_TILE_UNIT_HEIGHT * (1 + height)
	cylinder_mesh.radial_segments = 6
	cylinder_mesh.rings = height
	cylinder_mesh.cap_bottom = false
	var array_mesh := ArrayMesh.new()
	array_mesh.add_surface_from_arrays(
			Mesh.PRIMITIVE_TRIANGLES,
			cylinder_mesh.get_mesh_arrays()
	)
	return array_mesh


## Triggers an update to the shape height.
func _on_HeightSource_height_changed(height: int) -> void:
	_update_tile_shape_height(height)

I am using this image as the texture. It will serve as a baseline for all other textures I will be making for the tiles.

hex_base_texture

The logic for creating the ArrayMesh works, but there is a problem with the UVs. The top UVs are good, but the UVs for the side vertices are not able to be set in such a way to allow for the side texture to be in the same orientation across all segments.

I’ve created a variant of the hex tile using some Blender models that I made, which has the desired texture pattern.

What I want to know is if there is a way to set up the UVs of a CylinderMesh so that the texture looks like the above.

Why not just use what you made in Blender?

I’ll likely continue with the Blender version, but I want to see if there is a way to make the CylinderMesh idea work. That, or confirm that it is not feasible to do so.

Originally, I was worried that the Blender version would be somehow slower than using CylinderMesh. Currently, the Blender version uses two models to create the tile, one for the top and another for a ring of side segments. Whenever the tile height is updated, the top is moved to the appropriate position, and a number of segment rings are added or deleted as required. The CylinderMesh method would delete and create a new mesh only once when the height is updated. Now, I think this concern is a product of wanting to prematurely optimize.

Not without intervention into vertex data. Vertical edges share vertices and UV is part of the vertex data. You’d need to duplicate those vertices so each face has its own 4 vertices with its own UVs.

1 Like

So, I went in and made some code that creates a hexagonal tile, keeping in mind that I would need to duplicate vertices.

## The UV coordinates for the top vertices of the mesh.
const UV_TOP: PackedVector2Array = [
	Vector2(0.98, 0.39), # top
	Vector2(0.74, 0.01), # top left
	Vector2(0.26, 0.01), # bottom left
	Vector2(0.02, 0.39), # bottom
	Vector2(0.26, 0.76), # bottom right
	Vector2(0.74, 0.76), # top right
]
## The UV coordinates for the side of the mesh. Each side segment uses the same
## texture (or is at least supposed to).
const UV_SIDE: PackedVector2Array = [
	Vector2(0.26, 0.79), # top left
	Vector2(0.72, 0.98), # bottom right
	Vector2(0.26, 0.98), # bottom left
	Vector2(0.72, 0.79), # top right
]


## Creates an array mesh for the hex tile.
func _create_mesh(height: int):
	var surface_array = []
	surface_array.resize(Mesh.ARRAY_MAX)
	
	var verts = PackedVector3Array()
	var uvs = PackedVector2Array()
	var normals = PackedVector3Array()
	
	# Create side meshes
	for h: int in height + 1:
		var bot_vert := Vector3(
			0.0,
			HexUtil.HEX_TILE_UNIT_HEIGHT * (h - 1),
			-HexUtil.HEX_TILE_RADIUS
		)
		var top_vert := Vector3(
			0.0, 
			HexUtil.HEX_TILE_UNIT_HEIGHT * h,
			-HexUtil.HEX_TILE_RADIUS
		)
		for s: int in 6:
			var vert0 := bot_vert.rotated(Vector3.UP, -PI / 3.0 * s)
			var vert1 := bot_vert.rotated(Vector3.UP, -PI / 3.0 * (s + 1))
			var vert2 := top_vert.rotated(Vector3.UP, -PI / 3.0 * (s + 1))
			var vert3 := top_vert.rotated(Vector3.UP, -PI / 3.0 * s)
			verts.append(vert0)
			verts.append(vert1)
			verts.append(vert2)
			verts.append(vert2)
			verts.append(vert3)
			verts.append(vert0)
			normals.append(vert0.normalized())
			normals.append(vert1.normalized())
			normals.append(vert2.normalized())
			normals.append(vert2.normalized())
			normals.append(vert3.normalized())
			normals.append(vert0.normalized())
			uvs.append(UV_SIDE[1])
			uvs.append(UV_SIDE[2])
			uvs.append(UV_SIDE[0])
			uvs.append(UV_SIDE[0])
			uvs.append(UV_SIDE[3])
			uvs.append(UV_SIDE[1])
	# Create top mesh
	var top_height: float = HexUtil.HEX_TILE_UNIT_HEIGHT * height
	var vertex_ref := Vector3(0.0, top_height, -HexUtil.HEX_TILE_RADIUS)
	var vert_t := vertex_ref
	var vert_tl := vertex_ref.rotated(Vector3.UP, PI / 3.0)
	var vert_bl := vertex_ref.rotated(Vector3.UP, PI / 3.0 * 2)
	var vert_b := vertex_ref.rotated(Vector3.UP, PI)
	var vert_br := vertex_ref.rotated(Vector3.UP, PI / 3.0 * 4)
	var vert_tr := vertex_ref.rotated(Vector3.UP, PI / 3.0 * 5)
	verts.append(vert_t)
	verts.append(vert_tr)
	verts.append(vert_tl)
	verts.append(vert_tl)
	verts.append(vert_tr)
	verts.append(vert_br)
	verts.append(vert_br)
	verts.append(vert_bl)
	verts.append(vert_tl)
	verts.append(vert_bl)
	verts.append(vert_br)
	verts.append(vert_b)
	normals.append(vert_t.normalized())
	normals.append(vert_tr.normalized())
	normals.append(vert_tl.normalized())
	normals.append(vert_tl.normalized())
	normals.append(vert_tr.normalized())
	normals.append(vert_br.normalized())
	normals.append(vert_br.normalized())
	normals.append(vert_bl.normalized())
	normals.append(vert_tl.normalized())
	normals.append(vert_bl.normalized())
	normals.append(vert_br.normalized())
	normals.append(vert_b.normalized())
	uvs.append(UV_TOP[0])
	uvs.append(UV_TOP[5])
	uvs.append(UV_TOP[1])
	uvs.append(UV_TOP[1])
	uvs.append(UV_TOP[5])
	uvs.append(UV_TOP[4])
	uvs.append(UV_TOP[4])
	uvs.append(UV_TOP[2])
	uvs.append(UV_TOP[1])
	uvs.append(UV_TOP[2])
	uvs.append(UV_TOP[4])
	uvs.append(UV_TOP[3])
	
	# Assign arrays to surface array.
	surface_array[Mesh.ARRAY_VERTEX] = verts
	surface_array[Mesh.ARRAY_TEX_UV] = uvs
	surface_array[Mesh.ARRAY_NORMAL] = normals
	
	var array_mesh := ArrayMesh.new()
	array_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, surface_array)
	mesh = array_mesh


## Triggers an update to the shape height.
func _on_HeightSource_height_changed(height: int) -> void:
	_create_mesh(height)

This is the result.

Too much repeating code. You can generate any cylinder mesh in 10 lines of code.

I’ve looked into it a bit more and there doesn’t seem to be a way to add or remove vertices of an existing CylinderMesh, or any mesh. You can adjust the details of existing vertices using MeshDataTool, but that doesn’t offer any way to update vertex count. In order to get the UVs set up the way I need without using external models is to create mesh from scratch using ArrayMesh. This involves not just setting the vertices of the mesh, but also ensuring that they are in the proper order so that they render properly.

That is what I was doing with the earlier code, but @normalized is correct about it repeating too much. I can create a CylinderMesh with minimal code, but that is not enough for what I want to do.

I’ve reduced the repeated code, making use of the ARRAY_INDEX aspect of the surface array to define the triangles for the mesh instead of making sure that the vertices were placed in the correct order. Also updated the calculation for the normal values so that the tile renders as a cylinder instead of a sphere.

## Creates an array mesh for the hex tile.
func _update_mesh(height: int):
	var surface_array = []
	surface_array.resize(Mesh.ARRAY_MAX)
	
	var verts = PackedVector3Array()
	var uvs = PackedVector2Array()
	var normals = PackedVector3Array()
	var indices = PackedInt32Array()
	
	_create_side_segments(height, verts, normals, uvs, indices)
	_create_top_cap(height, verts, normals, uvs, indices)
	
	# Assign arrays to surface array.
	surface_array[Mesh.ARRAY_VERTEX] = verts
	surface_array[Mesh.ARRAY_TEX_UV] = uvs
	surface_array[Mesh.ARRAY_NORMAL] = normals
	surface_array[Mesh.ARRAY_INDEX] = indices
	
	var array_mesh := ArrayMesh.new()
	array_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, surface_array)
	mesh = array_mesh


## Creates the sides of the hex mesh, setting the UVs to match the reference
## texture: res://art/hex_base_texture.png
func _create_side_segments(
	height: int,
	verts: PackedVector3Array,
	normals: PackedVector3Array,
	uvs: PackedVector2Array,
	indices: PackedInt32Array
) -> void:
	var base_vert := Vector3(0.0, 0.0, -HexUtil.HEX_TILE_RADIUS)
	for h: int in height + 1:
		base_vert.y = HexUtil.HEX_TILE_UNIT_HEIGHT * (h - 1)
		# Create ring of faces
		for i: int in 6:
			var base_index: int = verts.size()
			# Define vertices for a face.
			var vert_br := base_vert.rotated(Vector3.UP, PI / 3.0 * i)
			var vert_tr := vert_br
			vert_tr.y += HexUtil.HEX_TILE_UNIT_HEIGHT
			var vert_bl := base_vert.rotated(Vector3.UP, PI / 3.0 * (i + 1))
			var vert_tl := vert_bl
			vert_tl.y += HexUtil.HEX_TILE_UNIT_HEIGHT
			verts.append_array([vert_br, vert_tr, vert_tl, vert_bl])
			# Determine normals for lighting.
			var n_rigt := Vector3(vert_br.x, 0.0, vert_br.z).normalized()
			var n_left := Vector3(vert_bl.x, 0.0, vert_bl.z).normalized()
			normals.append_array([n_rigt, n_rigt, n_left, n_left])
			# Assign UVs for vertices.
			uvs.append_array([UV_SIDE[1], UV_SIDE[3], UV_SIDE[0], UV_SIDE[2]])
			# Define triangles for face
			indices.append_array([base_index, base_index + 1, base_index + 3])
			indices.append_array([base_index + 1, base_index + 2, base_index + 3])


## Creates the top of the hex mesh, setting the UVs to match the reference
## texture: res://art/hex_base_texture.png
func _create_top_cap(
	height: int,
	verts: PackedVector3Array,
	normals: PackedVector3Array,
	uvs: PackedVector2Array,
	indices: PackedInt32Array
) -> void:
	var base_index: int = verts.size()
	# Create vertices.
	var top_height: float = HexUtil.HEX_TILE_UNIT_HEIGHT * height
	var start_vert := Vector3(0.0, top_height, -HexUtil.HEX_TILE_RADIUS)
	for i: int in 6:
		var vert: Vector3 = start_vert.rotated(Vector3.UP, PI / 3.0 * i)
		verts.append(vert)
		normals.append(Vector3.UP)
		uvs.append(UV_TOP[i])
	# Define triangles using indices.
	# Top Triangle
	indices.append_array([base_index, base_index + 5, base_index + 1])
	# Upper Middle Triangle
	indices.append_array([base_index + 5, base_index + 2, base_index + 1])
	# Lower Middle Triangle
	indices.append_array([base_index + 2, base_index + 5, base_index + 4])
	# Bottom Triangle
	indices.append_array([base_index + 3, base_index + 2, base_index + 4])

Final result.

Using SurfaceTool might be an easier option.

@tool
extends MeshInstance3D

@export_tool_button("CREATE") var make_button = make

const UV_SIDE: PackedVector2Array = [
	Vector2(0.26, 0.79), # top left
	Vector2(0.72, 0.98), # bottom right
	Vector2(0.26, 0.98), # bottom left
	Vector2(0.72, 0.79), # top right
]

func make():
	var surface: SurfaceTool = SurfaceTool.new()
	
	var rot60 := Transform3D().rotated(Vector3.UP, TAU / 6)
	var radius := Vector3.RIGHT
	var height := Vector3.DOWN * 0.5
	var uv_side := [0, 3, 2, 3, 1, 2]
	
	surface.begin(Mesh.PRIMITIVE_TRIANGLES)
	surface.set_smooth_group(-1)
	
	for i in 6:
		var rot := Transform3D().rotated(Vector3.UP, i * TAU / 6)
		for vert in rot * PackedVector3Array([Vector3.ZERO, rot60 * radius, radius]): 
			var uv := vert * Vector3(0.43, 0.0, 0.43) + Vector3(0.5, 0.0, 0.39)
			surface.set_uv(Vector2(uv.x, uv.z) )
			surface.add_vertex(vert)
		for vert in rot * PackedVector3Array([radius, rot60 * radius, radius + height, rot60 * radius, rot60 * radius + height, radius + height]): 
			surface.set_uv(UV_SIDE[uv_side[0]])
			uv_side.push_back(uv_side.pop_front())
			surface.add_vertex(vert)
	surface.generate_normals()
	mesh = surface.commit()

Not that you need it for this but interesting thing to note is that this is also a generalized version that lets you create cylinders having any number of steps, Just extract that hardcoded 6 into a property and set its value.

1 Like