557 lines
18 KiB
GDScript
557 lines
18 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 SHAPE_TYPE_NAMES: PackedStringArray = [
|
|
"convex_hull", "trimesh", "box", "sphere", "capsule", "cylinder",
|
|
]
|
|
const BODY_TYPE_NAMES: PackedStringArray = [
|
|
"static", "rigid", "area", "character",
|
|
]
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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")
|
|
|
|
|
|
## True when `node` is a root node instantiated directly from a model file.
|
|
static func is_model_instance(node: Node) -> bool:
|
|
if node == null or not is_instance_valid(node):
|
|
return false
|
|
if node.scene_file_path.is_empty():
|
|
return false
|
|
return is_imported_model(node.scene_file_path)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Collision generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## 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`.
|
|
##
|
|
## `model` must be inside the scene tree: global transforms do not resolve on a
|
|
## detached node, which would silently bake every shape at the origin. The body
|
|
## itself is deliberately never parented here, because its own transform stays
|
|
## at identity and the shapes are already baked into that space.
|
|
##
|
|
## `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.
|
|
static func build_collision_body(
|
|
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 _find_added_body(model) != null:
|
|
return null # already has collision from this library
|
|
if not model.is_inside_tree():
|
|
push_warning("[MeshCollider] %s is not inside the scene tree; cannot bake "
|
|
% model.name + "collision without resolved global transforms.")
|
|
return null
|
|
|
|
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 shape := build_shape_for_mesh(mi, body as Node3D, 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:
|
|
var body := build_collision_body(model, 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 coordinate space of
|
|
## `body`.
|
|
##
|
|
## 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, body: Node3D,
|
|
shape_type: ShapeType = 0) -> Shape3D: # ShapeType.CONVEX_HULL
|
|
if mi == null or body == null:
|
|
return null
|
|
var mesh := mi.mesh
|
|
if mesh == null or mesh.get_surface_count() == 0:
|
|
return null
|
|
|
|
# Bake into the body's space. `global_transform` is the anchor even for a
|
|
# skinned mesh: the mesh's own node transform carries the armature scale and
|
|
# offset, which is where the geometry is actually drawn.
|
|
var to_local := body.global_transform.affine_inverse() * mi.global_transform
|
|
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diagnostics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## A one-line description of the collision under `root`, for log output.
|
|
static func describe_collision(root: Node) -> String:
|
|
var shapes := count_shapes(root)
|
|
if shapes == 0:
|
|
return "no collision"
|
|
var kinds: Dictionary = {}
|
|
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:
|
|
var k := (n as CollisionShape3D).shape.get_class()
|
|
kinds[k] = int(kinds.get(k, 0)) + 1
|
|
for c in n.get_children():
|
|
stack.append(c)
|
|
var parts: Array[String] = []
|
|
for k in kinds:
|
|
parts.append("%s x%d" % [k, kinds[k]])
|
|
return "%d shape(s): %s" % [shapes, ", ".join(parts)]
|