【Python】Pygame从零开始学习3

第十五章:高级游戏设计模式 - 第二部分:场景图 (Scene Graph)

15.1 什么是场景图?

场景图是一种树形数据结构,常用于计算机图形学和游戏开发中,用来组织和管理场景中的对象(通常称为节点)。在这个层次结构中,每个节点可以有自己的属性(如位置、旋转、缩放)、子节点,以及一个指向其父节点的引用(根节点除外)。

  • 层次结构 (Hierarchy): 场景图的核心是其分层组织。例如,一个角色模型可能是一个节点,它的手臂、腿、头部等可以是其子节点。车辆的轮子可以是车身节点的子节点。
  • 变换的继承 (Transformation Inheritance): 子节点会继承其父节点的变换(如平移、旋转、缩放)。这意味着如果你移动或旋转一个父节点,它的所有子节点也会相应地相对于父节点进行移动或旋转。这大大简化了复杂对象和动画的管理。
  • 逻辑分组 (Logical Grouping): 场景图允许将相关的游戏对象逻辑上组合在一起。例如,一个“房间”节点可能包含所有家具和装饰品的子节点。
  • 渲染和处理的便利性 (Convenience for Rendering and Processing): 遍历场景图可以方便地执行渲染、碰撞检测、剔除(culling)等操作。

想象一下太阳系:太阳是中心节点。地球是太阳的一个子节点,它有自己的公转(相对于太阳的变换)和自转(局部变换)。月球是地球的子节点,它继承了地球相对于太阳的变换,并加上自己相对于地球的公转和可能的自转。场景图以类似的方式组织游戏世界中的对象。

15.2 为什么使用场景图?

场景图提供了一系列优势,使得复杂场景的管理更加高效和直观:

  1. 组织性 (Organization): 为游戏世界中的大量对象提供了一个清晰、有条理的结构。这对于大型或复杂的场景尤为重要。
  2. 变换管理 (Transformation Management): 自动处理父子节点间的变换传递。改变父节点的变换(位置、旋转、缩放)会自动影响所有子孙节点,简化了对组合对象和层级动画的控制。例如,移动一个角色时,其携带的武器(作为子节点)会自动跟随。
  3. 高效剔除 (Efficient Culling):
    • 视锥剔除 (View Frustum Culling): 如果一个父节点被判断为完全在摄像机视锥之外(即玩家看不到它),那么它的所有子节点也不必被处理或渲染。这可以显著减少渲染和游戏逻辑的计算量。
    • 遮挡剔除 (Occlusion Culling): 更高级的剔除技术,判断对象是否被其他不透明对象遮挡。
  4. 状态排序和渲染优化 (State Sorting and Render Optimization): 场景图可以帮助优化渲染过程。例如,可以按材质、透明度或其他属性对节点进行分组和排序,以减少渲染状态的切换(这在底层图形API中通常是昂贵的操作)。透明对象通常需要从后往前渲染。
  5. 空间查询 (Spatial Queries): 层次结构可以加速空间查询,如射线拾取(判断鼠标点击了哪个对象)或范围查询(找到特定区域内的所有对象)。
  6. 可控的细节级别 (Level of Detail - LOD): 对于远处的复杂对象,可以切换到其简化的模型(一个拥有较少多边形的子节点),而近处的对象则使用高细节模型。场景图节点可以管理这些不同LOD的表示。
  7. 独立性与相对性: 每个节点维护其相对于父节点的“局部”变换。其在世界中的“全局”或“世界”变换是通过从根节点开始累积变换得到的。这使得在局部坐标系中定义对象的属性和动画变得容易。
15.3 场景图的核心组件

一个典型的场景图由以下主要部分构成:

15.3.1 节点 (Node)

节点是场景图的基本构建块。每个节点通常包含:

  • 父节点引用 (Parent Reference): 指向其父节点的指针或引用(根节点为 None)。
  • 子节点列表 (Children List): 一个包含其直接子节点的列表或集合。
  • 局部变换 (Local Transform): 定义了该节点相对于其父节点的位置、旋转和缩放。
  • 世界变换 (World Transform): 定义了该节点在世界坐标系中的最终位置、旋转和缩放。它通常是通过将其局部变换与其父节点的世界变换相结合来计算得到的。
  • 可见性标志 (Visibility Flag): 一个布尔值,指示该节点及其子树是否可见/应被渲染。
  • 其他属性: 根据需要,节点还可以包含名称、标签、绑定的游戏对象/逻辑组件、渲染组件等。
import math # 导入math模块,用于三角函数等

class Transform2D: # 定义2D变换类
    def __init__(self, position=(0.0, 0.0), rotation=0.0, scale=(1.0, 1.0)): # 初始化方法
        self.position = list(position)  # 局部位置 [x, y]
        self.rotation = float(rotation) # 局部旋转 (角度制)
        self.scale = list(scale)    # 局部缩放 [sx, sy]

    def __repr__(self): # 定义字符串表示
        return (f"Transform2D(pos=[{
     self.position[0]:.1f}, {
     self.position[1]:.1f}], "
                f"rot={
     self.rotation:.1f}, scale=[{
     self.scale[0]:.1f}, {
     self.scale[1]:.1f}])") # 返回格式化字符串

    def get_matrix(self): # 获取此变换的局部变换矩阵 (3x3仿射变换矩阵)
        # 这只是一个简化的表示,通常使用更完整的矩阵库
        # T * R * S (先缩放,再旋转,最后平移 - 这是物体坐标系到父坐标系的变换)
        rad = math.radians(self.rotation) # 将角度转换为弧度
        cos_r = math.cos(rad) # 计算旋转的余弦值
        sin_r = math.sin(rad) # 计算旋转的正弦值

        # 缩放矩阵
        S_m = [
            [self.scale[0], 0, 0],
            [0, self.scale[1], 0],
            [0, 0, 1]
        ]

        # 旋转矩阵
        R_m = [
            [cos_r, -sin_r, 0],
            [sin_r, cos_r,  0],
            [0,     0,      1]
        ]

        # 平移矩阵
        T_m = [
            [1, 0, self.position[0]],
            [0, 1, self.position[1]],
            [0, 0, 1]
        ]
        
        # 矩阵乘法辅助函数 (简单的3x3矩阵乘法)
        def mat_mul(A, B): # 定义矩阵乘法函数
            C = [[0, 0, 0], [0, 0, 0], [0, 0, 0]] # 初始化结果矩阵
            for i in range(3): # 遍历行
                for j in range(3): # 遍历列
                    for k in range(3): # 遍历中间项
                        C[i][j] += A[i][k] * B[k][j] # 执行乘法和累加
            return C # 返回结果矩阵

        # 变换顺序: 通常是 Scale -> Rotate -> Translate
        # 如果我们认为点先应用S, 再应用R, 再应用T: M = T * R * S
        # temp_matrix = mat_mul(R_m, S_m) # 先旋转再缩放 (或者理解为缩放后旋转)
        # final_matrix = mat_mul(T_m, temp_matrix) # 再平移
        
        # 更常见的顺序,点P' = T * R * S * P,所以局部变换矩阵 M_local = T * R * S
        # 如果从点变换角度看: P_parent = M_local * P_local
        # M_local = TranslationMatrix * RotationMatrix * ScaleMatrix
        
        # 构建一个组合矩阵 (T * R * S)
        # M = [ sx*cos(r)  -sy*sin(r)  tx ]
        #     [ sx*sin(r)   sy*cos(r)  ty ]
        #     [ 0           0          1  ]
        # 这个形式更直接,但要注意应用顺序。
        # 下面是直接构造 (假设是先缩放,然后围绕原点旋转,然后平移)
        m00 = self.scale[0] * cos_r # 矩阵元素 m00
        m01 = -self.scale[1] * sin_r # 注意这里如果是非均匀缩放和旋转的组合,分解会复杂
        m02 = self.position[0] # 矩阵元素 m02 (平移x)
        
        m10 = self.scale[0] * sin_r # 矩阵元素 m10
        m11 = self.scale[1] * cos_r # 矩阵元素 m11
        m12 = self.position[1] # 矩阵元素 m12 (平移y)

        # 如果缩放因子 sx, sy 不同,那么旋转 sy*sin(r) 和 sx*sin(r) 会导致剪切效果。
        # 标准的2D仿射变换矩阵通常如下组合:
        # Translate(tx, ty) * Rotate(angle) * Scale(sx, sy)
        #   [ 1  0  tx ]   [ cos -sin 0 ]   [ sx 0  0 ]
        #   [ 0  1  ty ] * [ sin  cos 0 ] * [ 0  sy 0 ]
        #   [ 0  0  1  ]   [  0    0  1 ]   [ 0  0  1 ]
        #
        # = [ 1  0  tx ]   [ sx*cos  -sy*sin  0 ]
        #   [ 0  1  ty ] * [ sx*sin   sy*cos  0 ]
        #   [ 0  0  1  ]   [  0        0      1 ]
        #
        # = [ sx*cos  -sy*sin  tx ]
        #   [ sx*sin   sy*cos  ty ]
        #   [  0        0       1 ]
        # 这假设了旋转发生在缩放之后 (在局部坐标系中),然后是平移。
        # 或者,对象先按sx,sy缩放,然后旋转,然后平移到父坐标系中的位置。

        matrix = [ # 定义最终的变换矩阵
            [self.scale[0] * cos_r, -self.scale[1] * sin_r, self.position[0]],
            [self.scale[0] * sin_r,  self.scale[1] * cos_r, self.position[1]],
            [0,                     0,                      1             ]
        ]
        return matrix # 返回计算得到的3x3变换矩阵

    @staticmethod
    def multiply_matrices(m1, m2): # 静态方法,用于乘以两个3x3矩阵
        # m_result = m1 * m2
        res = [[0,0,0],[0,0,0],[0,0,0]] # 初始化结果矩阵
        for i in range(3): # 遍历行
            for j in range(3): # 遍历列
                for k in range(3): # 遍历中间项
                    res[i][j] += m1[i][k] * m2[k][j] # 执行乘法和累加
        return res # 返回结果矩阵

    @staticmethod
    def identity_matrix(): # 静态方法,返回单位矩阵
        return [[1,0,0],[0,1,0],[0,0,1]] # 返回3x3单位矩阵

class SceneNode: # 定义场景节点类
    _node_count = 0 # 类变量,用于生成唯一的节点ID(可选)

    def __init__(self, name=None): # 初始化方法
        self.name = name if name else f"Node_{
     SceneNode._node_count}" # 节点名称
        SceneNode._node_count += 1 # 节点计数增加

        self.parent = None # 父节点引用
        self.children = [] # 子节点列表

        self.local_transform = Transform2D() # 局部变换
        self._world_transform_matrix = Transform2D.identity_matrix() # 世界变换矩阵,初始为单位矩阵
        self._dirty_transform = True  # 变换是否“脏”,需要重新计算

        self.visible = True # 节点是否可见
        self.game_object = None # 可选,关联的游戏对象或实体ID

    def __repr__(self): # 定义字符串表示
        return f"<SceneNode '{
     self.name}' (Children: {
     len(self.children)})>" # 返回节点信息

    def add_child(self, child_node): # 添加子节点的方法
        if not isinstance(child_node, SceneNode): # 检查子节点类型
            raise TypeError("Child must be a SceneNode instance.") # 抛出类型错误
        if child_node.parent: # 如果子节点已有父节点
            child_node.parent.remove_child(child_node) # 从原子节点中移除
        
        child_node.parent = self # 设置子节点的父节点为当前节点
        self.children.append(child_node) # 将子节点添加到当前节点的子节点列表
        child_node.invalidate_transform() # 将子节点的变换标记为脏
        return child_node # 返回添加的子节点

    def remove_child(self, child_node): # 移除子节点的方法
        if child_node in self.children: # 如果子节点在列表中
            child_node.parent = None # 清除子节点的父节点引用
            self.children.remove(child_node) # 从子节点列表中移除
            child_node.invalidate_transform() # 将被移除的子节点的变换标记为脏 (虽然它不再是子节点)
        else: # 如果子节点不在列表中
            print(f"警告: 节点 {
     child_node.name} 不是 {
     self.name} 的子节点。") # 打印警告信息

    def invalidate_transform(self): # 标记变换为“脏”的方法
        self._dirty_transform = True # 设置变换为脏
        for child in self.children: # 遍历所有子节点
            child.invalidate_transform() # 递归地将子节点的变换标记为脏

    def set_position(self, x, y): # 设置局部位置的方法
        self.local_transform.position[0] = float(x) # 设置x坐标
        self.local_transform.position[1] = float(y) # 设置y坐标
        self.invalidate_transform() # 标记变换为脏

    def set_rotation(self, angle_degrees): # 设置局部旋转的方法
        self.local_transform.rotation = float(angle_degrees) # 设置旋转角度
        self.invalidate_transform() # 标记变换为脏

    def set_scale(self, sx, sy): # 设置局部缩放的方法
        self.local_transform.scale[0] = float(sx) # 设置x方向缩放
        self.local_transform.scale[1] = float(sy) # 设置y方向缩放
        self.invalidate_transform() # 标记变换为脏

    def get_world_transform_matrix(self): # 获取世界变换矩阵的方法
        if self._dirty_transform: # 如果变换是脏的
            local_matrix = self.local_transform.get_matrix() # 获取局部变换矩阵
            if self.parent: # 如果存在父节点
                parent_world_matrix = self.parent.get_world_transform_matrix() # 获取父节点的世界变换矩阵
                self._world_transform_matrix = Transform2D.multiply_matrices(parent_world_matrix, local_matrix) # 世界变换 = 父世界变换 * 局部变换
            else: # 如果是根节点
                self._world_transform_matrix = local_matrix # 世界变换即为局部变换
            self._dirty_transform = False # 清除脏标记
        return self._world_transform_matrix # 返回计算或缓存的世界变换矩阵

    def get_world_position(self): # 获取节点在世界坐标系中的位置
        # 世界变换矩阵的最后一列是世界位置 (对于仿射变换)
        wt_matrix = self.get_world_transform_matrix() # 获取世界变换矩阵
        return wt_matrix[0][2], wt_matrix[1][2] # 返回x, y坐标

    def get_world_rotation_and_scale(self): # 尝试从世界变换矩阵分解出旋转和缩放
        # 这在非均匀缩放和剪切存在时会变得复杂
        # 对于简化的2D情况 (无剪切,正交基向量)
        # M = [ a  -b  tx ]
        #     [ b   a  ty ]  (如果a=sx*cos, b=sy*sin, 且sx=sy, 则scale=sx, rot=atan2(b,a))
        #     [ 0   0   1 ]
        # M = [ sx*cos_r  -sy*sin_r  tx ]
        #     [ sx*sin_r   sy*cos_r  ty ]
        m = self.get_world_transform_matrix() # 获取世界变换矩阵
        
        # 计算 x 轴的缩放 sx 和旋转分量
        sx = math.sqrt(m[0][0]**2 + m[1][0]**2) # 计算x轴缩放因子
        # 计算 y 轴的缩放 sy
        sy = math.sqrt(m[0][1]**2 + m[1][1]**2) # 计算y轴缩放因子
        
        # 检查是否有镜像 (行列式符号)
        determinant = m[0][0] * m[1][1] - m[0][1] * m[1][0] # 计算行列式
        if determinant < 0: # 如果行列式为负
            # 通常意味着有一次镜像反射,这会使旋转的解释复杂化
            # 简单处理:如果x轴缩放为负,我们反转它并调整旋转
            if m[0][0] < 0: sx = -sx # 如果m[0][0]为负,反转sx
        
        # 旋转 (以弧度为单位)
        # 假设没有剪切 (skew)
        # m[0][0] = sx * cos(rot)
        # m[1][0] = sx * sin(rot)
        # cos(rot) = m[0][0] / sx
        # sin(rot) = m[1][0] / sx
        world_rotation_rad = math.atan2(m[1][0], m[0][0]) # 计算世界旋转 (弧度)
        world_rotation_deg = math.degrees(world_rotation_rad) # 转换为角度

        # 注意: 这里的sy计算可能不完全准确,如果存在剪切。
        # 更鲁棒的方法是使用SVD分解或极分解,但这超出了这里的范围。
        # 对于简单的不带剪切的变换链,这应该是可接受的近似。
        # 我们返回 (sx, sy) 作为平均或近似的缩放值。
        return world_rotation_deg, (sx, sy) # 返回世界旋转角度和缩放因子元组

    def update_recursive(self, dt, events): # 递归更新节点及其子节点的方法
        # 1. 更新当前节点的逻辑 (如果需要)
        self.update_self(dt, events) # 调用自身更新逻辑

        # 2. 更新世界变换 (如果它或父节点是脏的,get_world_transform_matrix会处理)
        self.get_world_transform_matrix() # 确保世界变换是最新的

        # 3. 递归更新子节点
        for child in self.children: # 遍历子节点
            if child.visible: # 如果子节点可见
                child.update_recursive(dt, events) # 递归调用子节点的更新方法

    def render_recursive(self, surface, camera_offset=(0,0)): # 递归渲染节点及其子节点的方法
        if not self.visible: # 如果节点不可见
            return # 直接返回

        # 1. 获取此节点的世界变换
        world_matrix = self.get_world_transform_matrix() # 获取世界变换矩阵
        
        # 2. 应用变换并渲染此节点的内容 (如果它是一个可渲染节点)
        self.render_self(surface, world_matrix, camera_offset) # 调用自身渲染逻辑

        # 3. 递归渲染子节点
        # 渲染顺序有时很重要 (例如,画家算法,先渲染远的)
        # 简单的深度优先遍历通常用于2D
        for child in self.children: # 遍历子节点
            child.render_recursive(surface, camera_offset) # 递归调用子节点的渲染方法

    def update_self(self, dt, events): # 节点自身的更新逻辑 (可被子类重写)
        # 默认情况下,节点本身没有特定的更新逻辑
        pass # 空操作

    def render_self(self, surface, world_matrix, camera_offset=(0,0)): # 节点自身的渲染逻辑 (可被子类重写)
        # 默认情况下,节点本身不渲染任何东西
        # 子类(如SpriteNode)会重写此方法
        # 作为一个例子,我们可以在节点的世界位置绘制一个小圆点
        # import pygame # (需要pygame)
        # world_x, world_y = world_matrix[0][2], world_matrix[1][2] # 获取世界坐标
        # screen_x = int(world_x - camera_offset[0]) # 计算屏幕x坐标
        # screen_y = int(world_y - camera_offset[1]) # 计算屏幕y坐标
        # if 0 <= screen_x < surface.get_width() and 0 <= screen_y < surface.get_height(): # 如果在屏幕内
        #     #pygame.draw.circle(surface, (200, 200, 200), (screen_x, screen_y), 3) # 绘制小圆点
        pass # 空操作

    def find_node_by_name(self, name): # 按名称查找节点 (深度优先)
        if self.name == name: # 如果当前节点名称匹配
            return self # 返回当前节点
        for child in self.children: # 遍历子节点
            found = child.find_node_by_name(name) # 递归查找
            if found: # 如果在子树中找到
                return found # 返回找到的节点
        return None # 未找到则返回None
15.3.2 变换 (Transformations)

Transform2D 类所示,变换通常包括:

  • 平移 (Translation): 改变节点在父坐标系中的位置 (x, y, (z))。
  • 旋转 (Rotation): 围绕节点的某个轴(2D中通常是z轴,即垂直于屏幕的轴)旋转节点。
  • 缩放 (Scale): 改变节点的大小,可以沿不同轴独立缩放(非均匀缩放)或统一缩放。

这些变换通常使用矩阵来表示和组合。一个节点的局部变换矩阵乘以其父节点的世界变换矩阵,得到该节点自身的世界变换矩阵。
WorldTransform_Child = WorldTransform_Parent * LocalTransform_Child

矩阵乘法的顺序很重要。通常,局部变换应用顺序是:先缩放,然后旋转,最后平移。即 M_local = Translate_Matrix * Rotate_Matrix * Scale_Matrix。当将局部点 P_local 变换到父坐标系 P_parent 时,P_parent = M_local * P_local

15.3.3 叶节点 (Leaf Nodes) 和可渲染对象

场景图中的叶节点通常是那些实际代表可见对象的节点,例如精灵、模型、光源、摄像机等。它们没有子节点(或者它们的子节点是辅助性的,如骨骼动画的骨骼)。
当场景图被遍历以进行渲染时,只有这些叶节点(或具有可渲染组件的中间节点)会实际执行绘制操作。

import pygame # 导入pygame模块

# 一个简单的可渲染节点示例
class SpriteNode(SceneNode): # 定义精灵节点类,继承自SceneNode
    def __init__(self, image_path=None, name=None, color=(255,0,0), size=(30,30)): # 初始化方法
        super().__init__(name=name) # 调用父类初始化
        self.image = None # 精灵图像
        self.rect = None # 精灵矩形
        self._color = color # 如果没有图像,则使用颜色绘制矩形
        self._size = size # 如果没有图像,矩形的大小

        if image_path: # 如果提供了图像路径
            try:
                self.image = pygame.image.load(image_path).convert_alpha() # 加载图像并转换为带alpha通道格式
                self.rect = self.image.get_rect() # 获取图像的矩形
            except pygame.error as e: # 捕获pygame错误
                print(f"错误: 无法加载图像 '{
     image_path}': {
     e}") # 打印错误信息
                self.image = None # 将图像设为None
        
        if not self.image: # 如果没有加载图像 (或加载失败)
             # 创建一个基于颜色的占位符图像
            self.image = pygame.Surface(self._size, pygame.SRCALPHA) # 创建一个带alpha通道的Surface
            self.image.fill(self._color + (180,)) # 用指定颜色填充 (半透明)
            self.rect = self.image.get_rect() # 获取矩形

    def render_self(self, surface, world_matrix, camera_offset=(0,0)): # 节点自身的渲染逻辑
        if not self.image or not self.rect: # 如果没有图像或矩形
            return # 直接返回

        # 1. 获取原始图像的角点 (局部坐标,通常以左上角为(0,0))
        #    或者,更常见的是,我们想让变换作用于图像的中心点。
        #    如果 local_transform.position 是指图像的中心点相对于父节点的偏移,
        #    那么我们需要在渲染时调整图像的绘制位置。
        
        # 假设 local_transform 定义了图像 *中心点* 的位置、旋转和缩放
        # 世界变换矩阵 (world_matrix) 将局部坐标系原点变换到世界坐标系中的某个位置和方向。
        # 如果我们的图像锚点是左上角,我们需要先平移图像,使其中心在局部原点,
        # 然后应用世界变换,再绘制。
        # 或者,我们直接变换图像的四个角点。

        # 更简单的方法:Pygame 的 transform 函数可以直接旋转和缩放 Surface。
        # 我们需要世界旋转和世界缩放。
        world_pos_x, world_pos_y = world_matrix[0][2], world_matrix[1][2] # 获取世界位置
        world_rot_deg, (world_scale_x, world_scale_y) = self.get_world_rotation_and_scale_from_matrix(world_matrix) # 从世界矩阵获取旋转和缩放

        # 缩放图像 (如果缩放变化了)
        # 注意: 频繁缩放/旋转原始图像会导致质量下降。通常保留原始图像,只在渲染时变换。
        # 为了演示,我们直接变换。在实际应用中,可能需要缓存变换后的图像,或者使用OpenGL等更底层的API。
        original_width, original_height = self.rect.size # 获取原始图像尺寸
        scaled_width = int(original_width * world_scale_x) # 计算缩放后的宽度
        scaled_height = int(original_height * world_scale_y) # 计算缩放后的高度

        if scaled_width <= 0 or scaled_height <= 0: # 如果缩放后尺寸无效
            return # 不渲染

        # 如果每次都从原始图像进行缩放和旋转,性能会较差
        # 更好的做法是有一个原始图像,然后动态地变换它
        # 这里用 pygame.transform.rotozoom
        
        # 我们需要一个原始图像的副本进行操作,或者有一个干净的原始图像
        # current_image = self.image # 假设 self.image 是原始图像

        # 变换顺序: Pygame 的 rotozoom 是先缩放再旋转。
        # 缩放因子是相对于原始图像的。
        transformed_image = pygame.transform.rotozoom(self.image, -world_rot_deg, world_scale_x) # 使用rotozoom进行旋转和缩放 (pygame旋转是逆时针)
        # 注意: rotozoom 的 scale 参数是单一值,用于等比缩放。
        # 如果需要非均匀缩放,需要使用 pygame.transform.scale 然后 pygame.transform.rotate
        # 或者直接变换四个角点然后绘制多边形 (更复杂)。
        # 为了简化,我们假设 world_scale_x 和 world_scale_y 相似,或只用 world_scale_x
        
        transformed_rect = transformed_image.get_rect() # 获取变换后图像的矩形
        
        # transformed_rect.center 设置的是图像的中心点在屏幕上的位置
        # world_pos_x, world_pos_y 是我们节点中心的世界坐标
        screen_x = world_pos_x - camera_offset[0] # 计算屏幕x坐标
        screen_y = world_pos_y - camera_offset[1] # 计算屏幕y坐标
        transformed_rect.center = (screen_x, screen_y) # 将变换后图像的中心设置到计算的屏幕位置

        # 剔除 (简单的屏幕边界剔除)
        surface_rect = surface.get_rect() # 获取屏幕矩形
        if transformed_rect.colliderect(surface_rect): # 如果变换后的图像与屏幕相交
            surface.blit(transformed_image, transformed_rect) # 在屏幕上绘制变换后的图像
            # print(f"渲染 {self.name} 在 {transformed_rect.topleft}") # 打印渲染信息

    def get_world_rotation_and_scale_from_matrix(self, m): # 从给定矩阵分解旋转和缩放的辅助方法
        # (与 SceneNode 中的类似方法重复,可以考虑将其作为 Transform2D 的静态方法或工具函数)
        sx = math.sqrt(m[0][0]**2 + m[1][0]**2) # 计算x轴缩放因子
        sy = math.sqrt(m[0][1]**2 + m[1][1]**2) # 计算y轴缩放因子 (注意: 这假设是正交基,无剪切)
                                                # 对于 rotozoom,我们可能只需要一个平均缩放或主轴缩放
        
        world_rotation_rad = math.atan2(m[1][0], m[0][0]) # 计算世界旋转 (弧度)
        world_rotation_deg = math.degrees(world_rotation_rad) # 转换为角度

        # 对于 pygame.transform.rotozoom, scale 是一个单一值。
        # 我们可以取 sx, sy 的平均值,或者只用 sx。这里用 sx。
        # 或者,如果设计上允许非均匀缩放,渲染逻辑会更复杂。
        # 假设我们希望 SpriteNode 的 'scale' 主要影响其大小,rotozoom 主要处理旋转和均匀缩放。
        return world_rotation_deg, (sx, sy) # 返回旋转角度和缩放因子
15.4 场景图的操作

场景图支持多种核心操作:

15.4.1 更新变换 (Updating Transformations)

当一个节点的局部变换改变时,它自身以及其所有子孙节点的世界变换都可能需要重新计算。这通常通过一个“脏标志”(dirty flag)系统来优化:

  1. 当节点的局部变换被修改时,该节点被标记为“脏”。
  2. 在更新阶段,从根节点开始递归遍历场景图。
  3. 如果一个节点是脏的,或者其父节点的世界变换已更新(因此也影响了当前节点),则重新计算当前节点的世界变换:WorldTransform_Current = WorldTransform_Parent * LocalTransform_Current
  4. 然后清除当前节点的脏标志。
  5. 继续递归到子节点。

在我们的 SceneNode 实现中,invalidate_transform() 会将当前节点及其所有子节点标记为脏。get_world_transform_matrix() 会在需要时(即节点是脏的)执行计算,并依赖父节点的 get_world_transform_matrix() 来获取最新的父世界变换,从而隐式地实现了这种级联更新。

15.4.2 渲染 (Rendering)

渲染过程通常也是一个递归遍历:

  1. 从根节点开始。
  2. 如果当前节点可见:
    a. 获取(或确保已计算)当前节点的世界变换矩阵。
    b. 如果当前节点是可渲染的(例如,是一个 SpriteNode 或包含渲染数据):
    i. 设置图形上下文的变换为当前节点的世界变换矩阵(例如,在OpenGL中加载此矩阵)。
    ii. (在Pygame中,我们通常将世界坐标转换为屏幕坐标,并使用Pygame的变换函数来旋转/缩放表面,然后blit到计算出的屏幕位置)。
    iii. 绘制该节点。
    c. 递归访问其所有子节点。

渲染顺序可能很重要,例如为了正确处理透明度(通常从后往前渲染透明对象)或为了实现某些2D分层效果。场景图的遍历顺序(深度优先、广度优先,或自定义顺序)可以影响最终的渲染结果。

15.4.3 剔除 (Culling)

剔除是场景图的一个关键优化。其目的是避免处理和渲染那些对最终图像没有贡献的节点。

  • 视锥剔除 (View Frustum Culling):
    • 摄像机定义了一个可见的空间区域,称为视锥(在2D中可以简化为一个矩形)。
    • 在遍历场景图时,可以检查每个节点的包围体积(Bounding Volume,如包围盒或包围球)是否与视锥相交。
    • 如果一个节点的包围体积完全在视锥之外,那么该节点及其整个子树都可以被安全地跳过,不进行渲染或进一步处理。
    • 这需要将节点的局部包围体积变换到世界空间,然后与世界空间的视锥进行比较。
  • 包围盒剔除 (Bounding Box Culling - 简单2D示例):
    对于2D,我们可以检查节点的AABB (Axis-Aligned Bounding Box,轴对齐包围盒) 是否在屏幕矩形内。
# SceneNode 中可以添加获取包围盒的方法
# class SceneNode:
    # ...
    # def get_world_aabb(self):
        # # 这需要节点定义其局部包围盒
        # # 然后将局部包围盒的角点通过世界变换矩阵进行变换
        # # 然后找到变换后角点的最小和最大x,y值,形成新的AABB
        # # 这是一个简化的概念,实际实现需要考虑旋转
        # # 对于SpriteNode,其rect已经包含了大小信息
        # if hasattr(self, 'rect') and self.rect: # 如果节点有rect属性 (如SpriteNode)
        #     # 获取世界变换后的四个角点
        #     points = [
        #         (self.rect.left, self.rect.top),
        #         (self.rect.right, self.rect.top),
        #         (self.rect.right, self.rect.bottom),
        #         (self.rect.left, self.rect.bottom),
        #     ]
        #     world_matrix = self.get_world_transform_matrix() # 获取世界变换矩阵
            
        #     transformed_points = [] # 存储变换后的点
        #     for p_x, p_y in points: # 遍历局部坐标点
        #         # 应用变换 M * [px, py, 1]'
        #         w_x = world_matrix[0][0]*p_x + world_matrix[0][1]*p_y + world_matrix[0][2] # 计算世界x坐标
        #         w_y = world_matrix[1][0]*p_x + world_matrix[1][1]*p_y + world_matrix[1][2] # 计算世界y坐标
        #         transformed_points.append((w_x, w_y)) # 添加到列表

        #     if not transformed_points: return None # 如果没有点,返回None
            
        #     min_x = min(p[0] for p in transformed_points) # 计算最小x值
        #     max_x = max(p[0] for p in transformed_points) # 计算最大x值
        #     min_y = min(p[1] for p in transformed_points) # 计算最小y值
        #     max_y = max(p[1] for p in transformed_points) # 计算最大y值
        #     return pygame.Rect(min_x, min_y, max_x - min_x, max_y - min_y) # 返回AABB矩形
        # return None # 默认返回None

# 在 render_recursive 中可以加入剔除逻辑
# class SceneNode:
    # def render_recursive(self, surface, camera_offset=(0,0), view_rect=None):
        # if not self.visible: return # 如果不可见,直接返回

        # world_aabb = self.get_world_aabb() # 获取世界AABB
        # if view_rect and world_aabb: # 如果有视口矩形和AABB
            # # 将世界AABB转换为屏幕AABB (考虑相机)
            # screen_aabb = world_aabb.move(-camera_offset[0], -camera_offset[1]) # 移动AABB到屏幕坐标
            # if not view_rect.colliderect(screen_aabb): # 如果不与视口相交
                # return # 剔除此节点及其子树

        # ... (原来的渲染逻辑) ...
        # for child in self.children:
            # child.render_recursive(surface, camera_offset, view_rect) # 向下传递view_rect

实现精确高效的剔除可能比较复杂,尤其是当节点旋转时,其轴对齐包围盒(AABB)会变大。使用 OBB(Oriented Bounding Box,定向包围盒)或包围球可能更精确,但碰撞检测也更复杂。

15.4.4 拾取 (Picking)

拾取是指确定鼠标光标(或其他射线)指向场景图中的哪个对象。

  1. 将屏幕点击坐标转换为世界坐标(如果使用了相机,需要反向变换)。
  2. 从根节点开始递归遍历场景图。
  3. 对于每个节点,将其世界包围体积(或更精确的形状)与点击的世界坐标(或射线)进行测试。
    • 一种方法是将世界点击坐标反向变换到节点的局部坐标系中 (LocalClick = Inverse(WorldTransform_Node) * WorldClick),然后检查这个局部点击坐标是否在节点的局部形状内(例如,一个精灵的局部矩形)。
  4. 通常选择最“顶层”(离相机最近,或在2D中具有最高绘制顺序)且包含点击的节点。
15.5 Python 场景图基本示例

让我们构建一个简单的场景图并用 Pygame 进行可视化。

# (SceneNode, Transform2D, SpriteNode 类定义如上)

class Scene: # 定义场景类
    def __init__(self): # 初始化方法
        self.root = SceneNode(name="SceneRoot") # 创建根节点
        self.camera_offset = [0, 0] # 相机偏移量

    def add_node(self, node, parent_name=None): # 添加节点到场景的方法
        target_parent = self.root # 默认父节点为根节点
        if parent_name: # 如果指定了父节点名称
            found_parent = self.root.find_node_by_name(parent_name) # 按名称查找父节点
            if found_parent: # 如果找到
                target_parent = found_parent # 设置为目标父节点
            else: # 如果未找到
                print(f"警告: 未找到名为 '{
     parent_name}' 的父节点。将添加到根节点。") # 打印警告
        
        target_parent.add_child(node) # 将节点添加到目标父节点
        return node # 返回添加的节点

    def update(self, dt, events): # 更新场景中所有节点的方法
        self.root.update_recursive(dt, events) # 从根节点开始递归更新

    def render(self, surface): # 渲染场景中所有节点的方法
        surface.fill((50, 50, 50)) # 用深灰色填充背景
        view_rect = surface.get_rect() # 获取屏幕视口矩形
        # 在SceneNode的render_recursive中加入对view_rect和camera_offset的考虑
        self.root.render_recursive(surface, tuple(self.camera_offset)) # 从根节点开始递归渲染
        pygame.display.flip() # 更新整个屏幕显示

# --- Pygame 主循环 ---
def run_scene_example(): # 定义运行场景示例的函数
    pygame.init() # 初始化pygame所有模块
    screen_width, screen_height = 800, 600 # 定义屏幕宽高
    screen = pygame.display.set_mode((screen_width, screen_height)) # 创建屏幕对象
    pygame.display.set_caption("场景图示例") # 设置窗口标题
    clock = pygame.time.Clock() # 创建时钟对象,用于控制帧率

    game_scene = Scene() # 创建场景实例

    # 创建一些节点
    # 父节点 "ArmBase"
    arm_base = SceneNode(name="ArmBase") # 创建名为 "ArmBase" 的节点
    arm_base.set_position(screen_width / 2, screen_height / 2) # 设置其在屏幕中心的位置
    game_scene.add_node(arm_base) # 添加到场景根节点

    # 可视化 ArmBase (例如一个大的SpriteNode代表关节)
    base_sprite = SpriteNode(name="BaseJointSprite", color=(0,0,255), size=(20,20)) # 创建蓝色关节精灵节点
    arm_base.add_child(base_sprite) # 将其作为ArmBase的子节点 (位置相对于ArmBase为0,0)

    # 第一个臂段 "UpperArm"
    upper_arm_node = SceneNode(name="UpperArm") # 创建名为 "UpperArm" 的节点
    upper_arm_node.set_position(75, 0) # 相对于 ArmBase 的位置 (向右75像素)
    arm_base.add_child(upper_arm_node) # 添加为ArmBase的子节点

    upper_arm_sprite = SpriteNode(name="UpperArmSprite", color=(0,255,0), size=(100,15)) # 创建绿色臂段精灵
    # 精灵的中心默认在(0,0)局部坐标。如果精灵的宽度是100,我们希望它从关节处向外延伸,
    # 需要将精灵的局部位置调整为其宽度的一半。
    upper_arm_sprite.set_position(50, 0) # 使其渲染时,(0,0)点是其左边缘中点
    upper_arm_node.add_child(upper_arm_sprite) # 添加为UpperArm的子节点

    # 第二个臂段 "ForeArm"
    fore_arm_node = SceneNode(name="ForeArm") # 创建名为 "ForeArm" 的节点
    fore_arm_node.set_position(75, 0) # 相对于 UpperArm 末端的位置 (再向右75)
    upper_arm_node.add_child(fore_arm_node) # 添加为UpperArm的子节点

    fore_arm_sprite = SpriteNode(name="ForeArmSprite", color=(255,0,0), size=(80,10)) # 创建红色臂段精灵
    fore_arm_sprite.set_position(40, 0) # 调整局部位置
    fore_arm_node.add_child(fore_arm_sprite) # 添加为ForeArm的子节点

    # 一个独立的旋转方块
    spinning_square_node = SpriteNode(name="Spinner", color=(255,255,0), size=(50,50)) # 创建黄色旋转方块精灵节点
    spinning_square_node.set_position(100,100) # 设置其初始世界位置 (因为父是根)
    game_scene.add_node(spinning_square_node) # 添加到场景根节点

    running = True # 游戏循环标志
    total_time = 0 # 总时间,用于动画

    while running: # 主循环开始
        dt = clock.tick(60) / 1000.0 # 获取每帧的时间差 (秒),并限制帧率为60FPS
        total_time += dt # 累加总时间

        for event in pygame.event.get(): # 遍历所有事件
            if event.type == pygame.QUIT: # 如果是退出事件
                running = False # 设置循环标志为False
            if event.type == pygame.KEYDOWN: # 如果是键盘按下事件
                if event.key == pygame.K_ESCAPE: # 如果按下ESC键
                    running = False # 设置循环标志为False
                # 相机控制 (简单示例)
                if event.key == pygame.K_LEFT: game_scene.camera_offset[0] -= 20 # 左移相机
                if event.key == pygame.K_RIGHT: game_scene.camera_offset[0] += 20 # 右移相机
                if event.key == pygame.K_UP: game_scene.camera_offset[1] -= 20 # 上移相机
                if event.key == pygame.K_DOWN: game_scene.camera_offset[1] += 20 # 下移相机


        # 动画更新
        arm_base.set_rotation(total_time * 30) # 旋转基座 (30度/秒)
        upper_arm_node.set_rotation(math.sin(total_time * 2) * 45) # 上臂摆动 (-45到+45度)
        fore_arm_node.set_rotation(math.cos(total_time * 3) * 60) # 前臂摆动 (-60到+60度)
        
        current_spinner_rot = spinning_square_node.local_transform.rotation # 获取当前旋转
        spinning_square_node.set_rotation(current_spinner_rot + 90 * dt) # 独立方块自转 (90度/秒)
        
        # 场景更新 (会递归更新所有变换)
        game_scene.update(dt, []) # 更新场景逻辑和变换

        # 场景渲染
        game_scene.render(screen) # 渲染场景到屏幕

    pygame.quit() # 退出pygame

if __name__ == '__main__': # 如果作为主程序运行
    # 重要: 确保 SpriteNode.render_self 和 SceneNode.render_self 中的示例pygame.draw代码
    # 要么被正确实现 (如SpriteNode中那样),要么被注释掉,以避免在未完全设置时出错。
    # 上面的 SpriteNode 已经有了基本的pygame渲染逻辑。
    # SceneNode 的 render_self 默认是pass,所以没问题。
    run_scene_example() # 运行场景示例

SpriteNode.render_self 的调整说明:
在上面的 SpriteNoderender_self 方法中,我们使用了 pygame.transform.rotozoom。这个函数围绕图像的中心进行旋转和缩放。
因此,当设置 transformed_rect.center = (screen_x, screen_y) 时,screen_x, screen_y (它们来自节点的世界位置 world_pos_x, world_pos_y) 应该代表了你希望精灵的 中心点 在屏幕上的位置。
如果 SceneNodelocal_transform.position 定义的是节点的锚点(例如,对于一个臂段,可能是它的关节连接点),而你的精灵图像的视觉中心并不在其局部坐标的 (0,0) 点,那么你可能需要在创建 SpriteNode 时,或者在 SpriteNodeset_position 中,调整精灵自身的局部变换,使其视觉中心与节点的逻辑锚点对齐,或者在 render_self 中进行补偿。
在示例中,upper_arm_sprite.set_position(50, 0) 就是一个这样的调整,假设 UpperArmSprite 的图像宽度是100,它的局部 (0,0) 是其左边缘中点,通过将其局部 x 设置为 50,我们使得它的实际渲染中心(假设图像本身是对称的)对齐了其父节点 UpperArmNode 的逻辑原点(即关节)。

运行上述代码需要注意:

  1. Pygame 已安装 (pip install pygame)。
  2. 将所有类 (Transform2D, SceneNode, SpriteNode, Scene) 和 run_scene_example 函数放在同一个 Python 文件中。
  3. 确保 SpriteNode 使用的图像路径有效,或者像示例中那样,如果没有提供图像路径,它会创建一个基于颜色的占位符 Surface

这个例子演示了:

  • 节点的层级关系(手臂的各个部分)。
  • 局部变换(每个臂段相对于其父臂段的旋转和位置)。
  • 世界变换的自动计算和继承(旋转 arm_base 会带动整个手臂)。
  • 节点可以有自己的渲染逻辑 (SpriteNode)。
  • 一个简单的相机偏移。
15.6 场景图与实体组件系统 (ECS) 的关系

场景图和ECS是两种不同的设计模式,但它们并非互斥,实际上可以很好地协同工作。

  • ECS 关注点: 数据组织、行为和逻辑的分离。实体是ID,组件是数据,系统是逻辑。ECS 强调组合优于继承,以及数据的高效迭代和处理。
  • 场景图关注点: 空间层次结构、变换的级联、渲染优化(尤其是剔除)和对层级关系的直观管理。

结合方式:

  1. 场景图节点作为组件:

    • 可以创建一个 SceneGraphNodeComponent,它包含一个 SceneNode 实例或其引用。
    • 拥有此组件的实体将被集成到场景图中。
    • TransformComponent (ECS中的) 的数据可以用来驱动 SceneNode 的局部变换,反之亦然,或者其中一个作为主导。
    • 例如,PhysicsSystem (ECS) 更新实体的 TransformComponent,然后一个 SceneGraphSyncSystem (ECS) 读取这些更新,并应用到相应的 SceneNodelocal_transform
    # --- 伪代码:ECS 组件 ---
    # class TransformComponent: # ECS中的变换组件
    #     def __init__(self, x=0, y=0, rotation=0, scale_x=1, scale_y=1):
    #         self.x, self.y, self.rotation, self.scale_x, self.scale_y = x,y,rotation,scale_x,scale_y
    
    # class SceneGraphLinkComponent: # ECS中的场景图链接组件
    #     def __init__(self, node_name, parent_node_name=None):
    #         self.node = None # 指向实际的SceneNode实例
    #         self.node_name = node_name # 节点名称
    #         self.parent_node_name = parent_node_name #期望的父节点名称
    #         self.is_added_to_scene = False # 是否已添加到场景图
    
    # --- 伪代码:ECS 系统 ---
    # class SceneGraphIntegrationSystem(System): # ECS中的场景图集成系统
    #     def __init__(self, world, scene_graph_instance): # 初始化方法
    #         super().__init__(world) # 调用父类初始化
    #         self.scene = scene_graph_instance # 场景图实例
    
    #     def process(self, dt, events): # 处理方法
    #         # 1. 创建并添加新的节点到场景图
    #         for entity_id, (link_comp,) in self.world.get_entities_with_components_iter(SceneGraphLinkComponent): # 遍历有链接组件的实体
    #             if not link_comp.is_added_to_scene: # 如果节点尚未添加到场景
    #                 # 这里简化,假设SpriteNode是默认类型,实际可能需要更多配置
    #                 new_node = SpriteNode(name=link_comp.node_name) # 创建新的精灵节点
    #                 link_comp.node = new_node # 将节点实例存回组件
    #                 self.scene.add_node(new_node, link_comp.parent_node_name) # 添加到场景图
    #                 link_comp.is_added_to_scene = True # 标记为已添加
    #                 print(f"实体 {entity_id} 的节点 {link_comp.node_name} 已添加到场景图。") # 打印信息
    
    #         # 2. 同步 ECS TransformComponent 到 SceneNode.local_transform
    #         for entity_id, (trans_comp, link_comp) in self.world.get_entities_with_components_iter(TransformComponent, SceneGraphLinkComponent): # 遍历同时有变换和链接组件的实体
    #             if link_comp.node and link_comp.is_added_to_scene: # 如果节点存在且已添加
    #                 node = link_comp.node # 获取场景节点
    #                 # 检查是否有变化,避免不必要的 invalidate_transform 调用
    #                 if (node.local_transform.position[0] != trans_comp.x or
    #                     node.local_transform.position[1] != trans_comp.y): # 如果位置有变化
    #                     node.set_position(trans_comp.x, trans_comp.y) # 设置节点位置
                    
    #                 if node.local_transform.rotation != trans_comp.rotation: # 如果旋转有变化
    #                     node.set_rotation(trans_comp.rotation) # 设置节点旋转
                    
    #                 if (node.local_transform.scale[0] != trans_comp.scale_x or
    #                     node.local_transform.scale[1] != trans_comp.scale_y): # 如果缩放有变化
    #                     node.set_scale(trans_comp.scale_x, trans_comp.scale_y) # 设置节点缩放
            
            # (可选) 3. 从场景图中移除已销毁实体的节点
            # 这需要跟踪已销毁的实体,并查找其对应的 SceneGraphLinkComponent
    
  2. 场景图管理渲染实体:

    • ECS 管理所有游戏逻辑和大多数数据。
    • 场景图专门用于组织那些需要渲染并且具有层次关系的实体。
    • RenderSystem (ECS) 遍历具有 RenderableComponentTransformComponent 的实体。如果这些实体也通过某种方式(例如,ID映射或组件)链接到场景图节点,则 RenderSystem 触发场景图的渲染过程,或者从场景图节点获取最终的世界变换和可见性信息来指导其渲染。
  3. 分离使用:

    • ECS 用于管理非层级或逻辑上独立的游戏对象(例如,粒子效果中的单个粒子、UI元素中的独立按钮、游戏世界中的“物品掉落”等)。
    • 场景图用于管理具有明确父子关系的对象(例如,复杂的角色模型和骨骼、车辆及其部件、编辑器中的对象层级)。

选择哪种结合方式取决于游戏的具体需求。
如果游戏中的大多数对象都受益于层次结构,那么将场景图节点与ECS实体紧密耦合(如第一种方式)可能是有意义的。如果只有一部分对象需要场景图的特性,那么场景图可以作为一个专门的子系统,由ECS中的特定系统与之交互。

重要的是要明确每个系统的职责:

  • ECS:实体生命周期、数据聚合(组件)、通用逻辑执行(系统)。
  • 场景图:空间层次结构、变换传播、渲染相关的组织和优化(如剔除)。
15.7 场景图的优缺点

优点:

  • 直观的组织: 对于具有自然层级关系的对象(如骨骼动画、机器人手臂、嵌套UI元素)来说,场景图提供了一种非常自然的组织方式。
  • 自动变换传播: 父节点的变换自动影响子节点,简化了对复杂组合对象的动画和定位。
  • 高效剔除: 允许通过剔除父节点来快速排除大量不可见对象,从而优化渲染性能。
  • 渲染状态管理: 可以通过在场景图节点中存储渲染状态(如材质、着色器)并在遍历时应用它们,来帮助分组和排序渲染调用,减少状态切换。
  • 支持LOD (Level of Detail): 很容易在节点中管理不同细节级别的模型,并根据距离或其他标准选择合适的模型进行渲染。
  • 选择和操作: 便于通过名称或在层次结构中导航来选择和操作对象,这在游戏编辑器中尤其有用。

缺点:

  • 不适用于所有对象: 对于没有明显层级关系或行为非常独立的对象(例如,一个简单的粒子系统中的每个粒子),场景图的开销可能不值得。ECS在这里可能更灵活。
  • 更新成本: 对于非常深或非常宽的场景图,如果许多节点的变换频繁改变,递归更新世界变换的成本可能会很高。需要通过脏标志等机制进行优化。
  • 刚性结构: 父子关系是固定的。如果一个对象需要动态地改变其“父”行为的来源,或者同时受多个“父”影响,场景图的直接表达能力有限(可能需要通过约束或其他间接方式实现)。
  • 潜在的上帝对象: 根节点或高级别节点可能需要了解或管理许多子节点的特定行为,尽管理想情况下节点应该是相对独立的。
  • 遍历成本: 即使有剔除,遍历大型场景图仍然有其固有的CPU成本。
15.8 变体和高级主题
  • 场景图层 (Scene Layers): 将场景图节点分配到不同的渲染层,可以控制渲染顺序(例如,背景层、角色层、UI层),并允许选择性地渲染或更新某些层。
  • 包围体层次结构 (Bounding Volume Hierarchies - BVH): 场景图本身可以看作一种BVH。更通用的BVH(如AABB树、OBB树)常用于加速碰撞检测和射线追踪,它们根据对象的空间位置和大小来组织层次结构,不一定与逻辑上的父子关系完全一致。
  • Quadtrees / Octrees: 这些是空间分割数据结构,常用于2D/3D场景中高效地进行范围查询、邻近搜索和碰撞检测。它们可以将场景图中的节点(或其引用的游戏对象)组织到空间单元格中。一个场景图节点本身可以是一个Quadtree或Octree的根,用于管理其子空间中的大量对象。
  • 组件化场景节点: 场景图节点本身也可以采用类似组件的设计。一个节点可以附加不同的“行为组件”或“属性组件”(如 RenderComponent, PhysicsColliderComponent, LightComponent),而不仅仅是固定的属性集。这使得场景图节点更加灵活和可扩展。
  • 与物理引擎的集成: 物理引擎中的刚体通常有自己的变换,这些变换需要与场景图节点的变换同步。通常,物理引擎驱动变换,然后场景图节点从物理体更新其状态。

场景图是一种强大而灵活的工具,尤其适用于构建具有复杂空间关系和视觉层次的游戏世界。理解其核心原理、操作以及如何与其他模式(如ECS)结合,将为你的游戏开发工具箱增添一个重要的架构选项。

第十六章:高级游戏设计模式 - 第三部分:行为树 (Behavior Trees) 用于AI

16.1 什么是行为树?

行为树 (Behavior Tree, BT) 是一种用于控制自主代理(如游戏中的NPC、机器人)行为的数学模型和图形化设计工具。它以树状结构组织一系列简单的行为或任务,通过定义节点之间的执行逻辑来决定代理在特定情境下应该执行哪个动作。

与传统的有限状态机 (Finite State Machines, FSMs) 或分层状态机 (Hierarchical State Machines, HFSMs) 相比,行为树在表达复杂、多层次、目标驱动的行为时,通常被认为更加灵活、可扩展和易于维护。FSMs 在状态数量增多时,状态之间的转换逻辑可能变得非常复杂和难以管理(所谓的“状态爆炸”问题)。行为树通过其模块化和可组合的特性,试图缓解这些问题。

核心思想:

  • 模块化: 行为被分解成小的、可重用的节点。
  • 层次化: 节点组织成树形结构,父节点控制其子节点的执行。
  • 反应式: 行为树通过周期性的“tick”(滴答)信号来执行。在每个tick中,树从根节点开始遍历,根据当前的游戏世界状态和节点的内部逻辑来决定执行路径。
  • 状态反馈: 每个节点在执行后会返回一个状态:成功 (Success)失败 (Failure)运行中 (Running)。父节点根据子节点返回的状态来决定如何继续执行。

行为树在游戏AI领域被广泛应用,尤其是在需要NPC表现出多种行为、能够适应环境变化、并追求特定目标的游戏中(例如,射击游戏中的敌人AI、策略游戏中的单位AI、角色扮演游戏中的伙伴AI)。

16.2 为什么选择行为树?(相较于FSM/HFSM)

虽然FSM和HFSM在某些情况下仍然非常有用(特别是对于定义明确、状态转换固定的行为),但行为树在以下方面通常表现出优势:

  1. 模块化和可重用性:

    • BT中的每个节点(无论是执行一个简单动作还是控制一组子行为)都是一个独立的单元。这些单元可以被轻松地重用在树的不同部分,甚至在不同的AI代理之间。
    • 例如,一个“移动到目标位置”的动作节点可以被用于巡逻、追逐、逃跑等多种高级行为。
  2. 可扩展性:

    • 向行为树中添加新的行为或修改现有行为通常比在复杂的FSM中添加新状态或转换更容易。你可以在树的适当位置插入新的子树或节点,而对其他部分的影响较小。
  3. 可读性和可维护性:

    • 行为树的图形化表示(即使在代码中实现,其逻辑结构也易于可视化)通常比布满转换线的FSM图更容易理解。设计者可以直观地看到AI决策的流程。
    • 由于其层次结构,调试行为也变得相对容易,可以追踪是哪个分支或节点导致了预期的或非预期的行为。
  4. 目标导向与反应性平衡:

    • 行为树很容易构建既能追求长期目标(例如,通过一个序列节点按步骤完成任务)又能对突发事件做出反应(例如,一个高优先级的选择器分支处理被攻击的情况)的AI。
  5. 并行行为:

    • 特定的行为树节点(如并行节点)允许同时执行多个行为分支,这对于模拟需要同时进行多种活动的AI(如一边移动一边射击)非常有用。FSM要表达这种并发性通常比较困难。
  6. 避免状态爆炸:

    • FSM中,如果AI需要考虑很多变量来决定行为,状态数量和转换数量会急剧增加。行为树通过组合简单的构建块来处理复杂性,而不是显式定义每一个可能的组合状态。

当然,行为树也有其自身的复杂性,例如需要理解不同节点类型的行为和它们如何交互。设计一个良好、高效的行为树也需要经验。

16.3 行为树的核心概念
16.3.1 Tick (滴答)

行为树不是持续运行的;它们是被周期性地“tick”的。一个tick可以被认为是一个执行AI逻辑的脉冲或信号。在游戏循环的每一帧或以某个固定频率(例如,每秒10次),AI系统会向行为树的根节点发送一个tick信号。
当一个节点被tick时,它会执行其特定的逻辑,并可能将tick信号传递给它的一个或多个子节点。

16.3.2 执行状态 (Execution Status)

每个行为树节点在被tick并完成其当前逻辑单元后,必须向上返回一个执行状态。这个状态对于父节点决定接下来如何执行至关重要。标准的状态有三种:

  • Success (成功): 表示该节点已成功完成其任务。

    • 例如,一个“移动到目标”的动作节点,当AI到达目标点时,会返回Success
    • 一个“检查生命值是否低于X”的条件节点,如果生命值确实低于X,会返回Success
  • Failure (失败): 表示该节点未能完成其任务。

    • 例如,“移动到目标”节点,如果路径被阻塞且无法找到新路径,可能会返回Failure
    • “检查生命值是否低于X”的条件节点,如果生命值不低于X,会返回Failure
  • Running (运行中): 表示该节点已经开始执行其任务,但尚未完成,需要在后续的tick中继续执行。

    • 例如,“移动到目标”节点,在AI正在前往目标点的过程中,每一tick都会返回Running
    • 一个需要数秒钟才能完成的“施法”动作,在其持续时间内会返回Running

Running状态是行为树能够处理耗时行为的关键。如果一个节点返回Running,那么在下一个tick中,通常会再次tick同一个节点(或其子树中的某个正在运行的节点),而不是从根节点重新开始整个评估过程(尽管某些行为树实现允许这种“重新评估”策略)。

16.3.3 黑板 (Blackboard) 或上下文 (Context)

行为树节点需要访问和修改关于AI代理状态和游戏世界的信息。黑板 (Blackboard) 是一种常用的机制,它充当行为树的共享内存或数据存储区。

  • 节点可以从黑板读取数据(例如,玩家的最后已知位置、AI的当前生命值、是否有可用弹药)。
  • 节点也可以向黑板写入数据(例如,更新寻路目标点、记录一个搜索区域)。
  • 黑板有助于解耦行为树的逻辑与AI代理的具体数据表示。AI代理(或控制它的系统)负责在每个tick之前或之后用相关数据填充或更新黑板。

在ECS的背景下,黑板的数据可以直接映射到AI实体的组件上,或者黑板本身可以是一个组件,由系统填充和访问。

16.4 行为树的节点类型 (Node Types)

行为树由不同类型的节点组成,每种类型都有其特定的行为和控制子节点的方式。

from enum import Enum # 导入Enum模块,用于创建枚举类型

class BTStatus(Enum): # 定义行为树节点状态的枚举
    SUCCESS = 1 # 成功
    FAILURE = 2 # 失败
    RUNNING = 3 # 运行中
    INVALID = 4 # 无效状态 (例如,节点未初始化或出错)

class BTNode: # 定义行为树节点的基类
    """
    行为树节点的抽象基类。
    所有具体的节点类型都应从此类继承。
    """
    _node_id_counter = 0 # 类变量,用于生成唯一的节点ID

    def __init__(self, name=None): # 初始化方法
        self.id = BTNode._node_id_counter # 分配唯一ID
        BTNode._node_id_counter += 1 # ID计数器增加
        self.name = name if name else f"{
     self.__class__.__name__}_{
     self.id}" # 节点名称,如果未提供则基于类名和ID生成
        self.status = BTStatus.INVALID # 节点的初始状态为无效

    def tick(self, blackboard): # tick方法,由子类实现
        """
        执行节点的逻辑。
        这个方法必须被子类重写。
        它应该返回一个 BTStatus (SUCCESS, FAILURE, RUNNING)。
        :param blackboard: 一个包含AI状态和世界信息的黑板对象。
        :return: BTStatus
        """
        raise NotImplementedError("子类必须实现 tick() 方法") # 如果子类未实现,则抛出NotImplementedError

    def reset(self): # 重置节点状态的方法
        """
        重置节点的内部状态到初始状态。
        通常在节点从 RUNNING 状态转换完成,或者行为树需要重新开始时调用。
        """
        self.status = BTStatus.INVALID # 将状态重置为无效
        # 子类可能需要重写此方法以重置其特定的内部状态

    def __repr__(self): # 定义节点的字符串表示
        return f"<{
     self.name} (ID: {
     self.id}, Status: {
     self.status.name if self.status else 'None'})>" # 返回节点信息字符串

这个基类 BTNode 定义了所有节点共享的接口:一个 tick 方法用于执行逻辑,一个 reset 方法用于重置内部状态,以及一些基本属性如ID和名称。

16.4.1 叶节点 (Leaf Nodes)

叶节点位于行为树的末端,它们不包含任何子节点。它们是实际执行动作或检查条件的地方。

a) 动作节点 (Action Nodes)

动作节点封装了AI代理可以执行的具体行为。
例如:MoveToTarget, AttackEnemy, PlayAnimation, PatrolPoint, Wait.

class BTActionNode(BTNode): # 定义行为树动作节点的基类
    """
    动作节点的基类。动作节点执行实际的游戏逻辑。
    """
    def __init__(self, name=None): # 初始化方法
        super().__init__(name) # 调用父类BTNode的初始化方法

    # tick 方法将由具体的动作子类实现
    # reset 方法通常不需要为简单的无状态动作重写,但有状态的动作可能需要

# 示例:一个简单的打印动作
class PrintMessageAction(BTActionNode): # 定义打印消息动作节点类
    def __init__(self, message, name=None): # 初始化方法
        super().__init__(name if name else "PrintMessage") # 调用父类初始化,如果未提供名称则使用默认名称"PrintMessage"
        self.message = message # 要打印的消息
        self.executed = False # 标记此动作是否已执行过(对于一次性动作)

    def tick(self, blackboard): # tick方法
        if not self.executed: # 如果尚未执行
            print(f"[{
     self.name}] AI 执行: {
     self.message} (黑板数据示例: {
     blackboard.get('entity_id', 'N/A')})") # 打印消息,并尝试从黑板获取entity_id
            self.executed = True # 标记为已执行
            self.status = BTStatus.SUCCESS # 设置状态为成功
            return self.status # 返回成功状态
        # 如果已经执行过并且希望它只执行一次,则可以直接返回之前的状态
        # 或者根据设计,如果它是一个持续的检查,可能每次都重新评估
        return self.status # 如果已执行,返回之前的状态 (SUCCESS)

    def reset(self): # 重置方法
        super().reset() # 调用父类的reset方法
        self.executed = False # 重置执行标记,以便下次可以再次执行
        # print(f"[{self.name}] 已重置") # 打印重置信息

# 示例:一个模拟耗时操作的动作
class TimedWaitAction(BTActionNode): # 定义定时等待动作节点类
    def __init__(self, duration_ms, name=None): # 初始化方法
        super().__init__(name if name else "TimedWait") # 调用父类初始化
        self.duration_ms = duration_ms # 等待的毫秒数
        self.start_time = None # 开始等待的时间戳

    def tick(self, blackboard): # tick方法
        current_time = blackboard.get('current_game_time_ms', 0) # 从黑板获取当前游戏时间 (毫秒)
        
        if self.start_time is None: # 如果是第一次tick这个动作
            self.start_time = current_time # 记录开始时间
            # print(f"[{self.name}] 开始等待 {self.duration_ms}ms (当前时间: {current_time}ms)") # 打印开始等待信息
            self.status = BTStatus.RUNNING # 设置状态为运行中
            return self.status # 返回运行中状态

        elapsed_time = current_time - self.start_time # 计算已过时间
        if elapsed_time >= self.duration_ms: # 如果已过时间达到或超过设定的持续时间
            # print(f"[{self.name}] 等待完成 (耗时: {elapsed_time}ms)") # 打印等待完成信息
            self.status = BTStatus.SUCCESS # 设置状态为成功
            self.start_time = None # 重置开始时间,以便下次可以重新开始等待
            return self.status # 返回成功状态
        else: # 如果等待尚未完成
            self.status = BTStatus.RUNNING # 保持状态为运行中
            return self.status # 返回运行中状态

    def reset(self): # 重置方法
        super().reset() # 调用父类reset
        self.start_time = None # 重置开始时间
        # print(f"[{self.name}] 已重置") # 打印重置信息
b) 条件节点 (Condition Nodes)

条件节点用于检查游戏世界中的某个特定条件是否为真。它们通常不执行任何改变游戏世界的动作,只是返回 Success(条件满足)或 Failure(条件不满足)。它们从不返回 Running
例如:IsHealthLow, IsPlayerVisible, HasEnoughAmmo, IsPathClear.

class BTConditionNode(BTNode): # 定义行为树条件节点的基类
    """
    条件节点的基类。条件节点检查某个条件是否为真。
    它们通常立即返回 SUCCESS 或 FAILURE。
    """
    def __init__(self, name=None): # 初始化方法
        super().__init__(name) # 调用父类BTNode的初始化

    # tick 方法将由具体的条件子类实现
    # 条件节点通常是无状态的,reset 可能不需要特殊处理

# 示例:检查黑板中某个值是否为 True
class CheckBlackboardFlagCondition(BTConditionNode): # 定义检查黑板标志条件节点类
    def __init__(self, flag_key, name=None): # 初始化方法
        super().__init__(name if name else f"CheckFlag_{
     flag_key}") # 调用父类初始化,名称包含标志键
        self.flag_key = flag_key # 要检查的黑板中的键

    def tick(self, blackboard): # tick方法
        value = blackboard.get(self.flag_key, False) # 从黑板获取标志值,如果不存在则默认为False
        if value: # 如果值为True (或真值)
            # print(f"[{self.name}] 条件满足: '{self.flag_key}' 为 True.") # 打印条件满足信息
            self.status = BTStatus.SUCCESS # 设置状态为成功
            return self.status # 返回成功
        else: # 如果值为False (或假值)
            # print(f"[{self.name}] 条件失败: '{self.flag_key}' 为 False 或不存在.") # 打印条件失败信息
            self.status = BTStatus.FAILURE # 设置状态为失败
            return self.status # 返回失败

    # reset 通常不需要,因为条件是无状态的
16.4.2 组合节点 (Composite Nodes)

组合节点是行为树的内部节点,它们可以有一个或多个子节点。它们定义了其子节点被tick的顺序和逻辑。

a) 序列节点 (Sequence Node)

序列节点会按顺序从左到右(或按添加顺序)tick它的子节点。

  • 如果一个子节点返回 Failure,则序列节点立即停止并返回 Failure,不再tick后续的子节点。
  • 如果一个子节点返回 Running,则序列节点立即停止并返回 Running。在下一个tick中,它会从那个返回Running的子节点继续。
  • 只有当所有子节点都返回 Success 时,序列节点才会返回 Success

序列节点通常用于表示一系列必须按顺序完成才能成功的任务(逻辑上的 “AND”)。
例如:一个“泡茶”序列可能是:1. 烧水 (Action) -> 2. 拿杯子 (Action) -> 3. 放茶叶 (Action) -> 4. 倒水 (Action)。任何一步失败,整个“泡茶”行为就失败。

class BTCompositeNode(BTNode): # 定义行为树组合节点的基类
    """
    组合节点的基类,它可以有多个子节点。
    """
    def __init__(self, children=None, name=None): # 初始化方法
        super().__init__(name) # 调用父类BTNode的初始化
        self.children = children if children is not None else [] # 子节点列表,如果未提供则为空列表

    def add_child(self, child_node): # 添加子节点的方法
        if not isinstance(child_node, BTNode): # 检查子节点类型
            raise TypeError("子节点必须是 BTNode 的实例。") # 抛出类型错误
        self.children.append
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

宅男很神经

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值