更新自研插件
This commit is contained in:
@@ -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*
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user