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])