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