更新自研插件

This commit is contained in:
2026-10-04 18:32:58 +08:00
parent b99952d3c9
commit 604612baac
12 changed files with 219 additions and 97 deletions
+224
View File
@@ -0,0 +1,224 @@
# Mesh Collider
把 FBX / GLB 拖进 Godot 场景,**自动给这个实例加上碰撞体**。
**不复制模型、不生成新场景、不写任何文件。** 加进去的碰撞节点属于**你正在编辑的场景**,所以模型文件在项目里仍然只被引用一份。
面向 Godot **4.7.2**。
---
## 2.0 改了什么
1.0 的版本会把模型"本地化"——把整棵节点树压平、把网格数据内嵌进一个新的 `_local.tscn`。实测代价是 **1.0–3.1× 的额外存储**,以及"模型重导出后场景里是旧几何而且不报错"的风险。
2.0 **删掉了全部本地化逻辑**。现在只做一件事:往实例上挂碰撞。
之所以这样可行,是因为一个容易被忽略的事实:**实例化的场景节点可以添加子节点**。你只是不能改它内部已有的节点,而注入的碰撞体是**新子节点** —— 它作为父场景自己的内容被序列化,不引用模型文件,也就没有膨胀。
---
## 安装
1. 把 `addons/mesh_collider/` 复制到你项目的 `addons/` 下;
2. **项目 → 项目设置 → 插件**,启用 **Mesh Collider**;
3. 菜单出现在 **项目 → 工具** 下。
设置项在 **项目设置 → mesh_collider**。
---
## 用法
**拖进去就行。** 插件会轮询当前编辑的场景,发现新拖入的模型实例就自动加碰撞,并且整个操作走编辑器的撤销系统 —— **Ctrl+Z 可以撤销,Ctrl+Shift+Z 可以重做**。
菜单里还有三个手动操作:
| 菜单项 | 作用 |
| --- | --- |
| 给选中模型加碰撞 | 对当前选中的模型实例执行一次 |
| 移除选中模型的碰撞 | 移除本插件加的那个碰撞体(同样可撤销) |
| 设置 | 跳到项目设置页 |
---
## 生成的节点结构
```
Chair_PC_04 ← 模型实例,仍然引用 res://.../chair.glb
└─ CollisionBody (StaticBody3D) ← 本插件加的,属于你的场景
└─ Chair_PC_04Shape (CollisionShape3D)
```
这个布局与 **Godot 自己**用 `-col` / `-convcol` 名字后缀导入时生成的完全一致。我实测过每种后缀的行为:
| 后缀 | Godot 生成的形状 |
| --- | --- |
| `-col` / `_col` | 网格下挂 `StaticBody3D` → `ConcavePolygonShape3D`(三角网格) |
| `-convcol` / `_convcol` | 同上,形状换成 `ConvexPolygonShape3D`(凸包) |
| `-colonly` / `-convcolonly` | 网格同级生成 `@StaticBody3D@<id>`,网格隐藏 |
保持一致是有意的:两种机制产出的东西对你后续编辑而言是同一种东西,可以混用、可以互相替换。
### 那为什么不直接用 `-col`?
`-col` **更省事,建议先用它** —— 能用就别引插件。插件的价值在这几种情况:
- 要**按实例**调整碰撞(不同实例用不同形状/层);
- 模型是买来的成品,不想改它的名字或导入设置;
- 只想给**场景里某一个**实例加碰撞,而不是所有用这个模型的地方;
- 想在编辑器里直接看到、选中、手动微调这些碰撞节点。
---
## 设置
设置有两个入口,值完全同步:
- **右侧停靠栏的 `MC` 标签页**(检查器那一列)—— 装了插件就有;
- **项目设置 → mesh_collider** —— 想和其他项目设置一起看时用。
> **关于位置。** 你希望它出现在「检查器 / 信号 / 分组」那一排。那三个标签属于 Godot **内建的检查器容器**,插件 API 没有向它注入标签页的接口,所以做不到完全并排。可达的最接近位置是**右列停靠栏**,面板会和检查器出现在同一列里。`MC` 之上悬停会显示完整名称。
>
> **关于悬浮说明。** 面板**没有**使用 `EditorInspector`,而是手写表单 —— 因为 `EditorInspector` 仅在少数列出的枚举类型上把 `hint_string` 当 tooltip,勾选框和数值框悬停时什么都不显示,做不到"每个设置项都有说明"。手写之后每个设置项的标签和控件都能带 tooltip。
面板不使用 `EditorInspector`,而是逐项构建控件,但值仍然读写**同一批 ProjectSettings 键**,所以和项目设置页不会漂移。改动**立即写入**,插件下一次扫描就读到,没有"应用"这一步。
页面底部三个按钮:
| 按钮 | 作用 |
| --- | --- |
| 恢复默认值 | 把插件拥有的**全部**设置改回默认值 |
| 保存到 project.godot | 立即落盘(不点的话,编辑器退出或保存项目时也会写) |
| 在项目设置中打开 | 跳到项目设置页 |
| 设置 | 默认 | 说明 |
| --- | --- | --- |
| `ask_before_adding` | **`true`** | 拖入模型时**弹窗询问**是否添加碰撞 |
| `auto_add_collision` | `false` | 不询问时直接添加;仅在 `ask_before_adding` 关闭后生效 |
| `always_add_without_asking` | `false` | 由弹窗的「不再询问」写入 |
| `collision_shape` | **`Trimesh`** | `Convex Hull` / `Trimesh` / `Box` / `Sphere` / `Capsule` / `Cylinder` |
| `collision_body` | `StaticBody3D` | `StaticBody3D` / `RigidBody3D` / `Area3D` / `CharacterBody3D` |
| `skip_meshes_with_collision` | `false` | 跳过已经位于物理体下的网格(模型自带碰撞时打开) |
| `collision_layer` / `collision_mask` | `1` | 碰撞层 / 掩码 |
| `model_extensions` | `fbx glb gltf dae obj blend …` | 识别为模型的后缀 |
| `skip_dirs` | `addons/`, `.godot/` | 永不扫描的目录 |
### 拖入模型时的三种行为
默认**不静默改动你的场景**,而是弹窗询问:
| `ask_before_adding` | `auto_add_collision` | 拖入模型时 |
| --- | --- | --- |
| **`true`**(默认) | 忽略 | **弹窗询问**,包含「不再询问」勾选框和设置入口提示 |
| `false` | `true` | 直接添加碰撞,不问 |
| `false` | `false` | 什么都不做,只能从 项目 > 工具 手动执行 |
弹窗里的「不再询问」按你本次的选择记忆:选「添加碰撞」→ 以后直接添加;选「不添加」→ 以后不再添加也不再询问。两种情况都会把 `ask_before_adding` 关掉,想恢复询问就在 `MC` 面板里重新打开。
一次拖入多个模型只会弹**一个**弹窗,确认后整批处理。
### 形状怎么选
| 形状 | 精度 | 代价 |
| --- | --- | --- |
| **Trimesh**(默认) | 逐三角面精确,索引一个不差,不做任何简化 | 顶点多时开销大;**只能用于静态物体** |
| **Convex Hull** | 凸包,凹形被"填平"(椅子坐垫下方、脚轮之间会变实心) | 可用于移动物体,开销可控 |
| **Box / Sphere / Capsule / Cylinder** | 单个包围体,最粗 | 最省性能 |
**默认给 Trimesh,是因为静态场景物件本来就该用最精确的形状。** 精度不足的典型症状是"碰撞比模型胖"——那是凸包把凹陷填平了,换成 Trimesh 即可。
这几种基础形状会**按每个网格的实际位置自动偏移居中**,不会全部堆在原点。
> **Trimesh 与移动刚体不兼容。** `ConcavePolygonShape3D` 是静态专用形状,Godot 不会用它驱动物理模拟,而且**失效是静默的**。为避免踩坑,构建时会自动降级为凸包并打印告警 —— 需要会移动的碰撞体时,请把 `collision_shape` 换成凸包或基础形状。
### 怎么确认当前用的是哪种形状
在场景里选中 `CollisionBody` 下的 `CollisionShape3D`,看检视面板里的 **Shape** 属性:
| 看到 | 说明 |
| --- | --- |
| `ConcavePolygonShape3D` | **Trimesh** —— 逐面精确 |
| `ConvexPolygonShape3D` | **凸包** —— 凹形已被填平 |
| `BoxShape3D` / `SphereShape3D` / … | 基础形状 |
用凸包时,"碰撞比模型胖"是必然的,不是精度不足:凸包**无法表达凹形**,增加网格密度也不会改善。
### 升级提示
`collision_shape` 的默认值在 2.0 内定稿时从 `Convex Hull` 改成了 `Trimesh`。如果你之前跑过更早的版本,启动时插件会检测到"这个值仍是旧默认值"并自动迁移,同时打印一条日志。**已经生成的碰撞体不会跟着变** —— 要重新执行一次「移除选中模型的碰撞」→「给选中模型加碰撞」。
### 模型自带碰撞时
如果模型是用 `-col` 后缀导入的,它内部已经有碰撞体了。这时把 `skip_meshes_with_collision` 打开,插件会跳过那些已经有碰撞的子树,避免重复。
---
## 脚本里直接用
核心库不依赖编辑器,运行时也能用:
```gdscript
const MeshColliderLib := preload("res://addons/mesh_collider/mesh_collider.gd")
# 运行时:直接挂上去
var body := MeshColliderLib.add_collision(
model_instance,
MeshColliderLib.ShapeType.CONVEX_HULL,
MeshColliderLib.BodyType.STATIC)
if body != null:
print("shapes: ", MeshColliderLib.count_shapes(body))
# 检查 / 移除
MeshColliderLib.has_injected_collision(model_instance) # bool
MeshColliderLib.has_any_collision(model_instance) # 子树里任何碰撞
MeshColliderLib.remove_collision(model_instance) # bool
```
编辑器里请用插件(走撤销系统)。`add_collision` 是给运行时和工具脚本用的便捷包装;**放进场景保存时**记得节点要有 `owner`,否则不会被序列化。
---
## 三个必须知道的限制
**1. 碰撞体不会跟着动画变形。**
碰撞是按模型**静止姿态的几何**烘焙的,不是按骨骼运行时变形后的形状。这是静态烘焙的固有性质,游戏里通常也是用胶囊/凸包代替。
需要碰撞跟随骨骼时,把 `collision_body` 改成 `RigidBody3D` / `CharacterBody3D`,并自己用骨骼驱动它。
**2. 模型重新导出后,碰撞体不会自动更新。**
碰撞体是你场景里的普通节点,Godot 不会因为模型变了就重建它。**换了模型要重新生成**:先「移除选中模型的碰撞」,再「给选中模型加碰撞」即可。
> 这一条正是 1.0 本地化方案想解决的问题,但它的代价(1–3× 存储 + 静默过期)不值得。原生 `-col` 方案没有这个问题,因为它把碰撞生成放在导入流程里 —— 这也是我建议优先考虑它的原因。
**3. 模型的世界尺寸太小,物理会不准。**
Godot 的物理按"1 单位 = 1 米"调参。如果模型导入后只有几厘米大(某些 FBX 会这样),碰撞虽然几何正确,但**碰撞检测会变得不可靠** —— 表现为穿透、抖动、站在物体上却下滑。
这不是碰撞体的问题,改形状类型也没用。正确做法是**把模型调制到真实比例**:在导入设置里调 `Scale`,或给模型的父节点设 `scale`,让它变成真实尺寸(一把椅子约 1 米、一个角色约 1.7 米)。
---
## 已知取舍
- **检测靠轮询**(0.4 秒一次)。Godot 没有暴露"模型被拖进来"的信号,这是唯一可靠的做法。
- **变换在注入前一次性快照**。所有网格的世界变换在**模型确实处于场景树中**时解析好,再交给构建函数。这有两个原因:刚体是**故意游离**的(由撤销系统负责挂载),而 `global_transform` 在树外会打印 `Condition "!is_inside_tree()" is true` 并返回单位阵;更重要的是,依赖"读取瞬间节点仍在树里"会让碰撞在边界情况下静默烘焙到原点。快照把这件事变成确定的,缺了变换的网格会被跳过而不是猜一个。
- **已验证的 API 兼容性**:本插件只使用在 Godot 4.7.2 上实测过的 API。踩过的坑包括 `MeshInstance3D.skeleton` 是 `NodePath` 而不是 `Skeleton3D`、`ClassDB.can_instantiate()` 才是判断节点能否容纳子节点的正确方法、以及 GDScript 不允许 `const` 字典的值引用其他常量(所以库里的枚举默认值写成字面量索引)。
---
## 工程结构
```
demo_project/
├─ addons/mesh_collider/ ← 插件本体(复制这个目录即可)
│ ├─ plugin.cfg
│ ├─ mesh_collider_plugin.gd 编辑器集成(检测 / 注入 / 撤销 / 停靠面板注册)
│ ├─ mesh_collider.gd 核心逻辑(纯逻辑,不依赖编辑器)
│ ├─ mesh_collider_settings.gd 项目设置 + 面板用的 label / tooltip 文本
│ ├─ mesh_collider_panel.gd 右侧 `MC` 标签页的设置面板
│ └─ README.md
├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX)
└─ tests/ 辅助脚本
```
*Mesh Collider 2.0 · 面向 Godot 4.7.2*
+638
View File
@@ -0,0 +1,638 @@
@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",
]
## 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")
## 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
# ---------------------------------------------------------------------------
## 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
# ---------------------------------------------------------------------------
# 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)]
@@ -0,0 +1 @@
uid://byxlq5nu6745l
@@ -0,0 +1,259 @@
@tool
extends EditorPlugin
## Mesh Collider - editor integration.
##
## Adds a physics body with baked collision shapes to a model instance in the
## scene you are editing. Nothing is written to disk and no copy of the model is
## made: the added nodes belong to your scene, so the model file stays referenced
## exactly once.
##
## Every change goes through the editor's undo system, so Ctrl+Z reverts an
## injection and Ctrl+Shift+Z re-applies it.
##
## SCOPE - this plugin touches no editor UI beyond its own menu entries.
##
## There is no dock panel, no dialog, no overlay and no per-frame work. Earlier
## versions had all four, and each of them caused trouble that could not be
## reproduced or diagnosed from here:
##
## * a per-frame scan of the whole edited scene (heavy on a large scene), and a
## SceneTree.node_added hook that made it run even more often;
## * `AcceptDialog` / `PopupPanel` / a hand-built overlay, every one of which
## opened thousands of pixels too tall;
## * a dock panel whose wrapping labels widened its dock column and pushed the
## editor's own docks and the viewport out of the window.
##
## So discovery is now explicit (a menu action) and reports to the Output panel,
## and all configuration lives in Project Settings > mesh_collider. Nothing here
## participates in the editor's layout.
const Collider := preload("mesh_collider.gd")
const Settings := preload("mesh_collider_settings.gd")
const MENU_SCAN := "Mesh Collider: 扫描场景中的模型 / Scan scene for models"
const MENU_ADD := "Mesh Collider: 给选中模型加碰撞 / Add collision to selected"
const MENU_REMOVE := "Mesh Collider: 移除选中模型的碰撞 / Remove collision"
const MENU_SETTINGS := "Mesh Collider: 打开项目设置 / Open project settings"
func _enter_tree() -> void:
Settings.install()
add_tool_menu_item(MENU_SCAN, _scan_pressed)
add_tool_menu_item(MENU_ADD, _add_pressed)
add_tool_menu_item(MENU_REMOVE, _remove_pressed)
add_tool_menu_item(MENU_SETTINGS, _open_settings)
print("[MeshCollider] ready. All actions are under 项目 > 工具; settings are in "
+ "Project Settings > mesh_collider. No dock panel, no dialogs.")
func _exit_tree() -> void:
remove_tool_menu_item(MENU_SCAN)
remove_tool_menu_item(MENU_ADD)
remove_tool_menu_item(MENU_REMOVE)
remove_tool_menu_item(MENU_SETTINGS)
# ---------------------------------------------------------------------------
# Detection - only ever runs when the user asks for it
# ---------------------------------------------------------------------------
## Collect the model instances in the edited scene that have no collision yet.
func _find_pending_models() -> Array[Node]:
var out: Array[Node] = []
var scene_root: Node = get_editor_interface().get_edited_scene_root()
if scene_root == null:
return out
_collect_model_instances(scene_root, out)
return out
func _collect_model_instances(node: Node, out: Array[Node]) -> void:
for child in node.get_children():
if child is Node3D and _is_pending_model(child):
out.append(child)
_collect_model_instances(child, out)
## A model instance we should act on: instantiated straight from a configured
## model file, and not already carrying one of our bodies.
func _is_pending_model(node: Node) -> bool:
if node.scene_file_path.is_empty():
return false
if not Settings.is_configured_model(node.scene_file_path):
return false
if not ResourceLoader.exists(node.scene_file_path):
return false # not imported yet
return not Collider.has_injected_collision(node)
## Report what a scan found, and act if the user has auto mode on. The Output
## panel is the only feedback channel - nothing is drawn over the editor.
func _scan_pressed() -> void:
var pending := _find_pending_models()
if pending.is_empty():
print("[MeshCollider] 当前场景里没有待处理的模型 / no pending models in this scene")
return
var names: Array[String] = []
for node in pending:
if is_instance_valid(node):
names.append(String(node.name))
print("[MeshCollider] 发现 %d 个未添加碰撞的模型:" % names.size())
for n in names:
print(" • " + n)
if Settings.get_bool(Settings.KEY_AUTO):
_add_all(pending)
else:
print("[MeshCollider] 未改动场景。选中模型后用 项目 > 工具 > "
+ "『给选中模型加碰撞』处理,或打开设置里的『扫描后自动添加』。")
func _add_all(pending: Array[Node]) -> void:
var added := 0
for node in pending:
if is_instance_valid(node) and _inject(node):
added += 1
print("[MeshCollider] 已为 %d 个模型添加碰撞。" % added)
# ---------------------------------------------------------------------------
# Injection
# ---------------------------------------------------------------------------
## Add collision to `model` through the undo system. Returns true when a body was
## added.
func _inject(model: Node, source: String = "") -> bool:
if not is_instance_valid(model):
return false
if not (model is Node3D):
return false
if Collider.has_injected_collision(model):
return false
if source.is_empty():
source = String(model.scene_file_path)
# Everything below reads `global_transform`, which only resolves inside the
# tree. Without this check Godot logs
# `Condition "!is_inside_tree()" is true. Returning: Transform3D()` and the
# shapes would be baked at the origin.
if not model.is_inside_tree():
push_warning("[MeshCollider] %s is not in the scene tree; skipped."
% model.name)
return false
var skip_existing := Settings.get_bool(Settings.KEY_SKIP_EXISTING)
if skip_existing and Collider.has_any_collision(model):
print("[MeshCollider] %s already has collision; skipped." % model.name)
return false
# Resolve the mesh transforms first, while tree membership is certain, then
# build the body from that snapshot. The body itself is built detached on
# purpose: the editor's undo system must own every tree change in this plugin,
# so inserting it here would mean undoing a change that had already been
# applied behind undo's back.
var model_xform: Transform3D = (model as Node3D).global_transform
var spaces := Collider.collect_mesh_spaces(model, model_xform, skip_existing)
# The settings script returns plain ints (it deliberately does not reference
# the MeshCollider class), so convert to the core's enum types explicitly
# rather than relying on an implicit int-to-enum conversion.
var shape: Collider.ShapeType = Settings.shape_type() as Collider.ShapeType
var kind: Collider.BodyType = Settings.body_type() as Collider.BodyType
var body := Collider.build_collision_body(
model,
spaces,
model_xform,
shape,
kind,
skip_existing,
Settings.get_int(Settings.KEY_LAYER),
Settings.get_int(Settings.KEY_MASK))
if body == null:
print("[MeshCollider] %s: no usable mesh geometry found." % model.name)
return false
var scene_root: Node = get_editor_interface().get_edited_scene_root()
var undo: EditorUndoRedoManager = get_undo_redo()
undo.create_action("Mesh Collider: add collision to %s" % model.name)
undo.add_do_method(model, "add_child", body, true)
# `owner` can only be assigned once the body is inside the tree - without it
# Godot would not serialise the body or its shapes into the saved scene.
undo.add_do_method(Collider, "prepare_for_scene", body, scene_root)
undo.add_do_reference(body)
undo.add_undo_method(model, "remove_child", body)
undo.commit_action()
get_editor_interface().mark_scene_as_unsaved()
print("[MeshCollider] %s (%s): added %s with %d shape(s)"
% [model.name, source.get_file(), body.get_class(), Collider.count_shapes(body)])
return true
## Remove the collision this plugin added to `model`, undoably.
func _remove(model: Node) -> void:
if not is_instance_valid(model):
return
var body := _find_added_body(model)
if body == null:
print("[MeshCollider] %s has no collision added by this plugin." % model.name)
return
var scene_root: Node = get_editor_interface().get_edited_scene_root()
var undo: EditorUndoRedoManager = get_undo_redo()
undo.create_action("Mesh Collider: remove collision from %s" % model.name)
undo.add_do_method(model, "remove_child", body)
undo.add_undo_method(model, "add_child", body, true)
undo.add_undo_method(body, "set_owner", scene_root)
# Keep the object alive so redo can put the same body back.
undo.add_undo_reference(body)
undo.commit_action()
get_editor_interface().mark_scene_as_unsaved()
print("[MeshCollider] %s: collision removed." % model.name)
func _find_added_body(model: Node) -> Node:
if not is_instance_valid(model):
return null
for child in model.get_children():
if child.has_meta(Collider.META_OWNED):
return child
return null
# ---------------------------------------------------------------------------
# Menu actions
# ---------------------------------------------------------------------------
func _add_pressed() -> void:
var targets := _selected_models()
for model in targets:
_inject(model)
if targets.is_empty():
print("[MeshCollider] 先选中一个拖入的 FBX/GLB 实例 / "
+ "select a model instance first")
func _remove_pressed() -> void:
var targets := _selected_models()
for model in targets:
_remove(model)
if targets.is_empty():
print("[MeshCollider] 先选中一个模型实例 / select a model instance first")
## Selected nodes that are model instances. Copied into a fresh array, because
## injection modifies the tree while the selection array is still live.
func _selected_models() -> Array[Node]:
var out: Array[Node] = []
var selection: Array[Node] = get_editor_interface().get_selection().get_selected_nodes()
for node in selection:
if node is Node3D and not String(node.scene_file_path).is_empty() \
and Settings.is_configured_model(node.scene_file_path):
out.append(node)
return out
func _open_settings() -> void:
get_editor_interface().call_deferred("set_main_screen_editor", "Project")
print("[MeshCollider] 设置项在 项目设置 > mesh_collider / "
+ "settings are under Project Settings > mesh_collider")
@@ -0,0 +1 @@
uid://dkjs4o4ligbxn
@@ -0,0 +1,282 @@
@tool
class_name MeshColliderSettings
extends RefCounted
## Configuration for Mesh Collider.
##
## Everything lives in `project.godot` under `mesh_collider/*`, so it is
## per-project and shows up in **Project Settings → Mesh Collider**.
const ROOT := "mesh_collider/"
## Names mirrored from `MeshCollider`'s enums, kept here as plain data.
##
## The settings script deliberately does NOT reference the `MeshCollider` class:
## annotating a return type as `MeshCollider.ShapeType` made GDScript fail to
## parse the plugin with "Could not resolve external class member" when the
## settings script was preloaded. Keeping the dependency one-way (plugin ->
## settings, plugin -> core, settings -> nothing) removes that whole class of
## resolution-order problem.
##
## KEEP IN STEP with the enum order in mesh_collider.gd.
const SHAPE_COUNT := 6
const BODY_COUNT := 4
const SHAPE_NAMES: PackedStringArray = [
"convex_hull", "trimesh", "box", "sphere", "capsule", "cylinder",
]
const BODY_NAMES: PackedStringArray = [
"static", "rigid", "area", "character",
]
const DEFAULT_MODEL_EXTENSIONS: PackedStringArray = [
"fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm",
]
const KEY_AUTO := ROOT + "auto_add_collision"
const KEY_SHAPE := ROOT + "collision_shape"
const KEY_BODY := ROOT + "collision_body"
const KEY_SKIP_EXISTING := ROOT + "skip_meshes_with_collision"
const KEY_LAYER := ROOT + "collision_layer"
const KEY_MASK := ROOT + "collision_mask"
const KEY_EXTENSIONS := ROOT + "model_extensions"
const KEY_SKIP_DIRS := ROOT + "skip_dirs"
## The shape this plugin shipped as its default before 2.0 finalised.
##
## `install()` only writes a default when the setting does not exist yet, so a
## project that already ran an earlier build keeps the old value forever - which
## is exactly what happened when this default moved from Convex Hull to Trimesh.
## A stored value still equal to this legacy default is treated as "untouched"
## and migrated.
const LEGACY_SHAPE_DEFAULT := 0 # MeshCollider.ShapeType.CONVEX_HULL
## NOTE: a function rather than a `const` dictionary on purpose. GDScript
## requires a const dictionary's *values* to be constant expressions, so a
## PackedStringArray cannot be built inside one ("isn't a constant expression").
##
## The enum-backed defaults below are written as literal indices for the same
## reason applied one level out: they must not depend on another script's enum
## resolving. Keep them in step with MeshCollider's enum order.
##
## Each entry carries two kinds of text, and they are NOT interchangeable:
## * `hint_string` - consumed by ProjectSettings / the built-in inspector. An
## enum uses it for its option list, so it cannot hold prose.
## * `label` / `tooltip` - consumed by this plugin's own settings panel, which
## labels each row and shows the tooltip on hover.
static func project_info() -> Dictionary:
return {
KEY_AUTO: {
"name": KEY_AUTO,
"type": TYPE_BOOL,
"default": false,
"hint_string": "Add collision to every pending model found by a scan",
"label": "扫描后自动添加",
"tooltip": "打开后,点『扫描场景中的模型』会直接把找到的模型全部加上碰撞。\n"
+ "关闭(默认):扫描只列出结果,由你决定是否处理。\n"
+ "插件不做任何后台轮询,只有你点按钮时才扫描。",
},
KEY_SHAPE: {
"name": KEY_SHAPE,
"type": TYPE_INT,
"default": 1, # MeshCollider.ShapeType.TRIMESH
"hint": PROPERTY_HINT_ENUM,
"hint_string": "Convex Hull,Trimesh,Box,Sphere,Capsule,Cylinder",
"label": "碰撞形状",
"tooltip": "Trimesh:逐三角面精确,零简化。静态物体的最佳选择。\n"
+ "Convex Hull:凸包,凹形会被填平,可用于会移动的物体。\n"
+ "Box / Sphere / Capsule / Cylinder:单个包围体,最省性能。",
},
KEY_BODY: {
"name": KEY_BODY,
"type": TYPE_INT,
"default": 0, # MeshCollider.BodyType.STATIC
"hint": PROPERTY_HINT_ENUM,
"hint_string": "StaticBody3D,RigidBody3D,Area3D,CharacterBody3D",
"label": "刚体类型",
"tooltip": "碰撞体挂在哪种物理节点下。\n"
+ "StaticBody3D:静态场景物件。\n"
+ "RigidBody3D:参与物理模拟的道具。\n"
+ "Area3D:只做触发检测,不阻挡。\n"
+ "CharacterBody3D:玩家/NPC,移动由脚本接管。",
},
KEY_SKIP_EXISTING: {
"name": KEY_SKIP_EXISTING,
"type": TYPE_BOOL,
"default": false,
"hint_string": "Skip meshes already under a physics body",
"label": "跳过已有碰撞的网格",
"tooltip": "打开后,已经位于物理体下方的网格不再重复生成碰撞。\n"
+ "模型自带碰撞时(例如用 -col 后缀导入)应该打开。",
},
KEY_LAYER: {
"name": KEY_LAYER,
"type": TYPE_INT,
"default": 1,
"hint": PROPERTY_HINT_LAYERS_3D_PHYSICS,
"label": "碰撞层",
"tooltip": "生成的碰撞体属于哪些层。用掩码表示,1 = 第 1 层。",
},
KEY_MASK: {
"name": KEY_MASK,
"type": TYPE_INT,
"default": 1,
"hint": PROPERTY_HINT_LAYERS_3D_PHYSICS,
"label": "碰撞掩码",
"tooltip": "生成的碰撞体会和哪些层发生碰撞。用掩码表示。",
},
KEY_EXTENSIONS: {
"name": KEY_EXTENSIONS,
"type": TYPE_PACKED_STRING_ARRAY,
"default": PackedStringArray([
"fbx", "glb", "gltf", "dae", "obj", "blend", "md2", "md3", "vrm",
]),
"hint": PROPERTY_HINT_ARRAY_TYPE,
"hint_string": "String",
"label": "识别为模型的后缀",
"tooltip": "逗号分隔,不带点。只有这些后缀的文件会被当作模型处理。",
},
KEY_SKIP_DIRS: {
"name": KEY_SKIP_DIRS,
"type": TYPE_PACKED_STRING_ARRAY,
"default": PackedStringArray(["res://addons/", "res://.godot/"]),
"hint": PROPERTY_HINT_ARRAY_TYPE,
"hint_string": "String",
"label": "永不扫描的目录",
"tooltip": "逗号分隔。这些目录下的模型不会被自动处理。",
},
}
## Cached copy of `project_info()`. The dictionary is rebuilt from scratch on
## every call, and the getters below are called on every scan, so keeping one
## instance avoids pointless allocation.
static var _cached_info: Dictionary = {}
static func _all_info() -> Dictionary:
if _cached_info.is_empty():
_cached_info = project_info()
return _cached_info
static func _info(key: String) -> Dictionary:
var all := _all_info()
if all.has(key):
return all[key]
return {"name": key, "type": TYPE_NIL, "default": null}
## Register every setting with ProjectSettings, so the values appear in the UI
## and carry a usable default even before the user touches anything.
##
## Existing values are never overwritten, with one narrow exception: the shape
## default this plugin changed after shipping, migrated below.
static func install() -> void:
var all := _all_info()
for key in all:
var info: Dictionary = all[key]
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, info["default"])
ProjectSettings.add_property_info(info)
# Record the default as the initial value, so an untouched setting is not
# reported as a modified project setting.
ProjectSettings.set_initial_value(key, info["default"])
# `install()` only writes a default when the setting is absent, so a project
# that already ran an earlier build would keep the old default forever. Only
# a value still equal to that old default is treated as untouched and
# migrated; a genuinely chosen value is left alone.
var stored := int(ProjectSettings.get_setting(KEY_SHAPE,
all[KEY_SHAPE]["default"]))
var wanted := int(all[KEY_SHAPE]["default"])
if stored != LEGACY_SHAPE_DEFAULT or wanted == LEGACY_SHAPE_DEFAULT:
return
ProjectSettings.set_setting(KEY_SHAPE, wanted)
print(("[MeshCollider] collision_shape was still the old default "
+ "(Convex Hull) - updated to '%s'. Set it back in Project Settings "
+ "> mesh_collider if that was intentional.")
% SHAPE_NAMES[clampi(wanted, 0, SHAPE_NAMES.size() - 1)])
# Only the migration is persisted. Writing on every install would spill all of
# the plugin's defaults into project.godot for no benefit.
var err := ProjectSettings.save()
if err != OK:
push_warning("[MeshCollider] could not save project settings (err %d)" % err)
static func get_bool(key: String) -> bool:
return bool(ProjectSettings.get_setting(key, _info(key)["default"]))
static func get_int(key: String) -> int:
return int(ProjectSettings.get_setting(key, _info(key)["default"]))
static func get_strings(key: String) -> PackedStringArray:
var value = ProjectSettings.get_setting(key, _info(key)["default"])
if value is PackedStringArray:
return value
var out := PackedStringArray()
if value is Array:
for v in value:
out.append(String(v))
return out
static func set_value(key: String, value) -> void:
ProjectSettings.set_setting(key, value)
## Restore every setting this plugin owns back to its registered default.
## Returns the number of settings that actually changed.
static func reset_to_defaults() -> int:
var all := _all_info()
var changed := 0
for key in all:
var info: Dictionary = all[key]
if ProjectSettings.get_setting(key) != info["default"]:
ProjectSettings.set_setting(key, info["default"])
changed += 1
return changed
## The configured shape type, clamped to the valid enum range.
##
## Returns a plain int rather than `MeshCollider.ShapeType` on purpose; see
## SHAPE_NAMES. Callers use it as an enum value.
static func shape_type() -> int:
return clampi(get_int(KEY_SHAPE), 0, SHAPE_COUNT - 1)
## The configured body type, clamped to the valid enum range.
static func body_type() -> int:
return clampi(get_int(KEY_BODY), 0, BODY_COUNT - 1)
## True when `path` looks like a model, according to the configured extensions.
static func is_configured_model(path: String) -> bool:
if path.is_empty():
return false
var ext := path.get_extension().to_lower()
if ext.is_empty():
return false
var configured := get_strings(KEY_EXTENSIONS)
if configured.is_empty():
configured = DEFAULT_MODEL_EXTENSIONS
for e in configured:
if String(e).to_lower().trim_prefix(".") == ext:
return true
return false
## True when `path` is inside a directory the plugin must never scan.
static func should_skip(path: String) -> bool:
var p := path.replace("\\", "/")
for dir in get_strings(KEY_SKIP_DIRS):
var d := String(dir).replace("\\", "/")
if d.is_empty():
continue
if not d.ends_with("/"):
d += "/"
if p.begins_with(d):
return true
return false
@@ -0,0 +1 @@
uid://brsdbmd42dcqs
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Mesh Collider"
description="给拖入场景的 FBX/GLB 模型自动添加碰撞体,不复制模型、不生成新场景。Adds collision to dragged-in models without duplicating the model or creating a new scene."
author="yvmou"
version="2.0.0"
script="mesh_collider_plugin.gd"