视图、边界和裁剪

简介

本指南介绍了在“景观”中视图边界和裁剪的工作原理。本指南介绍了如何设置视图边界、如何解读命令的作用,以及视图边界对现有 Views 子系统的影响。

概念

设置视图边界

嵌入器必须为 View 和 ViewHolder 创建一对令牌,并且还必须在其视图中分配空间以便布局嵌入的 ViewHolder。这是通过在嵌入视图的 ViewHolder 上设置边界来实现的。如需设置视图的视图边界,必须对其对应的 ViewHolder 调用 SetViewProperties。您可以在创建视图本身并将其关联到 ViewHolder 之前或之后调用 SetViewProperties,因此不必担心您进行设置的顺序。边界本身是通过在 3D 空间中指定最小和最大点 (xyz) 设置的。

边界范围和边衬区

ViewProperties 结构体集中的 bounding_box 属性用于设置视图的边界。最小和最大边界表示轴对齐边界框的最小和最大坐标点。

insets_from_mininsets_from_max 属性提示边界框与边衬区之间的区域可能会被遮挡。Views 不使用边衬区值来确定边界框,而是使用在外部绘制的任何内容

{ bounding_box.min + inset_from_min, bounding_box.max - inset_from_max }

可能会被其祖先视图遮挡。模糊的原因及其相关的规则因产品而异。

示例

// Create a pair of tokens to register a view and view holder in
// the scene graph.
auto [view_token, view_holder_token] = scenic::ViewTokenPair::New();

// Create the actual view and view holder.
scenic::View view(session, std::move(view_token), "View");
scenic::ViewHolder view_holder(session, std::move(view_holder_token),
                               "ViewHolder");
// Set the bounding box dimensions on the view holder.
view_holder.SetViewProperties({.bounding_box{.min{0, 0, -200}, .max{500, 500, 0}},
                               .inset_from_min{20, 30, 0},
                               .inset_from_max{20, 30, 0}});

上述代码会创建一个 View 和 ViewHolder 对,其边界从 (0, 0, -200) 开始,一直延伸到 (500, 500, 0)。边界本身始终与轴对齐。

坐标系

Views 的 3D 系统由许多坐标空间组成。它们大致可以分为:图层空间、世界空间和局部空间。空间点在每个坐标系中都具有独立的坐标。转换矩阵描述了如何在两个坐标空间之间映射空间点;该点在系统 A 中的坐标将乘以转换矩阵,以获取该点在系统 B 中的坐标(或者矩阵的反方向坐标)。

所有 View 坐标系均采用右手模式。

坐标空间示意图

层空间(屏幕空间)

图层空间是与屏幕上的某个区域相对应的 2D 坐标空间。- 原点:左上角,X 轴指向右侧,Y 轴指向下方。与显示屏轴对齐。 - 尺寸:通常与显示尺寸相同(全屏图层)。

相机转换

相机转换矩阵可将坐标从世界空间转换为图层空间。

这种转换实际上包含几个步骤。

首先,我们从世界空间转换为中间空间,称为“镜头空间”。这是每个方向上从 -1 到 1 的标准化三维坐标系。该坐标系为右手坐标,X 轴指向右侧,Y 轴向下,Z 轴指向“屏幕”。镜头在世界空间中的位置和方向决定了转换。

接下来,投影转换矩阵会决定如何将镜头空间展平为层空间的二维空间。此转换决定了要执行的投影类型:正交投影或透视投影。常见的选择是正交,其中世界基本在 Z 轴方向上扁平化,同时保持 X 轴和 Y 轴。

最后,可以应用裁剪空间转换,以缩放和平移 2D 空间以启用放大功能。1

世界空间

世界空间是场景图的根视图所在的位置,也是相机的位置。因此,整个场景图嵌入了此空间中,而相机空间与场景之间的关系则在该空间中定义。

转换节点

Scene Graph 中的每个节点都有一个与之关联的本地空间。该空间由该节点的转换矩阵定义,该矩阵会将节点的本地空间坐标转换为其父节点的本地空间。此矩阵取决于节点的位置、旋转角度和缩放比例(由其父节点定义)。

每个节点的局部空间与世界空间相关,方法是将每个转换矩阵向上乘以场景图,并在场景图的根处终止(其转换矩阵是从根的本地空间到世界空间的转换)。这个相乘矩阵称为节点的“全局转换”。

节点的转换矩阵是该节点的平移、缩放和旋转的组合。应用顺序是缩放,然后是旋转,最后是平移。由于这些值都可以写为自己的转换矩阵,并且矩阵的乘法顺序很重要,因此我们可以将完整转换写为:

parent_local_point = translation matrix * rotation_matrix * scale_matrix * local_point

示例

我们有一个由 Node1 和 Node2 两个节点组成的场景图。Node1 是 Node2 的子级,P1 是 Node1 的本地空间中的一个点,Node1 和 Node2 都有旋转、缩放和平移。若要获取 P1 的世界空间坐标,您可以按顺序组合所有父级的转换: p1_world_position = (translation_node2 * rotation_node2 * scale_node2) * (translation_node1 * rotation_node1 * scale_node1) * p1_local_position

本地空间(场景视图空间)2

由于并非每个会话都充分了解完整的场景图,因此它们改为在其视图的本地坐标空间中执行操作。这是会话定义其节点转换的空间,也是传递输入坐标的空间。

  • 原点:视图的“后角”。视图的 Z 维度通常设置为 -1000 到 0(作为一个副作用,即最初采用左手坐标系,后来变为右手坐标系),因此视图的原点位于最大 Z 值,但 X 和 Y 值最小。通常与世界空间轴对齐。

视图边界

视图边界通过局部坐标指定,其世界空间位置由视图节点的全局转换确定。

输入坐标源自图层空间,通常对应于以屏幕左上角的原点为起点的像素坐标。输入系统与合成器和相机协同工作,通过光线投射和命中测试将输入设备坐标映射到世界空间。

示例

// Create a view and view-holder token pair.
auto [view_token, view_holder_token] = scenic::ViewTokenPair::New();
scenic::View view(session, std::move(view_token), "View");
scenic::ViewHolder view_holder(session, std::move(view_holder_token),
                               "ViewHolder");

// Add the view holder as a child of the scene.
scene.AddChild(view_holder);

// Translate the view holder and set view bounds.
view_holder.SetTranslation(100, 100, 200);
view_holder.SetViewProperties({.bounding_box{.max{500, 500, 200}}});

在上面的代码中,局部空间中的视图边界的最小值和最大值分别为 (0, 0, 0)(500, 500, 200),但由于父节点经过了 (100, 100, 200) 转换,因此世界空间中的视图边界实际上的世界空间边界最小值和最大值分别为 (100, 100, 200)(600, 600, 400)。不过,视图本身看不到这些世界空间边界,只会在其自己的局部空间中处理其边界。

居中几何图形

一个几何图形(例如 RoundedRectangle)的质心是其中心,而对于视图,其边界的质心是其最小坐标。这意味着,如果视图和作为该视图子项的圆角矩形具有相同的平移值,则圆角矩形的中心将以视图边界的最小坐标进行渲染。如需修复此问题,请在形状节点上应用另一个平移,以将其移动到视图边界的中心。

居中几何图形图

调试线框渲染

为帮助调试视图边界,您可以在线框模式下渲染边界的边缘,以查看您的视图在世界空间中的确切位置。此功能可使用 Scape 命令按视图应用:

// This command turns on wireframe rendering of the specified
// view, which can aid in debugging.
struct SetEnableDebugViewBoundsCmd {
    uint32 view_id;
    bool display_bounds;
};

此命令接受一个 view id 和一个 bool,用于切换是否应显示视图边界。默认的显示颜色是白色,但您可以通过对指定的 ViewHolder 运行 SetViewHolderBoundsColorCmd 来选择不同的颜色:

// This command determines the color to be set on a view holder’s debug
// wireframe bounding box.
struct SetViewHolderBoundsColorCmd {
    uint32 view_holder_id;
    ColorRgbValue color;
};

射线投射和撞击测试

通过光线投射进行的命中测试可将图层空间坐标映射到场景几何图形和坐标。最终,输入将传递给具有视图局部坐标的视图。如坐标系部分所述,视图局部坐标由视图节点的全局转换(从世界空间映射到局部空间)确定。

命中射线

在注入触摸输入事件时,我们需要知道两件事:1. 我们要将输入事件发送到哪个(些)客户端?2. 在客户端本地坐标系中的输入事件坐标是什么?

输入坐标从层空间到世界空间的转换涉及输入系统、合成器层和相机。

输入坐标空间

为了确定我们要将事件发送到哪个客户端,我们执行点击测试。其实现方法是将光线(在“世界空间”中)投影到场景中,以查看它与哪些对象相交,然后将输入发送到最近的命中对象。当我们击中物体时,我们会记下场景中命中点的位置。然后,我们计算命中对象所属视图的“World Space to Local Space”转换,并使用该转换将输入事件发送到客户端的本地坐标。

原始输入坐标是以屏幕像素为单位的二维坐标。输入系统和合成器同意一个惯例,如上图所示的 3 维空间坐标(蓝色),其中观看量的深度为 1,近平面为 z = 0,远平面为 z = -1。考虑到这一点,输入系统通过计算触摸坐标的世界空间坐标(通过反转相机转换)来构建命中光线,并将其设置为朝向场景的起点,Z 为 0,方向为 (0, 0, 1)

那么,在“世界空间”中,上述命中光线的起点为 (x, y, -1000),方向为 (0, 0, 1000)

规则

在执行点击测试时,Sce 会针对 ViewNode 的边界运行测试,然后再确定光线是否应继续检查该节点的子节点。

  • 如果光线完全错过某个视图的边界框,那么所有子视图都不会命中。

  • 如果一条射线与边界框相交,则只会认为位于这条射线出入边界框的几何图形范围内的几何图形代表一次命中。例如,无法命中裁剪的几何图形。

如果忘记为视图设置边界,则无法命中任何以该视图子项存在的几何图形。这是因为边界将为 null 且因此无限小,这也意味着屏幕不会渲染任何几何图形。

在调试模式下,null 边界框将触发 escher::BoundingBox 类中的 FXL_DCHECK,指出边界框尺寸需要大于或等于 2。

极端情况

光线与边界框的一侧垂直只是擦过其边缘的情况不算作命中。由于构成边界框的六个平面本身就是裁剪平面,因此直接位于裁剪平面上的任何内容也会被裁剪。

碰撞

当光线投射检测到位于相同距离的两次或更多命中时,就会发生碰撞。冲突表示可碰撞的目标重叠并占据场景中的相同位置。这被认为是不正确的行为,Sense 并不在发生冲突时提供点击测试排序保证。客户端必须防止冲突。

可能发生冲突的方法有两种:

  • 同一视图中的节点之间发生冲突。所属视图必须确保元素在视图中的正确位置。

  • 不同视图中的节点之间发生冲突。父视图必须防止其子视图的裁剪边界之间出现任何交集。

遵循这些规则也是一种最佳做法,以免为视觉内容造成 Z-fighting。

检测到冲突时,系统会按会话 ID 和资源 ID 记录冲突节点的警告。


  1. 处理放大的更好解决方案是,将缩放节点直接插入图表,然后直接进行操作。这种方法会导致一些问题,因为应用对它们的大小和应该分配的内存量感到困惑(因为指标事件通过缩放发出),而裁剪空间转换则作为一种权宜之计引入。而且,由于输入操作目前是在相机空间内处理的,因此会带来一个糟糕的副作用,那就是滑动手势在放大的情况下看起来可能会变小。 

  2. 请勿与常见 3D 图形的“模型-视图-投影”表示法中的视图空间(我们在此称为“相机空间”)混淆。