65.9K
CodeProject 正在变化。 阅读更多。
Home

在3D面板中为交互式2D元素添加动画

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.60/5 (49投票s)

2008 年 4 月 8 日

CPOL

11分钟阅读

viewsIcon

292292

downloadIcon

7480

探讨 Panel3D,一个在 3D 空间中显示其子元素的自定义 WPF 面板

panel3D_screenshot.png

引言

本文评测了一个名为 Panel3D 的自定义 WPF 面板,它在三维空间中承载二维元素。您可以向 Panel3D 实例提供要显示的元素,或者将其用作 ItemsControl 的项目宿主。如果您将 Panel3D 用作 ListBox 的项目宿主,则最前面的项目将自动成为 ListBox 中的选定项目。 Panel3D 沿直线排列其 3D 模型,并提供 3D 模型沿该路径的动画移动。此外,该面板实现了一种简单的 UI 虚拟化形式,因此即使包含数百个项目,它也能快速运行。

背景

WPF 提供了出色的支持来创建 2D 和 3D 用户界面。随着 WPF 的成熟,这两个编程领域正在融合,允许 3D 场景显示交互式 2D 元素。尽管迄今为止已取得进展,但 WPF 仍未开箱即用地支持一些看似基本的概念。其中一个人为的限制是,没有内置支持将面板的子元素托管在 3D 空间中。本文通过使用我的 Panel3D 类来展示如何绕过此限制。

Panel3D 简史

我当然不能声称我是这个自定义 3D 面板的唯一发明者。不久前,我的朋友兼 WPF Disciple 伙伴 WPF Disciple Sacha Barber 请我评测他的一个 WPF 项目。他正在研究一种创建自定义面板的方法,该面板通过使用引用 2D 元素的 VisualBrush 来绘制 Viewport3D 中的子元素。那个演示项目让我思考了一个类似的问题:如何在 3D 空间中承载面板的 2D 元素。我想找到一种方法将面板的子元素置于 3D 空间中,而不仅仅是用 VisualBrush 绘制 3D 模型。我还希望该面板能用作 ItemsControl 的项目宿主。

经过几次 尝试挫折 后,我找到了一个巧妙的解决方法。我克隆了面板的子元素,并将克隆体托管在 Viewport3D 中。这绝对不是理想的,但它有效。幸运的是,这引起了另一位 WPF Disciple 的注意:Dr. WPF。这位博士动用了他才华横溢的大脑,最终找到了一种创建面板的方法,该面板的子元素可以托管在面板本身之外。我的 Panel3D 源自他的 LogicalPanel,因此如果您有兴趣了解全局,我建议您在此 阅读 他关于此的精彩文章。

介绍 Panel3D

Panel3D 具有简单的 API。它公开了几个公共依赖项属性

  • AllowTransparency — 获取/设置场景中的模型是否支持真正的半透明,以便可以看到前面模型后面的模型。默认值为 false
  • AutoAdjustOpacity — 获取/设置 Panel3D 是否根据每个模型的视觉索引自动调整其不透明度。默认值为 true
  • Camera — 获取/设置用于查看 3D 场景的相机。
  • IsMovingItems — 返回此 Panel3D 中显示的模型的调用 MoveItems 方法后是否正在动画到新位置。
  • DefaultAnimationLength — 获取/设置移动项目所需的时间。调用 MoveItems 时可以覆盖此值。默认值为 700 毫秒。
  • ItemLayoutDirection — 获取/设置一个 Vector3D,描述了项目定位的方向。默认值为 (-1, +1.3, -7)。
  • MaxVisibleModels — 获取/设置一次最多可以显示的 3D 模型数量。默认值为 10。此属性的最小值是 2。

Panel3D 声明了两个公共方法,其中一个有两个重载

  • int GetVisibleIndexFromChildIndex(int childIndex) — 返回表示面板 Children 集合中指定索引处的 2D 元素的 3D 模型的可见索引。两个索引值都基于零。前面模型的可见索引是 0,3D 场景中每个连续模型的可见索引比前一个模型高一。如果指定索引处的元素当前不在视口中,则可见索引为 -1。当您想在用户单击模型后将其带到 3D 场景的前面时,此方法很有用。
  • void MoveItems(int itemCount, bool forward) — 以默认动画长度向前或向后移动项目。
  • void MoveItems(int itemCount, bool forward, TimeSpan animationLength) — 以指定的动画长度向前或向后移动项目。

该类还公开了一个冒泡路由事件,名为 ItemsHostLoaded,当面板是 ItemsControl 的项目宿主时,该事件很有用。添加此事件的处理程序使您能够获取对 Panel3D 的引用,因为不知何故,它的 Loaded 事件在这种情况下不会冒泡。

使用 Panel3D

您可以轻松创建一个 Panel3D 并为其提供一些要显示的子元素。以下是 DirectWindow.xaml 文件中的 XAML,它是演示项目的一部分。

<pnl3D:Panel3D xmlns:pnl3D="clr-namespace:Panel3DLib;assembly=Panel3DLib">
  <TextBox
    AcceptsReturn="True"
    MaxLines="8"
    Text="Howdy"
    Width="100" Height="100"
    />

  <Button Width="100" Height="100">Destroy Universe</Button>

  <CheckBox IsChecked="True">Is this cool?</CheckBox>
</pnl3D:Panel3D>

该 XAML 看起来与添加元素到任何其他 WPF 面板的 XAML 完全相同。由于 Panel3D 间接派生自抽象的 Panel 类,因此您可以像使用任何其他面板一样使用它。运行程序并编辑 TextBox 后,UI 如下所示

panel3D_directMode.png

如果您决定使用 Panel3D 作为 ItemsControl 的项目宿主,您可以使用下面的 XAML

<ItemsControl
  xmlns:pnl3D="clr-namespace:Panel3DLib;assembly=Panel3DLib"
  ItemsSource="{Binding Path=FooCollection}"
  >
  <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
      <pnl3D:Panel3D />
    </ItemsPanelTemplate>
  </ItemsControl.ItemsPanel>
</ItemsControl>

上面的代码片段使用 ItemsControl 的标准 ItemsPanel 属性来指定 Panel3D 将承载控件的项目。您也可以将 Panel3D 用作 ListBox 的项目宿主,它提供了项目选择的概念。 Panel3D 中选定的项目始终是前面的项目。 Panel3D 不支持一次显示多个选定项目。

您可以通过在 XAML 中设置一些属性来利用 Panel3D 的更高级功能。这是演示项目 Panel3DConfigurations.xaml 文件中提供的一个面板配置

<ItemsPanelTemplate x:Key="Across.Right">
  <pnl3D:Panel3D
    DefaultAnimationLength="0:0:0.5"
    ItemLayoutDirection="3.8, .55, -6.831"
    >
    <pnl3D:Panel3D.Camera>
      <PerspectiveCamera
        LookDirection="3, 0.8, -10"
        Position="+1.75, 0, 5"
        UpDirection="0, 1, 0"
        />
    </pnl3D:Panel3D.Camera>
  </pnl3D:Panel3D>
</ItemsPanelTemplate>

当您应用这些设置并在演示应用程序中查看 BoundWindow 时,UI 如下所示

panel3D_boundMode.png

如上所示,调整 Panel3DItemLayoutDirectionCamera 属性可以极大地改变我们查看其承载项的方式。 Panel3D 沿由 ItemLayoutDirection 属性返回的 Vector3D 定义的直线排列其项目。 Camera 属性返回的相机对象决定了我们从哪个角度查看该 3D 模型线。演示项目包含一个简单的运行时编辑器,用于 ItemLayoutDirection 属性,如下面的带注释的屏幕截图所示

panel3D_vector3dEditor.png

将 2D 元素置于 3D 空间

Panel3D 必须执行的基本任务之一是将 2D 元素映射到 3D 模型。当一个子元素被添加到面板时,它必须创建一个承载 2D 子元素的 3D 模型,并将其显示在正确的位置。当该 2D 子元素被移除时,相应的 3D 模型也必须被移除。

以下是负责 Panel3D 此方面的主要方法

protected override void OnLogicalChildrenChanged(
  UIElement elementAdded, UIElement elementRemoved)
{
    // Do not create a model for the Viewport3D.
    if (elementAdded == _viewport)
        return;

    bool add =
        elementAdded != null &&
        !_elementTo3DModelMap.ContainsKey(elementAdded);

    if (add)
        this.AddModelForElement(elementAdded);

    bool remove =
        elementRemoved != null &&
        _elementTo3DModelMap.ContainsKey(elementRemoved);

    if (remove)
        this.RemoveModelForElement(elementRemoved);
}

该方法重写了从 Dr. WPF 的 LogicalPanel 继承的方法。响应接收到一个新的逻辑子项,该方法将执行

void AddModelForElement(UIElement element)
{
    var model = BuildModel(element);

    // Add the new model at the correct location in our list of models.
    int idx = base.Children.IndexOf(element);
    _models.Insert(idx, model);

    _elementTo3DModelMap.Add(element, model);

    // If the scene has more than just a light source, grab the first
    // element and use it as the front model.  Otherwise, the scene
    // does not have any of our models in it yet, so pass the new one.
    var frontModel =
        _viewport.ModelCount > 0 ?
        _viewport.FrontModel :
        model;

    this.BuildScene(frontModel);
}

我们不打算详细说明逻辑子项被移除时会发生什么,因为它基本上是上面方法的反向操作。创建新 3D 模型的方法在前面方法的顶部被调用。现在让我们关注该逻辑

/// <summary>
/// Returns an interactive 3D model that hosts
/// the specified UIElement.
/// </summary>
Viewport2DVisual3D BuildModel(UIElement element)
{
    var model = new Viewport2DVisual3D
    {
        Geometry = new MeshGeometry3D
        {
            TriangleIndices = new Int32Collection(
                new int[] { 0, 1, 2, 2, 3, 0 }),
            TextureCoordinates = new PointCollection(
                new Point[]
                    {
                        new Point(0, 1),
                        new Point(1, 1),
                        new Point(1, 0),
                        new Point(0, 0)
                    }),
            Positions = new Point3DCollection(
                new Point3D[]
                    {
                        new Point3D(-1, -1, 0),
                        new Point3D(+1, -1, 0),
                        new Point3D(+1, +1, 0),
                        new Point3D(-1, +1, 0)
                    })
        },
        Material = new DiffuseMaterial(),
        Transform = new TranslateTransform3D(),
        // Host the element in the 3D object.
        Visual = element
    };

    Viewport2DVisual3D.SetIsVisualHostMaterial(model.Material, true);

    return model;
}

该方法创建一个 Viewport2DVisual3D 对象并对其进行配置,该对象承载面板的 2D 子元素。它通过将 Visual 属性设置为 2D 元素,然后将其 Material 上的附加 IsVisualHostMaterial 属性设置为 true 来建立此关系。在此 了解更多关于 Viewport2DVisual3D 的信息。

3D 场景的虚拟化

UI 虚拟化是一种标准的性能改进技术,用于处理列表中的数百或数千个项目。其思想是,您仅为当前可见的项目创建 UI 元素并将其保留。当您将项目带入视图时,会为其创建 UI 元素。当滚动出视图时,该项目的 UI 元素将被丢弃。由于通常只有一小部分项目可见,因此这可能会对显示大量项目所需的内存占用产生巨大影响。

Panel3D 采用了一种不同的 UI 虚拟化。在开发面板时,我发现当它包含许多项目时,对其调用 MoveItems 方法会导致动画速度非常慢。问题是所有 3D 模型都在视口中,并且每个模型都需要动画到其新位置和不透明度。这需要大量的处理能力。

我决定将视口可以显示的最多项目数限制为十个,这样项目移动就会更快。当一个模型移出视图时,我会向视口添加另一个模型。这给人一种所有项目都在视口中的错觉。

但是,需要注意的是,我并没有惰性地创建或销毁 3D 模型;我只是根据需要将它们添加到视口或从中移除。如果您想显示一万个项目,所有这些 3D 模型占用的内存可能非常昂贵。在这种情况下,您将需要承担实现真正 UI 虚拟化的 VirtualizingPanel3D 的更具挑战性的任务。祝您好运!

我的简单 UI 虚拟化方案存在于两个地方。BuildScene 方法(由上一节中看到的 AddModelForElement 方法调用)限制了添加到场景中的模型数量。该方法如下所示

/// <summary>
/// Tears down the current 3D scene and constructs a new one
/// where the specified model is the front object in view.
/// </summary>
void BuildScene(Viewport2DVisual3D frontModel)
{
    _viewport.RemoveAllModels();

    // Add in some 3D models, starting with the one in front.
    var current = frontModel;
    for (int i = 0; _viewport.ModelCount < this.MaxVisibleModels; ++i)
    {
        this.ConfigureModel(current, i);

        _viewport.AddToBack(current);

        current = this.GetNextModel(current);
        if (_viewport.Children.Contains(current))
            break;
    }
}

虚拟化难题的另一部分在于移动场景中项目的代码。我们接下来检查该逻辑。

沿路径为 3D 模型设置动画

到目前为止,开发 Panel3D 最具挑战性的方面是项目移动算法。这花了我一段时间才弄清楚。该逻辑必须考虑许多事情,包括上一节讨论的 UI 虚拟化问题。

它的工作原理实际上非常简单。如果项目移动一个位置,则最前面的或最后面的项目会移动到 Viewport3DChildren 集合的另一端。如果新的最前面的项目是 ListBoxItem,我会将所有者 ListBoxSelectedItem 属性设置为该项目。真正的魔力发生在下一步,当我们启动一系列动画来移动项目并(可选地)调整它们的不透明度时。该逻辑使用模型在视口 Children 集合中的顺序索引来确定其应存在的位置以及其不透明度。一旦所有动画都处于活动状态,就会启动一个 DispatcherTimer。当计时器滴答时,将执行一些清理代码。这些清理代码强制执行规则,即一次只能存在一定数量的模型在视口中(由 MaxVisibleModels 属性确定)。

该算法的主要方法如下

/// <summary>
/// Moves the items forward or backward over the specified animation length.
/// </summary>
public void MoveItems(int itemCount, bool forward, TimeSpan animationLength)
{
    bool go = this.MoveItems_CanExecute(itemCount, forward, animationLength);
    if (!go)
        return;

    // Prepare some flags that control this algorithm.
    _abortMoveItems = false;
    this.IsMovingItems = true;

    // Move the 3D models to their new position in
    // the Viewport3D's Children collection.
    this.MoveItems_RelocateModels(itemCount, forward);

    // If we are the items host of a Selector, select the first child element.
    this.MoveItems_SelectFrontItem();

    // Start moving the models to their new locations
    // and apply the new opacity values.
    this.MoveItems_BeginAnimations(forward, animationLength);

    // Start the timer that ticks when the animations are finished.
    this.MoveItems_StartCleanupTimer(animationLength);
}

我将不展示使魔力工作的子例程。如果您想了解其工作原理,请下载源代码并在 Panel3D.cs 源文件中的 MoveItems 部分进行查看。但是,我将展示清理计时器滴答时执行的回调方法,因为它展示了我是如何遵守每个场景最多十个项目的 UI 虚拟化规则的。

/// <summary>
/// Invoked when the items stop moving, due to a call to MoveItems().
/// </summary>
void OnMoveItemsCompleted(object sender, EventArgs e)
{
    _moveItemsCompletionTimer.Stop();

    if (_abortMoveItems)
        return;

    // Remove any extra models from the scene.
    while (this.MaxVisibleModels < _viewport.ModelCount)
        _viewport.RemoveBackModel();

    this.IsMovingItems = false;

    if (0 < _moveItemsRequestQueue.Count)
    {
        MoveItemsRequest req = _moveItemsRequestQueue.Dequeue();
        this.MoveItems(req.ItemCount, req.Forward, req.AnimationLength);
    }
}

另外,Panel3D 足够智能,可以知道用户何时请求在它仍在移动项目时移动项目。它维护一个请求结构队列,每个结构代表在动画进行期间对 MoveItems 方法的调用。在完成一个移动项目的请求后,它会检查在动画期间是否有其他请求到达。如果有,它将启动对 MoveItems 的另一个调用,并重新开始整个过程。

修订历史

  • 2007 年 5 月 19 日 — 修复了面板,使其子元素支持透明度。添加了 AllowTransparencyAutoAdjustOpacity 属性。更新了源代码和演示应用程序下载。我在此 博客 中介绍了此更改的实现方式。感谢 Sajiv Thomas 解释了如何解决此问题!
  • 2007 年 4 月 9 日 — 在收到一些功能请求后,我将 MaxVisibleModels 公开为属性,添加了 IsMovingItems 属性,添加了 GetVisibleIndexFromChildIndex 方法,并在(演示应用程序中)添加了在用户单击 3D 场景中的项目时将其置于最前面的功能。我还采纳了 Andrew Smith 的建议,重写了使 Panel3D 与所有者 Selector 控件的选定项目保持同步的逻辑。
  • 2007 年 4 月 8 日 — 创建了本文
© . All rights reserved.