(针对 2006 年 2 月 CTP) 进行了修订
达伦·大卫, 流体
Karsten Januszewski,Microsoft Corporation
2006 年 3 月
2007 年 1 月:更新了可下载的示例
总结:了解Windows Presentation Foundation (以前代号为“Avalon”) 如何用于创建沉浸式体验,使 The North Face 的品牌和目录在零售环境中栩栩如生。 ) (28 个打印页
下载关联的示例代码 ,TNF_Samples.msi。
先决条件
若要成功运行该演示,需要安装以下内容:
- x86 系统上的 Windows XP Service Pack 2 或 Windows Vista 2 月 CTP (版本 5308)
- 2005 年 2 月 WinFX 社区技术预览版
- 2006 年 2 月 WinFX SDK
- Microsoft Visual Studio 2005 或 Microsoft Visual C# Express 2005
- 适用于 WinFX 的 Visual Studio 2005 扩展
目录
简介
应用程序模型
状态管理
图像蒙太奇
视频轮播
致谢
简介
“Avalon 支持 Fluid 在线零售客户需求的丰富互动品牌体验。 随着电子商务从单纯执行交易发展到提供引人注目的用户体验,Avalon 将成为这一发展的关键组成部分。
-塔米尔·谢诺克,Fluid首席执行官
Fluid 是在线零售客户体验的先驱,与北脸(一家首屈一指的户外产品制造商)合作,为有成就的登山者、登山者、极限滑雪者和探险家提供市场上技术最先进的产品,使用Windows Presentation Foundation (WPF) 平台 (以前代号为“Avalon”) 开发概念证明零售展台。 该项目的目标是展示如何使用 WPF 来创建沉浸式体验,使 The North Face 的品牌和目录在零售环境中栩栩如生。 你可以watch PDC 2005 主题演讲中的简短视频来演示概念证明。
最终的结果被称为北脸In-Store探险家。 它由 Fluid 构建,重点介绍了 WPF 提供的多项功能:
硬件加速呈现: 在动画图像蒙太奇上合成视频材料的动画 3D 网格;子像素明文类型文本;具有基于矢量的形状的 2D 动画;等等。 所有这一切都由基础 WPF 引擎实现,该引擎是硬件加速的,并将这些不同的媒体集成到通用体验中。
适用于所有媒体类型的统一编程模型: 概念证明是 WPF 如何提供通用编程模型的一个很好的示例,该模型将 2D 矢量形状、2D 动画、图像、3D 几何图形、3D 动画、视频和文本集成到单个通用平台中。
3D 支持: 3D 以微妙但强大的方式使用,用于增强和用户体验,采用第三个维度提供直观的用户界面隐喻。
对.NET Framework的完全访问权限:概念证明使用的不仅仅是 WPF:它使用 .NET 平台的功能(如 XML 反序列化功能),表明 WPF 拥有 .NET 的所有功能。
本文将演示如何生成概念证明,讨论在创建概念证明时使用的设计决策和性能优化行为。 本文将介绍如何设计整个应用程序模型的体系结构,特别是深入讨论为应用程序生成的自定义状态管理器。 其次,它将讨论如何创建蒙太奇图像。 最后,它将深入探讨视频轮播的创建,包括一些 3D 技巧,这些技巧对应用程序性能良好至关重要。 有一些代码示例与这三个讨论相关联,可以下载。
应用程序模型
编写 WPF 应用程序时要做出的第一个决策是如何构建应用程序的不同部分之间的转换。 WPF 提供一种内置机制来处理这些转换,该机制基于在页面之间导航的非常常见的隐喻。 对于 North Face In-Store Explorer 概念证明,应用程序的一个选项是创建 并使用 System.Windows.NavigationApplication
导航基础结构在每个屏幕之间导航。 但是,North Face In-Store Explorer 的概念证明要求在每个屏幕之间进行动态转换,例如应用程序缩放的元素和屏幕之间的淡出。
NavigationApplication
无法支持此方案,因此使用了一种替代方法,利用 WPF 组合多个层的功能。
因此,应用程序不使用 WPF 的任何导航功能,由单个窗口组成。 然后,问题就是创建一种机制,以在此单个窗口中的应用程序的不同屏幕之间转换。 应用程序的每个屏幕都是 Canvas
。 North Face In-Store Explorer 概念证明始终保持全屏,用户无法选择重设大小,因此决定 Canvas
使用 而不是 Grid
。
Canvas
使用 不提供任何重排,但允许绝对定位项。 在屏幕之间切换成为在单个窗口中操作画布的问题。
有两个选项可用于实例化和操作不同的画布元素。 第一个选项是“根据需要”实例化画布,在调用画布时将其插入可视化树,并在不再需要画布时将其删除。 第二个选项是实例化应用程序开头的所有画布,然后根据需要显示/隐藏/操作它们。 选择了第二种方法,既允许屏幕之间的动态转换,又可以实现性能目标。 在应用程序启动时加载所有画布会降低从可视化树动态添加/删除画布的任何性能成本。
由于所有画布都加载到单个窗口中,因此此单个窗口的 XAML 和代码可能会变得难以操作。 为了规避这种情况,每个屏幕都创建为一个控件。 这些控件不是作为单独的 dll 创建的,因为无需在此特定应用程序之外重复使用它们。 每个屏幕和每个屏幕的组件都是作为应用程序本身的控件创建的。 本文随附的名为“体系结构”的示例项目中使用了相同的方法。
在 XAML 中连接控件
在以下 XAML 文件中,我们将了解如何实例化一组控件,但不一定在画布上可见。 查看“体系结构”项目中的 Window1.xaml:
<Window x:Class="Architecture.Window1"
xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
Title="Architecture"
xmlns:ui="clr-namespace:Architecture.UI"
xmlns:l="clr-namespace:Architecture"
Loaded="WindowLoaded"
>
<Canvas Background="BurlyWood" Width="1024" Height="768" x:Name="MainCanvas">
<ui:Screen3 x:Name="Screen3Canvas" Visibility="Collapsed"/>
<ui:Screen2 x:Name="Screen2Canvas" Visibility="Collapsed" />
<ui:Screen1 x:Name="Screen1Canvas" Visibility="Collapsed"/>
<ui:Logo x:Name="LogoCanvas" Canvas.Left="{x:Static l:Constants.LOGOPANEL_POS_LEFT_OFFSCREEN}" Canvas.Top="300"/>
</Canvas>
</Window>
首先,查看声明 xmlns:l="clr-namespace:Architecture".
clr 命名空间和 xml 命名空间之间的此映射的属性,允许从应用程序本身引用和实例化 XAML 中的类。 若要使 Architecture.UI 命名空间中的类能够实例化,还需要其他映射。
请考虑类 Logo
。 在项目中,有一个名为 Screen1.xaml 的 XAML 文件,其中包含 canvas 的根元素和 Screen1.xaml.cs 中的代码隐藏类,其中包含派生自 Canvas
的分部类。 为了实例化此类,我们声明 <ui:Screen1 />
。 请注意,如果以后希望通过代码访问此 x:Name
元素,则必须 (提供 ,而不是仅使用 name 属性) 元素,因为元素存在于与默认 WPF 命名空间不同的命名空间中,因此 XAML 如下所示:
<ui:Screen1 x:Name="Screen1Canvas" Visibility="Collapsed"/>
“隐藏”用户界面控件的技术
虽然许多 UIElement 在 window1.xaml 中实例化,但并非所有 UIElement 都立即可见。 两种技术用于“隐藏”应用程序的不同屏幕。
第一种方法是将元素的可见性属性设置为折叠,如上面的 Screen1 XAML 中所示。 通过将元素的可见性设置为折叠或隐藏,元素稍后可以可见。 由于元素已实例化并添加到可视化树中,因此与手动将其插入可视化树或导航到控件相比,显示控件的性能成本是名义上的。 值得指出的是,Visibility 属性可以有三种状态: 可见、 隐藏 和 折叠。 Visibility.Hidden 表示元素将占用布局空间,但不可见,而 Visibility.Collapsed 表示元素不占用布局空间。 此外,通过使用 Visibility.Collapsed,系统不会计算布局,因此减少对性能的损害。
隐藏main窗口中元素的第二种方法是将它们置于屏幕外。 这对于在屏幕上对元素进行动画处理非常方便,使元素从左侧、右侧、顶部或底部滑入的效果。
因此,在上面的 XAML 中 <,ui:Logo /> 的 Canvas.Left 属性设置为
"{x:Static l:Constants.LOGOPANEL_POS_LEFT_OFFSCREEN}"
. 此常量的值是 -300,这使其远离屏幕。 稍后,Canvas.Left 属性会进行动画处理,这将产生画布从左侧滑到屏幕上的效果。
另请注意,值得一提的是,在 XAML 中使用静态常量。 项目文件中有一个名为 Constants.cs 的类,如下所示:
public static class Constants
{
public const double LOGOPANEL_POS_LEFT_OFFSCREEN = -300;
public const double LOGOPANEL_POS_LEFT_ONSCREEN = 50;
public const double LOGOPANEL_POS_RIGHT_OFFSCREEN = 800;
}
此类使用语法 {x:Static l:Constants.LOGOPANEL_POS_LEFT_OFFSCREEN}
在 XAML 中引用。 当然,该常量也可在代码中引用,它是声明性标记和代码之间的奇偶校验示例。 请注意, xmlns:l
此处使用 ,它引用了 Window1.xaml 中的其他映射处理指令。
Z 索引
最后,“z-index”还会影响单个窗口中 UI 元素的可见性。 此索引与 3D 空间中的 z 坐标无关;相反,“z-index”是 WPF 跟踪可能共享相同空间坐标的元素的相对顺序的方式。 WPF 布局引擎允许网格或画布的子元素共享相同的坐标。 如果多个元素共享相同的坐标,并且元素不透明,则元素将被其他元素遮盖,具体取决于其“z-index”。 WPF 中元素的“z-index”由它们在 XAML 或代码中的顺序决定,并确定元素从前到后布局的方式,元素首先定义在最后方。 因此,查看上述 XAML,XAML 中的最后一个元素“徽标”实际上“位于窗口中其他元素的顶部。
如果出于某种原因,需要在运行时操作“z-index”以操作其顺序,则可以删除并插入该元素以重新确认其位置。
状态管理
选择单窗口方法 (应用程序的所有屏幕都驻留在一个窗口) ,因此更需要跟踪应用程序的状态。 在本例中,State 表示用户当前正在查看的屏幕以及不同屏幕之间需要哪些切换等信息。 因此,应用程序需要在某种类型的状态管理基础结构中生成。
可以在体系结构代码示例中找到类似于北面In-Store资源管理器概念证明中使用的状态管理器。 应用程序的状态管理器封装在单个类中,main窗口为其获取静态实例。 在 statemanager 类中是一个枚举,它表示应用程序的所有不同状态:
public enum ScreenStates
{
AppIntro,
Screen1,
Screen2,
Screen3
}
为了管理这些状态,类具有一个变量 , CurrentState
它使用该变量来记住当前状态。 此外,类具有布尔变量 InTransition
,用于跟踪状态管理器是否正在转换。 这是一个信号灯,用于在应用程序处于切换状态中间时阻止应用程序尝试切换状态,这会导致同时发生混乱的转换。
状态管理器还具有成员变量,代表main窗口以及应用程序的所有不同屏幕(由控件表示),如上文所述。 在Loaded
main窗口中,有代码将每个“实时”控件连接到静态状态管理器,以便状态管理器可以操作屏幕并在屏幕之间创建切换。 下面是 事件中的 Loaded
代码的一部分:
private StateManager _StateManager;
private void WindowLoaded( object sender, EventArgs e )
{
// . . .
_StateManager = StateManager.GetInstance();
_StateManager.MainWindow = this;
_StateManager.Screen1 = Screen1Canvas;
_StateManager.Screen2 = Screen2Canvas;
// . . .
}
示例状态转换
让我们演练一个状态转换示例。 如果编译并启动体系结构可执行文件,可以通过单击鼠标左键来启动状态更改。 可以随时单击鼠标右键,将状态重置回应用程序开始。
因此,若要启动转换,应用程序将侦听main窗口上的鼠标单击。 下面是代码的 OnLeftMouseClicked
一部分以及 SetState
代码:
private void OnLeftMouseClicked(object o, EventArgs e)
{
switch (_StateManager.CurrentState)
{
case (int)StateManager.ScreenStates.AppIntro:
_StateManager.SetState(StateManager.ScreenStates.Screen1);
break;
// . . .
}
}
public void SetState(ScreenStates state)
{
if (_InTransition) return;
switch (state)
{
case ScreenStates.AppIntro:
AppIntro();
break;
// . . .
}
}
通过调用 SetState
方法调用状态管理器。 请考虑第一次单击时会发生什么情况,这会调用 AppIntro
方法。 让我们看一下该方法:
private void AppIntro()
{
_InTransition = true;
Screen3.Visibility = Visibility.Collapsed;
_Logo.AnimateIn();
HandleStateChangeComplete(ScreenStates.AppIntro);
}
首先,请注意, InTransition
标志设置为 true,这将确保在发生此转换时不会尝试其他转换。 然后,屏幕 3 折叠。 这是一点清理,以防我们重置状态。 接下来,将在徽标本身上启动动画。 请记住,在状态管理器中,我们可以操作不同的控件,因为我们有每个控件的实例。 在这种情况下, AnimateIn
徽标上的 方法会创建从屏幕外放大徽标的动画。 最后, HandleStateChanged
状态管理器中的 方法发出信号,表示转换已完成
更有趣的状态更改
更有趣的状态更改,涉及这两个更改是下一个。 此状态更改将导致徽标在屏幕外进行动画处理,然后使当前折叠的 Screen1 画布可见。 由于所需的效果是徽标在 Screen1 画布可见 之前 完成其动画,因此动画需要在动画完成时设置回调方法。
private void Screen1Intro()
{
_InTransition = true;
_Logo.AnimateOut(OnLogoOffScreen);
}
private void OnLogoOffScreen(object sender, EventArgs e)
{
Clock clock = sender as Clock;
if ( clock == null ) return;
if (clock.CurrentState != ClockState.Active)
{
_Screen1.Visibility = Visibility.Visible;
HandleStateChangeComplete(ScreenStates.Screen1);
}
}
查看徽标类的 方法中的 AnimateOut
代码:
public void AnimateOut(EventHandler callback)
{
DoubleAnimation da = new DoubleAnimation(Canvas.GetLeft(this),
Constants.LOGOPANEL_POS_RIGHT_OFFSCREEN, TimeSpan.FromSeconds(2));
da.BeginTime = null;
AnimationClock ac = da.CreateClock();
ac.CurrentStateInvalidated += new EventHandler(callback);
this.ApplyAnimationClock(Canvas.LeftProperty, ac);
ac.Controller.Begin();
}
此方法采用 EventHandler
回调,这是 OnLogoOffScreen
状态管理器中的 方法。 在动画开始之前, CurrentStateInvalidated
动画的事件在代码中连接,以便在动画完成后,将发生回调,使屏幕 1 画布的可见性可见。
本文不会深入分析所有其他特定转换代码,但所有转换的方法都是一致的:main窗口调用状态管理器SetState
方法,传递要调用的转换。 然后,状态管理器能够调用不同控件上的方法来初始化它们和/或对其进行动画处理。
值得一看 HandleStateChangeComplete
的是 方法,该方法在每次转换完成后调用。
private void HandleStateChangeComplete( KioskStates state )
{
_InTransition = false;
_CurrentState = ( int ) state;
}
首先,此代码将 _InTransition
标志设置回 false,以便可以发生新的转换。 然后,它会设置 _CurrentState
变量,以便应用程序了解其当前状态。
代码示例中的实际转换相对简单,但如果查看 The North Face In-Store Explorer 概念证明,你将看到一些非常令人兴奋的状态转换,这些转换使用此基本基础结构来设置转换。
图像蒙太奇
应用程序首次启动时,背景中会显示一系列图像。 每个图像在屏幕上平移,并淡入第二个图像。 这被称为“肯伯恩斯”效果,指的是肯·伯恩斯,纪录片制片人肯·伯恩斯,他率先使用移动和缩放静止图像,使纪录片生动起来。 对于“北脸”,该效果添加了一个微妙而恒定的移动,使应用程序“感觉生动”。
本文随附的另一个名为 ImageMontage 的代码示例对此效果进行了说明。 此示例演示了如何生成 ImageMontage 的基础知识。 Image Montage 是使用单个类(派生自 System.Windows.Controls.Canvas
)生成的,ImageMontageCanvas
该类包含加载图像、循环浏览图像和对其进行动画处理的功能。 图像本身包含在 System.Collections.ObjectModel.ObservableCollection
包含所有图像的 中。 类 ObservableCollection
是由 WPF 提供的专用集合类,它已针对 WPF 中的列表的使用进行了优化,尤其是在数据绑定的情况下。 它将引发数据绑定可以选取的更改通知。 有关 用法ObservableCollection
的详细信息,请参阅“优化 Windows Presentation Foundation 中的性能”白皮书中的数据绑定部分。
使用图像填充图像蒙太奇
若要填充ObservableCollection
图像的 ,类的ImageMontageCanvas
构造函数中存在一个调用LoadImages
的方法。
在 方法中 LoadImage
,请注意用于实际从文件中提取图像并将其设置为 WPF System.Windows.Controls.Image
类的代码。 Image 的 Source 属性为 System.Windows.Media.Imaging.BitmapImage
。 通过将 URI 传递到 的构造函数 BitmapImage
中的映像,可以设置文件系统中的映像。
public void LoadImages()
{
DirectoryInfo dir = new DirectoryInfo(@"images");
foreach (FileInfo f in dir.GetFiles("*.jpg"))
{
Image newImage = new Image();
newImage.Source = new BitmapImage(new Uri(f.FullName, UriKind.Relative)); ;
this.Images.Add(newImage);
}
}
加载图像后,图像蒙太奇可以开始。 让我们看一下该代码的一部分。 没有任何 XAML, ImageMontage
但是纯在代码中创建的 WPF 类。 它派生自 Canvas
。 此处使用画布的基本原理是,无需进行布局或调整大小 - 画布上唯一的就是固定大小的图像。
显示和淡化图像
虽然我们不会介绍图像类的每个实现细节,但我们将重点介绍 类中的关键方法。 方法Init()
是一个公共方法,main窗口调用它以开始蒙太奇。
public void Init()
{
DisplayImage(this.Images[_CurrentImageIndex]);
DoFade(this.Images[_CurrentImageIndex], 0, 1);
PanAndScale(this.Images[_CurrentImageIndex]);
Start();
}
中 Init()
发生的第一件事是 DisplayImage
调用 ,传递集合中的第一个图像。 方法 DisplayImage()
设置图像的不透明度及其在画布上的位置,然后将图像添加到可视化树。
protected void DisplayImage( Image img )
{
img.Opacity = 0;
Canvas.SetTop( img, 0 );
Canvas.SetLeft( img, 0 );
this.Children.Add( img );
}
调用 后 DisplayImage
, Init()
方法对图像调用 DoFade
,这将启动动画以淡入第一个图像的不透明度。
protected void DoFade(Image img, double startOpacity, double endOpacity)
{
DoubleAnimation anim = new DoubleAnimation(startOpacity, endOpacity, TimeSpan.FromSeconds(3));
AnimationClock ac = anim.CreateClock();
ac.CurrentStateInvalidated +=
delegate(object sender, EventArgs e)
{
if (sender == null)
return;
Clock clock = sender as Clock;
if (clock == null)
return;
if (clock.CurrentState == ClockState.Filling)
{
if (img.Opacity > .1)
{
this.HandleImageFadeInComplete();
}
else
{
this.Children.Remove(img);
}
}
};
img.BeginAnimation(Image.OpacityProperty, anim);
}
让我们演练此动画代码。 由于图像的不透明度正在进行动画处理,并且不透明度为 double 类型,因此会创建强类型动画 DoubleAnimation。 在动画上设置多个属性(如 From、To、BeginTime 和 Duration)后,将从动画创建 AnimationClock。 然后,将 CurrentStateInvalidated 事件连接起来。 我们需要执行此操作,以便在图像淡入零后从 ImageMontage 画布中删除该图像。 最后,我们可以从时钟开始,并将时钟应用于图像本身。
使用 C# 匿名方法
代码最有趣的部分是下一行,其中特定方法连接到 AnimationClock 的 CurrentStateInvalidated 事件。 我们使用此处的一项新 C# 功能匿名方法来访问要删除的图像。 如果只为 CurrentStateInvalidated 事件分配了方法,则应用程序将作为发送方对象接收与该动画关联的 AnimationClock 实例,但它不会授予我们访问正在创建动画的图像的权限。 因此,我们将能够执行操作时钟,但无法直接访问图像本身。 对于此应用程序,我们需要对正在进行动画处理的图像的引用,因为我们希望在图像不透明度达到 0 后从画布中删除该图像。
用于将图像传递给事件处理程序的技术是使用 C# 匿名方法功能 (有关详细信息,请参阅 Juval Lowry) 使用匿名方法、迭代器和分部类创建优雅代码 。 这使我们能够在连接事件处理程序时获取本地参数(图像和画布本身)。
根据不透明度所发生的情况,我们可以触发另一个事件,让应用程序知道淡出已完成,如果图像不再可见 (不透明度小于 0.1) 我们可以通过在 ImageMontage 本身上调用 Children.Remove 从树中删除图像。
这种使用匿名方法的技术是一种在元素的动画时钟事件处理程序中获取动画元素的有效方法,在很多方案中都有潜在的适用性。
平移和缩放图像
若要实现跨图像平移和缩放的效果,图像的宽度会随着其画布的顶部和左侧位置的动画处理而进行动画处理:
protected void PanAndScale(Image img)
{
double startW = 800;
double endW = 1500;
double startX = 0;
double endX = -40;
double startY = 0;
double endY = -50;
DoubleAnimation anim = new DoubleAnimation(startW, endW, TimeSpan.FromSeconds(13));
img.BeginAnimation(Image.WidthProperty, anim);
// Animate position to keep Image centered
DoubleAnimation anim1 = new DoubleAnimation(startY, endY, TimeSpan.FromSeconds(13));
img.BeginAnimation(Canvas.TopProperty, anim1);
DoubleAnimation anim2 = new DoubleAnimation(startX, endX, TimeSpan.FromSeconds(13));
img.BeginAnimation(Canvas.LeftProperty, anim2);
}
可以试验图像宽度的值以及移动画布 X 和 Y 的距离,以更改平移和缩放的外观。
从图像更改为图像
Init () 方法中的最后一个方法是 Start () 。 这会启动 DispatcherTimer(每 10 秒调用一次),这是导致图像持续循环的原因。
public void Start()
{
if (_ImageChangeTimer == null)
{
_ImageChangeTimer = new DispatcherTimer(TimeSpan.FromSeconds(10), //Time to wait
DispatcherPriority.Background, // Priority
new EventHandler(OnImageChangeTimer), //Handler
this.Dispatcher); // Current dispatcher.
}
_ImageChangeTimer.Start();
}
要讨论的最后一种方法是 OnImageChangeTimer,它是每十秒调用一次的方法。 它在功能上与 Init () 方法相同:它调用 DoFade 以淡出当前图像,然后在新映像中调用 DoFade,最后将_CurrentImageIndex递增 1。
Voilà! 在那里,你有 ImageMontage 类的螺母和螺栓。
视频轮播
简介
在 The North Face In-Store Explorer 概念证明中,在初始品牌文本向用户缩小后,3D 空间中会出现一个轮播面板,并不断旋转,视频映射到每个面板。 观看者的想法是,用户可以选择要watch的任何视频。 轮播是一个引人注目的用户界面比喻,利用 WPF 中的 3D 引擎。
可通过多种方式在 WPF 中实现轮播。 对于 The North Face In-Store Explorer 概念证明,旋转木马是使用一个名为 ListBox3D 的控件生成的,该控件派生自 System.Windows.Controls.ListBox。 起初,这听起来可能很奇怪。 毕竟,列表框往往如下所示:
但是,使用 WPF 时,ListBox 只是一组可选项的用户界面隐喻。 该控件的样式完全由应用程序开发人员和设计器决定。 人们可以将其称为柏拉图控件的概念,在柏拉图的哲学之后-有 ListBox 的抽象概念,然后是无限数量的 ListBox 实例。 在本例中,ListBox 是一个 3D 控件。
通过派生自 Listbox,ListBox3D 控件获取 ListBox 的各种功能,例如添加项、删除项和选择项。 但是,ListBox3D 的外观与其 2D 基类没有任何共同之处。 ListBox3D 使用 Viewport3D 设置样式。 因此,ListBox3D 中的每个项都是 3D 几何图形。 然后,视频将用作每个项目几何形状上的材料。 单击任何一个 ListBox3D 项时,将引发所选事件,就像使用泛型 ListBox 时所期望的那样。
为了说明其工作原理,本文包含一个名为 VideoCarousel 的示例。 它实现了在北面In-Store资源管理器概念证明中看到的基本功能。
轮播的核心由两个类组成。 第一个派生自 System.Windows.Controls.ListBox,即新的 ListBox3D。 第二个派生自 DispatcherObject,表示列表中的单个项。
public class List3DItem : DispatcherObject
{
}
public class List3D : ListBox
{
}
设置和实例化 ListBox3D
在深入了解这些类的内脏之前,需要设置 ListBox3D 的样式,如上文所述。 样式设置将在 XAML 中作为资源完成。 通过提供设置 Template 属性的样式,我们可以接管控件的可视化树。 它是包含该控件的可视树的控件模板。 控件模板旨在是外部用户不可见的自包含实现详细信息单元。 在本例中,我们将创建单个 Viewport3D。
<Style x:Key="Carousel3DStyle" TargetType="{x:Type l:ListBox3D}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type l:ListBox3D}">
<Viewport3D Focusable="true" ClipToBounds="true" >
<Viewport3D.Camera>
<PerspectiveCamera
FarPlaneDistance="50"
UpDirection="0,1,0"
NearPlaneDistance="1"
Position="0.0,2.5,10.0"
LookDirection="0.0,-2.5,-9.0"
FieldOfView="45" />
</Viewport3D.Camera>
</Viewport3D>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
VideoCarousel 的 XAML 的其余部分非常简单。 我们实例化一个 ListBox3D 以及覆盖整个画布的透明网格:
<Canvas Background="Black" Width="1024" Height="768" x:Name="MainCanvas" Loaded="WindowLoaded">
<l:ListBox3D x:Name="Carousel"
Canvas.Top="0" Canvas.Left="0"
Width="1024" Height="768"
Visibility="Visible"
Style="{DynamicResource Carousel3DStyle}"
SelectionChanged="OnList3DItemSelected"/>
<Grid x:Name="CarouselMouseEventInterceptor"
Canvas.Top="0" Canvas.Left="0"
Width="1024" Height="768"
Background="Transparent"
PreviewMouseLeftButtonUp="CarouselMouseEventInterceptorClicked"></Grid>
</Canvas>
请注意自定义列表框如何使用我们在窗口的资源部分中声明的样式。 另请注意列表框如何连接 ItemSelected 事件。 但是,应用程序必须执行一些工作来引发该事件:我们不会使用自定义列表框免费进行命中测试。 事实上,这是列表框下方的网格发挥作用的地方。 由于它遵循 XAML 声明中的列表框,因此就“z-index”而言,网格位于列表框的“前面”。 此网格的目的是截获鼠标单击,并将其传递到 3D 命中测试引擎。 由于在视区区域中单击可能不会命中其中一个网格,因此,如果实际上用户实际单击了其中一个网格,我们只想引发 ItemSelected 事件。 稍后我们将探讨其工作原理。
现在,让我们看一下列表框的生成和激活方式。 为了启动并运行我们的轮播,发生了一系列事情:
- ListBox3D 初始化。 自定义列表框被实例化并调用其构造函数。 构造函数会触发将顶级模型组添加到视区以及相机和灯的代码。 它会在视区的整个模型组上创建转换。
- ListItem3D 初始化。 ListItems 将添加到列表框。 在 listitem 的构造函数中,将提取将与该 listitem 关联的网格。 此外,还将为每个网格创建一个视频画笔。
- ListItem3D 布局。 旋转木马将通过将视区中的每个网格放置在彼此等距的位置来生成。
- ListBox3D 动画。 旋转木马动画将开始。
- ListItem 选择。 如何捕获 ListItem 所选事件。
- 网格创建附录。 讨论如何创建具有断开连接三角形的网格,以允许在网格的两侧使用材料。
让我们深入了解其中每个步骤。
ListBox 初始化
在 ListBox3D 的构造函数中,连接了两个事件: Loaded
Initialized
:
public ListBox3D()
{
this.Initialized += new EventHandler(OnInitialized);
this.Loaded += new RoutedEventHandler(OnLoaded);
}
这两个不同事件的原因是,在 Initialized
上 ListBox3D
设置属性之前,会调用 。 设置 Loaded
属性后,将调用 事件。 在 Initialized 事件中,我们将创建一个名为 的 MainGroup
顶级模型组。 (缩放、旋转、平移) 的标准转换集将添加到此顶级组。 这些转换将允许我们稍后旋转整个 Model3DGroup。 然后,将单个白色环境光作为子项添加到_MainGroup。 _MainGroup本身将不包含任何 GeometryModel3D;它将包含一个名为“_ModelItems”的子 ModelGroup3D,我们稍后将向其添加网格,这些网格是包含实际 3D 几何图形的 ListItem3D 对象集。 请注意,此时尚未将这些 Model3DGroup 添加到视区。 这将在视区的 OnLoaded 事件中发生。
触发 OnLoaded 事件时,我们首先需要操作视区,以便可以添加 ModelGroups 以及操作视区上的一些其他属性。 进入视区本身会比人们想象的要复杂得多。 由于视区嵌套在样式的控件模板内,因此无法从 ListBox3D 本身直接访问它。 实际上,我们必须通过浏览 ListBox3D 的可视化树来找到它,如以下 FindViewport3D 方法所示:
private FrameworkElement FindViewport3D(Visual parent)
{
for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
Visual visual = VisualTreeHelper.GetChild(parent, i);
if ((visual is FrameworkElement) && (visual is Viewport3D))
{
return (visual as FrameworkElement);
}
else
{
FrameworkElement result = FindViewport3D(visual);
if (result != null)
return result;
}
}
return null;
}
每当出现需要从已设置样式的控件中提取子元素的情况时,此方法就有可能在很多情况下重复使用。 获得对视区的引用后,可以添加 _MainGroup ModelGroup3D。
所有这些操作都可以在 XAML 中完成,但是在代码中完成的。 这两种方法之间没有区别。 有些人可能会发现能够更直观地在 XML 中查看可视化树的分层形状;其他人可能会发现,实际上实例化对象并手动将它们添加到集合更舒适。
此时,我们有了一个功能齐全的视区,随时可以使用灯光、相机和一组默认转换,这些转换稍后可用于操作其中的所有模型。 现在我们需要一些 ListItem3D!
ListItem3D 初始化
现在,我们已准备好将一些 ListItem3D 对象添加到 ListBox3D。 为此,我们在自定义 ListBox3D 上调用自己的 Add 方法,这将创建新的 ListItem,设置 Listitem 的 VideoSrc 属性,并调用基 ListBox 类的受保护 AddChild 方法:
public void Add(string VideoSrc)
{
ListBox3DItem expListItem = new ListBox3DItem();
expListItem.VideoSrc = VideoSrc;
this.AddChild(expListItem);
}
此处我们可以看到派生自 ListBox 的原因之一:ListItem3D 对象集合的管理完全由 ListBox 基类固有的功能处理。
创建 ListItem3D 时,将调用其构造函数,并提取 ListItem3D 本身的实际几何图形,由 _ItemGroup Model3DGroup 成员变量表示。 _ItemGroup 成员变量由 GetMainGroup () 方法填充,该方法实例化将为每个项生成网格的类。 本文稍后将讨论如何创建 3D 几何图形。 现在,只要了解每个 ListItem3D 实例化时,其构造函数都会生成一个网格,并将其与 ListItem3D 的 _ItemGroup 属性相关联。
此时,我们在 ListBox3D 中有一组 ListItem3D 对象。 但是,如果此时运行代码,则看不到任何内容,因为我们尚未执行任何工作来实际将网格添加到视区;我们只向 ListBox3D 添加了 ListItem3D 对象。 我们有责任实际将网格添加到视区,使用一组默认的翻译(包括缩放、平移和旋转)来定位网格。
ListItem 布局
当从 Window1.cs 代码调用 ListBox3D 类上的 Build 方法时,几何图形实际上会添加到视区。 此外,视频材料位于其关联的几何形状上。
在视区中布局几何图形在数学上有点棘手,因为所需的效果是每个 ListItem3D 彼此相等,以及视区中心 (0,0,0) 。 此外,需要旋转每个几何图形,使其正面在旋转模型时面向查看器。 ListBox3D 布局的设计要求它是动态的,因此无论添加多少项,ListItem3D 对象都将定位为正确轨道。
检查 Build 方法中的代码将揭示这是如何实现的。 基本上,这是通过将 ListItem3D 项的总数除以 360 来实现的,然后是每个项的角度偏移量。 因此,如果轮播中有四个项目,则偏移量为 90 度。 考虑到此偏移角度,项集合通过循环运行,基于此偏移设置每个旋转角度,递增总角度。
还基于增量偏移角度生成每个项的平移向量,在以下方法中使用 算法:
private Vector3D GetTranslationOffsetForCarouselAngle(double angle)
{
double radian = Math.PI * angle / 180.0;
double x = _Radius * Math.Cos(radian);
double z = _Radius * Math.Sin(radian);
return new Vector3D(x, 0, z);
}
建立这些转换后,可以通过调用其 SetDefaultPosition 方法并传递缩放、平移和旋转向量,将它们添加到 ListItem3D Model3DGroup。 然后,我们实际将 Model3DGroup 添加到视区_ModelItems集合。
生成代码的最后一步是调用每个 ListBox3D 对象的 Initialize 方法,该方法将使用视频绘制网格,如下所示:
if (this.VideoSrc != "")
{
if (_FrontVideoDrawing.Clock == null)
{
//because our MediaElement is instantiated in code, we need to set its loaded and unloaded behavior to be manual
_FrontVideoDrawing.LoadedBehavior = MediaState.Manual;
_FrontVideoDrawing.UnloadedBehavior = MediaState.Manual;
MediaTimeline mt = new MediaTimeline(new Uri(@"media\" + (String)this.VideoSrc, UriKind.Relative));
//mt.RepeatBehavior = RepeatBehavior.Forever;
//there are issues w/ RepeatBehavior in the Feb CTP so instead we
//wire up the current state invalidated event to get repeat behavior
mt.CurrentStateInvalidated += new EventHandler(mt_CurrentStateInvalidated);
MediaClock mc = mt.CreateClock();
_FrontVideoDrawing.Clock = mc;
_FrontVideoDrawing.Width = 5;
_FrontVideoDrawing.Height = 10;
VisualBrush db = new VisualBrush(_FrontVideoDrawing);
Brush br = db as Brush;
MaterialGroup mg = new MaterialGroup();
mg.Children.Add(new DiffuseMaterial(br));
GeometryModel3D gm3dFront = (GeometryModel3D)_ItemGroup.Children[0];
//only need to paint it one place to show up two places!
gm3dFront.Material = mg;
}
}
若要将视频添加到 WPF 应用程序,需要创建 一个 MediaTimeline
,并将视频的位置传递给 时间线。 从时间线,我们将创建一个媒体时钟,可以使用该时钟来控制时间线。 我们将设置时间线的重复行为无限循环,以便每个视频在到达其末尾时自动重启。 我们将时钟与 MediaElement 相关联,然后通过创建一个 VisualBrush
使用 MediaElement
的 ,然后在漫射材料中使用该画笔将视频绘制到网格上。 请注意,我们尚未开始播放视频,但我们只是做好了一切准备。 稍后当 ListBox3D 中的代码调用 PlayVideo () 时,将启动这些视频。
由于我们只绘制了网格的一侧,视频如何显示在两侧? 这是使用生成的网格实现的性能优化技巧,稍后将对此进行说明。
ListBox 动画
现在,我们的网格已正确定位并且视频被绘制在它们上,我们实际上可以开始旋转模型并开始播放视频,这发生在 的 ListBox3D
方法中Activate
。 此方法开始播放每个视频,然后调用 StartAutoRotation
方法,该方法执行两项操作:
首先,它初始化代码以调整音量。 如果所有视频同时播放声音,则会导致乱码音频。 因此,我们需要应用程序逻辑来操作每个视频的音量,具体取决于它在轮播中的位置。 设置 , DispatcherTimer
与每个视频在用户面前的时间量相关:
_VolumeAdjustTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(ADJUST_VOLUME_INTERVAL),
//Time to wait
DispatcherPriority.Background, // Priority
new EventHandler(this.OnVolumeAdjustTimer), //Handler
this.Dispatcher); // Current dispatcher.
在计时器 的回调中, OnVolumeAdjustTimer
为每个视频设置音量,具体取决于它是否在前面。 由于整个轮播是动态的,因此有一种算法用于确定当前用户面前的视频,该算法用于相应地设置音量,可在 中 OnVolumeAdjustTimer method
浏览。
其次,方法 StartAutoRotation
启动轮播的动画。 轮播有两种main类型的动画。 一个是展示视频时,这是观众面前较慢的动画。 另一个动画是当旋转木马缠绕时,这是一个更快的动画。 应用程序根据 RotationState
枚举决定要执行的动画:
if (_AutoRotationState != (int)ExpeditionCarousel3DRotationStates.ActivationRotate)
{
ShowcaseItem();
}
else
{
RotateToNextItem();
}
其中每种方法都类似,因为它们计算旋转角度,调用 RotateModel
方法,然后设置新的 RotationState
。 也许更值得调查的是 RotateModel
方法,该方法是实际动画的开始位置:
private void RotateModel(double start, double end, int duration)
{
RotateTransform3D rt3D = _GroupRotateTransformY.GetCurrentValue();
Rotation3D r3d = rt3D.Rotation;
DoubleAnimation anim = new DoubleAnimation();
anim.From = start;
anim.To = end;
anim.BeginTime = null;
anim.AccelerationRatio = 0.1;
anim.DecelerationRatio = 0.6;
anim.Duration = new TimeSpan(0, 0, 0, 0, duration);
AnimationClock ac = anim.CreateClock();
ac.CurrentStateInvalidated += new EventHandler(OnRotateEnded);
ac.Controller.Begin();
r3d.ApplyAnimationClock(Rotation3D.AngleProperty, ac);
}
public void OnRotateEnded(object sender, EventArgs args)
{
if (sender == null)
return;
Clock clock = sender as Clock;
if (clock == null)
return;
if (clock.CurrentState == ClockState.Filling)
{
if (this.IsAutoRotating)
{
if (this.AutoRotationState == (int)ExpeditionCarousel3DRotationStates.ShowcaseRotate)
{
this.RotateToNextItem();
}
else
{
this.ShowcaseItem();
}
}
clock.CurrentStateInvalidated -= new EventHandler(this.OnRotateEnded);
clock = null;
}
}
轮播本身的旋转在此处通过创建一个 DoubleAnimation
动画来实现,该动画将应用于 AngleProperty
Rotation3D
Y 轴 的 RotationTransform
对象的 。 此处要注意的另一个有趣的事情是用于继续旋转模型的机制。 创建动画时,事件 CurrentStateInvalidated
会连接起来,以便在动画结束时触发事件,以便我们可以启动新的动画。 只要应用程序运行,此模式就一直持续。
ListItem 选择
在“北面In-Store资源管理器概念证明”中,选择对象时 ListItem3D
会启动不同的动画。 虽然该代码不是视频轮播代码示例的一部分,但代码示例已准备好处理事件 ItemSelected
。 如果在调试器中运行代码并在 方法上 OnList3DItemSelected
设置断点,则会在操作中看到此代码。
应用程序本身有责任引发此事件,它通过响应覆盖视区的透明网格上的鼠标单击来执行此操作。 网格连接PreviewMouseLeftButtonUp
事件,将事件ListBox3D
OnPreviewLeftClick
传递到 的 方法,该方法包含以下代码:
public void OnPreviewLeftClick(object sender, MouseButtonEventArgs e)
{
Point p = e.GetPosition(this);
DoHitTest(_MainViewport3D, p);
//here we get back the selected listitem and can do with it what we will
if ((_ciHitTest != null))
{
ListBox3DItem[] ListBox3DItemList = { _ciHitTest };
ListBox3DItem[] ListBox3DItemListRemoved = { };
//we also raise the OnSelectionChanged event for anyone listening
OnSelectionChanged(new SelectionChangedEventArgs(ListBox3D.SelectionChangedEvent,
ListBox3DItemListRemoved, ListBox3DItemList));
}
}
很明显,鼠标位置是从事件参数中提取的,并传递给命中测试方法。 最终,如果检测到命中,将从 ListBox 基类引发受保护的 OnSelectionChanged
事件,可在应用程序代码中处理该基类。
网格创建附录
讨论视频轮播的最后一种方法是如何创建网格。 最初,几何图形是使用 3D 建模工具创建的。 然后,这些几何图形导出为 .obj 文件,并导入到 Expression Interactive Designer 工具中。 然后,在北脸In-Store资源管理器概念证明中提取并使用生成的 XAML。 此工作流成功,但存在重大限制。 导出的几何图形由六个网格组成:一个用于正面,一个用于背面,四个网格围绕两侧。
若要了解为什么这不是理想的解决方案,请务必了解 WPF3D 的 3D 模型的显示方式。 WPF 中显示的每个 3D 网格都有一个关联的材料,GeometryModel3D 使用这两者的组合。 一般情况下,如果设计人员必须选择更多的 GeometryModel3D(每个多边形较少)或减少每个多边形数较少的 GeometryModel3D,则应选择后者。 例如,假设设计师想要构造一个立方体,其中每个人脸都由同一材料绘制。 该立方体由 12 个三角形组成,因此设计器可以想象创建 12 个 GeometryModel3D,每个几何图形都包含一个仅包含一个三角形的网格。 或者,设计器可以创建单个 GeometryModel3D,其中包含包含所有 12 个三角形的单个网格。 后者的效率要高得多。
因为作为视频轮播,概念证明的设计需要能够看到视频在几何图形的两侧播放。 由于原始几何图形由几何图形正面的不同网格组成,因此必须使用两种不同的视频材料。 如果只使用了一个,当项目在轮播中最远时,视频将被遮盖。 但是,在每个网格上绘制两个视频被证明是一个很大的性能损失,就像从多个 GeometryModel3D 而不是一个 GeometryModel3D 创建一个立方体一样。
此问题的解决方案是将正面网格和背面网格合并为单个网格,需要单个材料,并驻留在单个 GeometryModel3D 中。 这在 Avalon 中是可能的,因为不需要网格的三角形以任何方式连接。 网格只是三角形的集合,这些三角形可以完全取消关联。 网格中的所有三角形都使用相同的材料绘制,并且它们都受到相同的转换,但它们不需要连接。 这样就可以生成实际包含两个平面的单个网格。 对于视频轮播示例,两个平面以纯并行的方式相互偏移:在北面In-Store资源管理器的概念证明中,这些平面实际上是弯曲的,相互偏移,以创建球面壳的切片。
由于在 WPF 的当前版本上始终启用背面剔除,因此必须记住,网格的后脸将缠绕在正面的背面。 此外,为了给人一种印象,即材料的背面确实与正面相同,应反转水平纹理组件,使背面具有正面的“镜像图像”。
因此,视频轮播演示中使用的 Model3DGroup 如下所示:
<Model3DGroup x:Key="ListItem3DModel3DGroup" >
<Model3DGroup.Children>
<!--Single Mesh With Both Front And Back Face
With Reversed Texture Coordinates and Opposite
Winding Order on the Back Face-->
<GeometryModel3D>
<GeometryModel3D.Geometry>
<MeshGeometry3D
Positions="-0.5,0.5,0.125 -0.5,-0.5,0.125 0.5,-0.5,0.125
0.5,0.5,0.125 0.5,0.5,-0.125 0.5,-0.5,-0.125 -0.5,-0.5,-0.125 -0.5,0.5,-0.125"
TextureCoordinates="0,0 0,1 1,1 1,0 0,0 0,1 1,1 1,0"
TriangleIndices="0 1 2 2 3 0 4 5 6 6 7 4" />
</GeometryModel3D.Geometry>
<GeometryModel3D.Material>
<DiffuseMaterial Brush="#48565E" />
</GeometryModel3D.Material>
</GeometryModel3D>
<GeometryModel3D>
<GeometryModel3D.Geometry>
<!--Single Mesh That creates the four sides-->
<MeshGeometry3D
Positions="0.5,0.5,0.125 0.5,-0.5,0.125 0.5,-0.5,-0.125
0.5,0.5,-0.125 -0.5,0.5,-0.125 -0.5,-0.5,-0.125 -0.5,-0.5,0.125 -
0.5,0.5,0.125 -0.5,0.5,-0.125 -0.5,0.5,0.125 0.5,0.5,0.125 0.5,0.5,-
0.125 -0.5,-0.5,0.125 -0.5,-0.5,-0.125 0.5,-0.5,-0.125 0.5,-0.5,0.125"
TextureCoordinates="0,0 0,1 1,1 1,0 0,0 0,1 1,1 1,0 0,0
0,1 1,1 1,0 0,0 0,1 1,1 1,0"
TriangleIndices="0 1 2 2 3 0 4 5 6 6 7 4 8 9 10 10 11 8
12 13 14 14 15 12" />
</GeometryModel3D.Geometry>
<GeometryModel3D.Material>
<DiffuseMaterial Brush="#48565E" />
</GeometryModel3D.Material>
</GeometryModel3D>
</Model3DGroup.Children>
</Model3DGroup>
如果仔细观察第一个 MeshGeometry3D,你会注意到位置集合中的前四个点的 Z 坐标为 1.25,最后四个点的 Z 坐标为 -1.25。 实际上,前四个坐标会创建正面。
最后四个创建背面:
反向缠绕发生在 TriangleIndices 集合中。 由位置 0 - 3 组成的前两个三角形是逆时针缠绕的,而由位置 4 - 7 组成的最后两个三角形是顺时针缠绕的
(应注意,此预发行版 WPF 的性能特征在发布前会进行重大更改,此处进行的优化可能与最终版本无关。)
致谢
向帮助实现这一概念证明的以下个人大声疾呼:
大卫·泰特勒鲍姆、汤姆·泰勒、汤姆·马尔卡希、马特·考金斯、罗伯特·霍格