UI 前沿技术

无需进行数学运算的投影转换

Charles Petzold

下载代码示例

几乎在任何图形系统中,转换都是最重要的功能,但它事实上并未参与绘制。转换的作用是利用数学公式(通常以矩阵乘法的形式表示)来修改坐标,从而改变可视对象的外观。

Silverlight 从其出现开始一直采用由 UIElement 定义的 RenderTransform 属性,而此前,该属性一直在 Windows Presentation Foundation (WPF) 中应用。由于该属性是由 UIElement 定义的,因此,您可以将其用于图形对象、文本、控件和媒体。只需将 RenderTransform 设置为类型 TranslateTransform、ScaleTransform、RotateTransform、SkewTransform、MatrixTransform(为实现对转换矩阵的完全控制)或 TransformGroup 的对象,即可实现多种转换的组合。

利用 RenderTransform 设置的转换类型全部属于二维 (2D) 仿射转换。仿射转换始终规规矩矩,因此有些乏味:直线永远转换为直线,椭圆始终转换为椭圆,正方形始终转换为平行四边形。转换前的两条平行线在转换后永远是平行的。

伪 3D

Silverlight 3 引入了名为 Projection 的新 UIElement 属性,从而实现对图形对象、文本、控件和媒体设置非仿射转换。非仿射转换不会保留转换前的平行状态。

Silverlight 3 中允许的非仿射转换类型仍以矩阵乘法表示,并且对其能够执行的操作仍有限制。直线永远转换为直线,正方形始终转换为简单凸四边形。此处的“四边形”是指由四条边组成的图形(又称作“四角形”);此处的“简单”是指除了顶点之外,各边不会相交;此处的“凸”是指各内角小于 180 度。

这种非仿射转换可用于创建锥化转换,在此转换中,正方形和长方形的对边朝一个方向进行一定的锥化。图 1 显示一些文本通过非常简单的 Projection 属性设置实现锥化转换。

图 1 锥化转换文本
图 1 锥化转换文本

文本看起来有一定的三维 (3D) 立体感,这是因为文本的尾端看起来有逐渐远去的感觉,这种效果称作“透视投影”。

从某种意义上说, Projection 属性使 Silverlight 具有了一定的“伪 3D”功能。但 Silverlight 不是真正的 3D 系统,这是因为它不支持在 3D 空间中定义对象,没有镜头概念、光源或阴影 — 也许最重要的是不支持在 3D 空间中根据对象的排列剪辑对象。

此外,在进行投影转换时,程序员需要开始考虑三维,尤其是 3D 旋转。幸运的是,Silverlight 的开发人员使 Projection 属性的常规和简单使用非常容易进行。

更简单的方法

您可以将 Projection 属性设置为 Matrix3DProjection 对象或 PlaneProjection 对象。Matrix3DProjection 属性只定义了一个对象,但它属于 4x4 Matrix3D 结构,需要大量数学运算。(有关此结构的使用方法,请参阅我在 2009 年 7 月 23 日和 2009 年 7 月 31 日在网站 —charlespetzold.com 上发布的博客文章。)

但是在此篇文章中,我已自我承诺在大多数情况下避免数学运算,这意味着我将坚持使用 PlaneProjection 类。尽管该类定义了 12 个可设置属性,但我只重点关注其中的 6 个。

在此篇文章中,可下载的源代码是 PlaneProjectionExperimenter,它允许您对这 6 个属性进行互动实验。图 2 显示了运行中的程序。您可以通过编译该可下载程序运行它,也可以在我的网站 charlespetzold.com/silverlight/PlaneProjectionDemos 运行它,同时还可以运行此篇文章中的所有其他程序。现在,先忽略中间的蓝点。


图 2 PlaneProjectionExperimenter 程序

PlaneProjection 的三个重要属性分别是 RotationX、RotationY 和 RotationZ,您可以使用三个滚动条更改这三个属性。这些属性假定了一个 3D 坐标系,其中,X 值向右增加,Y 值沿屏幕向下增加。(这与常见的 Silverlight 坐标系一致,但与典型的 3D 坐标系不一致,在 3D 坐标系中,Y 值通常沿屏幕向上增加。)如果增加 Z 值,图片看起来会朝观察者的方向移出屏幕。这三种属性使程序能够绕三个轴旋转。

X 和 Y 的滚动条与旋转轴垂直。例如,左边的垂直滚动条使图片绕 X 轴旋转,图片通过字母的中心水平运动。字母的顶部和底部看起来好像向前翻转或向后翻转。

开始时,您最好尝试单独旋转其中每个轴,然后在进行下一次实验之前刷新浏览器。您可以使用右手定则预计旋转的方向。使您的拇指指向正轴方向。(如果是 X 轴,拇指向右,如果是 Y 轴,拇指向下,如果是 Z 轴,拇指向自己。)其他手指的弯曲方向即表示正旋转角的方向。负旋转角以相反方向旋转。

复合旋转取决于应用各种旋转的顺序。使用 PlaneProjection 将牺牲这些旋转的部分灵活性。PlaneProjection 始终先应用 RotationX,然后应用 RotationY,最后应用 RotationZ。为什么这么说呢?尝试将 RotationZ 值设为 0,然后操作 RotationX 和 RotationY。您将看到 RotationX 始终使字母绕字母本身的水平轴旋转,而 RotationY 使字母绕窗口的垂直轴旋转,这意味着向字母应用 RotationY 之前,字母已经以 RotationX 角度旋转。现在,将 RotationX 和 RotationY 设为任意值,然后操作 RotationZ。此次旋转同样相对于窗口进行,但对字母的外观毫无影响。

在现实工作中,您可以只设置 Projection 的一个属性,同样可以获得满意的效果。图 1 中的文本是 TaperText 项目的一部分,并使用下列 XAML 显示:

<TextBlock Text="TAPER"
           FontFamily="Arial Black"
           FontSize="144"
           HorizontalAlignment="Center"
           VerticalAlignment="Center">
    <TextBlock.Projection>
        <PlaneProjection RotationY="-60" />
    </TextBlock.Projection>
</TextBlock>

如果采用 RenderTransform,Projection 不会影响布局。布局系统看见的是一个未转换和未投影的元素。

当然,RotationX、RotationY 和 RotationZ 属性全部受依赖关系属性支持,因此,它们可以成为动画目标。

FlipPanel

仅仅凭借对 PlaneProjection 的这些已有知识,我们就可以编写“面板翻转”的代码,这种方法通过组织面板前部和后部的控件(看起来大致如此),最小化应用程序在屏幕上的占用面积。在 WPF 中,FlipPanel 控件需要在 2D 和 3D 之间来回切换。而在 Silverlight 中,FlipPanel 变得非常简单。

FlipPanelDemo 项目包括由 UserControl 派生的 FlipPanel 控件。图 3 显示的代码部分定义了类型 UIElement 的两个新属性(名为“Child1”和“Child2”)和公共方法(名为“Flip”和“FlipBack”)。

图 3 FlipPanel.xaml.cs 文件

using System;
using System.Windows;
using System.Windows.Controls;

namespace FlipPanelDemo
{
    public partial class FlipPanel : UserControl
    {
        public static readonly DependencyProperty Child1Property =
            DependencyProperty.Register("Child1",
                typeof(UIElement),
                typeof(FlipPanel),
                new PropertyMetadata(null, OnChild1Changed));

        public static readonly DependencyProperty Child2Property =
            DependencyProperty.Register("Child2",
                typeof(UIElement),
                typeof(FlipPanel),
                new PropertyMetadata(null, OnChild2Changed));

        public FlipPanel()
        {
            InitializeComponent();
        }

        public UIElement Child1
        {
            set { SetValue(Child1Property, value); }
            get { return (UIElement)GetValue(Child1Property); }
        }

        public UIElement Child2
        {
            set { SetValue(Child2Property, value); }
            get { return (UIElement)GetValue(Child2Property); }
        }

        public void Flip()
        {
            flipStoryboard.Begin();
        }

        public void FlipBack()
        {
            flipBackStoryboard.Begin();
        }

        static void OnChild1Changed(DependencyObject obj,
                        DependencyPropertyChangedEventArgs args)
        {
            (obj as FlipPanel).child1Container.Content = args.NewValue;
        }

        static void OnChild2Changed(DependencyObject obj,
                        DependencyPropertyChangedEventArgs args)
        {
            (obj as FlipPanel).child2Container.Content = args.NewValue;
        }
    }
}

图 4 中的 XAML 部分显示了 Child1 和 Child2 如何占用相同的空间和应用投影转换。在初始位置时,Child2 的 PlaneProjection 转换将 RotationX 设置为 -90 度,这意味着与观察者成直角,因此实际上变得不可见。“flip”动画将 RotationX 设置为 90 度,从而使 Child1 离开我们的视野;它们将 RotationX 设置为 0 度,从而使 Child2 进入我们的视野。“flip back”动画则执行相反的操作。

图 4 FlipPanel.xaml 文件

<UserControl x:Class="FlipPanelDemo.FlipPanel"
             xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation" 
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml">
    <UserControl.Resources>
        <Storyboard x:Name="flipStoryboard">
            <DoubleAnimation Storyboard.TargetName="planeProjection1"
                             Storyboard.TargetProperty="RotationX"
                             To="90"
                             Duration="0:0:0.5" />
            
            <DoubleAnimation Storyboard.TargetName="planeProjection2"
                             Storyboard.TargetProperty="RotationX"
                             To="0"
                             BeginTime="0:0:0.5"
                             Duration="0:0:0.5" />
        </Storyboard>

        <Storyboard x:Name="flipBackStoryboard">
            <DoubleAnimation Storyboard.TargetName="planeProjection1"
                             Storyboard.TargetProperty="RotationX"
                             To="0"
                             BeginTime="0:0:0.5"
                             Duration="0:0:0.5" />
            <DoubleAnimation Storyboard.TargetName="planeProjection2"
                             Storyboard.TargetProperty="RotationX"
                             To="-90"
                             Duration="0:0:0.5" />
        </Storyboard>

    </UserControl.Resources>
    
    <Grid x:Name="LayoutRoot" Background="White">
        <ContentControl Name="child1Container">
            <ContentControl.Projection>
                <PlaneProjection x:Name="planeProjection1" />
            </ContentControl.Projection>
        </ContentControl>
        <ContentControl Name="child2Container">
            <ContentControl.Projection>
                <PlaneProjection x:Name="planeProjection2" 
                                 RotationX="-90" />
            </ContentControl.Projection>
        </ContentControl>
    </Grid>
</UserControl>

通常,Child1 和 Child2 将设置为某种使用了各种控件的面板。在 FlipPanelDemo 程序中,Child1 和 Child2 只有一个按钮和一些不同的颜色可用于触发翻转。对用户而言,翻转操作似乎比导航到新页面更加贴心,因为这样做看似一组控件不会永远消失,可以轻松找回一样。

旋转中心

所有的旋转都要相对于一个中心。二维旋转是相对于一个点。三维旋转是相对于 3D 空间中的一条线,通常称作“旋转轴”。为了方便起见,并为了避免在 Silverlight 中引入 3D 线和 3D 向量,我们认为投影转换是相对于一个 3D 点。

PlaneProjection 类支持使用三个属性来更改旋转中心:

  • CenterOfRotationX(相对坐标;默认值是 0.5)
  • CenterOfRotationY(相对坐标;默认值是 0.5)
  • CenterOfRotationZ(绝对坐标;默认值是 0)

我们首先来看前面两个属性。和 RenderTransformOrigin 属性一样,这些值是相对于转换元素的左上角。它们之间的区别在于 RenderTransformOrigin 的默认值是 (0, 0),而 PlaneProjection 的默认值使旋转以该元素为中心。

在 PlanProjectionExperimenter 中,可以使用蓝点更改 CenterOfRotationX 和 CenterOfRotationY。(工具提示指示当前值。)您可能马上注意到,CenterOfRotationX 不会影响绕 X 轴的旋转,CenterOfRotationY 不会影响绕 Y 轴的旋转。

如果您将 CenterOfRotationX 设为 0,则绕 Y 轴的旋转将相对于元素的左侧;同理,如果将其设为 1,旋转将绕右侧进行。为了获得非常大的旋转幅度,您可以将该值设为负数或大于 1 的数。该元素看似将大幅度靠近或远离观察者。

请尝试:将 CenterOfRotationY 设为接近 0 的值。然后将 RotationX 设为 90 度或近似值。您将注意到该元素仍然可见。在 FlipPanel 中,元素在旋转 90 度或 -90 度后将变得不可见,这是因为我们看的是它的侧边。PlaneProjection 类中的数学计算假定观察者的眼睛(或假设存在的镜头)始终与元素的中心对齐,并从 Z 轴的负方向直视过去。如果某物偏离中心并回转 90 度,您仍可以从元素的中心看到它。

CenterOfRotationZ 轴的操作方式与 X 轴和 Y 轴不同。它使用 Silverlight 的绝对坐标而非相对坐标,并且默认值是 0。在 PlaneProjectionExperimenter 中,您可以利用鼠标滚轮更改该值。蓝点的放大和缩小表示该属性的变化。

您可能很难在大脑中形象化地想象如何通过非默认 CenterOfRotationZ 旋转,因此,您最好花些时间去试验一下。

我们假定投影的元素位于 3D 空间中的 XY 平面,Z 轴为 0。如果将 CenterOfRotationZ 属性保留为默认值 0,并操作 RotationX 或 RotationY,字母的某些部分会在它们移到正 Z 轴空间时变大,并在移入负 Z 轴空间时变小。现在将 CenterOfRotationZ 的值增加到 200 左右,并操作 RotationY。字母将变大,这是因为它围绕 Z 等于 200 的中心从 Z 等于 0 的空间区域旋转到 Z 等于 400 的空间区域。当您将 CenterOfRotationZ 设为 500 或更大值时,整个字母旋转将出现问题,这是因为 PlaneProjection 的内部运算假定观察者(或镜头)距离 XY 平面 1,000 个单位。当旋转中心为 500 时,元素实际上投影到镜头的后面。

三个面板轮播

我们能否利用三边而非两边实现类似 FlipPanel 的效果?可以,但这需要一些三角学的知识以及在 Z 索引上花费大量精力。

图 5 显示了想象中的顶视图。您从 Y 轴的负方向 — 监视器的上方(也就是向下看)即可看到该视图。粗线条表示三个面板。所有这些面板其实位于相同的位置。之所以看起来在不同的位置是因为投影转换所致。前面的可见面板没有应用旋转。另外两个应用了旋转,其中,RotationY 设置为 120(右边的那一个)和 -120 度(左边那一个)。两个旋转都以与黑点对应的负 CenterOfRotationZ 值为中心。那个值是多少?

图 5 三个面板轮播建议
图 5 三个面板轮播建议

如果您通过虚线将该点连接到右边的顶点,虚线将与 X 轴形成 30 度的角。30 度角的正切值是 0.577。该比值来自点到 X 轴的距离除以面板宽度的一半。如果面板的宽度是 200 个单位,则该点在 -57.7。将 CenterOfRotationZ 设定为该值。

现在,若要实现从一个面板旋转到下一个面板的轮播,只需设置将三个面板的 RotationY 增加 120 度的动画即可。

其实并不完全是这样。虽然我们是在 3D 空间中想像这些面板,但他们实际上存在于 2D 空间,并且彼此堆叠。在旋转过程中,必须更改三个面板附加的 Canvas.ZIndex 属性才能对面板重新排序。这也是将元素看似移到其他元素的“前面”或“后面”的方法。

让我们假设基于图 5 图表进行顺时针旋转。开始时,前面板的 Z 索引为 2(前台),右面板的 Z 索引为 1,左面板的 Z 索引为 0(后台)。现在开始 120 度的旋转。旋转到一半的时候,即各面板旋转了 60 度并且两个面板具有相同可见度时,必须更改 Z 索引。应将待显示面板的 Z 索引设为 2,将待隐藏面板的 Z 索引设为 1。

ThreePanelCarousel 项目中实现了所有这些机制。当然,在实际应用中,这三个面板将包含一些控件;但在此演示程序中,它们只有一个按钮和不同的颜色。我将面板设为半透明状态,从而使您能够看清其中的变化。图 6 显示旋转中的面板。

图 6 旋转中的 ThreePanelCarousel 程序
图 6 旋转中的 ThreePanelCarousel 程序

您也许想知道是否能够对三个面板的复合组应用同一个投影旋转。答案是不行。您不能对子元素混合多个投影转换。

其他效果

在实验 Projection 属性的同时,不要忘了“增强”应用程序 3D 性的其他效果。无论何时,一点阴影就能起到意想不到的效果,您只需利用渐变画笔即可轻松模拟包括移动阴影在内的各种阴影。

AsTheDaysGoBy 程序利用综合动画来模拟一页页翻动台历的效果,如同在旧电影中表现时光飞逝的手法。日历页面将 Projection 属性设置为 PlaneProjection 对象,并将 CenterOfRotationX 和 CenterOfRotationY 属性都设置为 0。RotationX 和 RotationY 利用动画效果将页面向上方和左边移动,同时利用动画渐变画笔向上涂抹页面。最后,当页面的“不透明度”属性设为 0 时,页面将从视觉上消失。图 7 显示正在工作中的程序。

图 7 AsTheDaysGoBy 程序
图 7 AsTheDaysGoBy 程序

动画持续一秒钟,因此,程序每分钟经历 60 天,每小时经历 10 年。一个月后,程序将接近最大 DateTime 值,并将很快出现异常而终止。您欣赏的动画将在那个时候结束。

Charles Petzold 是《MSDN 杂志》的长期特约编辑。他的最新著作是“The Annotated Turing:A Guided Tour Through Alan Turing’s Historic Paper on Computability and the Turing Machine”(Wiley,2008)。Petzold 的博客网站是 charlespetzold.com