@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