Files
2026-10-04 19:27:54 +08:00

603 lines
20 KiB
GDScript

@tool
class_name MeshCollider
extends RefCounted
## Collision baking for imported 3D models.
##
## This library adds a physics body as a CHILD of a model instance that lives in
## your own scene. Because the added node is new, it belongs to the scene that
## contains the instance rather than to the imported asset - so the model file
## stays referenced once and nothing is copied or duplicated on disk.
##
## The resulting layout is deliberately identical to the one Godot's own
## `-col` / `-convcol` name-suffix import produces:
##
## ModelInstance (instance of res://.../model.glb)
## └── CollisionBody (StaticBody3D)
## ├── MeshShape (CollisionShape3D) transform = identity
## └── …
##
## Every collision shape is baked in the coordinate space of the body, so a
## CollisionShape3D never inherits a non-uniformly scaled transform from the
## model's own node hierarchy.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Extensions treated as "an imported model".
const MODEL_EXTENSIONS: PackedStringArray = [
"fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm",
]
## Name of the physics body the library adds.
const BODY_NAME := "CollisionBody"
## Metadata stamped on an added body, so it can always be identified as ours -
## by the plugin, by undo, or by a later removal pass - without guessing from
## the node name alone.
const META_OWNED := &"_mesh_collider_body"
## Metadata on a generated shape recording how far its centre sits from the
## body's origin. Trimesh and convex shapes carry that offset inside their own
## points, but the primitive shapes are always centred on their own origin and
## need this to be placed correctly.
const META_SHAPE_OFFSET := &"_mesh_collider_shape_offset"
enum ShapeType {
CONVEX_HULL, ## Mesh.create_convex_shape() - good for props and characters.
TRIMESH, ## Mesh.create_trimesh_shape() - exact, static bodies only.
BOX, ## Per-mesh AABB box - cheapest.
SPHERE,
CAPSULE,
CYLINDER,
}
enum BodyType {
STATIC, ## StaticBody3D - level geometry.
RIGID, ## RigidBody3D - physically simulated props.
AREA, ## Area3D - triggers.
CHARACTER, ## CharacterBody3D - player/NPC.
}
const BODY_TYPE_NAMES: PackedStringArray = [
"static", "rigid", "area", "character",
]
## Resolve a requested shape/body pair into one that actually works.
##
## A `ConcavePolygonShape3D` (Trimesh) is a static-only shape: Godot will not
## drive a moving RigidBody3D / CharacterBody3D from one, and the failure is
## silent. Rather than let that through, fall back to a convex hull and say so.
## Returns `{ shape, body, changed, reason }`.
static func resolve_shape_and_body(requested_shape: ShapeType,
requested_body: BodyType) -> Dictionary:
var out := {
"shape": requested_shape,
"body": requested_body,
"changed": false,
"reason": "",
}
if requested_shape != ShapeType.TRIMESH:
return out
if requested_body == BodyType.STATIC or requested_body == BodyType.AREA:
return out
out["shape"] = ShapeType.CONVEX_HULL
out["changed"] = true
out["reason"] = ("Trimesh collision is static-only, so %s cannot use it; "
+ "fell back to Convex Hull. Pick a convex shape type for moving bodies, "
+ "or use StaticBody3D.") % BODY_TYPE_NAMES[requested_body]
return out
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
## Lowercase extension of a path, without the dot.
static func extension_of(path: String) -> String:
return path.get_extension().to_lower()
## True when `path` looks like a model file.
static func is_model_file(path: String) -> bool:
if path.is_empty():
return false
return MODEL_EXTENSIONS.has(extension_of(path))
## True when `path` is a model file that has actually finished importing.
static func is_imported_model(path: String) -> bool:
if not is_model_file(path):
return false
if not ResourceLoader.exists(path):
return false
# The `.import` sidecar is written once the asset is usable.
return FileAccess.file_exists(path + ".import")
# ---------------------------------------------------------------------------
# Collision generation
# ---------------------------------------------------------------------------
## The transform that maps a mesh's vertices into the space of `body_global`.
##
## Resolved from `body_global` (a plain Transform3D) rather than by reading
## `body.global_transform` off the body node, because the body is deliberately
## detached while its shapes are baked - and reading `global_transform` on a node
## outside the tree makes Godot log `Condition "!is_inside_tree()" is true` and
## return an identity transform.
static func mesh_space_in(body_global: Transform3D, mi: MeshInstance3D) -> Transform3D:
return body_global.affine_inverse() * mi.global_transform
## Build the `mesh_spaces` map consumed by `build_collision_body`.
##
## MUST be called while `model` is inside the scene tree: global transforms do
## not resolve otherwise, and every shape would silently end up baked at the
## origin. Keys are mesh instance ids.
static func collect_mesh_spaces(model: Node, body_global: Transform3D,
exclude_existing_bodies: bool = false) -> Dictionary:
var meshes: Array[MeshInstance3D] = []
_collect_meshes(model, meshes, exclude_existing_bodies)
var spaces: Dictionary = {}
for mi in meshes:
spaces[mi.get_instance_id()] = mesh_space_in(body_global, mi)
return spaces
## Build a physics body with baked collision shapes for `model`, and return it
## DETACHED, or `null` if there was nothing to add.
##
## The caller owns the returned node and is responsible for putting it in the
## tree (the editor plugin does so through the undo system, so an injection can
## be reverted with Ctrl+Z). Intended to be added as a child of `model`.
##
## `body_global` is the transform the body will end up with once parented - for
## the usual "body as a child of the model instance" case that is simply
## `model.global_transform`. Shapes are baked relative to it, so the body's own
## transform stays at identity.
##
## `mesh_spaces` must come from `collect_mesh_spaces`, gathered while the model
## was still in the tree. A mesh missing from it is skipped rather than guessed
## at, because a wrong transform is worse than a missing shape.
##
## `exclude_existing_bodies` skips meshes that already sit under a physics body.
## Turn it on for models that ship their own collision (Godot's `-col` suffixes).
##
## NOTE: the shape/body defaults are written as literal enum indices rather than
## `ShapeType.CONVEX_HULL`. GDScript requires default parameter values to be
## constant expressions, and a qualified enum member is not reliably accepted -
## the same restriction that rules out `const` dictionaries referencing other
## constants. Keep these literals in step with the enum order above.
##
## `shape_type` is subject to `resolve_shape_and_body` and may be corrected (with
## a warning) if the requested combination cannot work.
static func build_collision_body(
model: Node,
mesh_spaces: Dictionary,
body_global: Transform3D = Transform3D.IDENTITY,
shape_type: ShapeType = 0, # ShapeType.CONVEX_HULL
body_type: BodyType = 0, # BodyType.STATIC
exclude_existing_bodies: bool = false,
collision_layer: int = 1,
collision_mask: int = 1) -> Node:
if model == null or not is_instance_valid(model):
return null
if not (model is Node3D):
return null
if _find_added_body(model) != null:
return null # already has collision from this library
# Enforce the static-only rule for Trimesh here, at the single choke point, so
# neither the plugin nor a script caller can accidentally ask for a concave
# shape on a moving body.
var resolved := resolve_shape_and_body(shape_type, body_type)
if resolved["changed"]:
push_warning("[MeshCollider] " + String(resolved["reason"]))
shape_type = resolved["shape"]
body_type = resolved["body"]
var meshes: Array[MeshInstance3D] = []
_collect_meshes(model, meshes, exclude_existing_bodies)
if meshes.is_empty():
return null
var body := _make_body(body_type)
if body == null:
return null
body.name = BODY_NAME
if body is Node3D:
(body as Node3D).transform = Transform3D.IDENTITY
if body is CollisionObject3D:
(body as CollisionObject3D).collision_layer = collision_layer
(body as CollisionObject3D).collision_mask = collision_mask
body.set_meta(META_OWNED, true)
var added := 0
for mi in meshes:
var key := mi.get_instance_id()
if not mesh_spaces.has(key):
continue # no resolved transform for this mesh; don't guess
var to_local: Transform3D = mesh_spaces[key]
var shape := build_shape_for_mesh(mi, to_local, shape_type)
if shape == null:
continue
var cs := CollisionShape3D.new()
cs.name = _unique_child_name(body, _shape_name(mi))
cs.shape = shape
# Primitive shapes report where their centre belongs; trimesh and convex
# hulls already carry that offset inside their own points.
cs.transform = Transform3D.IDENTITY
if shape.has_meta(META_SHAPE_OFFSET):
var offset = shape.get_meta(META_SHAPE_OFFSET)
if offset is Vector3:
cs.transform = Transform3D(Basis(), offset)
body.add_child(cs, true)
added += 1
if added == 0:
# Nothing usable - hand back nothing rather than an empty body.
body.free()
return null
return body
## Give `body` and everything under it the ownership that makes it serialise
## into the scene. Assigning `owner` only takes effect once the node is inside
## the tree, which is why this cannot happen at build time.
static func prepare_for_scene(body: Node, scene_root: Node) -> void:
if body == null or not is_instance_valid(body):
return
_stamp_owner(body, scene_root)
static func _stamp_owner(node: Node, scene_root: Node) -> void:
node.owner = scene_root
for child in node.get_children():
_stamp_owner(child, scene_root)
## Add collision to `model` directly, without the undo system.
##
## Convenient at runtime or in a tool script. In the editor prefer the plugin,
## which routes through undo so the change can be reverted.
static func add_collision(
model: Node,
shape_type: ShapeType = 0, # ShapeType.CONVEX_HULL
body_type: BodyType = 0, # BodyType.STATIC
exclude_existing_bodies: bool = false,
collision_layer: int = 1,
collision_mask: int = 1) -> Node:
if model == null or not is_instance_valid(model):
return null
if not (model is Node3D):
return null
if not model.is_inside_tree():
push_warning("[MeshCollider] %s is not inside the scene tree; collision cannot be baked without resolved global transforms."
% model.name)
return null
# Resolve every mesh transform up front, while tree membership is certain.
var model_xform: Transform3D = (model as Node3D).global_transform
var spaces := collect_mesh_spaces(model, model_xform, exclude_existing_bodies)
var body := build_collision_body(model, spaces, model_xform, shape_type,
body_type, exclude_existing_bodies, collision_layer, collision_mask)
if body == null:
return null
model.add_child(body, true)
body.owner = model.owner
return body
## Remove a body previously added by `add_collision`. Returns true if a body was
## removed.
static func remove_collision(model: Node) -> bool:
var body := _find_added_body(model)
if body == null:
return false
var parent := body.get_parent()
if parent != null:
parent.remove_child(body)
body.free()
return true
## The body this library added to `model`, or null if there is none.
static func _find_added_body(model: Node) -> Node:
if model == null or not is_instance_valid(model):
return null
for child in model.get_children():
if child.has_meta(META_OWNED):
return child
return null
## True when a body added by this library is present under `model`.
static func has_injected_collision(model: Node) -> bool:
return _find_added_body(model) != null
## True when anything in the subtree already provides collision.
static func has_any_collision(root: Node) -> bool:
if root == null or not is_instance_valid(root):
return false
var stack: Array[Node] = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is CollisionShape3D or n is CollisionPolygon3D:
return true
for c in n.get_children():
stack.append(c)
return false
## Count the collision shapes under `root`.
static func count_shapes(root: Node) -> int:
var total := 0
var stack: Array[Node] = [root]
while not stack.is_empty():
var n: Node = stack.pop_back()
if n is CollisionShape3D and (n as CollisionShape3D).shape != null:
total += 1
for c in n.get_children():
stack.append(c)
return total
static func _collect_meshes(node: Node, out: Array[MeshInstance3D],
skip_physics_subtrees: bool) -> void:
if node is MeshInstance3D:
var mi := node as MeshInstance3D
if mi.mesh != null and mi.mesh.get_surface_count() > 0:
out.append(mi)
for child in node.get_children():
# A subtree that is already a physics body brings its own collision.
if skip_physics_subtrees and (child is PhysicsBody3D or child is Area3D):
continue
if child.has_meta(META_OWNED):
continue
_collect_meshes(child, out, skip_physics_subtrees)
static func _make_body(body_type: BodyType) -> Node:
match body_type:
BodyType.RIGID:
return RigidBody3D.new()
BodyType.AREA:
return Area3D.new()
BodyType.CHARACTER:
return CharacterBody3D.new()
_:
return StaticBody3D.new()
static func _shape_name(mi: MeshInstance3D) -> String:
var n := String(mi.name)
if n.is_empty():
n = "Mesh"
return n + "Shape"
## A name that is not already taken by a child of `parent`. Shapes are added to
## `body` as they are named, so `has_node` alone is enough to keep them unique -
## no separate bookkeeping needed.
static func _unique_child_name(parent: Node, wanted: String) -> String:
var name := wanted
var i := 1
while parent.has_node(NodePath(name)):
name = "%s%d" % [wanted, i]
i += 1
return name
# ---------------------------------------------------------------------------
# Shape construction
# ---------------------------------------------------------------------------
## Build one shape resource for `mi`, baked into the space described by
## `to_local`.
##
## `to_local` comes from `mesh_space_in`, resolved while the model was known to
## be inside the scene tree. Passing it in - rather than reading
## `mi.global_transform` here - is what keeps this function from depending on
## tree membership at bake time.
##
## Returns null when no shape can be produced. The `shape_type` default is a
## literal enum index for the reason given on `build_collision_body`.
static func build_shape_for_mesh(mi: MeshInstance3D, to_local: Transform3D,
shape_type: ShapeType = 0) -> Shape3D: # ShapeType.CONVEX_HULL
if mi == null:
return null
var mesh := mi.mesh
if mesh == null or mesh.get_surface_count() == 0:
return null
match shape_type:
ShapeType.TRIMESH:
return _trimesh_shape(mi, to_local)
ShapeType.BOX:
return _box_shape(mi, to_local)
ShapeType.SPHERE:
return _sphere_shape(mi, to_local)
ShapeType.CAPSULE:
return _capsule_shape(mi, to_local)
ShapeType.CYLINDER:
return _cylinder_shape(mi, to_local)
_:
# ConvexPolygonShape3D builds the hull from whatever points it is
# given, so the mesh's vertices are enough - and unlike
# `Mesh.get_faces()` they are also available for skinned meshes.
return _convex_shape(mi, to_local)
## One surface's vertices, transformed into the body's space.
static func _surface_points(mi: MeshInstance3D, surface: int,
to_local: Transform3D) -> PackedVector3Array:
var out := PackedVector3Array()
var mesh := mi.mesh
if mesh == null:
return out
var arrays := mesh.surface_get_arrays(surface)
if arrays.is_empty():
return out
var verts = arrays[Mesh.ARRAY_VERTEX]
if not (verts is PackedVector3Array):
return out
var v: PackedVector3Array = verts
for p in v:
out.append(to_local * p)
return out
static func _mesh_points(mi: MeshInstance3D, to_local: Transform3D) -> PackedVector3Array:
var out := PackedVector3Array()
var mesh := mi.mesh
if mesh == null:
return out
for s in mesh.get_surface_count():
out.append_array(_surface_points(mi, s, to_local))
return out
## The mesh's AABB in the body's space. Uses the mesh's own stored AABB when it
## is trustworthy, which avoids walking every vertex for the primitive types.
static func _aabb_in_space(mi: MeshInstance3D, to_local: Transform3D) -> AABB:
var mesh := mi.mesh
if mesh == null:
return AABB()
# A skinned mesh's stored AABB is in bind space, so it does not describe the
# posed geometry; fall back to the real vertices for those.
if mi.skin != null and not mi.skeleton.is_empty():
return _aabb_of(_mesh_points(mi, to_local))
return _aabb_of_transformed(mesh.get_aabb(), to_local)
static func _aabb_of_transformed(box: AABB, xform: Transform3D) -> AABB:
var pts := PackedVector3Array()
for corner in _aabb_corners(box):
pts.append(xform * corner)
return _aabb_of(pts)
static func _aabb_of(pts: PackedVector3Array) -> AABB:
if pts.is_empty():
return AABB()
var box := AABB(pts[0], Vector3.ZERO)
for i in range(1, pts.size()):
box = box.expand(pts[i])
return box
static func _aabb_corners(a: AABB) -> Array[Vector3]:
return [
a.position,
a.position + Vector3(a.size.x, 0, 0),
a.position + Vector3(0, a.size.y, 0),
a.position + Vector3(0, 0, a.size.z),
a.position + Vector3(a.size.x, a.size.y, 0),
a.position + Vector3(a.size.x, 0, a.size.z),
a.position + Vector3(0, a.size.y, a.size.z),
a.position + a.size,
]
static func _convex_shape(mi: MeshInstance3D, to_local: Transform3D) -> ConvexPolygonShape3D:
var points := _mesh_points(mi, to_local)
if points.size() < 4:
# A hull needs at least a tetrahedron's worth of points.
return null
var shape := ConvexPolygonShape3D.new()
shape.points = points
return shape
static func _trimesh_shape(mi: MeshInstance3D, to_local: Transform3D) -> ConcavePolygonShape3D:
var mesh := mi.mesh
var faces := PackedVector3Array()
for s in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(s)
if arrays.is_empty():
continue
var verts = arrays[Mesh.ARRAY_VERTEX]
if not (verts is PackedVector3Array):
continue
# Gather this surface's points once, then index into them so triangle
# order and count are preserved exactly.
var points := _surface_points(mi, s, to_local)
if points.is_empty():
continue
var idx = arrays[Mesh.ARRAY_INDEX]
if idx is PackedInt32Array and (idx as PackedInt32Array).size() >= 3:
var indices: PackedInt32Array = idx
for k in range(0, indices.size() - 2, 3):
var a := indices[k]
var b := indices[k + 1]
var c := indices[k + 2]
if a < 0 or b < 0 or c < 0 \
or a >= points.size() or b >= points.size() or c >= points.size():
continue
faces.append(points[a])
faces.append(points[b])
faces.append(points[c])
elif points.size() >= 3:
for k in range(0, points.size() - 2, 3):
faces.append(points[k])
faces.append(points[k + 1])
faces.append(points[k + 2])
if faces.size() < 3:
return null
var shape := ConcavePolygonShape3D.new()
shape.set_faces(faces)
return shape
static func _box_shape(mi: MeshInstance3D, to_local: Transform3D) -> BoxShape3D:
var box := _aabb_in_space(mi, to_local)
if box.size.length_squared() <= 0.0:
return null
var shape := BoxShape3D.new()
shape.size = box.size
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
return shape
static func _sphere_shape(mi: MeshInstance3D, to_local: Transform3D) -> SphereShape3D:
var box := _aabb_in_space(mi, to_local)
var r: float = maxf(box.size.x, maxf(box.size.y, box.size.z)) * 0.5
if r <= 0.0:
return null
var shape := SphereShape3D.new()
shape.radius = r
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
return shape
static func _capsule_shape(mi: MeshInstance3D, to_local: Transform3D) -> CapsuleShape3D:
var box := _aabb_in_space(mi, to_local)
var s := box.size
# Godot's capsule is Y-up: the two largest horizontal axes give the radius.
var r: float = maxf(s.x, s.z) * 0.5
var h: float = maxf(s.y, r * 2.0)
if r <= 0.0:
return null
var shape := CapsuleShape3D.new()
shape.radius = r
shape.height = h
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
return shape
static func _cylinder_shape(mi: MeshInstance3D, to_local: Transform3D) -> CylinderShape3D:
var box := _aabb_in_space(mi, to_local)
var s := box.size
var r: float = maxf(s.x, s.z) * 0.5
var h: float = maxf(s.y, 0.001)
if r <= 0.0:
return null
var shape := CylinderShape3D.new()
shape.radius = r
shape.height = h
shape.set_meta(META_SHAPE_OFFSET, box.get_center())
return shape