首页
/ Bevy 0.20 UI 溢出裁剪支持旋转:基于 Sutherland–Hodgman 的多边形裁剪新实现

Bevy 0.20 UI 溢出裁剪支持旋转:基于 Sutherland–Hodgman 的多边形裁剪新实现

2026-09-08 16:14:44作者:段琳惟

在 Bevy 0.20 中,UI 节点的溢出(overflow)裁剪实现被彻底重写:此前裁剪时完全忽略节点的旋转与缩放,导致旋转后的内容在错误的区域被裁剪或发生形变;现在裁剪区域以多边形形式参与计算,采用简化版 Sutherland–Hodgman 算法对后代节点逐级求交。本文基于该版本发布说明(见 _release-content/release-notes/transformed_ui_clipping.md)与 bevy_ui/bevy_ui_render 的实际源码,讲解新裁剪模型的数据结构、clip_polygon 的算法实现、渲染侧的应用,以及如何运行官方示例验证旋转裁剪效果。阅读后你将理解“继承式裁剪区域”如何在旋转/缩放的变换树中正确工作,并掌握在 0.20 中配置与调试 UI 溢出裁剪的方法。

一、问题背景:旧的轴对齐矩形裁剪为何在旋转下失真

按 0.20 的发布说明描述,此前的 Bevy 在处理 UI 节点溢出裁剪时,只把裁剪区域当作单个轴对齐(axis-aligned)矩形来处理:

Until now, Bevy ignored rotation and scaling when clipping UI node overflow, causing content to be distorted or clipped in the wrong place.

这会造成两类可见的缺陷:

  • 旋转场景下裁剪位置错误:一个设置了 UiTransform 旋转的裁剪节点,其内容会按照未旋转的世界轴对齐矩形被切掉,而不是沿节点自身的边界被切掉,视觉上就像“内容被切错地方”。
  • 缩放场景下内容形变/错位:节点的 scale 没有参与裁剪坐标系换算,内容缩放到裁剪框之外时,边界判定与屏幕表现不一致。

这正是 PR #24148(作者 @Ickshonpe)所修复的问题,其核心思路在发布说明中概括为两点:

  1. 后代节点不再与“单个轴对齐矩形”求交,而是逐一与祖先链上继承下来的每一个裁剪区域求交
  2. 每个裁剪区域都携带从世界空间到该裁剪节点局部空间的仿射变换,从而让旋转、缩放都被正确计入。

用户可见的 overflow API 完全不变,即 Node { overflow: ... } 字段及其枚举值没有破坏性变更,升级到 0.20 不需要改 UI 代码。

二、新的数据模型:每个裁剪区域携带自己的坐标系

2.1 从单一矩形到“裁剪区域列表”

crates/bevy_ui/src/ui_node.rs#L2754-L2840 中可以看到新引入的两个核心类型。

单个裁剪区域 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,
}

rect 存的是该裁剪节点自身局部空间下的裁剪矩形(例如以自身中心为原点、大小等于节点尺寸),而 world_to_clip_local 是一个 Affine2 仿射变换,负责把任何世界坐标换算进这个局部空间。旋转与缩放信息就藏在这个变换里——只要把被裁剪多边形顶点乘上 world_to_clip_local,再与 rect 求交,就等价于在旋转/缩放后的局部坐标里做轴对齐裁剪。

一个节点的“继承裁剪”则用 CalculatedClip 表示:

pub enum CalculatedClip {
    Rects(SmallVec<[CalculatedClipRect; 2]>),
    /// The node and descendants are fully clipped.
    FullyClipped,
}

SmallVec 的容量为 2,绝大多数情况下两到三个嵌套裁剪层即可命中内联存储,避免堆分配。

2.2 祖先链裁剪区域的累积:update_clipping

裁剪区域沿节点树向下传播的逻辑位于 crates/bevy_ui/src/update.rs#L20-L113update_clipping_system / update_clipping。其流程可以概括为:

  1. 节点若带 OverrideClipFixedNode 组件,则丢弃所有继承的裁剪区域,开启新的裁剪上下文(对应测试 fixed_node_opens_new_clipping_contextoverride_clip_opens_new_clipping_context)。
  2. 若节点 display 为 None(不可显示),后代整体标记为 CalculatedClip::FullyClipped
  3. 把当前继承的 CalculatedClip 写入(或移除)该节点的组件,供渲染侧使用。
  4. 为子节点计算新的继承裁剪:如果当前节点自身不产生裁剪,直接把 maybe_inherited_clip 原样透传给子节点;如果当前节点产生裁剪,则取出自身世界变换的逆矩阵,调用 clip.push_rect(rect, world_to_clip_local)新区域追加到继承列表尾部,再递归下传。

由于每个裁剪区域都带着“世界→该裁剪节点局部空间”的变换,后代在求交时互不干扰——祖父的裁剪框按祖父的坐标系判定,父亲的按父亲的坐标系判定,正确反映了各自的旋转与缩放。

2.3 resolve_clip_rect:裁剪矩形如何由布局结果确定

每个节点的局部裁剪矩形在 crates/bevy_ui/src/ui_node.rs#L246-L276resolve_clip_rect 中计算:

let mut clip_rect = Rect::from_center_size(Vec2::ZERO, self.size);
let clip_inset = match overflow_clip_margin.visual_box {
    VisualBox::BorderBox => BorderRect::ZERO,
    VisualBox::ContentBox => self.content_inset(),
    VisualBox::PaddingBox => self.border(),
};
clip_rect = clip_rect.inflate(overflow_clip_margin.margin.max(0.) / self.inverse_scale_factor);
clip_rect.min += clip_inset.min_inset;
clip_rect.max -= clip_inset.max_inset;
if overflow.x == OverflowAxis::Visible { /* 拉成 ±infinity */ }
if overflow.y == OverflowAxis::Visible { /* 拉成 ±infinity */ }

要点:

  • 以节点尺寸 self.size 构造以自身中心为原点的矩形;
  • OverflowClipMargin 决定基准盒子(BorderBox/ContentBox/PaddingBox,对应 CSS 的 overflow-clip-margin 语义)以及向外扩大的 margin(margin 为负数时按 0 处理,并用 inverse_scale_factor 换算成物理坐标);
  • 若某一轴是 OverflowAxis::Visible,该轴边界直接置为 ±∞,等于该轴不裁剪。

OverflowOverflowClipMargin 的定义与 0.20 之前一致,仍位于 crates/bevy_ui/src/ui_node.rs#L1545-L1709,包含 clip()clip_x()clip_y()hidden()scroll() 等构造器以及 visible() 判断。

三、clip_polygon:Sutherland–Hodgman 在 UI 裁剪中的落地

3.1 为什么改用多边形裁剪

被裁剪的内容(边框阴影、渐变、纹理九宫格等)本质上是矩形面片,进入渲染前要经历“被一系列矩形逐次削减”的过程。旧方案直接输出一个世界对齐矩形再交给 GPU 裁剪,无法表达“多个旋转裁剪区域的交集”。新方案把输入面片当作凸多边形顶点序列,对每个继承的裁剪矩形(在其局部坐标系下即轴对齐矩形)执行一次多边形裁剪,最后输出仍是一个凸多边形(三角形扇),再交给顶点阶段绘制。核心实现位于 crates/bevy_ui_render/src/clipping.rs

clip_polygon 的签名如下:

pub fn clip_polygon<T: Copy>(
    clip: Option<&CalculatedClip>,
    vertices: &[(Vec2, T)],
    interpolate: impl Fn(T, T, f32) -> T + Copy,
) -> SmallVec<[(Vec2, T); INLINE_CAPACITY]>

其中 T 是每个顶点携带的属性(例如 UV 坐标、顶点色),interpolate 用于在“边与裁剪边相交产生的新顶点”上插值出属性值,这正是渲染正确性的关键——裁剪切出来的新顶点必须携带正确的 UV,否则纹理在边缘会错位。

3.2 逐区域、逐边的核心循环

clip.rects() 中的每个区域,算法把矩形四条边按“左→右→下→上”拆成 (edge, distance_normal) 四元组:

for (edge, distance_normal) in [
    (-region.rect.min.x, Vec2::X),
    (region.rect.max.x, Vec2::NEG_X),
    (region.rect.max.y, Vec2::NEG_Y),
    (-region.rect.min.y, Vec2::Y),
] { /* edge_clip(...) */ }

其中 edge 是边界位置常量,distance_normal 是指向裁剪内侧的法向。每个顶点先被变换到该裁剪区域局部空间:

let distance = world_to_clip.transform_point2(vertex.0).dot(distance_normal) + edge;
let is_visible = 0. <= distance;

distance >= 0 表示该顶点位于这条边内侧,从而把三维的“多边形对轴对齐矩形裁剪”化约为一维的有符号距离判定。这是经典 Sutherland–Hodgman 的标准形态:遍历输入多边形每条边,依据相邻两个顶点相对裁剪边的内外状态,决定保留、丢弃或在交点处插入新顶点:

// 跨越裁剪边界时,在交点插入新顶点并插值属性
if is_visible != is_previous_visible {
    let t = previous_distance / (previous_distance - distance);
    output.push((
        previous.0.lerp(vertex.0, t),
        interpolate(previous.1, vertex.1, t),
    ));
}
if is_visible { output.push(vertex); }

注意一个关键细节:变换点后 world_to_clip 是 Affine2,包含了旋转与缩放,因此尽管裁剪矩形在自己的局部空间里是轴对齐的,最终裁剪出的形状在世界空间里却是斜的——这正是支持旋转的核心机制。

3.3 边界情况处理

clip_polygonedge_clip 中还包含几处防御性逻辑:

  • 输入顶点少于 3 个时直接返回空(不可能形成可见区域);
  • clipNone(没有任何继承裁剪)时原样返回所有顶点;
  • CalculatedClip::FullyClipped 时返回空列表;
  • 裁剪矩形边长为 ∞(对应 OverflowAxis::Visible)时跳过该边(edge.is_finite() 检查);
  • 某轮裁剪后可见顶点不足 3 个,则提前 break 或清空结果,避免产生退化多边形;
  • 内部通过两块 SmallVecvisible_regionscratch)乒乓交换,避免重复分配。

3.4 单元测试如何验证旋转裁剪

crates/bevy_ui_render/src/clipping.rs#L111-L198 内置了五个针对性测试:

  • unclipped_quad_returns_all_vertices:无裁剪时 4 个顶点原样返回;
  • fully_clipped_returns_empty_vertices_list:整体被裁掉时输出为空;
  • trim_quad_with_axis_aligned_clip:轴对齐裁剪只保留框内区域;
  • nested_clip_rects_compose:构造了一个旋转 0.3 弧度的裁剪矩形与一个轴对齐矩形叠加,验证“多个裁剪区域按各自坐标系组合”后结果仍落在有效范围内——这正是 PR 修复场景的直接回归测试;
  • quad_outside_clip_rect_returns_empty_vertices_list:裁剪框完全在多边形外时输出为空。

这些测试证明:即便某个裁剪区域带旋转矩阵(world_to_clip_local 来自 Affine2::from_mat2(...) 的逆),多边形也能被正确削减到各区域交集内部。

四、渲染侧的下游消费:阴影、渐变与材质管线

CalculatedClip 在 CPU 布局阶段写入节点组件后,由渲染提取阶段复制到 GPU 侧的 ExtractedUiNode 系列结构。多处渲染消费者都改为携带 clip: Option<CalculatedClip>,并在生成几何前调用 clip_polygon

这些调用点的共性模式是:先算出一个矩形/多边形的世界坐标顶点,再把顶点连同属性(UV)交给 clip_polygon,最后把返回的顶点序列提交给后续渲染阶段。由于算法本身与具体顶点属性解耦(通过泛型 T 与插值闭包),它可被所有 UI 几何类型复用,这是这次重构在代码组织上最大的收益。

五、用户侧零改动:API 不变与官方示例

5.1 Overflow API 没有任何破坏

发布说明特别强调 “The user-facing overflow API is unchanged”。这意味着 0.20 中你依然用同样的方式声明裁剪:

commands.spawn((
    Node {
        width: px(200.),
        height: px(200.),
        overflow: Overflow::clip(),   // x/y 都裁剪
        ..default()
    },
    BackgroundColor(Color::srgb(0.3, 0.3, 0.3)),
    UiTransform::from_rotation(Rot2::degrees(30.)),
));

所有相关枚举与构造器(OverflowAxis::Visible/Clip/Hidden/ScrollOverflow::clip_x()/hidden()/scroll() 等)的语义与 0.19 保持一致,升级仅涉及行为修复,不需要重构 UI 代码。

5.2 官方示例:多层旋转节点的裁剪与缩放

仓库提供了演示该特性的完整可运行示例 examples/ui/scroll_and_overflow/overflow_transform.rs,其文件头注释说明它“显示一个带文本和图片的旋转节点,被多个旋转的祖先节点裁剪”。

运行方式:

cargo run --example overflow_transform

该示例搭建了三层嵌套的裁剪容器(overflow: Overflow::clip() + 各自的 UiTransform::from_rotation),在 Update 阶段让每层以不同角速度持续旋转(rotate_nodes),最内层再叠一个使用 UiTransform 旋转 45°、并且 scale 随时间正弦变化的卡片(InnerNode,由 scale_inner 驱动)。卡片内包含一张图片和文本。运行后可以看到:

  • 文本与图片被逐级裁入每一层旋转中的裁剪框;
  • 卡片自身的旋转/缩放持续变化时,裁剪边界始终贴合卡片实际朝向而非世界轴;
  • 内层卡片还带有 BoxShadow,其阴影同样按旋转裁剪区域正确收边。

这组动态效果直观对应了发布说明中“descendants are clipped against each of the clipping regions inherited from their ancestors”的描述,也顺带演示了上一节的阴影裁剪在旋转场景下的表现。

5.3 相关示例与调试辅助

若想进一步对比边界行为,同目录下的 overflow.rs(基础裁剪/滚动)、overflow_clip_margin.rs(裁剪 margin 与外扩盒子)以及 overflow_debug.rs(可视化调试裁剪区域)也都基于同一套 API,可作为理解 Overflow 各枚举值效果的补充。UI 调试叠加层中关于裁剪的展示位于 crates/bevy_ui_render/src/debug_overlay.rs

六、小结

Bevy 0.20 的 UI 溢出裁剪重写,本质上是一次“坐标系与数据结构”的升级:将单一的世界对齐矩形,替换为携带各自仿射变换的裁剪区域列表CalculatedClip / CalculatedClipRect),并在渲染前用 clip_polygon 执行简化版 Sutherland–Hodgman 多边形裁剪,把旋转与缩放正确地纳入求交判定。

从源码结构可以总结出该方案的三条设计主线:

  1. 布局阶段只负责推导update_clipping_systemupdate.rs 中沿节点树累积裁剪区域并写入 CalculatedClip 组件,不接触几何;
  2. 裁剪算法被泛型化clipping.rsclip_polygon 与顶点属性解耦,阴影、渐变、九宫格等渲染路径共享同一实现;
  3. 对外 API 保持稳定Overflow 系列配置的写法与语义不变,配合 overflow_transform.rs 官方示例即可直接验证效果。

对开发者而言,升级到 0.20 后旋转/缩放 UI 的裁剪表现会“自动变正确”,无需修改任何代码——这正是这次重构最实用的价值所在。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390