Bevy 0.20 UI 溢出裁剪支持旋转:基于 Sutherland–Hodgman 的多边形裁剪新实现
在 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)所修复的问题,其核心思路在发布说明中概括为两点:
- 后代节点不再与“单个轴对齐矩形”求交,而是逐一与祖先链上继承下来的每一个裁剪区域求交;
- 每个裁剪区域都携带从世界空间到该裁剪节点局部空间的仿射变换,从而让旋转、缩放都被正确计入。
而用户可见的 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-L113 的 update_clipping_system / update_clipping。其流程可以概括为:
- 节点若带
OverrideClip或FixedNode组件,则丢弃所有继承的裁剪区域,开启新的裁剪上下文(对应测试fixed_node_opens_new_clipping_context、override_clip_opens_new_clipping_context)。 - 若节点
display为 None(不可显示),后代整体标记为CalculatedClip::FullyClipped。 - 把当前继承的
CalculatedClip写入(或移除)该节点的组件,供渲染侧使用。 - 为子节点计算新的继承裁剪:如果当前节点自身不产生裁剪,直接把
maybe_inherited_clip原样透传给子节点;如果当前节点产生裁剪,则取出自身世界变换的逆矩阵,调用clip.push_rect(rect, world_to_clip_local)把新区域追加到继承列表尾部,再递归下传。
由于每个裁剪区域都带着“世界→该裁剪节点局部空间”的变换,后代在求交时互不干扰——祖父的裁剪框按祖父的坐标系判定,父亲的按父亲的坐标系判定,正确反映了各自的旋转与缩放。
2.3 resolve_clip_rect:裁剪矩形如何由布局结果确定
每个节点的局部裁剪矩形在 crates/bevy_ui/src/ui_node.rs#L246-L276 的 resolve_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,该轴边界直接置为 ±∞,等于该轴不裁剪。
Overflow 与 OverflowClipMargin 的定义与 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_polygon 与 edge_clip 中还包含几处防御性逻辑:
- 输入顶点少于 3 个时直接返回空(不可能形成可见区域);
clip为None(没有任何继承裁剪)时原样返回所有顶点;CalculatedClip::FullyClipped时返回空列表;- 裁剪矩形边长为 ∞(对应
OverflowAxis::Visible)时跳过该边(edge.is_finite()检查); - 某轮裁剪后可见顶点不足 3 个,则提前 break 或清空结果,避免产生退化多边形;
- 内部通过两块
SmallVec(visible_region与scratch)乒乓交换,避免重复分配。
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:
- 边框阴影:见 crates/bevy_ui_render/src/box_shadow.rs#L525 附近,阴影几何的四个角点作为多边形输入,被继承裁剪区域削减后再展开为三角形扇,因此旋转节点上投射的阴影也能被正确裁入旋转的父裁剪框;
- 渐变:见 crates/bevy_ui_render/src/gradient.rs 中
use crate::clipping::clip_polygon及其几何构建路径; - 纹理九宫格与常规材质管线:
bevy_ui_render下的 ui_texture_slice_pipeline.rs、ui_material_pipeline.rs 与 lib.rs 均引入了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/Scroll、Overflow::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 多边形裁剪,把旋转与缩放正确地纳入求交判定。
从源码结构可以总结出该方案的三条设计主线:
- 布局阶段只负责推导:
update_clipping_system在 update.rs 中沿节点树累积裁剪区域并写入CalculatedClip组件,不接触几何; - 裁剪算法被泛型化:clipping.rs 的
clip_polygon与顶点属性解耦,阴影、渐变、九宫格等渲染路径共享同一实现; - 对外 API 保持稳定:
Overflow系列配置的写法与语义不变,配合 overflow_transform.rs 官方示例即可直接验证效果。
对开发者而言,升级到 0.20 后旋转/缩放 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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00