Godot 4 透明窗口在 AMD 显卡上 GPU 卡死的排查与修复
背景
在一款基于 Godot 4(gl_compatibility / OpenGL 渲染器)开发的桌面宠物应用中,实现了一个”气球飞走”功能:宠物会抓着气球束飞离主窗口,进入一个独立的透明浮动窗口(popup_transparent),然后缓慢飘走消失。
该功能在 Debug 版本、Nvidia 显卡和集成显卡上均表现正常。但在 AMD 独立显卡 + Release 版本的环境下,触发气球功能后游戏会完全卡死(无崩溃,仅 GPU 命令队列挂起)。
初步排查
怀疑方向一:窗口创建时序
最初怀疑是透明窗口创建后 GPU Surface 初始化未完成就尝试渲染,导致驱动死锁。
参考 FlyingIconManager 的安全模式(屏幕外初始化 + 2 帧等待),对气球窗口做相同处理:
1 2 3 4 5 6 7 8 9
| # 1. 实例化后先放到屏幕外 balloon_window.position = Vector2(-9999, -9999) get_tree().root.add_child(balloon_window)
# 2. 等待 2 帧让 GPU Surface 初始化 await get_tree().process_frame await get_tree().process_frame
# 3. 移动到目标位置再开始内容填充
|
结果:仍然卡死,只是卡死的时机延后了。
怀疑方向二:内容加载时序
通过阶段性日志(Release 模式需用 LogManager.warn 而非 info,因控制台级别为 WARN),逐步缩小范围:
1 2 3 4 5 6 7
| [1/7] balloon_window 实例化 [2/7] add_child 完成 [3/7] 等待帧完成 [4/7] attach_bundle 完成 [5/7] 关闭描边 [6/7] attach_pet_visual ← 卡死在这里 [7/7] grab_focus(从未出现)
|
卡死点固定在将宠物视觉节点 attach 到气球窗口时。
真正的根因
用户启用了宠物的”描边”效果。该效果的实现结构为:
1 2 3 4
| WalkableSpineSprite └── SubViewportContainer (ShaderMaterial - 轮廓检测着色器) └── SubViewport └── SpineSprite (实际宠物模型)
|
将这整棵子树 reparent 到独立透明窗口时,AMD 的 OpenGL 驱动无法正确处理以下嵌套结构:
1 2 3 4
| 透明 Popup Window └── SubViewportContainer + Shader └── SubViewport └── SpineSprite
|
这是 AMD gl_compatibility 驱动在 Release 模式下(无 Debug 开销的降速缓冲)特有的限制:透明窗口内嵌套 SubViewport + Shader 会导致 GPU 渲染管线挂起。
Debug 版本不复现的原因:Debug 编译产生的额外开销恰好提供了足够的时间窗,使 AMD 驱动能完成异步的表面初始化。
解决方案
在将宠物视觉节点转移到气球窗口之前,彻底拆除 SubViewport 包裹结构,并断开全局描边设置信号,防止飞行途中被重新启用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| ## 飞走前彻底拆除描边的 SubViewport 结构 ## 同时断开全局描边信号监听,防止飞行途中被外部重新启用描边 func _disable_outline_for_flyaway(visual: Node2D) -> void: if visual.has_method("is_outline_enabled"): _outline_was_enabled = visual.is_outline_enabled() # 无论 enable_outline 当前是否开启,只要 SubViewport 结构存在就必须拆除 # 因为关闭描边时 _update_outline_enabled() 只清 material 不拆 SubViewport if visual.get("_outline_initialized"): visual._cleanup_outline_structure() # 断开全局描边设置监听,防止飞行途中用户开启描边导致在透明窗口内创建 SubViewport if visual.get("follow_global_setting") and EventBus.has_signal("outline_setting_changed"): if EventBus.is_connected("outline_setting_changed", visual._on_outline_setting_changed): EventBus.disconnect("outline_setting_changed", visual._on_outline_setting_changed)
## 嬍物归还主窗口后重新连接监听并同步当前设置 func _restore_outline_after_return(visual: Node2D) -> void: # 重新连接全局描边设置监听 if visual.get("follow_global_setting") and EventBus.has_signal("outline_setting_changed"): if not EventBus.is_connected("outline_setting_changed", visual._on_outline_setting_changed): EventBus.connect("outline_setting_changed", visual._on_outline_setting_changed) # 同步当前全局设置(飞行期间用户可能改了设置) if visual.get("follow_global_setting") and visual.has_method("_update_outline_from_global"): visual._update_outline_from_global() elif _outline_was_enabled and visual.has_method("is_outline_enabled"): visual.enable_outline = true # setter 会调用 _setup_outline_structure() _outline_was_enabled = false
|
关键点:
enable_outline = false 只是把 SubViewportContainer.material 设为 null,SpineSprite 仍然在 SubViewport 内部渲染。必须调用 _cleanup_outline_structure() 才能将 SpineSprite 移回父节点并销毁 SubViewport。
仅拆除结构不够 —— 必须同时断开全局设置信号监听(EventBus.outline_setting_changed),否则飞行期间用户通过设置界面开启描边会触发 _setup_outline_structure(),在透明窗口内重新创建 SubViewport 导致闪退。
完整流程:
1 2 3 4 5 6 7 8 9
| func _move_bundle_to_window() -> void: # 1. 实例化,屏幕外放置 # 2. add_child,等待 2 帧(GPU Surface 初始化) # 3. set_follow_target + 移动到宠物位置 # 4. attach_bundle(气球束,无 SubViewport) # 5. 拆除描边 SubViewport 结构 ← 关键步骤 _disable_outline_for_flyaway(visual) # 6. attach_pet_visual(此时 SpineSprite 是直接子节点,无嵌套 Viewport) # 7. grab_focus + start_fly_away
|
经验总结
| 场景 |
风险 |
建议 |
| 透明窗口(popup_transparent)+ AMD GPU |
高 |
避免在窗口内放 SubViewport |
| 跨窗口 reparent 含 SubViewport 的节点 |
高 |
reparent 前先拆除 SubViewport |
| 跨窗口期间的全局设置信号 |
高 |
转移前断开信号,归还后重连并同步 |
| Release 模式诊断日志 |
中 |
使用 WARN 级别,不用 INFO |
| Debug 正常、Release 卡死 |
— |
优先排查 GPU Surface 初始化和渲染结构嵌套 |
核心原则:进入独立透明窗口渲染的内容,应尽量保持扁平化的节点结构,避免 SubViewport 嵌套。