OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / model_collision.gd

251 lines · gd ref:
extends RefCounted
class_name ModelCollision
## COLLISION FOR A RUNTIME MODEL — five modes (user, 2026-10-03 + 2026-10-04):
##
##   convex     ONE hull over every mesh. The default: cheap and good enough for most props.
##   trimesh    the exact triangles. Walk-inside-able; costly, and never for moving things.
##   decompose  VHACD convex decomposition (Mesh.convex_decompose — VHACD builds into
##              every target with 3D physics, modules/vhacd/config.py).
##   named      the author's own collision meshes, by Godot's NAME SUFFIXES.
##   none       walk-through decoration (torches, flames, grass).
##
## THE SUFFIX RULES ARE THE EDITOR'S, PORTED, because the editor applies them only at
## import (editor/import/3d/resource_importer_scene.cpp `_pre_fix_node`) and runtime
## GLTFDocument applies none. An artist who tags `Wall-colonly` in Blender expects what
## Godot's importer does with it, so this matches it rule for rule:
##   -colonly / -convcolonly   collision only: the visual mesh is REMOVED
##   -col / -convcol           collision AND the visual mesh is kept
##   "conv" = one convex hull (create_convex_shape(clean=true, simplify=false), exactly
##   as _pre_gen_shape_list calls it); otherwise the trimesh.
##   colonly/convcolonly are tested FIRST, then col/convcol — the importer's order.
## Not ported: empties' draw types (a .blend-import meta GLTFDocument never sets), and
## -rigid / -navmesh / -occ / -noimp, which are not collision.
##
## SHAPES ARE BAKED IN MODEL SPACE WITH THE SCALE APPLIED. A scaled physics body is
## something Godot's physics handles badly (non-uniform worst of all), so the body stays
## at scale 1 and the points are scaled instead. Shapes are plain resources, so the
## cache can hand the same ones to every copy of a model at that scale.

## "auto" (the default) = the author's NAMED collision meshes when the file has any,
## else one convex hull. A file that went to the trouble of tagging Wall-colonly meant
## it; making the player discover a dropdown before that works is the bug the user hit
## (2026-10-04: "it renders them and doesn't treat them as invisible colliders").
const MODES := ["auto", "convex", "trimesh", "decompose", "named", "none"]


## Godot's _teststr, byte for byte: strip trailing digits / spaces / '_' (Blender's
## ".001" arrives as "_001"), then "$suffix" anywhere, or "-suffix" / "_suffix" at the end.
static func teststr(what_in: String, s: String) -> bool:
	var what := _strip_tail(what_in)
	if what.containsn("$" + s):
		return true
	if what.to_lower().ends_with("-" + s):
		return true
	if what.to_lower().ends_with("_" + s):
		return true
	return false


## Godot's _fixstr: the name with the suffix removed (the stripped tail put back).
static func fixstr(what_in: String, s: String) -> String:
	var what := _strip_tail(what_in)
	var end := what_in.substr(what.length())
	if what.containsn("$" + s):
		return what.replace("$" + s, "") + end
	if what.to_lower().ends_with("-" + s):
		return what.substr(0, what.length() - (s.length() + 1)) + end
	if what.to_lower().ends_with("_" + s):
		return what.substr(0, what.length() - (s.length() + 1)) + end
	return what


static func _strip_tail(s: String) -> String:
	var w := s
	while w.length() > 0:
		var c := w.unicode_at(w.length() - 1)
		if (c >= 48 and c <= 57) or c <= 32 or c == 95:
			w = w.substr(0, w.length() - 1)
		else:
			break
	return w


## What a node's name asks for under the named mode:
## {"shape": "convex"|"trimesh"|"", "keep_visual": bool}. The importer's order.
static func suffix_rule(node_name: String) -> Dictionary:
	if teststr(node_name, "colonly"):
		return {"shape": "trimesh", "keep_visual": false}
	if teststr(node_name, "convcolonly"):
		return {"shape": "convex", "keep_visual": false}
	if teststr(node_name, "col"):
		return {"shape": "trimesh", "keep_visual": true}
	if teststr(node_name, "convcol"):
		return {"shape": "convex", "keep_visual": true}
	return {"shape": "", "keep_visual": true}


## Build collision for a model scene (NOT in the tree; transforms are read relative to
## `root`). Returns {shapes: Array[Shape3D], hide: Array[NodePath], named_found: int}.
## `hide` lists the visual meshes the named mode removes (-colonly / -convcolonly);
## the caller frees them from its own instance, since the cached scene is shared.
## `pivot` is the point the scale is taken about, in model space — the model's BASE
## (user, 2026-10-04: "scale around the base, where it stands"); ModelCache.base_of.
static func build(root: Node, mode: String, scale: float, pivot: Vector3 = Vector3.ZERO) -> Dictionary:
	var out := {"shapes": [], "hide": [], "named_found": 0, "mode": mode}
	if not MODES.has(mode):
		return out
	var meshes: Array = []
	_collect(root, root, _root_xform(root), meshes)
	# -colonly / -convcolonly ARE COLLIDERS BY DECLARATION: never drawn, in ANY mode.
	# (Under "convex" they used to render as ordinary meshes.)
	out["hide"] = hidden_paths_of(meshes)
	if mode == "auto":
		mode = "convex"
		for m: Dictionary in meshes:
			if String(suffix_rule(String(m["name"]))["shape"]) != "":
				mode = "named"
				break
		out["mode"] = mode
	if mode == "none":
		return out
	# x' = p + s(x - p): scale ABOUT the pivot, so the base stays where it stands.
	var s := scale_about(pivot, scale)
	match mode:
		"convex":
			var pts := PackedVector3Array()
			for m: Dictionary in meshes:
				pts.append_array(_xform_points(_hull_points(m["mesh"]), s * (m["xform"] as Transform3D)))
			if pts.size() >= 4:
				var c := ConvexPolygonShape3D.new()
				c.points = pts
				(out["shapes"] as Array).append(c)
		"trimesh":
			var faces := PackedVector3Array()
			for m: Dictionary in meshes:
				faces.append_array(_xform_points((m["mesh"] as Mesh).get_faces(), s * (m["xform"] as Transform3D)))
			if faces.size() >= 3:
				var t := ConcavePolygonShape3D.new()
				t.set_faces(faces)
				(out["shapes"] as Array).append(t)
		"decompose":
			for m: Dictionary in meshes:
				for part: Variant in decompose(m["mesh"]):
					var c := ConvexPolygonShape3D.new()
					c.points = _xform_points((part as ConvexPolygonShape3D).points, s * (m["xform"] as Transform3D))
					(out["shapes"] as Array).append(c)
		"named":
			for m: Dictionary in meshes:
				var rule := suffix_rule(String(m["name"]))
				if String(rule["shape"]) == "":
					continue
				out["named_found"] = int(out["named_found"]) + 1
				var x: Transform3D = s * (m["xform"] as Transform3D)
				if rule["shape"] == "convex":
					var c := ConvexPolygonShape3D.new()
					c.points = _xform_points(_hull_points(m["mesh"]), x)
					(out["shapes"] as Array).append(c)
				else:
					var t := ConcavePolygonShape3D.new()
					t.set_faces(_xform_points((m["mesh"] as Mesh).get_faces(), x))
					(out["shapes"] as Array).append(t)
	return out


## Uniform scale about a point: x' = p + s(x - p). The ONE definition the visual
## (model_object) and the collision points share, so they cannot drift apart.
static func scale_about(pivot: Vector3, scale: float) -> Transform3D:
	return Transform3D(Basis().scaled(Vector3.ONE * scale), pivot * (1.0 - scale))


## A model's BASE in model space: the centre of its DRAWN meshes' bounds in X / Z, at
## their lowest Y — the point it stands on. -colonly meshes are not drawn, so they do
## not count. Vector3.ZERO for a model with no meshes.
static func base_of(root: Node) -> Vector3:
	var meshes: Array = []
	_collect(root, root, _root_xform(root), meshes)
	var hidden := hidden_paths_of(meshes)
	var box := AABB()
	var any := false
	for m: Dictionary in meshes:
		if hidden.has(m["path"]):
			continue
		var a: AABB = (m["xform"] as Transform3D) * (m["mesh"] as Mesh).get_aabb()
		box = a if not any else box.merge(a)
		any = true
	if not any:
		return Vector3.ZERO
	var c := box.get_center()
	return Vector3(c.x, box.position.y, c.z)


## The visual meshes a model never draws (-colonly / -convcolonly), by path from root.
static func hidden_paths(root: Node) -> Array:
	var meshes: Array = []
	_collect(root, root, _root_xform(root), meshes)
	return hidden_paths_of(meshes)


static func hidden_paths_of(meshes: Array) -> Array:
	var out: Array = []
	for m: Dictionary in meshes:
		if not bool(suffix_rule(String(m["name"]))["keep_visual"]):
			out.append(m["path"])
	return out


## VHACD hulls for one mesh, unscaled (the caller scales the points). Separate so the
## cache can run VHACD once per model and rescale per copy.
##
## Through MeshInstance3D.create_multiple_convex_collisions, because that is the only
## SCRIPT door to VHACD in this engine: Mesh::convex_decompose exists in C++
## (scene/resources/mesh.cpp:931) but is not bound, and the call fails at runtime with
## "Nonexistent function 'convex_decompose'" (gate_model_collision found it). The
## bound method adds a "<name>_col" StaticBody3D of CollisionShape3Ds to the instance;
## its shapes are taken and the scratch node freed.
static func decompose(mesh: Mesh) -> Array:
	var st := MeshConvexDecompositionSettings.new()
	st.max_convex_hulls = 32
	var mi := MeshInstance3D.new()
	mi.name = "Decompose"
	mi.mesh = mesh
	mi.create_multiple_convex_collisions(st)
	var out: Array = []
	for body in mi.get_children():
		for cs in body.get_children():
			if cs is CollisionShape3D and (cs as CollisionShape3D).shape is ConvexPolygonShape3D:
				out.append((cs as CollisionShape3D).shape)
	mi.free()
	return out


## The scene root's OWN transform: identity for GLTFDocument's usual wrapper, but a real
## one for a GODOT_single_root file (the file's node IS the root). Included so collision
## and the base live in the same space as the drawn model (model_object composes the
## visual as scale_about(base) * root transform).
static func _root_xform(root: Node) -> Transform3D:
	return (root as Node3D).transform if root is Node3D else Transform3D.IDENTITY


static func _collect(node: Node, root: Node, parent_x: Transform3D, out: Array) -> void:
	var x := parent_x
	if node != root and node is Node3D:
		x = parent_x * (node as Node3D).transform
	if node is MeshInstance3D and (node as MeshInstance3D).mesh != null:
		out.append({"mesh": (node as MeshInstance3D).mesh, "xform": x, "name": String(node.name),
			"path": root.get_path_to(node)})
	for c in node.get_children():
		_collect(c, root, x, out)


static func _hull_points(mesh: Mesh) -> PackedVector3Array:
	var c := mesh.create_convex_shape(true, false) as ConvexPolygonShape3D
	return c.points if c != null else PackedVector3Array()


static func _xform_points(pts: PackedVector3Array, x: Transform3D) -> PackedVector3Array:
	var out := PackedVector3Array()
	out.resize(pts.size())
	for i in pts.size():
		out[i] = x * pts[i]
	return out