Perspective : les contrôles 3D skinables

Perspective : les contrôles 3D skinables

La lecture de cet article requiert une connaissance préalable du développement 3D avec Perspective .

En attendant FrameworkElement3D et Control3D...

UIElement3D, publié dans .NET 3.5, est le premier pas vers une architecture de classes 3D aussi riche fonctionnellement que celle de WPF 2D. Logiquement, on devrait trouver dans de futures versions de .NET les classes FrameworkElement3D, puis Control3D, etc. Les équipes de Redmond vont le faire un jour, j'en suis sûr. Du moins je l'espère :-) Mais l'attente est trop longue, alors pour patienter, voici une tentative...

Les objets 3D de la bibliothèque Perspective, dans sa version 0.3, ont été refactorisés pour hériter de UIElement3D. De là à les transformer en contrôles interactifs, on pourrait penser qu'il n'y a qu'un pas.

Mais la philosophie des contrôles WPF 2D va plus loin : leur fonction est séparée de leur représentation visuelle, au moyen de techniques telles que les styles et les templates (implémentées dans les classes FrameworkElement et Control). La prise en compte de cette philosophie en 3D pourrait paraître au premier abord trop ambitieuse pour un auteur isolé.

Et pourtant, sans avoir la prétention d'aboutir à un système aussi riche et sophistiqué que WPF 2D, j'ai réalisé dans la version 0.4 de la bibliothèque Perspective un système de contrôles 3D qui respecte cette philosophie, en exploitant des techniques simples de WPF (les ressources et UIElement3D) associées aux objets 3D basiques de Perspective (Box3D, Bar3D, etc.).

Cette bibliothèque est open-source, alors n'hésitez pas à vous en inspirer, et à me proposer des suggestions d'évolution le cas échéant.

Contrôles 3D et skins

Dans la version actuelle de Perspective, 4 classes de contrôles 3D interactifs sont fournies :

  • Button3D est un bouton-poussoir classique. Il dispose d'un événement Click, émis quand on l'appuie et le relâche.
  • Fader3D est un bouton à glissière (cf. Slider WPF 2D). Sa propriété Value dépend de la position du bouton, manipulé en faisant glisser la souris verticalement.
  • Knob3D est un bouton rotatif, type bouton de volume d'un ampli. Il fonctionne comme Fader3D.
  • Gyroscope3D est une maquette de gyroscope qui s'anime quand on clique dessus.

Les 3 premiers types de contrôles permettent de réaliser par exemple le visuel de maquettes d'appareils électroniques (synthétiseurs virtuels, etc.).

Button3D, Fader3D et Knob3D

L'exemple ci-dessus est disponible dans la page ButtonFaderKnob3D.xaml du projet PerspectiveDemo.UI. Démo en ligne

Button3D, Fader3D et Knob3D ont un ancêtre commun : ContentControl3D.

La classe ContentControl3D (de l'espace de noms Perspective.Wpf3D.Primitives) hérite de UIElement3D. Elle dispose d'une propriété Content, de type UIElement3D, qui sert à la représentation visuelle du contrôle. En lui assignant un objet ContainerUIElement3D, il est possible de réaliser un visuel composite.

Par défaut, cette propriété est chargée automatiquement depuis le skin courant. ContentControl3D exploite pour cela le singleton SkinManager (de l'espace de noms Perspective.Wpf3D).

3 skins sont actuellement fournis sous la forme de ressources intégrées à l'assembly Perspective.Wpf3D.dll :

  • PsycheRock, skin par défaut (en référence à Pierre Henry).
  • LaMoisson (en référence à une célèbre toile, avec laquelle - dois-je le préciser ? - le contenu du skin n'a rien à voir :-).
  • BlackAndWhite.

Il est possible de changer le skin courant au moyen du singleton SkinManager contenu dans chaque assembly Perspective contenant du visuel (y compris l'application démo).

private void LoadSkin(string skin)
{
    SkinManager.Current.LoadSkin(skin);
    Perspective.Wpf.SkinManager.Current.LoadSkin(skin);
    Perspective.Wpf3D.SkinManager.Current.LoadSkin(skin);
}

Application d'un Skin

Dans ce skin, les matériaux sont différents, et le bouton du Fader3D est sphérique.

Comme les objets 3D de Perspective héritent tous de UIElement3D, il est possible de les utiliser pour personnaliser le visuel d'un contrôle au moyen de sa propriété Content. La propriété OverridesDefaultSkin doit être activée au préalable.

Exemple en XAML d'un bouton de robinet :

<p:Knob3D 
    x:Name="myKnob" 
    Minimum="0.0" Maximum="360.0"
    OverridesDefaultSkin="True"
    MouseRightButtonDown="myKnob_MouseRightButtonDown">
    <p:Knob3D.Transform >
        <RotateTransform3D>
            <RotateTransform3D.Rotation>
                <AxisAngleRotation3D 
                Angle="{Binding RelativeSource={RelativeSource AncestorType={x:Type p:Knob3D}}, Path=Angle}"    
                Axis="0.0, 0.0, 1.0"/>
            </RotateTransform3D.Rotation>
        </RotateTransform3D>
    </p:Knob3D.Transform>
    <p:Knob3D.Content>
        <ContainerUIElement3D>
            <p:Bar3D 
                SideCount="3" 
                RoundingRate="0.15"
                Material="{DynamicResource ControlMaterial}" />
        </ContainerUIElement3D>
    </p:Knob3D.Content>
</p:Knob3D>

Personnalisation de Content

Exemple en C# :

Dans le code C# ci-dessous, workshop représente le Workshop3D courant.

private void GenerateCustomKnob()
{
    AxisAngleRotation3D rotation = new AxisAngleRotation3D();
    rotation.Axis = new Vector3D(0.0, 0.0, 1.0);
    RotateTransform3D rotateTransform = new RotateTransform3D();
    rotateTransform.Rotation = rotation;
    TranslateTransform3D translateTransform = new TranslateTransform3D(2.0, 3.0, 0.0);
    Transform3DGroup tg = new Transform3DGroup();
    tg.Children.Add(rotateTransform);
    tg.Children.Add(translateTransform);
    Perspective.Wpf3D.Shapes.Bar3D bar = new Perspective.Wpf3D.Shapes.Bar3D();
    bar.SideCount = 6;
    bar.RoundingRate = 0.15;
    Perspective.Wpf3D.Controls.Knob3D knob = new Perspective.Wpf3D.Controls.Knob3D();
    knob.OverridesDefaultSkin = true;
    knob.Transform = tg;
    knob.Content = bar;
    Binding b = new Binding("Angle");
    b.Source = knob;
    BindingOperations.SetBinding(rotation, AxisAngleRotation3D.AngleProperty, b);
    workshop.Children.Add(knob);
}

Les 2 exemples ci-dessus sont disponibles dans la page CustomKnob3D.xaml du projet PerspectiveDemo.UI. Démo en ligne

Button3D

Button3D hérite de ButtonBase3D (qui hérite de ContentControl3D).

Comme la classe ButtonBase 2D (de l'espace de noms System.Windows.Controls.Primitives), ButtonBase3D expose un événement Click et une propriété IsPressed.

Mais ButtonBase3D dispose aussi d'une propriété PressEffect, dont la valeur est automatiquement animée quand le bouton est enfoncé ou relâché. Cette animation peut ainsi être répercutée dans les éléments du skin par databinding. Cela palie à l'absence d'une classe FrameworkElement3D qui offrirait des capacité de style dynamique et d'animation.

Fader3D et Knob3D

Fader3D et Knob3D héritent de RangeBase3D (qui hérite de ContentControl3D).

RangeBase3D présente des fonctionnalités similaires à celles de la classe RangeBase 2D (de l'espace de noms System.Windows.Controls.Primitives). Elle permet de gérer un élément disposant d'une valeur (propriété Value) à l'intérieur d'un intervalle (propriétés Minimum et Maximum).

RangeBase3D offre les services supplémentaires suivants :

  • Changement de valeur par glissement vertical de la souris (propriétés DragHeight et IsDragging), ou par la manipulation de sa molette (propriété MouseWheelIncrement).
  • Asservissement de différents contrôles de cette famille (ainsi que leurs équivalents 2D de l'espace de noms Perspective.Wpf, Fader et Knob) : multi-sélection par Ctrl-Clic, propriété IsSelected. Cela peut s'avérer très pratiques par exemple sur une table de mixage virtuelle.

Les skins de Perspective mettent en évidence les contrôles sélectionnés. Les contrôles 3D apparaissent en surbrillance (application d'un EmissiveMaterial) tandis que les contrôles 2D apparaissent entourés.

Multi-sélection de contrôles 2D et 3D à intervalle

L'exemple ci-dessus est disponible dans la page Mixer.xaml du projet PerspectiveDemo.UI. Démo en ligne

Architecture des skins

Les skins sont stockés sous la forme de fichier XAML dans les sources de l'assembly, dans le dossier nommé Skins. Ce dossier contient le contenu commun aux différents skins. Le contenu spécifique à un skin est stocké dans un sous-dossier de même nom que le skin.

Le mécanisme des skins tente de reproduire un système équivalent aux ControlTemplate 2D de WPF. Mais les styles, les triggers et les templates ne sont pas applicables à UIElement3D. Il a donc fallu trouver des alternatives.

Un skin contient 2 types de fichiers :

  • Les fichiers de contenu des classes héritées de ContentControl3D (par exemple Knob3D.xaml), qui contiennent un UIElement3D assigné automatiquement à la propriété Content de la classe concernée.
  • Un fichier de ressource Resource.xaml contenant un ResourceDictionary permettant de partager des objets communs à différents fichiers de contenu.

Chaque ContentControl3D charge automatiquement son visuel depuis un fichier de contenu spécifié dans la ressource nommée ContentFiles (de type Hashtable) définie dans le fichier Resources.xaml du dossier du skin courant. Voici un extrait de la définition du ContentFiles du skin LaMoisson :

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:col="clr-namespace:System.Collections;assembly=mscorlib"
    xmlns:sys="clr-namespace:System;assembly=mscorlib"    
    xmlns:p3D="clr-namespace:Perspective.Wpf3D">
    <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="/Perspective.Wpf3D;component/Skins/Resources.xaml" />
    </ResourceDictionary.MergedDictionaries>
    <!--Content files : path relative to Skins directory - no .xaml extension-->
    <col:Hashtable x:Key="ContentFiles">
        <sys:String x:Key="Fader3D">LaMoisson/Fader3D</sys:String>
    </col:Hashtable>
    ...

Les chemins sont relatifs au dossier Skins. Les fichiers de contenu communs (du dossier Skins) Button3D.xaml et Knob3D.xaml sont automatiquement utilisés par le skin LaMoisson. Ceci évite de dupliquer des fichiers de contenu identiques dans différents skins. Par contre, un fichier de contenu spécifique, stocké dans le dossier Skins/LaMoisson, est utilisé pour Fader3D.

Le ResourceDictionary du fichier Resources.xaml commun du dossier Skins est fusionné avec les ressources du skin. Ainsi, un fichier de contenu peut faire référence à des ressources communes ou spécifiques au skin courant.

Voici le contenu du fichier Knob3D.xaml :

<ContainerUIElement3D
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:p3D="clr-namespace:Perspective.Wpf3D.Shapes"
    xmlns:p3DControls="clr-namespace:Perspective.Wpf3D.Controls">
    <ContainerUIElement3D.Transform >
        <RotateTransform3D >
            <RotateTransform3D.Rotation>
                <AxisAngleRotation3D 
                    Angle="{Binding RelativeSource={RelativeSource AncestorType={x:Type p3DControls:Knob3D}}, Path=Angle}"    
                    Axis="0.0, 0.0, 1.0"/>
            </RotateTransform3D.Rotation>
        </RotateTransform3D>
    </ContainerUIElement3D.Transform>
    <ContainerUIElement3D Transform="{StaticResource KnobControlTransform}">
        <StaticResource ResourceKey="KnobControl"/>
        <!--Color part-->
        <p3D:Bar3D 
            SideCount="100" 
            Material="{DynamicResource ControlColorMaterial}" 
            Transform="{StaticResource ControlColorPartTransform}"/>
        <p3D:Bar3D 
            x:Name="PART_SelectionIndicator"
            SideCount="100" 
            Material="{DynamicResource SelectionIndicatorMaterial}" 
            Transform="{StaticResource ControlColorPartTransform}"/>
    </ContainerUIElement3D>
</ContainerUIElement3D>

Le visuel défini pour le Knob3D dans ce fichier est composé d'une imbrication de 2 ContainerUIElement3D, le 2ème étant composé d'un objet chargé depuis les ressources communes, KnobControl, et de 2 objets Bar3D (pastilles de couleur sur la face avant du bouton).

En 2D, les propriétés des objets contenus dans un ControlTemplate peuvent être liées à celles du contrôle via un TemplateBinding. Ce mécanisme n'est pas disponible en 3D, mais on peut utiliser à la place un Binding de type RelativeSource, ici pour lier l'angle de rotation du bouton à sa propriété Angle. Le code non-visuel du contrôle doit donc exposer des propriétés à cette fin.

Le code d'un contrôle 2D peut faire référence à des parties du template, qui doivent donc exister dans celui-ci avec un type donné et disposer d'un nom servant à les identifier. Par convention, ce nom est préfixé par "PART_". Par exemple, la piste de défilement d'un Slider doit être définie dans le template et s'appeler "PART_Track". Cette convention est reprise pour les skins 3D de Perspective.

En 2D, l'attribut TemplatePart, facultatif, permet à des fins de documentation d'associer le nom et le type de la partie nommée au type du contrôle. Dans Perspective, l'attribut SkinPart joue le même rôle.

La méthode FrameworkTemplate.FindName() permet de retrouver une partie nommée, pour l'exploiter dans le code du contrôle. La classe ContentControl3D de Perspective contient une méthode protégée FindName qui joue le même rôle dans le cadre d'un skin. Remarques :

  • La partie nommée doit être définie dans le fichier de contenu du contrôle, il n'est pas possible de la définir dans un fichier de ressource.
  • Le mécanisme des parties nommées est spécifique aux skins. Il n'est pas utilisable quand la propriété Content est assignée directement dans le code d'une fenêtre (l'attribut x:Name devant être unique, il y aurait un problème avec plusieurs instances du contrôle).

Voici les ressources communes utilisées par Knob3D :

<ContainerUIElement3D 
    x:Key="KnobControl" 
    x:Shared="false">
    <p3D:Bar3D SideCount="100" Material="{DynamicResource ControlMaterial}" >
        <p3D:Bar3D.Transform>
            <ScaleTransform3D ScaleZ="0.2" />
        </p3D:Bar3D.Transform>
    </p3D:Bar3D>
    <p3D:Bar3D SideCount="100" Material="{DynamicResource ControlMaterial}">
        <p3D:Bar3D.Transform>
            <Transform3DGroup>
                <ScaleTransform3D ScaleX="0.8" ScaleY="0.8" ScaleZ="0.75" />
                <TranslateTransform3D OffsetZ="0.2" />
            </Transform3DGroup>
        </p3D:Bar3D.Transform>
    </p3D:Bar3D>
    <p3D:Box3D Material="{DynamicResource IndicatorMaterial}">
        <p3D:Box3D.Transform>
            <Transform3DGroup>
                <ScaleTransform3D ScaleX="0.1" ScaleY="0.5" ScaleZ="0.8"/>
                <TranslateTransform3D OffsetY="-1.0" OffsetZ="0.2"/>
            </Transform3DGroup>
        </p3D:Box3D.Transform>
    </p3D:Box3D>
</ContainerUIElement3D>
<Transform3DGroup x:Key="ControlColorPartTransform" >
    <ScaleTransform3D ScaleX="0.7" ScaleY="0.7" ScaleZ="0.1" />
    <TranslateTransform3D OffsetZ="0.95" />
</Transform3DGroup>
<ScaleTransform3D x:Key="KnobControlTransform"
    ScaleX="0.5" ScaleY="0.5" ScaleZ="0.5" />

Remarquez l'utilisation de l'attribut x:Shared="false" pour forcer la création d'une instance spécifique à chaque utilisation de la ressource, ce qui s'avère indispensable pour les ressources de type UIElement3D destinées à la construction de l'arbre visuel des contrôles.

Les ressources de type Material sont décrites dans le fichier Resource.xaml spécifique au skin.

A propos de cet article

Auteur : Olivier Dewit.

Historique :

  • 22 mai 2008 : mise à jour pour la version 0.9 de Perspective.
  • 16 décembre 2007 : 1ère publication (Perspective version 0.4). Article original