更新自研插件
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
@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)]
|
||||
Reference in New Issue
Block a user