更新自研插件

This commit is contained in:
2026-10-04 17:09:39 +08:00
parent 59bd07f630
commit dfd8636476
14 changed files with 1077 additions and 1719 deletions
+106 -153
View File
@@ -1,39 +1,73 @@
# Model Localizer
# Mesh Collider
把 FBX / GLB 拖进 Godot 4.7 编辑器后,自动生成**独立的本地 `.tscn` 场景**并从网格烘焙**凸包碰撞体**。
把 FBX / GLB 拖进 Godot 场景,**自动给这个实例加上碰撞体**。
针对 Godot **4.7.2** 编写并实测。
**不复制模型、不生成新场景、不写任何文件。** 加进去的碰撞节点属于**你正在编辑的场景**,所以模型文件在项目里仍然只被引用一份。
![生成的本地场景](docs/generated_scene.png)
*由 `Chair_PC_04.glb` 生成的 `Chair_PC_04_local.tscn`,几何、材质、贴图完整。*
![碰撞可视化](docs/collision_debug.png)
*绿色为烘焙出的三角网格碰撞体叠加显示 —— 它逐面贴合模型(连椅子脚轮的细节都覆盖到了),这是 `Trimesh` 模式的效果。用 `tests/capture_dbg.tscn` 渲染。*
面向 Godot **4.7.2**。
---
## 它做什么
## 2.0 改了什么
往 3D 视口里拖入一个模型,插件会:
1.0 的版本会把模型"本地化"——把整棵节点树压平、把网格数据内嵌进一个新的 `_local.tscn`。实测代价是 **1.0–3.1× 的额外存储**,以及"模型重导出后场景里是旧几何而且不报错"的风险。
1. **感知拖入** —— 外部文件先被移动到 `res://models/`(可配置),不再散落在 `res://` 根目录;
2. **生成本地场景** —— 在模型旁边写出 `模型名_local.tscn`,节点树完全独立,不再引用模型的导入结果;
3. **烘焙碰撞** —— 每个网格生成一个 `ConvexPolygonShape3D`,挂到 `StaticBody3D` 下;
4. **替换实例** —— 把场景里那个模型实例换成生成好的本地场景(**可 Ctrl+Z 撤销**)。
2.0 **删掉了全部本地化逻辑**。现在只做一件事:往实例上挂碰撞。
已经自带碰撞的模型(`-col` / `-colonly` 后缀,或导入设置里生成过碰撞)会被**原样跳过**,绝不重复添加。
之所以这样可行,是因为一个容易被忽略的事实:**实例化的场景节点可以添加子节点**。你只是不能改它内部已有的节点,而注入的碰撞体是**新子节点** —— 它作为父场景自己的内容被序列化,不引用模型文件,也就没有膨胀。
---
## 安装
1. 把整个 `addons/model_localizer/` 目录复制到你项目的 `addons/` 下;
2. 打开 **项目 → 项目设置 → 插件**,启用 **Model Localizer**;
3. 插件菜单出现在 **项目 → 工具** 下。
1. 把 `addons/mesh_collider/` 复制到你项目的 `addons/` 下;
2. **项目 → 项目设置 → 插件**,启用 **Mesh Collider**;
3. 菜单出现在 **项目 → 工具** 下。
设置项在 **项目设置 → model_localizer**。
设置项在 **项目设置 → 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` **更省事,建议先用它** —— 能用就别引插件。插件的价值在这几种情况:
- 要**按实例**调整碰撞(不同实例用不同形状/层);
- 模型是买来的成品,不想改它的名字或导入设置;
- 只想给**场景里某一个**实例加碰撞,而不是所有用这个模型的地方;
- 想在编辑器里直接看到、选中、手动微调这些碰撞节点。
---
@@ -41,149 +75,69 @@
| 设置 | 默认 | 说明 |
| --- | --- | --- |
| `auto_process_scene_instances` | `true` | 自动处理拖进场景的模型 |
| `auto_swap_to_local` | `true` | 用生成的本地场景替换拖入的实例 |
| `auto_import_dropped_files` | `true` | 把外部拖入的模型文件移进下面的目录 |
| `dropped_files_dir` | `res://models/` | 外部文件的落地目录 |
| `output_dir` | `presets` | **相对路径** = 模型同目录下的子文件夹;`res://` 开头 = 用该绝对目录;留空 = 与模型同目录 |
| `output_suffix` | `_local` | 生成文件名后缀 |
| `add_collision` | `true` | 是否烘焙碰撞 |
| `collision_shape` | **`Trimesh`** | `Convex Hull` / `Trimesh` / `Box` / `Sphere` / `Capsule` / `Cylinder` |
| `collision_body` | **`StaticBody3D`** | `StaticBody3D` / `RigidBody3D` / `Area3D` / `CharacterBody3D` |
| `force_convex_hull` | `true` | **仅 `Convex Hull` 有效**:用真实三角面构建凸包 |
| `auto_add_collision` | `true` | 自动处理拖进场景的模型 |
| `collision_shape` | `Convex Hull` | `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/` | 永不扫描的目录 |
### 默认布局
按默认设置,以 `Chair_PC_04.glb` 为例,文件和生成的场景是这样摆放的:
```
assets/models/
├─ Chair_PC_04.glb ← 你拖进来的模型
├─ Chair_PC_04_Textures_4.png ← 它带来的贴图
└─ presets/
└─ Chair_PC_04_local.tscn ← 自动生成:静态物体子节点 + 三角网格碰撞
```
也就是说**场景永远生成在「模型所在目录的 `presets` 子文件夹」里**,该目录不存在时插件会自动创建。
### 形状怎么选
- **Trimesh**(默认)—— 逐面精确,不做凸包简化。**只能用在静态物体上**,顶点多时开销较大。适合地形、建筑,以及任何不需要移动的模型。
- **Convex Hull** —— 每个网格一个凸包。性能好,可用于会移动的物体;凹形物体会被"填平"。
- **Box / Sphere / Capsule / Cylinder** —— 最省性能。这几种形状会按网格的实际位置自动偏移居中。
- **Convex Hull**(默认)—— 每个网格一个凸包。性能好,可用于会移动的物体;凹形会被"填平"。
- **Trimesh** —— 逐面精确,不做凸包简化。**只能用在静态物体上**,顶点多时开销较大。
- **Box / Sphere / Capsule / Cylinder** —— 最省性能。这几种形状会**按每个网格的实际位置自动偏移居中**。
### 生成的场景根节点保持单位变换
### 模型自带碰撞时
生成的 `.tscn` **根节点变换固定为单位阵**,拖放位置记录在**场景实例**上。这样做有两个原因:
1. Godot 会对"根节点被变换"的场景弹出警告(`节点配置警告!建议不要变换场景的根节点`);
2. 即使你不理这个警告,Godot 里**实例自身的变换优先级高于场景内根节点的变换** —— 烘焙进根节点的位置会被静默丢弃。
所以位置信息放在它真正生效的地方:实例节点。序列化时 `placement_root_transform` 返回给插件,由插件负责把实例摆到正确位置。可以用 `tests/root_transform_warning_test.gd` 复核。
如果模型是用 `-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))
## 行为细节
- **幂等**:同一个模型在同一场景里只处理一次,不会反复生成。
- **模型重导出自动同步**:模型的 `.import` 变了(重新导入 / 换文件),生成场景会跟着刷新。
- **动画安全**:骨骼、`Skeleton3D`、`AnimationPlayer`、动画轨道路径都会保留并校验。实测一个 49 节点、8 个网格、2 段动画、434 条轨道的绑定模型,转换后轨道 **0 条失效**。
- **碰撞几何取自网格的绑定姿态几何**,按节点累积变换放置。实测每个碰撞体与对应网格的顶点包围盒**完全重合**(偏差 0.000000)。
### 关于蒙皮(绑定)模型的限制
对**骨骼绑定**的模型,碰撞体是按它在**静止姿态下的几何**生成的,而不是按运行时骨骼变形后的形状。这是静态烘焙的固有性质(游戏里常见的做法是用胶囊/凸包代替,而不是让碰撞跟随骨骼)。
需要知道的一点:某些 FBX 的**绑定姿态与静止姿态互相矛盾**(导出器没有保持一致)。在这类资产上,骨骼静止姿态给出的位置和渲染出来的位置对不上,没有一个统一变换能同时匹配两者。遇到这种情况,插件的碰撞体会贴合**绑定姿态几何**;如果你发现碰撞和看到的位置不符,**换用 `Convex Hull` 或 `Box` 通常更容易得到符合直觉的结果**。
需要碰撞跟随骨骼运动时,请把 `collision_body` 改成 `RigidBody3D` 或 `CharacterBody3D`,并自行用骨骼驱动它。
- **贴图仍共享**:生成场景通过 `[ext_resource]` 引用贴图,所以同一模型的多个实例共享贴图,不会把贴图复制多份。
- **只在编辑器里运行**,不进游戏包。
---
## 常见问题
**生成场景很大?**
网格数据(顶点、索引、UV)内嵌在 `.tscn` 里,这是"完全本地化"的代价。若模型本身不大而文件异常大,说明网格顶点很多,属于正常。想减小体积可以把 `collision_shape` 换成 `Box`,或把 `add_collision` 关掉后单独处理。
**碰撞体不跟着动画动?**
烘焙出来的是**静态**碰撞体。动画驱动的角色请把 `collision_body` 改成 `RigidBody3D`,或按需换成 `CharacterBody3D` 后自己接管移动。
**模型已有碰撞,插件又加了一遍?**
不会。检测到子树里已有 `CollisionShape3D` / `CollisionPolygon3D` 时整体跳过。如果你**想**重新烘焙,先删掉旧碰撞再执行一次「处理选中模型」(并先「重置记录」)。
**拖动后没反应?**
检查三件事:插件是否已在项目设置里启用;模型根节点是否直接挂在当前编辑场景根下;`auto_process_scene_instances` 是否为 `true`。也可以用「处理选中模型」手动触发。
**`Invalid array format for surface` 警告?**
FBX 的压缩网格在编辑器里重载时会打印这个。已实测生成的 `.tscn` 重新加载后**顶点数与索引数与原始完全一致**,几何无损,可忽略。
---
## 测试
`tests/` 下是随附的验证脚本(跑在本目录的 demo 工程里)。
```bash
# 1) 核心逻辑自测:72 项断言,覆盖路径识别、结构保真、碰撞、动画、序列化往返
godot --headless --path . --script tests/self_test.gd
# 2) 模拟「拖入模型」:先造出带模型实例的场景,再让编辑器加载它
godot --headless --path . --script tests/build_drop_scene.gd
godot --headless --editor --quit --path . --scene res://tests/drop_test.tscn
# 3) 校验生成的场景是否完好(网格表面 / 顶点 / 索引 / 碰撞)
godot --headless --path . --script tests/verify_local.gd -- \
res://assets/models/Chair_PC_04_local.tscn
# 4) 复核生成的场景根节点没有变换(不触发 Godot 的根节点警告)
godot --headless --path . --script tests/root_transform_warning_test.gd
# 5) 看一眼模型(务必带 opengl3;本机 Vulkan 不可用时默认渲染器会崩)
godot --rendering-driver opengl3 --path . res://tests/capture.tscn -- \
res://assets/models/Chair_PC_04_local.tscn shot.png
# 6) 把碰撞体以半透明绿色叠加显示
godot --rendering-driver opengl3 --path . res://tests/capture_dbg.tscn -- \
res://assets/models/Chair_PC_04_local.tscn collision.png
# 7) 物理验证:小球落到碰撞体上(窗口模式)
godot --rendering-driver opengl3 --path . res://tests/physics_test.tscn -- \
res://assets/models/Chair_PC_04_local.tscn
# 检查 / 移除
MeshColliderLib.has_injected_collision(model_instance) # bool
MeshColliderLib.has_any_collision(model_instance) # 子树里任何碰撞
MeshColliderLib.remove_collision(model_instance) # bool
```
> **注意**:步骤 5/6/7 依赖真实的场景树。headless `--script` 模式下 `SceneTree.root`
> **本身不在树里**,`global_transform` 不会传播 —— 任何涉及变换传播的检查都会读到
> 单位阵而给出**假结果**(包括假通过)。所以这类验证必须走编辑器 `--editor`,
> 或带 `--rendering-driver opengl3` 以窗口模式运行。
编辑器里请用插件(走撤销系统)。`add_collision` 是给运行时和工具脚本用的便捷包装;**放进场景保存时**记得节点要有 `owner`,否则不会被序列化。
### 实测结果
---
在 `Chair_PC_04.glb`(8 670 面)与 `GruffaloMouse.fbx`(18 126 面、49 节点、骨骼 + 434 条动画轨道)上:
## 两个必须知道的限制
| 检查项 | GLB | FBX |
| --- | --- | --- |
| 节点数(不含碰撞) | 2 → 2 | 49 → 49 |
| 网格实例 | 1 → 1 | 8 → 8 |
| 索引数 | 8 670 → 8 670 | 18 126 → 18 126 |
| 骨骼 / 动画播放器 | — | 1 → 1 / 1 → 1 |
| 动画轨道失效 | 0 | **0 / 434** |
| 碰撞体与网格顶点包围盒偏差 | 0.000000 | 0.000000(8/8 个网格) |
| 小球落到碰撞体上 | PASS | 见上方蒙皮限制说明 |
| 生成场景重量校验 | PASS | PASS |
**1. 碰撞体不会跟着动画变形。**
碰撞是按模型**静止姿态的几何**烘焙的,不是按骨骼运行时变形后的形状。这是静态烘焙的固有性质,游戏里通常也是用胶囊/凸包代替。
需要碰撞跟随骨骼时,把 `collision_body` 改成 `RigidBody3D` / `CharacterBody3D`,并自己用骨骼驱动它。
**2. 模型重新导出后,碰撞体不会自动更新。**
碰撞体是你场景里的普通节点,Godot 不会因为模型变了就重建它。**换了模型要重新生成**:先「移除选中模型的碰撞」,再「给选中模型加碰撞」即可。
> 这一条正是 1.0 本地化方案想解决的问题,但它的代价(1–3× 存储 + 静默过期)不值得。原生 `-col` 方案没有这个问题,因为它把碰撞生成放在导入流程里 —— 这也是我建议优先考虑它的原因。
---
## 已知取舍
- **检测靠轮询**(0.4 秒一次)。Godot 没有暴露"模型被拖进来"的信号,这是唯一可靠的做法。
- **已验证的 API 兼容性**:本插件只使用在 Godot 4.7.2 上实测过的 API。踩过的坑包括 `MeshInstance3D.skeleton` 是 `NodePath` 而不是 `Skeleton3D`、`ClassDB.can_instantiate()` 才是判断节点能否容纳子节点的正确方法、以及 GDScript 不允许 `const` 字典的值引用其他常量。
---
@@ -191,15 +145,14 @@ godot --rendering-driver opengl3 --path . res://tests/physics_test.tscn -- \
```
demo_project/
├─ addons/model_localizer/ ← 插件本体(复制这个目录即可)
├─ addons/mesh_collider/ ← 插件本体(复制这个目录即可)
│ ├─ plugin.cfg
│ ├─ model_localizer_plugin.gd 编辑器集成(探测 / 生成 / 替换)
│ ├─ model_localizer_core.gd 核心逻辑(纯逻辑,可 headless 自测)
│ ├─ localizer_settings.gd 项目设置
│ ├─ mesh_collider_plugin.gd 编辑器集成(检测 / 注入 / 撤销)
│ ├─ mesh_collider.gd 核心逻辑(纯逻辑,不依赖编辑器)
│ ├─ mesh_collider_settings.gd 项目设置
│ └─ README.md
├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX)
├─ docs/ 截图
└─ tests/ 验证脚本
├─ assets/models/ 测试模型(GLB + 带骨骼动画的 FBX)
└─ tests/ 辅助脚本
```
*Model Localizer · 面向 Godot 4.7.2*
*Mesh Collider 2.0 · 面向 Godot 4.7.2*