OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / prefabs / bevelled_block.gd

173 lines · gd ref:
extends Node3D
## A 10 x 10 x 20 block with chamfered edges — the thing a builder bot stacks.
##
## ⛔ ONE MESH, SHARED BY EVERY INSTANCE. The mesh is built once and cached on the
## script, so stacking a hundred blocks allocates a hundred MeshInstance3D nodes
## and exactly ONE ArrayMesh. Building it per instance would put the same ~100
## vertices in memory once per block and give the renderer no reason to batch
## them. That is what "instanced" has to mean here: the geometry is a shared
## resource, not a copy per object.
##
## Godot has no bevelled-box primitive and CSG has no bevel, so the chamfer is
## generated: 6 inset faces, 12 edge quads, 8 corner triangles. A chamfer rather
## than a round, because a round needs a subdivision parameter and a tolerance
## argument for something whose whole job is to catch the light on an edge.

## x, y, z. The user asked for 10 x 10 x 20.
const BLOCK_SIZE := Vector3(10.0, 10.0, 20.0)
## How far each edge is cut back. Big enough to read at a distance, small enough
## that the block is still obviously a block.
const BEVEL := 0.45

## The shared mesh. Static, so it outlives any one block and is built at most once
## per process.
static var _mesh: ArrayMesh = null
static var _material: StandardMaterial3D = null


static func shared_mesh() -> ArrayMesh:
	if _mesh == null:
		_mesh = _build_mesh(BLOCK_SIZE, BEVEL)
	return _mesh


static func shared_material() -> StandardMaterial3D:
	if _material == null:
		_material = StandardMaterial3D.new()
		_material.albedo_color = Color(0.72, 0.68, 0.60)
		_material.roughness = 0.75
		_material.metallic = 0.0
	return _material


func _ready() -> void:
	var mi := get_node_or_null(^"Mesh") as MeshInstance3D
	if mi == null:
		mi = MeshInstance3D.new()
		mi.name = "Mesh"
		add_child(mi)
	mi.mesh = shared_mesh()
	mi.material_override = shared_material()

	var body := get_node_or_null(^"Body") as StaticBody3D
	if body == null:
		body = StaticBody3D.new()
		body.name = "Body"
		add_child(body)
		var col := CollisionShape3D.new()
		col.name = "Collision"
		# The COLLIDER IS THE PLAIN BOX, deliberately. A stack has to be stood on
		# and walked round; chamfering the collision too would cost a convex hull
		# per block to save half a metre nobody can feel.
		var shape := BoxShape3D.new()
		shape.size = BLOCK_SIZE
		col.shape = shape
		body.add_child(col)


## Build the chamfered box. Every polygon is emitted with an outward winding,
## checked against the centroid rather than reasoned about per face — twelve edge
## quads and eight corner triangles is exactly where a sign error hides.
static func _build_mesh(size: Vector3, bevel: float) -> ArrayMesh:
	var h := size * 0.5
	var b: float = min(bevel, min(h.x, min(h.y, h.z)) * 0.9)
	var st := SurfaceTool.new()
	st.begin(Mesh.PRIMITIVE_TRIANGLES)
	# ⛔ FLAT SHADING, OR THE NORMALS COME OUT WRONG -- measured, not assumed.
	# generate_normals() SMOOTH-averages across coincident vertices by default. A
	# chamfered box has three near-perpendicular faces meeting at every corner and
	# two at every edge, all sharing a position, so the average is meaningless
	# there: probing the first version found 0 of 132 vertex normals matching
	# their own face, with the worst at dot = -0.92 -- pointing almost exactly
	# backwards. The WINDING was fine (0 of 44 triangles inward), which is why
	# this reads as a winding bug and is not one.
	st.set_smooth_group(-1)

	var axes := [Vector3(1, 0, 0), Vector3(0, 1, 0), Vector3(0, 0, 1)]
	var inset := Vector3(h.x - b, h.y - b, h.z - b)

	# ---- 6 faces, each inset by the bevel on its two in-plane axes ----
	for a in 3:
		for s in [1.0, -1.0]:
			var u: int = (a + 1) % 3
			var v: int = (a + 2) % 3
			var quad: Array[Vector3] = []
			for corner in [Vector2(1, 1), Vector2(-1, 1), Vector2(-1, -1), Vector2(1, -1)]:
				var p := Vector3.ZERO
				p[a] = s * h[a]
				p[u] = corner.x * inset[u]
				p[v] = corner.y * inset[v]
				quad.append(p)
			_emit_poly(st, quad)

	# ---- 12 edge quads: one per (pair of axes, pair of signs) ----
	for a in 3:
		for bb in range(a + 1, 3):
			var c: int = 3 - a - bb
			for sa in [1.0, -1.0]:
				for sb in [1.0, -1.0]:
					var p1 := Vector3.ZERO
					p1[a] = sa * h[a]
					p1[bb] = sb * inset[bb]
					var p2 := Vector3.ZERO
					p2[a] = sa * inset[a]
					p2[bb] = sb * h[bb]
					var q: Array[Vector3] = []
					for sc in [1.0, -1.0]:
						var e1 := p1
						e1[c] = sc * inset[c]
						q.append(e1)
					for sc2 in [-1.0, 1.0]:
						var e2 := p2
						e2[c] = sc2 * inset[c]
						q.append(e2)
					_emit_poly(st, q)

	# ---- 8 corner triangles ----
	for sx in [1.0, -1.0]:
		for sy in [1.0, -1.0]:
			for sz in [1.0, -1.0]:
				var t: Array[Vector3] = [
					Vector3(sx * h.x, sy * inset.y, sz * inset.z),
					Vector3(sx * inset.x, sy * h.y, sz * inset.z),
					Vector3(sx * inset.x, sy * inset.y, sz * h.z),
				]
				_emit_poly(st, t)

	st.generate_normals()
	st.generate_tangents()
	var m: ArrayMesh = st.commit()
	m.resource_name = "BevelledBlock_%dx%dx%d" % [int(size.x), int(size.y), int(size.z)]
	return m


## Emit a convex polygon as a fan, wound so GODOT calls it front-facing outward.
##
## ⛔ GODOT'S WINDING IS THE OPPOSITE OF (b-a) x (c-a), and this cost a visible
## bug. Winding by that cross product looks right and measures right by its own
## arithmetic -- a probe found 0 of 44 triangles "inward" -- while
## generate_normals() produced the exact negation: all 132 vertex normals at
## dot = -1.00, every one pointing TOWARD the origin. Front faces were therefore
## inward, the outsides were back-face culled, and the block rendered as its own
## interior.
##
## So the test is inverted on purpose: wind so that the (b-a) x (c-a) normal
## points INWARD, which is what Godot then treats as outward. The block is
## centred and convex, so the centroid direction is "outward" and no per-face
## reasoning is needed either way.
static func _emit_poly(st: SurfaceTool, pts: Array[Vector3]) -> void:
	if pts.size() < 3:
		return
	var centre := Vector3.ZERO
	for p in pts:
		centre += p
	centre /= float(pts.size())
	var n: Vector3 = (pts[1] - pts[0]).cross(pts[2] - pts[0])
	var ordered: Array[Vector3] = pts.duplicate()
	if n.dot(centre) > 0.0:
		ordered.reverse()
	for i in range(1, ordered.size() - 1):
		st.add_vertex(ordered[0])
		st.add_vertex(ordered[i])
		st.add_vertex(ordered[i + 1])