# agame 项目长期笔记 Godot 4.7.2(Forward+,Jolt Physics,D3D12)。**可用编辑器(2026-10-16 实测): `E:\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe` (mono 版;同目录另有 `..._console.exe`)。** ⚠️ 旧记录里的 `D:\_yvmou\Godot\...` **已失效 —— D 盘根本没有 `_yvmou` 目录**,别再照抄。 ## 代码约定 - **GDScript 警告被当作错误**(至少 `inference_on_variant`)。不要写成 `var x := some_variant_returning_call()`;要显式写类型,例如 `var s: Script = node.get_script() as Script`。改完脚本务必用 `--check-only --script res://xxx.gd` 自查。 - 缩进用 tab,注释用中文(项目既有风格)。 ## 无头验证的正确姿势(踩过坑) - `--check-only --script res://x.gd`:GDScript 语法校验,有效。 - `godot --headless --editor --path --quit`:验证编辑器插件能否加载,有效。 - **不要**用 `extends SceneTree` 的 `--script` 脚本去测 `_ready()` 相关行为: 这种模式下节点不会真正进入场景树(`is_inside_tree()` 为 false),`_ready` 不执行, 会得出完全错误的结论。正确做法是造一个临时场景,用位置参数当主场景跑: `godot --headless --path res://tools/_tmp_probe.tscn`。 - 即使是 `--headless`(dummy 渲染后端),**shader 编译错误仍会被报告** (`SHADER ERROR: ...`),所以可以用无头运行来校验 shader 语法。 ## 玩家 = LimboAI LimboHSM(2026-10-15 起) - LimboAI v1.8.1 是 **GDExtension 版**(addons/limboai 只有 bin/,无 plugin.cfg), **不需要启用 editor plugin**;ClassDB 里没有 HSMPlayer —— `LimboHSM`/`LimboState` 本身就是 Node。 - 玩家状态机在 `character/player/hsm/`:根 `player_hsm.gd`(Grounded⇄Airborne)+ Grounded{Idle,Walk,Sprint,Crouch,Sneak} + Airborne{Jump,Fall},全是场景节点、脚本挂状态上。 - 分工:**HSM 管「是什么状态」**(写 motor.target_speed/crouching/sprinting + 调 animator.play_state),**motor 只管物理**(重力/加速度/跳跃缓冲 + try_jump), animator 只管「怎么播」(play_state + set_gait 刷倍率)。player.gd 管线第一位 `hsm.update(delta)`(MANUAL 模式,仅权威端 initialize+set_active;远端靠 Sync 复制)。 - 走/站立转换按**输入**判定(非旧版的速度阈值)——顶墙时播 walk 不播 idle,是预期行为。 - 用法要点:子机 `_enter` 里可 `set_initial_state()` 动态挑入口子状态;环境判定 (起跳/踏空/落地)放**叶子**状态的 _update,别放父机;落地判定要带 `vy<=0` 门槛。 - 踩坑:新 class_name 后无头 `--check-only` 报「Could not find type」= 全局类缓存没刷新, 让编辑器 scan 一次即解;MCP node_set_property 写 enum int 失败 → 手改 tscn + 场景 force_reload。 ## toon 卡通渲染管线 - `shader/cel-shader-base.gdshader`(`USE_*` 宏开关各模块)+ `shader/includes/*.gdshaderinc`, 上游是 godot4-cel-shader 项目。 - `shader/toon.tres` 是材质模板;`shader/outline.gdshader` 作为 `next_pass` 描边。 - `shader/toon_apply.gd`(@tool Node3D)给每个 MeshInstance3D 复制一份独立材质, 因为**每个模型有各自的贴图**,所以必须独立(不能共享一份材质)。 - 已在项目设置里用了 **global uniform**:`diffuse_curve`、`specular_smoothness`、 `fresnel_smoothness`、`outline_width`、`outline_color`。 ⚠️ **但"全局实时生效"是错的**(2026-10-15 实测纠正):编辑器只在**启动时** 把 `[shader_globals]` 注册进 RenderingServer,之后外部改 project.godot(或 MCP 的 `project_manage settings_set`)**不会**影响正在运行的编辑器视口。 手动改完全局参数必须**重启编辑器**(或临时用 `test_run` 跑一段 `RenderingServer.global_shader_parameter_set()` 把值推进去)才能看到效果。 这正是当初"改了 outline_width 却毫无变化"的原因。 - `outline_width` 的实际像素宽度约为 `width × 0.057`(1364px 视口实测校准), 所以**原来的 2.0 实际只有约 0.1px —— 等于没描边**。要看到 2px 描边需要 width ≈ 30。 - 贴图尺寸极不统一(289 张 PNG / 24 种尺寸,1024²、512²、4096²、32² 混杂), 因此**不适合** Texture2DArray + per-instance uniform 方案(显存会爆且要额外打包流水线)。 - 编辑器插件 `addons/toon_refresh/`:改 toon.tres 时自动重刷 toon_apply 材质。 **只覆盖材质级参数**(color/specular/fresnel/uv),管不到 global uniform。 - ⚠️ **`fresnel`(轮廓光)不能给大平面用**:`fresnel_light()` 以 `1 - dot(normal, view)` 为激活量,水平地面/天花板稍远就是掠射角 → 直接饱和成满强度, 整片平面被 `fresnel_color` 点亮。**"远处地面发白"就是这么来的** (原来是 (0,0,0) 贡献恒 0 所以没暴露,2026-10-15 改成 (0.4,0.55,0.8) 后显形)。 真要留轮廓光就把 `a` 压到 ≤0.05,或把颜色调暗到接近物色。 - ⚠️ **MCP `material_manage(op="apply_to_node")` 直接改磁盘 .tscn**, 且重新序列化时会**删除失去引用的子资源与 ext_resource** (实测:地板实验后 `ShaderMaterial_mmyc1`/`tyxef` + 3 个 ext_resource 全被清掉, `saved_to:""` 也照样落盘)。拿 MCP 做材质实验前先备份,事后用 `git diff` 逐行核对恢复。 ## 卡通渲染调参(2026-10-15 定的基线取值) - `shader/toon_ramp_1d.tres`(新,替代已删除的 `new_gradient_texture_1d.tres`): 三级 cel 明暗带,灰阶 0.24 / 0.60 / 1.00,转场各留 0.08 软边。 注意 `diffuse_sample()` **只取红通道**,所以渐变做成彩色也没用。 - `specular_smoothness = 0.08`、`fresnel_smoothness = 0.12`; 原值 10.0 会把高光压到几乎不可见(`smoothstep(0.05, 10.05, x)`)。 - `outline_width = 30`、`outline_color = Color(0.08, 0.07, 0.1, 1)`。 - `toon.tres` 的 `specular` / `fresnel` **RGB 原来都是纯黑 = 两个模块完全失效**, 现已改为 `specular=(0.12,0.12,0.12,α0.45)`、`fresnel=(0.4,0.55,0.8,α0.18)`。 注意 specular 的 α 只用来推 gloss 指数,**亮度只能靠 rgb 调**。 - MCP `settings_set` 写不了 Color:传 dict 会落成 `{"r":..}` 嵌套字典(引擎无法绑定), 传 `"#rrggbb"` 会落成 String。**正确做法是手改 project.godot 写成 `Color(r,g,b,a)`。** int 型也一样:会落成 `={"type": "int","value": 4.0}` 的字典块,**用完记得手改回 `=4`**。 ## 阴影质量(2026-10-15 定位完毕) - **`DirectionalLight3D.light_angular_distance` 是本项目阴影锯齿的元凶。** 它开启 PCSS 软阴影:半影半径 = 遮挡距离 × tan(角径),但 Godot 的 PCSS 采样数固定 → 半径一大就变成**稀疏采样尖刺**,表现为: 1) 大面积表面(沙发扶手内侧面)上出现**成片的「梳齿/蕨叶」黑纹**; 2) 物体投影(白盘子)边缘出现**阶梯 + 毛刺**。 实测 `0.8°` 时 artifact 明显,**改成 `0.0` 后两者同时消失**。 ⚠️ 别以为角径越大越"柔软自然"——在这个引擎里它只会换来尖刺。 - 工程设置 `rendering/lights_and_shadows/{directional_shadow,positional_shadow}/soft_shadow_filter_quality` **默认只有 2(Soft Low)**,提到 **4(Soft High)** 实测高频噪点能量降 40%。枚举顺序: `0 Hard, 1 Soft Very Low, 2 Soft Low, 3 Soft Medium, 4 Soft High, 5 Soft Ultra`。 - `shadow_blur`(0~10,默认 1.0)在 angular=0 时**几乎测不出影响**,不用调。 - `shadow_bias` / `shadow_normal_bias` **别低于引擎默认 0.1 / 2.0**: 归零会让全身自阴影爆发(阴影面积 18484px → 114706px)。原场景的 0.05 / 1.5 偏低。 - ⚠️ **`shadow_normal_bias = 0` + `shadow_blur = 0` 同时出现 = 整幅画面三角颗粒/摩尔纹** (2026-10-15 实测,相邻像素亮度差 0.58 → 12.73,20 倍): `normal_bias = 0` 让**每个阴影贴图纹素**都自阴影抖动,而 `shadow_blur` 就是 PCF 半径, 归零后抖动不再被平滑,于是按贴图纹素频率原样显现(纹素斜投影 → 三角/四边颗粒)。 任意恢复其中一个即可(0.638 / 0.566)。只想锐化就把 blur 取 0、**normal_bias 留 2.0**。 - ⚠️ **提高阴影贴图分辨率救不了锯齿**:`directional_shadow/size 4096 → 8192` 在正常配置下只改善 ~8%,在坏配置下**反而更糟**(12.73 → 17.92), 因为纹素越细抖动频率越高。"锯齿 = 贴图不够大"是错误直觉。 - 室内场景可把 `directional_shadow_max_distance` 收到 45(原来 60),近场纹素密度提高 1.3×。 ## 验证"启动时固化"的设置(可复用套路) `[shader_globals]`、`rendering/lights_and_shadows/*`(阴影贴图尺寸、软阴影滤波质量)、 MSAA 等**只在引擎启动时读一次**,改 project.godot 对运行中的编辑器视口无效。 不要靠编辑器截图验证这类改动,走这条独立进程路线: 1. 造一个探针场景 `tools/_probe.tscn` + `tools/_probe.gd`:`extends Node3D`, 在 `_ready()` 里 `load("res://world/xxx.tscn").instantiate()`、加一台 Camera3D、 等 ~60 帧、`await RenderingServer.frame_post_draw` 后 `get_viewport().get_texture().get_image().save_png(...)`、`quit()`。 2. `DisplayServer.window_set_position(Vector2i(-6000,-6000))` 把窗口挪到屏幕外,不打扰用户。 3. 用 `--resolution 1364x772` + 位置参数指定场景跑: `godot --path --resolution 1364x772 res://tools/_probe.tscn`(**别加 `--headless`**, dummy 渲染后端出不了图)。 4. 想在探针里改灯的参数做单变量对照,直接在脚本里改 `DirectionalLight3D` 的属性再截图, 一次进程就能出多张对照图。 5. **要像素级对齐编辑器视口**:`res://tests/test_xxx.gd`(`@tool` + `extends McpTestSuite`, `print` 抓不到,得用 `FileAccess` 写文件)里读 `EditorInterface.get_editor_viewport_3d(0).get_camera_3d().global_transform`, 再 `test_run` 执行,就能拿到确切机位/ FOV。本项目的 sofa 机位是 `origin(-12.070005, 4.639197, -2.615989)`,`fov 70.01`,视口 1364×772。 6. 注意 main2 的「联机测试」UI 是 **autoload(`autoload/net.tscn`)**,不在场景树里, 探针要递归隐藏 `get_tree().root` 下的 `CanvasLayer`/`Control` 才遮不掉画面。 ## 坐标系陷阱 + 上游着色器的边界(重要) Godot 4 的 `fragment()` 里 `NORMAL` / `TANGENT` / `BINORMAL` 都处于**视图空间**, 而 `NORMAL_MAP` 要的是**切线空间**值、由引擎用 TANGENT/BINORMAL 还原。 把切线空间值直接赋给 `NORMAL` 会让整个场景的法线变成一个跟着相机转的常量 —— 表现为平视全黑、俯瞰才亮。 但**不要**因此去改上游文件:`cel-shader-base.gdshader` 与 `includes/normal-map.gdshaderinc` 来自 , 本项目的策略是**逐字跟随上游**。上游的法线贴图路径 `NORMAL = normal.vector;` 确实有上述坐标空间错误,但上游所有变体都是 `#define USE_NORMAL_MAP 0`, 全仓库从未启用过这条路径(README 也没提),所以是"能编译但结果错"的死代码。 本项目保持 `USE_NORMAL_MAP 0`,与上游一致;`toon.tres` 里残留的 `shader_parameter/normal_scale` 在宏关闭时会被忽略,无副作用。 **结论:要动上游 shader 之前,先用 `E:/Godot/godot4-cel-shader-main/` 里的干净副本 diff。** 正确做法(若将来真要开法线贴图):只写 `NORMAL_MAP = 原始切线空间值` + `NORMAL_MAP_DEPTH`,让引擎合成;不要自己写 `NORMAL`,也不要预先做 `*2-1` 重映射 (引擎自己做,蓝通道由引擎重建)。 ## 玩家尺寸 / 相机高度(2026-10-16 查证) 全在 `character/player/player.tscn`,「大小」是三层缩放叠加: 1. **`Player` 根节点整体 scale = 0.096096**(第 38 行)—— 主开关,模型+碰撞+相机一起缩放。 2. **`Body/mouse` 模型自身 scale = 0.18240134**(第 87 行,X 为负 = 180° 翻转朝向); 实际模型缩放 = 0.096096 × 0.1824 ≈ 0.0175。只想改模型不动碰撞就改这里。 3. **碰撞胶囊(局部)** `CapsuleShape3D: radius 0.54541016 / height 1.4063476`(第 33-35 行), `Collision` 节点 y = 0.7031738(= height/2,贴地)。 - ⚠️ **相机高度与缩放联动,改一个必须同步改另一个**:站立视线高 = `CameraRig.position.y` (当前 1.6,第 46 行),世界高度 = 1.6 × 0.096096 ≈ **0.154 m**;而 `Camera` 节点自身 scale = **10.4062605 = 1/0.096096**(第 49 行),唯一作用就是**抵消父级的 0.096 缩放**, 让相机在世界空间回到 1:1(否则 near/FOV 异常)。动了 `Player` 的 scale,这个倒数必须一起改。 - **无头探针实测(2026-10-16,真实实例化 `player.tscn`)**:`Player.global_scale = 0.096096`; 模型 `Body/mouse.global_scale = 0.017528`;**视觉包围盒 = 0.158 × 0.150 × 0.135 m(约 15cm)**; 胶囊世界尺寸 = 半径 5.24cm / 总高 13.5cm;**相机在脚底上方 0.1116 m**。 ⚠️ 相机净高度 ≠ 1.6 × 0.096 = 0.154 —— `Camera` 自身局部还带 `y = −0.439`, 实际 = (1.6 − 0.439) × 0.096096 ≈ **0.1116 m**(`DebugPanel._camera_height()` 用 `camera.global_position.y − player.global_position.y` 读数就是这个值)。 - ⚠️⚠️ **2026-10-16 定位并修复的重大 bug:`world/main.gd` 的玩家生成会把 Player 根节点的缩放静默抹掉。** 旧代码在 `spawn_function` 里写 `player.global_position = player_spawn_pos.global_position`, 但该节点**此刻还没入树**(spawner 之后才 add_child)。`Node3D.get_global_transform()` 未入树时 直接 `ERR_FAIL_COND_V(!is_inside_tree(), Transform3D())` 返回**单位矩阵**,于是 `set_global_transform()` 把 Player 的 basis 整体换成单位阵 —— **0.096096 那层缩放没了**。 实测后果(与游戏内 DebugPanel 逐位吻合):玩家模型 **1.561 m**、第一人称相机 **1.1610 m**, 面板显示「相机高度 1.161 m / 5.80 身高」;控制台每生成一个玩家刷一条 `ERROR: Condition "!is_inside_tree()" is true.`。 修法:`player.position = player_spawn_pos.position`(Main 与 PlayerSpawnPos 同级、Main 无变换, 局部坐标即世界坐标)。**通用规则:未入树的节点只能写局部 position/transform。** - 全项目**只有** `player.tscn` 一个 CharacterBody3D;`main2.tscn` 的 `World` 根节点无缩放(1:1), 所以 15cm 就是游戏内真实绝对尺寸。玩家是**约 15cm 高的小老鼠**,有意为之(见 `player_motor.gd` / `debug_panel.gd` 注释里的「小人国玩法,身高约 0.2 m」基准; 走速 2.419 m/s ≈ 12 身长/秒就是按 0.2 m 身高定标的 —— 缩放被抹掉时手感同样全错)。 - 相机自身 `scale = 10.4062605 = 1/0.096096`,作者意图是**抵消父级的 0.096**;实测 `Camera3D` 的全局缩放两种玩家缩放下都是 1.0,所以 `InteractionRay.target_position = (0,0.25,0)` 在世界里恒为 0.25 m —— **不随玩家缩放变化,改缩放时不用动它**。 - `ui/character_select.tscn`(选人界面)的预览模型只挂了 `0.18240133` **一层**缩放 (没有 Player 那层 0.096),模型 ≈ 1.56 m,机位 `PreviewCamera` 在 `(0,0.62,1.5)` 看向 `(0,0.45,0)` 也是按这个尺度配的。它是**进游戏前的展示界面**,与游戏内 15cm 天然不同尺(要一致就再乘 0.096 并把机位挪到 ~0.15 m 高)。 - **渲染/骨骼实测(2026-10-16)**:`main2.tscn` 的 `woman` AABB 高 **1.703 m**(世界是 1:1 真人尺度); 绕开 `main.gd` 直接实例化 `player.tscn` 时,玩家渲染出来约 15 cm、`Skeleton3D` 骨骼竖直跨度 0.099 m。⚠️ 这一组数据只对「直接实例化」成立 —— 走真实生成路径时缩放会被上面那条 bug 抹掉。 - ⚠️ **探针技巧**:`player.gd` 的 `_ready()` 会把**整只模型** `Body/mouse` 设为 `visible = false`, 探针里只把下面的 `MeshInstance3D` 设成 `true` **没用**(父节点还藏着), 必须显式 `player.get_node("Body/mouse").visible = true`。这个坑踩了三轮才排掉。 - ⚠️ 量 skinned mesh 时不能只看 `mi.global_transform * mi.get_aabb()`:那是 **mesh 资源**的 包围盒,**不含骨骼变换**,位置/尺寸都可能偏(实测玩家 mesh AABB 落在 Player 原点**下方**, 而真实骨骼在原点**上方**)。要真实数据就查 `Skeleton3D.get_bone_global_pose()`,或者直接渲染截图量像素。 - 蹲伏高度由 `player_look.gd` 的 `crouch_height_ratio = 0.6` 从 `CameraRig.position.y` 推导, 改 rig 的 Y 会自动跟随,不用改脚本。 - `world/main.tscn` 根节点 `Main`(Node3D)无缩放,`MultiplayerSpawner.spawn_path = ".."`, 所以 Player 的 0.096 即最终世界缩放。(`PlayerSpawnPos` 在 y=4.8,出生会先坠落。)