Проекционные преобразования в WinRT

116

Ранее я объяснил, почему для графических преобразований на плоскости удобно использовать математическое описание в виде матрицы 3 x 3 и для чего нужен выход в третье измерение. Аналогичным образом пространственное графическое преобразование выражается матрицей 4 x 4, и в Windows Runtime предусмотрена такая возможность.

Пространство имен Windows.UI.Xaml.Media.Media3D состоит всего из двух определений: структура Matrix3D доступна для всех программистов, а класс Matrix3DHelper представляет интерес в основном для программистов C++, для которых недоступны методы, определяемые Matrix3D. Свойства Matrix3D аналогичны свойствам обычной структуры Matrix:

Впрочем, программисты практически никогда не имеют дела с этой матрицей. Большинству из них вполне хватает класса PlaneProjection, упоминавшегося ранее.

Класс PlaneProjection предназначен в основном для выполнения поворота двумерных элементов в трехмерном пространстве. Вращение в трехмерном пространстве всегда выполняется относительно некоторой оси; класс PlaneProjection позволяет вращать элемент вокруг горизонтальной оси (свойство RotationX), вертикальной оси (RotationY) или концептуальной оси Z, выходящей из плоскости экрана. Поворот вокруг оси Z представляет собой обычный двумерный поворот, ничего особенно интересного в нем нет.

Направление вращения можно определить по правилу правой руки: направьте большой палец правой руки в положительном направлении оси (вправо для оси X, вниз для оси Y, из экрана для оси Z). Кривая, образованная пальцами, обозначает направление вращения для положительных углов.

Класс PlaneProjection применяет повороты в порядке X/Y/Z, но обычно вы используете только один из них. При нетривиальном использовании PlaneProjection можно заставить элементы появляться на экране и даже «переворачиваться», открывая изображение на «другой стороне» (вскоре мы рассмотрим пример).

Впрочем, есть и более прямолинейные применения. Следующая программа применяет независимые анимации к свойствам RotationX, RotationY и RotationZ имитируя вращение объекта TextBlock в трехмерном пространстве. Следующий файл XAML создает в нижней части приложения группу управляющих кнопок:

<Page ...>

    <Page.Resources>
        <Storyboard x:Key="xanimation" RepeatBehavior="Forever">
            <DoubleAnimation Storyboard.TargetName="planePrj"
                             Storyboard.TargetProperty="RotationX"
                             From="0" To="360" Duration="0:0:1.9" />
        </Storyboard>

        <Storyboard x:Key="yanimation" RepeatBehavior="Forever">
            <DoubleAnimation Storyboard.TargetName="planePrj"
                             Storyboard.TargetProperty="RotationY"
                             From="0" To="360" Duration="0:0:3.1" />
        </Storyboard>

        <Storyboard x:Key="zanimation" RepeatBehavior="Forever">
            <DoubleAnimation Storyboard.TargetName="planePrj"
                             Storyboard.TargetProperty="RotationZ"
                             From="0" To="360" Duration="0:0:4.3" />
        </Storyboard>
    </Page.Resources>

    <Grid Background="#FF1D1D1D">
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <TextBlock Text="3D-эффект"
                   FontSize="220"
                   VerticalAlignment="Center"
                   HorizontalAlignment="Center">
            <TextBlock.Projection>
                <PlaneProjection x:Name="planePrj" />
            </TextBlock.Projection>
        </TextBlock>

        <!-- Панель управления -->
        <Grid Grid.Row="1" HorizontalAlignment="Center">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition Width="Auto" />
            </Grid.ColumnDefinitions>

            <Grid.Resources>
                <Style TargetType="TextBlock">
                    <Setter Property="FontSize" Value="15" />
                    <Setter Property="VerticalAlignment" Value="Center" />
                </Style>

                <Style TargetType="Button">
                    <Setter Property="Width" Value="140" />
                    <Setter Property="Margin" Value="14" />
                </Style>
            </Grid.Resources>

            <TextBlock Text="Ось X: " Tag="xanimation" />
            <Button Content="Запустить" Grid.Column="1"
                    Click="Begin_Click" />
            <Button Content="Пауза" Grid.Column="2"
                    IsEnabled="False"
                    Click="Pause_Click" />

            <TextBlock Text="Ось Y: " Grid.Row="1" Tag="yanimation" />
            <Button Content="Запустить" Grid.Row="1" Grid.Column="1"
                    Click="Begin_Click" />
            <Button Content="Pause" Grid.Row="1" Grid.Column="2"
                    Click="Pause_Click" IsEnabled="False" />

            <TextBlock Text="Ось Z: " Grid.Row="2" Tag="zanimation" />
            <Button Content="Запустить" Grid.Row="2" Grid.Column="1"
                    Click="Begin_Click" />
            <Button Content="Pause" Grid.Row="2" Grid.Column="2"
                    Click="Pause_Click" IsEnabled="False" />
        </Grid>
    </Grid>
</Page>

Продолжительности отдельных объектов DoubleAnimation несколько отличаются для предотвращения повторяющихся эффектов при их одновременном воспроизведении. Кнопки в файле фонового кода используют методы Begin, Stop, Pause и Resume класса Storyboard для управления происходящим:

using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media.Animation;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        public MainPage()
        {
            this.InitializeComponent();
        }

        private void Begin_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            Button button = (Button)sender;
            string key = GetSibling(button, -1).Tag.ToString();
            Storyboard storyboard = (Storyboard)this.Resources[key];
            Button pauseResumeButton = (Button)GetSibling(button, 1);
            pauseResumeButton.Content = "Пауза";

            if (button.Content as string == "Запустить")
            {
                storyboard.Begin();
                button.Content = "Стоп";
                pauseResumeButton.IsEnabled = true;
            }
            else
            {
                storyboard.Stop();
                button.Content = "Запустить";
                pauseResumeButton.IsEnabled = false;
            }
        }

        private void Pause_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            Button button = (Button)sender;
            string key = GetSibling(button, -2).Tag.ToString();
            Storyboard storyboard = (Storyboard)this.Resources[key];

            if (button.Content as string == "Пауза")
            {
                storyboard.Pause();
                button.Content = "Пуск";
            }
            else
            {
                storyboard.Resume();
                button.Content = "Пауза";
            }
        }

        private FrameworkElement GetSibling(FrameworkElement e, int index)
        {
            Panel parent = (Panel)e.Parent;
            int i = parent.Children.IndexOf(e);
            return (FrameworkElement)parent.Children[i + index];
        }
    }
}

Результат выглядит примерно так:

Трехмерное вращение элемента с помощью класса PlaneProjection

Класс PlaneProjection также содержит ряд дополнительных свойств. Свойства CenterOfRotationX и CenterOfRotationY задаются в системе координат относительно элемента. По умолчанию используются значения 0,5 - центр элемента, который чаще всего используется на практике. Свойство CenterOfRotationZ задается в пикселах со значением по умолчанию 0, соответствующим поверхности экрана. Во внутренних вычислениях предполагается, что «камера» (или пользователь) рассматривает экран с расстояния 1000 пикселов (около 10 дюймов).

PlaneProjection также определяет три свойства LocalOffset для измерений X, Y и Z и три свойства GlobalOffset - величин сдвига в пикселах. Значения LocalOffset применяются до поворота, значения GlobalOffset применяются после поворота. Вы будете чаще использовать свойства GlobalOffset.

Рассмотрим маленький пример «перекидной панели» - эффекта, который когда-то реализовывался с большими трудностями и требовал применения полноценного 3D-программирования. Представьте панель, которая содержит набор элементов; эту панель можно перевернуть, чтобы использовать другой (хотя и логически связанный) набор элементов. В этом примере для представления «передней» и «обратной» сторон используются две панели Grid с разными цветами фона, каждая из которых содержит поле TextBlock:

<Grid Background="#FF1D1D1D">
        <Grid HorizontalAlignment="Center"
              VerticalAlignment="Center"
              Tapped="OnGridTapped">

            <Grid Name="grid1" Background="LimeGreen"
                  Canvas.ZIndex="1">
                <TextBlock Text="Привет"
                           HorizontalAlignment="Center"
                           FontSize="180" />
            </Grid>

            <Grid Name="grid2"
                  Background="DarkOrange"
                  Canvas.ZIndex="0">
                <TextBlock Text="Windows 8"
                           FontSize="180" />
            </Grid>

            <Grid.Projection>
                <PlaneProjection x:Name="projection" />
            </Grid.Projection>
        </Grid>
</Grid>

Обратите внимание на значения Canvas.ZIndex. Они гарантируют, что панель grid1 визуально располагается поверх grid2, даже если она предшествует ей в коллекции потомков их общего родителя.

Секция Resources содержит два определения Storyboard - для прямого и обратного переворота:

<Page.Resources>
        <Storyboard x:Key="flipStoryboard">
            <DoubleAnimationUsingKeyFrames
                             Storyboard.TargetName="projection"
                             Storyboard.TargetProperty="RotationY">
                <DiscreteDoubleKeyFrame KeyTime="0:0:0" Value="0" />
                <LinearDoubleKeyFrame KeyTime="0:0:0.99" Value="90" />
                <DiscreteDoubleKeyFrame KeyTime="0:0:1.01" Value="-90" />
                <LinearDoubleKeyFrame KeyTime="0:0:2" Value="0" />
            </DoubleAnimationUsingKeyFrames>

            <DoubleAnimation Storyboard.TargetName="projection"
                             Storyboard.TargetProperty="GlobalOffsetZ"
                             From="0" To="-1000" Duration="0:0:1"
                             AutoReverse="True" />

            <ObjectAnimationUsingKeyFrames
                             Storyboard.TargetName="grid1"
                             Storyboard.TargetProperty="(Canvas.ZIndex)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0" Value="1" />
                <DiscreteObjectKeyFrame KeyTime="0:0:1" Value="0" />
            </ObjectAnimationUsingKeyFrames>

            <ObjectAnimationUsingKeyFrames
                             Storyboard.TargetName="grid2"
                             Storyboard.TargetProperty="(Canvas.ZIndex)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0" Value="0" />
                <DiscreteObjectKeyFrame KeyTime="0:0:1" Value="1" />
            </ObjectAnimationUsingKeyFrames>
        </Storyboard>

        <Storyboard x:Key="flipBackStoryboard">
            <DoubleAnimationUsingKeyFrames
                             Storyboard.TargetName="projection"
                             Storyboard.TargetProperty="RotationY">
                <DiscreteDoubleKeyFrame KeyTime="0:0:0" Value="0" />
                <LinearDoubleKeyFrame KeyTime="0:0:0.99" Value="-90" />
                <DiscreteDoubleKeyFrame KeyTime="0:0:1.01" Value="90" />
                <LinearDoubleKeyFrame KeyTime="0:0:2" Value="0" />
            </DoubleAnimationUsingKeyFrames>

            <DoubleAnimation Storyboard.TargetName="projection"
                             Storyboard.TargetProperty="GlobalOffsetZ"
                             From="0" To="-1000" Duration="0:0:1"
                             AutoReverse="True" />

            <ObjectAnimationUsingKeyFrames
                             Storyboard.TargetName="grid1"
                             Storyboard.TargetProperty="(Canvas.ZIndex)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0" Value="0" />
                <DiscreteObjectKeyFrame KeyTime="0:0:1" Value="1" />
            </ObjectAnimationUsingKeyFrames>

            <ObjectAnimationUsingKeyFrames
                             Storyboard.TargetName="grid2"
                             Storyboard.TargetProperty="(Canvas.ZIndex)">
                <DiscreteObjectKeyFrame KeyTime="0:0:0" Value="1" />
                <DiscreteObjectKeyFrame KeyTime="0:0:1" Value="0" />
            </ObjectAnimationUsingKeyFrames>
        </Storyboard>
</Page.Resources>

Два объекта Storyboard очень похожи. Каждый из них содержит тег DoubleAnimationUsingKeyFrames для свойства RotationY объекта PlaneProjection. Свойство изменяется от 0 до 90° или -90° (точка, в которой объект находится под прямым углом к пользователю), после чего переключается на 180°, чтобы анимация могла продолжаться в том же направлении обратно до 0.

В то же время свойство GlobalOffsetZ изменяется от 0 до -1000 и обратно до 0. Так создается иллюзия того, что панель уходит за экран, готовясь к выполнению переворота.

На середине пути в каждом объекте Storyboard индексы Canvas.ZIndex меняются местами. Свойство Canvas.ZIndex также часто используется в качестве целевого для ObjectAnimationUsingKeyFrames.

Анимации запускаются касанием, которое обрабатывается в файле фонового кода:

using Windows.UI.Xaml;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media.Animation;

namespace WinRTTestApp
{
    public sealed partial class MainPage : Page
    {
        Storyboard flipStoryboard, flipBackStoryboard;
        bool flipped = false;

        public MainPage()
        {
            this.InitializeComponent();
            flipStoryboard = this.Resources["flipStoryboard"] as Storyboard;
            flipBackStoryboard = this.Resources["flipBackStoryboard"] as Storyboard;
        }

        private void OnGridTapped(object sender, TappedRoutedEventArgs args)
        {
            if (flipStoryboard.GetCurrentState() == ClockState.Active ||
                flipBackStoryboard.GetCurrentState() == ClockState.Active)
            {
                return;
            }

            Storyboard storyboard = flipped ? flipBackStoryboard : flipStoryboard;
            storyboard.Begin();
            flipped ^= true;
        }
    }
}

Большая часть этой логики предотвращает запуск одного объекта Storyboard до того, как завершится выполнение предыдущего объекта. При том способе, которым объекты Storyboard определены в нашей программе, это вызовет расхождения. (Попробуйте удалить команду return из OnGridTapped, чтобы увидеть неудовлетворительный результат.) Я бы предпочел, чтобы касание во время анимации просто переключало операцию на обратное направление, но это потребовало бы более сложной логики.

Пройди тесты
Лучший чат для C# программистов