Bevy UI 裁剪迁移指南:CalculatedClip 如何存储经过变换的裁剪矩形
在 Bevy 的 UI 渲染管线中,CalculatedClip 组件负责记录每个 UI 节点从祖先节点继承下来的所有裁剪区域。近期一次 API 变更(对应 PR 24148)将 CalculatedClip 从简单的矩形列表重构为带 Rects / FullyClipped 两个变体的枚举:每个裁剪区域不仅记录一个 Rect,还附带一个 Affine2 世界空间到裁剪节点局部空间的变换矩阵,从而正确支持祖先节点被缩放、旋转后的裁剪行为;同时新增了 FullyClipped 状态,表示节点及其后代被完全裁剪、不参与渲染与拾取。读完本文,你将掌握新 CalculatedClip 的类型结构、各查询/修改方法的语义(尤其是 contains_point 的坐标系约定)、组件在 UI 更新系统中如何逐层传播,以及渲染与拾取两条消费链路如何使用它,最后附一份旧代码的迁移适配清单。
变更总览:从“矩形列表”到“带变换的裁剪区域 + 完全裁剪状态”
迁移说明的核心内容可以概括为三点:
CalculatedClip现在是枚举,包含Rects与FullyClipped两个变体:Rects:一个 UI 实体从其祖先继承的裁剪区域列表,每个区域由一个Rect(裁剪矩形)和一个Affine2(世界空间到该裁剪节点局部空间的变换)共同定义;FullyClipped:表示该 UI 实体被完全裁剪,既不会被渲染,也不参与拾取(picking)。
- 坐标系语义明确化:裁剪矩形存储在其所属裁剪节点的局部空间中,
Affine2变换用于把世界坐标(物理像素)的点转换回局部空间再做包含判断。这一点在旋转/缩放的 UI 场景下尤为关键。 - 新增点包含查询:
CalculatedClip::contains_point可用于判断一个物理像素坐标系下的点是否被裁剪掉。
类型定义:CalculatedClip 与 CalculatedClipRect
CalculatedClip 定义在 bevy_ui 的 ui_node.rs,与其配套的单个裁剪区域结构 CalculatedClipRect 就在同一文件上方:
/// A single local-space clipping rect.
pub struct CalculatedClipRect {
/// The clip rect in the clipping node's local space.
pub rect: Rect,
/// Transform from world space into the clipping node's local space.
pub world_to_clip_local: Affine2,
}
/// The calculated clipping inherited by the node.
pub enum CalculatedClip {
/// Clip rects inherited from ancestors.
Rects(SmallVec<[CalculatedClipRect; 2]>),
/// The node and descendants are fully clipped.
FullyClipped,
}
几个值得注意的实现细节:
Rects内部使用SmallVec<[CalculatedClipRect; 2]>。典型 UI 树中嵌套裁剪层的数量很少(0~2 层),SmallVec让常见情况避免堆分配;嵌套超过两层时再转入堆内存。CalculatedClip派生了Component、Clone、Debug、PartialEq、Reflect,是标准 ECS 组件,可通过查询获取,也支持ron等序列化生态。Default实现为Rects(空 SmallVec),即“没有任何继承裁剪”的初始状态(见 ui_node.rs)。- 与
CalculatedClip配套的还有OverrideClip标记组件:带有该组件的节点会忽略任何继承的裁剪矩形,无论其祖先如何设置Overflow都不会被裁剪。
常用方法:判断、查询与修改
CalculatedClip 提供了四个核心方法(见 ui_node.rs),迁移后应统一通过它们访问状态,而不是直接拆枚举字段:
| 方法 | 签名要点 | 语义 |
|---|---|---|
is_fully_clipped |
const fn -> bool |
节点及其后代是否被完全裁剪(matches!(self, Self::FullyClipped)) |
rects |
-> Option<&[CalculatedClipRect]> |
未完全裁剪时返回继承的裁剪矩形切片;FullyClipped 返回 None |
contains_point |
(point: Vec2) -> bool |
判断物理像素坐标下的点是否同时位于所有继承裁剪矩形之内;FullyClipped 恒返回 false |
push_rect |
(rect: Rect, world_to_clip_local: Affine2) -> mut |
在未完全裁剪时向列表追加一个裁剪区域(已完全裁剪则无效) |
其中 contains_point 的实现清晰展示了“Rect + Affine2”这一组合的用法——先把世界坐标点变换到裁剪节点局部空间,再做矩形包含测试:
pub fn contains_point(&self, point: Vec2) -> bool {
match self {
Self::Rects(rects) => rects.iter().all(|clip_rect| {
clip_rect
.rect
.contains(clip_rect.world_to_clip_local.transform_point2(point))
}),
Self::FullyClipped => false,
}
}
with_rect(rect, transform) 则是“继承 + 追加”的组合糖:若当前状态未完全裁剪且给定变换可逆(transform.try_inverse()),就克隆自身并把新的世界空间矩形变换成裁剪局部空间后追加;否则直接返回 CalculatedClip::FullyClipped。
组件如何被计算:update_clipping_system 的逐层传播
CalculatedClip 由 bevy_ui 的 update_clipping_system 在每帧 UI 更新阶段沿 UI 树自顶向下计算。理解这段逻辑,才能正确理解两个变体在什么条件下出现:
- 从根节点开始递归:系统遍历所有 UI 根节点,对每个节点先处理“继承来的裁剪状态”,再决定要传递给子节点的状态。
- 新裁剪上下文的开启:若节点带有
OverrideClip或FixedNode组件,继承的裁剪状态被直接丢弃(maybe_inherited_clip = None),相当于“打开新的裁剪上下文”,祖先的裁剪不再影响该子树(见 update.rs)。 Display::None触发FullyClipped:Display::None的节点及其整棵后代子树都会被标记为FullyClipped(见 update.rs),这正是迁移说明中“既不会被渲染也不参与拾取”的来源。- 组件的增删维护:如果本帧没有继承裁剪,系统会移除该节点上的
CalculatedClip组件(而非保留空枚举),下游渲染因此以Option<&CalculatedClip> = None表示“无裁剪”;有继承裁剪时才插入/更新组件(见 update.rs)。 - 子节点裁剪区域的推导:当前节点若不裁剪(
overflow.is_visible())或已完全裁剪,原样下传;否则取自身全局变换的逆矩阵(transform.try_inverse()),把resolve_clip_rect(overflow, overflow_clip_margin)解析出的裁剪矩形连同“世界→局部”变换一起push_rect到继承列表后传给子节点;若变换不可逆,则子树整体变为FullyClipped(见 update.rs)。这里正是每个裁剪区域携带Affine2变换的代码落点:局部矩形 + 逆世界变换,二者缺一不可。
相关行为有专门的单元测试覆盖:fixed_node_opens_new_clipping_context 验证了 FixedNode 使后代 CalculatedClip 组件被移除(裁剪上下文重置),override_clip_opens_new_clipping_context 验证了 OverrideClip 的同样效果(测试位于 update.rs 附近的 tests 模块)。
下游消费:渲染裁剪与拾取判定
渲染:clip_polygon 的 Sutherland–Hodgman 裁剪
bevy_ui_render 侧的各管线(UI 材质管线、文本、box shadow、渐变、纹理切片等)在提取渲染实体时都会携带 clip: Option<CalculatedClip>,并以 Changed<CalculatedClip> / RemovedComponents<CalculatedClip> 作为重提取触发条件(如 ui_material_pipeline.rs、text.rs)。真正消费该组件的是 clip_polygon:它对输入多边形按“每条裁剪边依次裁剪”的 Sutherland–Hodgman 流程处理,FullyClipped 直接返回空顶点列表(clip.rects() 为 None 的分支),否则对每个裁剪区域的四条边逐一执行 edge_clip——边裁剪时先用 region.world_to_clip_local.transform_point2(...) 把顶点换算到裁剪局部空间再求有向距离,从而保证旋转/缩放祖先下的裁剪边界依然精确。该模块内建了一组单元测试,覆盖无裁剪、完全裁剪、轴对齐裁剪、嵌套旋转裁剪区域组合(nested_clip_rects_compose)以及完全越界等场景(见 clipping.rs 的 tests 模块)。
拾取:contains_point 直接排除被裁剪节点
bevy_ui 的 picking 后端在遍历 UI 节点做指针命中测试时,除了节点自身的 contains_point 判断,还会用继承裁剪做第二道过滤(见 picking_backend.rs):
if node.node.contains_point(*node.transform, *cursor_position)
&& node
.calculated_clip
.is_none_or(|clip| clip.contains_point(*cursor_position))
注意这里的语义闭环:组件不存在(None)表示没有任何裁剪;Rects 时逐区域判断指针位置;FullyClipped 时 contains_point 恒为 false,节点自然被跳过——与迁移说明“FullyClipped 的节点不可拾取”的表述一致。
迁移适配清单
如果你之前访问过 CalculatedClip 的旧字段,以下是对应的新写法:
- 读取裁剪矩形列表:旧结构体字段访问改为
clip.rects(),注意其返回Option<&[CalculatedClipRect]>,FullyClipped时为None,需要先匹配或is_some_and处理。 - 判断是否完全裁剪:使用
clip.is_fully_clipped()代替对旧字段为空/特殊的约定判断。 - 点包含测试:使用
clip.contains_point(point),point必须是物理像素坐标下的点;实现内部会完成到局部空间的坐标变换,调用方无需自行处理Affine2。 - 追加裁剪区域:使用
clip.push_rect(rect, world_to_clip_local)或基于已有状态调用with_rect(rect, transform)(后者以世界空间Rect+ 节点全局变换为入参,自动求逆并短路处理不可逆变换与FullyClipped)。 - 枚举匹配:需要区分两种状态时按
match clip { CalculatedClip::Rects(rects) => ..., CalculatedClip::FullyClipped => ... }处理;FullyClipped分支应等价于“不可见、不可拾取”。 - 新增标记组件语义:如果你的 UI 需要豁免裁剪(例如工具栏悬浮层),优先复用
OverrideClip或FixedNode,它们在 update_clipping_system 中统一重置继承裁剪,避免手写裁剪逻辑与引擎传播流程打架。
小结
这次变更让 Bevy UI 的裁剪模型在两个方向上更精确:裁剪区域携带“世界→裁剪局部空间”的 Affine2 变换,使旋转、缩放后的祖先裁剪边界在渲染(clip_polygon)与查询(contains_point)两条路径上保持一致;FullyClipped 变体把“不可见 + 不可拾取”的语义显式化,并由 Display::None 等场景统一生成。配合 update.rs 中的传播逻辑、clip_polygon 裁剪实现 以及 picking 后端 的测试用例,可以为自定义 UI 管线或裁剪相关工具的开发提供完整的参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00