Panda3D 玩家穿墙:如何修复由 mesh bounds 生成的碰撞盒?
应先统一 bounds 的坐标系和 CollisionNode 的挂载坐标系:挂到 self.render 下时,使用 np.getTightBounds(self.render);挂到 mesh 自身下面时,使用 np.getTightBounds(np)。
现有代码中的 mask 配置和 pusher 注册关系没有明显错误,CollisionBox 对 CollisionBox 也是 Panda3D 1.10 支持的组合。仅凭这段脚本,还不能确定穿墙是否只有一个原因,需要结合实际地图层级、碰撞盒位置和碰撞记录判断。碰撞体支持表
为什么创建了碰撞盒仍然可能穿墙?
bounds 与挂载位置可能使用了不同的坐标系
np.getTightBounds() 默认返回相对于 np 父节点坐标系的包围盒。它不保证使用 render 坐标,也不等同于 np 自身的局部坐标。
原代码随后直接执行:
self.render.attachNewNode(collNode)
如果 GLB 中相关父节点相对于 render 存在位移、旋转或缩放,碰撞盒就可能位置不对或尺寸有误。只有两个坐标系实际重合时,这种写法才恰好正确。NodePath bounds 文档
因此,挂载代码和 bounds 的获取方式应成对修改。不能只改成 np.attachNewNode(collNode),却继续使用无参数的 np.getTightBounds()。
可视化显示了盒子,不代表已经发生碰撞
显示 CollisionNode 可以确认碰撞体已经创建,但还需要确认它与玩家碰撞体在空间中相交,以及 traverser 是否返回了对应的 CollisionEntry。
原代码还有一个容易忽略的高度问题:
self.player.setPos(0, 0, 2)
CollisionBox(Point3(0, 0, 1), 0.5, 0.5, 1)
在玩家没有缩放、碰撞节点保持默认变换的情况下,碰撞盒中心位于 render 坐标的 z = 3,竖直范围为 z = 2 到 z = 4。如果实际墙体低于这个范围,视觉上玩家虽然走向了墙体,两个碰撞体也可能根本没有相交。
如何修改墙体碰撞盒的生成代码?
对于加载后保持静止的地图,可以先统一在 render 坐标系下生成碰撞盒,减少 GLB 层级变换带来的干扰。
在 __init__ 中,用下面的调用替换原来的碰撞初始化和地图碰撞生成部分:
self.setup_collisions()
再把下面的方法放进 FPSGame 类。它沿用原代码已经导入的类型,保留玩家的 CollisionBox:
def setup_collisions(self):
collision_mask = BitMask32.bit(1)
self.cTrav = CollisionTraverser("main-traverser")
self.pusher = CollisionHandlerPusher()
player_node = CollisionNode("playerCollider")
player_node.addSolid(
CollisionBox(Point3(0, 0, 1), 0.5, 0.5, 1)
)
player_node.setFromCollideMask(collision_mask)
player_node.setIntoCollideMask(BitMask32.allOff())
self.playerCollider = self.player.attachNewNode(player_node)
self.pusher.addCollider(self.playerCollider, self.player)
self.cTrav.addCollider(self.playerCollider, self.pusher)
self.wallColliders = []
meshes = self.map.findAllMatches("**/col_*")
print("Matched col_* nodes:", len(meshes))
for mesh in meshes:
# Bounds and collider placement both use render coordinates.
bounds = mesh.getTightBounds(self.render)
if bounds is None:
print("No geometry bounds:", mesh)
continue
min_b, max_b = bounds
center = (min_b + max_b) * 0.5
half_size = (max_b - min_b) * 0.5
# Retains the original minimum half-size; adjust to map units.
half_size.x = max(half_size.x, 0.1)
half_size.y = max(half_size.y, 0.1)
half_size.z = max(half_size.z, 0.1)
wall_node = CollisionNode("wall_" + mesh.getName())
wall_node.addSolid(
CollisionBox(
center, half_size.x, half_size.y, half_size.z
)
)
wall_node.setFromCollideMask(BitMask32.allOff())
wall_node.setIntoCollideMask(collision_mask)
wall_path = self.render.attachNewNode(wall_node)
self.wallColliders.append(wall_path)
wall_path.show()
print(mesh, "render bounds:", min_b, max_b)
self.playerCollider.show()
self.cTrav.showCollisions(self.render)
这段修改明确了坐标系,生成位置仍需在实际地图中验证。0.1 是沿用原脚本的最小半尺寸,并非适用于所有地图的推荐值;对应轴的完整厚度至少为 0.2 个场景单位。
如果希望盒子跟随 mesh 移动或旋转,可以改用另一组配对写法:
bounds = mesh.getTightBounds(mesh)
# Use these local bounds to construct wall_node.
wall_path = mesh.attachNewNode(wall_node)
两种方案中,包围盒轴对齐的参考坐标系不同。对于旋转墙体,render 坐标下的 AABB 可能比墙体本身宽,造成提前阻挡;局部包围盒可以随节点旋转,但要留意继承的非均匀缩放。Panda3D 对碰撞体缩放变换的处理存在限制,应尽量减少这类变换。碰撞体与缩放说明
setFluidPos 会与 CollisionHandlerPusher 冲突吗?
下面这行本身没有明显的坐标错误:
self.player.setFluidPos(
self.player.getPos() + direction * self.speed * dt
)
在当前结构中,玩家直接挂在 render 下,getPos() 返回父节点坐标系中的位置,传给无参考节点参数的 setFluidPos() 也使用这个坐标系。方向向量来自玩家朝向,在这组前提下可以这样相加。
这里需要区分“保存上一帧变换”和“利用上一帧变换进行碰撞检测”。setFluidPos() 不会自行调用 pusher。Panda3D 1.10 文档要求通过下面的设置启用 previous-transform 检测,并指出该机制仅用于 from collider 为 CollisionSphere 的情况:
self.cTrav.setRespectPrevTransform(True)
因此,原代码使用玩家 CollisionBox 时,不能仅凭 setFluidPos() 就认为已经具备沿运动路径的防穿透能力。快速移动物体的处理说明
先用低速移动验证普通碰撞。如果只有低帧率或高速移动时才穿过薄墙,再考虑使用球体作为玩家 from collider,并配合上述设置;或者将一次移动拆成多个子步,每个子步执行碰撞检测与推回。仅把位置更新拆成多次、最后才检测一次,并不能解决跨越薄墙的问题。
另外,当前类继承自 ShowBase,self.cTrav 就是应用的 base.cTrav,通常会自动逐帧执行,无须在 update() 中重复调用。自建的其他 traverser 才需要手动执行。CollisionTraverser 文档
怎样区分未检测到碰撞和未正确推回?
先检查实际加载的节点与空间位置
先打印地图层级、匹配数量和玩家位置:
self.map.ls()
print("Player position:", self.player.getPos(self.render))
print("Collider node position:", self.playerCollider.getPos(self.render))
重点检查:
**/col_*是否确实匹配到包含几何数据的节点。- 同一对象的父节点和子节点是否都匹配,导致重复生成碰撞盒。
- 玩家碰撞体与墙体碰撞盒在 X、Y、Z 三个方向是否真正重叠。
- 位置输出是否只是
CollisionNode的节点原点。原代码的 solid 中心还带有局部偏移(0, 0, 1),不能把节点位置直接当作盒子中心。
用独立的 CollisionHandlerQueue 验证检测结果
可以给类添加一个临时调试方法,使用独立 traverser 进行一次只记录、不推回的检测:
def probe_collisions(self):
from panda3d.core import CollisionHandlerQueue
queue = CollisionHandlerQueue()
probe = CollisionTraverser("collision-probe")
probe.addCollider(self.playerCollider, queue)
probe.traverse(self.render)
print("Detected entries:", queue.getNumEntries())
for index in range(queue.getNumEntries()):
entry = queue.getEntry(index)
print(
"from:", entry.getFromNodePath(),
"into:", entry.getIntoNodePath()
)
调用前,暂停正常移动,把玩家放到与某个已显示墙盒明确相交的位置,再立即调用这个方法。不要等 pusher 已经把玩家推出墙外后,才用空队列判断检测失败。CollisionHandlerQueue 只保存检测记录,不会阻止玩家移动。碰撞处理器文档
如果明确相交时仍没有记录,继续检查坐标、mask、solid 是否存在,以及墙体是否位于本次遍历的 render 子树中。
如果队列有墙体记录,但正常运行仍然穿墙,则检查 pusher 是否仍与玩家碰撞节点关联、推回目标是否为实际移动的 self.player,以及碰撞处理之后是否有其他任务重新写入玩家位置。原脚本中的这两行注册关系是正确的:
self.pusher.addCollider(self.playerCollider, self.player)
self.cTrav.addCollider(self.playerCollider, self.pusher)
也可以让现有 pusher 输出事件,观察正常运行时是否收到碰撞:
self.pusher.addInPattern("player-wall-in")
self.pusher.addAgainPattern("player-wall-again")
self.accept(
"player-wall-in",
lambda entry: print("Collision started:", entry.getIntoNodePath())
)
self.accept(
"player-wall-again",
lambda entry: print("Collision continues:", entry.getIntoNodePath())
)
pusher 继承了碰撞事件处理能力,可以在保留推回处理的同时观察事件。碰撞处理器事件说明
还需要注意哪些地图与代码问题?
col_ 前缀和“无纹理”只是资源组织方式,不会自动让普通 mesh 成为专用碰撞体,也不会自动使它不可见。当前方案使用的是程序生成的 CollisionBox。若要隐藏原始辅助 mesh,可以对它们调用 hide();隐藏显示与配置碰撞 mask 是两件不同的事。
包围盒只能近似 mesh 的外形。带门洞的墙体如果只生成一个大盒子,门洞也会被封住;凹形或不规则结构应拆分为多个简单碰撞体,或根据需要生成 CollisionPolygon。
这份脚本也没有实现重力或贴地逻辑。玩家不会因为创建了 col_floor 对应的 into collider 就自动落到地面,测试墙体阻挡时应先明确玩家高度。
最后,输入中的 Python 代码没有保留类、方法、循环和条件语句所需的缩进。如果实际文件也是这个格式,会先出现语法错误;若只是粘贴时丢失了缩进,排查时以实际能够运行的文件为准。以上代码片段需要按类方法和初始化调用的位置正确嵌入,实际阻挡效果还需使用地图资源验证。
备注:内容仅供参考。
