@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)]