Écrire son propre aspect pour Qt 3D

Partie 2 : mettre en place l'arrière-plan et les communications

| Sean Harmer | Alexandre Laurent |

Traducteur

Théodore Prévot

Dans l'article précédent, nous vous avions donné un aperçu du processus de création d'un aspect et nous vous avions montré comment implémenter la plupart des fonctionnalités de premier plan. Dans cet article, nous continuerons à mettre en place notre aspect en nous occupant des types de fonctionnalités d'arrière-plan correspondant à celle du premier plan (ces types s'occuperont des calculs à effectuer pour les aspects de premier plan, c'est-à-dire visibles) et nous configurerons la communication entre ceux-ci. De plus, nous les enregistrerons auprès de l'aspect. Cela nous prendra la majeure partie de cet article. L'article suivant conclura cette minisérie en vous montrant comment implémenter des tâches pour traiter nos composants d'aspect.

Commentez

Petit rappel de ce que nous avions fait la dernière fois, voici le diagramme de l'architecture de programme de la partie 1 :

- Titre : Écrire son propre aspect pour Qt 3D

- Auteurs : Sean Harmer, Alexandre Laurent

- Traducteur : Théodore Prévot

- Parution : 16 janvier 2018

- Mise à jour : 15 février 2018

- Licence : Copyright ® 2018 Théodore Prévot. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Création des nœuds de calcul

- II. Enregistrer les types auprès de l'aspect

- III. Communications entre le premier plan et l'arrière-plan

- IV. Initialisation du nœud d'arrière-plan

- V. Communication du premier plan à l'arrière-plan

- VI. Résumé

- VII. Remerciements

I - Création des nœuds de calcul

L'une des meilleures choses avec Qt 3D est sa capacité de rendu très élevée. Elle est atteinte en exécutant des tâches dans la pile d'exécution en arrière-plan. Pour y arriver sans avoir des entrelacs de points de synchronisation (ce qui limiterait les possibilités d'exécution parallèle), nous allons devoir sacrifier de la mémoire au profit de la vitesse, compromis classique en informatique. Si chaque aspect fonctionne sur sa propre copie de donnée, les données de chaque tâche sont protégées, ce qui évite une situation de compétition (situation qui survient lorsque différents objets tentent d'accéder à une même ressource en même temps).

Cela n'est pas aussi coûteux qu'il n'y paraît, car le nœud de calcul n'hérite pas de QObject : sa classe de base est Qt3DCore::QBackendNode, qui est beaucoup plus légère. Cela est dû au fait que les aspects enregistrent seulement les données dont ils ont besoin pour leurs calculs. Par exemple, si une même entité possède à la fois un aspect d'animation et un composant Material, l'animation n'enregistrera aucune donnée provenant du composant. Réciproquement, l'aspect de rendu ne s'intéresse pas aux clips ou aux composants d'animation.

Dans notre petit aspect, nous avons seulement un des types de composants de premier plan : FpsMonitor. Logiquement, nous aurons un seul type d'arrière-plan correspondant, que nous pouvons logiquement appeler FpsMonitorBackend :

fpsmonitorbackend.h

class FpsMonitorBackend : public Qt3DCore::QBackendNode
{
public:
FpsMonitorBackend()
: Qt3DCore::QBackendNode(Qt3DCore::QBackendNode::ReadWrite)
, m_rollingMeanFrameCount(5)
{}

private:
void initializeFromPeer(const Qt3DCore::QNodeCreatedChangeBasePtr &change) override
{
// TODO: Implement me!
}

int m_rollingMeanFrameCount;
};

La déclaration de cette classe est très simple. Comme prévu, nous héritons de Qt3DCore::QBackendNode, puis nous ajoutons une variable membre pour garder une copie des informations depuis le composant de premier plan FpsMonitor. Enfin, nous réécrivons la fonction virtuelle initializeFromPeer(). Elle sera appelée après que Qt 3D ait créé une instance de notre type d'arrière-plan. L'argument nous permet de récupérer les données envoyées depuis l'objet de premier plan correspondant, comme nous avions vu précédemment.

II - Enregistrer les types auprès de l'aspect

Nous savons maintenant implémenter des composants d'arrière-plan et de premier plan. La prochaine étape est de les enregistrer auprès de l'aspect : ainsi, ce dernier peut instancier le nœud de calcul à chaque fois qu'un nœud de premier plan est créé, de même pour la destruction. Nous faisons cela en passant par un intermédiaire connu comme un mapper de nœuds.

Pour créer un mapper de nœuds, il suffit d'écrire une sous-classe de Qt3DCore::QNodeMapper et de réécrire les fonctions virtuelles de création, de consultation et de destruction d'objets lorsqu'il y a lieu. La manière dont vous créerez, consulterez et détruirez les objets dépend uniquement de vous. Qt 3D n'impose aucun schéma d'organisation particulier. L'aspect qui gère le rendu fait des choses plutôt absconses avec un gestionnaire de mémoire par seaux et l'alignement de la mémoire pour les types SIMD, mais nous pouvons faire ici quelque chose de beaucoup plus simple.

Nous allons stocker des pointeurs vers les nœuds d'arrière-plan dans une table de hachage QHash avec la classe CustomAspect et les indexer par l'identifiant de chaque nœud Qt3DCore::QNodeId. L'identifiant du nœud est utilisé pour identifier un nœud donné de manière unique, même entre le premier plan et tous les aspects d'arrière-plan. Dans Qt3DCore::QNode, l'identifiant est disponible avec la fonction id(), tandis que, dans QBackendNode, ce dernier est donné par la fonction peerId(). Pour les deux objets correspondants qui représentent le composant, les fonctions id() et peerId() renvoient la même valeur QNodeId.

Il n'y a plus qu'à écrire le code ! Ajoutons un peu de stockage aux nœuds d'arrière-plan pour la classe CustomAspect.

customaspect.h

class CustomAspect : public Qt3DCore::QAbstractAspect
{
Q_OBJECT
public:
...
void addFpsMonitor(Qt3DCore::QNodeId id, FpsMonitorBackend *fpsMonitor)
{
m_fpsMonitors.insert(id, fpsMonitor);
}

FpsMonitorBackend *fpsMonitor(Qt3DCore::QNodeId id)
{
return m_fpsMonitors.value(id, nullptr);
}

FpsMonitorBackend *takeFpsMonitor(Qt3DCore::QNodeId id)
{
return m_fpsMonitors.take(id);
}
...

private:
QHash<Qt3DCore::QNodeId, FpsMonitorBackend *> m_fpsMonitors;
};

Maintenant, nous pouvons tout simplement implémenter un mapper de nœuds dans cette classe :

fpsmonitorbackend.h

class FpsMonitorMapper : public Qt3DCore::QBackendNodeMapper
{
public:
explicit FpsMonitorMapper(CustomAspect *aspect);

Qt3DCore::QBackendNode *create(const Qt3DCore::QNodeCreatedChangeBasePtr &change) const override
{
auto fpsMonitor = new FpsMonitorBackend;
m_aspect->addFpsMonitor(change->subjectId(), fpsMonitor);
return fpsMonitor;
}

Qt3DCore::QBackendNode *get(Qt3DCore::QNodeId id) const override
{
return m_aspect->fpsMonitor(id);
}

void destroy(Qt3DCore::QNodeId id) const override
{
auto fpsMonitor = m_aspect->takeFpsMonitor(id);
delete fpsMonitor;
}

private:
CustomAspect *m_aspect;
};

Pour poser la dernière pièce du puzzle, nous devons maintenant indiquer à l'aspect comment ces types et ces mappers fonctionnent entre eux. Nous effectuons cela en appelant la fonction QAbstractAspect::registerBackendType(), que l'on passe dans un pointeur commun au mapper qui créera, trouvera et détruira le nœud d'arrière-plan correspondant au moment voulu. L'argument de la fonction est le type du nœud de premier plan pour lequel le mapper devrait être appelé. L'endroit approprié pour faire cela est tout simplement le constructeur de la classe CustomAspect. Dans notre cas, il ressemble à ça :

customaspect.cpp

CustomAspect::CustomAspect(QObject *parent)
: Qt3DCore::QAbstractAspect(parent)
{
// Register the mapper to handle creation, lookup, and destruction of backend nodes
auto mapper = QSharedPointer<FpsMonitorMapper>::create(this);
registerBackendType<FpsMonitor>(mapper);
}

Et c'est tout ! Avec cette déclaration, à chaque fois qu'un composant FpsMonitor est ajouté au premier plan (cette scène), l'aspect cherchera le mapper pour ce type d'objet. Ici, il trouvera notre objet enregistré FpsMonitorMapper et il appellera la fonction create() pour créer le nœud d'arrière-plan correspondant et gérer sa mémoire. Même histoire pour la destruction (techniquement, la suppression du composant de la scène) du nœud de premier plan. La fonction get() du mapper est utilisée en interne pour appeler les fonctions virtuelles sur le nœud d'arrière-plan au moment approprié (par exemple, lorsque les propriétés informent qu'elles ont été changées).

III - Communications entre le premier plan et l'arrière-plan

Maintenant, nous pouvons créer, accéder et détruire le nœud d'arrière-plan depuis n'importe quel nœud de premier plan. Allons voir comment nous pouvons les faire parler entre eux. Il y a trois étapes pour effectuer cela :

1. Initialisation – lorsque notre nœud d'arrière-plan est créé, nous avons la possibilité de l'initialiser avec les données envoyées depuis le nœud de premier plan ;

2. Premier plan vers arrière-plan – lorsque les propriétés du nœud de premier plan changent, il peut envoyer les nouvelles valeurs au nœud d'arrière-plan pour qu'il se mette à jour ;

3. Arrière-plan vers premier plan – lorsque nos tâches traitent les données stockées dans les nœuds d'arrière-plan, il en résulte parfois une valeur de retour qui devra être envoyée au nœud de premier plan.

Voici la réponse aux deux premières étapes ; quant à la troisième, nous verrons cela dans le prochain article lorsque nous introduirons les tâches.

IV - Initialisation du nœud d'arrière-plan

Toutes les communications entre le premier plan et les objets d'arrière-plan fonctionnent en envoyant des sous-classes de Qt3DCore::QSceneChanges. Leur nature et leur concept sont similaires à QEvent, mais l'arbitre des changements a la possibilité de les manipuler en cas de conflits entre plusieurs aspects, il peut les remettre dans l'ordre selon leur priorité ou effectuer toutes les autres manipulations qui seraient requises plus tard.

Dans le but d'initialiser le nœud d'arrière-plan au moment de la création, nous utilisons la classe Qt3DCore::QSceneChanges afin d'envelopper notre type spécifique de données. Quand Qt 3D veut informer l'arrière-plan de l'état initial du nœud de premier plan, il appelle la fonction privée QNode::createNodeCreationChange(). Cette fonction retourne un nœud contenant chaque information dont nous avons besoin pour accéder à notre nœud d'arrière-plan. Nous devons le faire en copiant les données plutôt qu'en déréférençant un pointeur de l'objet de premier plan, car, au moment où l'arrière-plan traite la demande, on risque une situation de compétition. Pour notre simple composant, son implémentation ressemblera à cela :

fpsmonitor.h

struct FpsMonitorData
{
int rollingMeanFrameCount;
};

fpsmonitor.cpp

Qt3DCore::QNodeCreatedChangeBasePtr FpsMonitor::createNodeCreationChange() const
{
auto creationChange = Qt3DCore::QNodeCreatedChangePtr<FpsMonitorData>::create(this);
auto &data = creationChange->data;
data.rollingMeanFrameCount = m_rollingMeanFrameCount;
return creationChange;
}

Le changement initié par le nœud de premier plan est passé en paramètre au nœud de calcul (par le truchement de l'arbitre des changements) et est traité par la fonction virtuelle initializeFromPeer() :

fpsmonitorbackend.cpp

void FpsMonitorBackend::initializeFromPeer(const Qt3DCore::QNodeCreatedChangeBasePtr &change)
{
const auto typedChange = qSharedPointerCast<Qt3DCore::QNodeCreatedChange<FpsMonitorData>>(change);
const auto &data = typedChange->data;
m_rollingMeanFrameCount = data.rollingMeanFrameCount;
}

V - Communication du premier plan à l'arrière-plan

À ce stade, les nœuds d'arrière-plan renvoient l'état initial du nœud de premier plan. Que se passerait-il si l'utilisateur changeait une propriété du nœud de premier plan ? Les données de notre nœud d'arrière-plan seraient alors périmées.

La bonne nouvelle est que cette situation est facile à gérer. L'implémentation de Qt3DCore::QNode prend soin du moindre problème pour nous. Intérieurement, il écoute les signaux de notification Q_PROPERTY et, quand il voit qu'une propriété a changé, il crée un objet QPropertyUpdatedChange pour nous et l'envoie à l'arbitre des changements qui, à son tour, le délivre à la fonction sceneChangeEvent() du nœud d'arrière-plan.

Ainsi, notre rôle en tant que créateur de ce nœud d'arrière-plan se limite à déclarer cette fonction, d'extraire les données depuis l'objet de changement et de mettre à jour notre situation interne. Souvent, vous voudrez alors définir le nœud d'arrière-plan comme invalide : c'est comme cela que l'aspect sait qu'il doit traiter le prochain rafraîchissement. Cependant, nous allons juste mettre à jour son état pour renvoyer la dernière valeur au premier plan.

fpsmonitorbackend.cpp

void FpsMonitorBackend::sceneChangeEvent(const Qt3DCore::QSceneChangePtr &e)
{
if (e->type() == Qt3DCore::PropertyUpdated) {
const auto change = qSharedPointerCast<Qt3DCore::QPropertyUpdatedChange>(e);
if (change->propertyName() == QByteArrayLiteral("rollingMeanFrameCount")) {
const auto newValue = change->value().toInt();
if (newValue != m_rollingMeanFrameCount) {
m_rollingMeanFrameCount = newValue;
// TODO: Update fps calculations
}
return;
}
}
QBackendNode::sceneChangeEvent(e);
}

Si vous ne voulez pas utiliser l'envoi automatique des changements de propriété intégré de Qt3DCore::QNode, vous pouvez le désactiver en enveloppant la propriété du signal d'émission avec un appel de QNode::blockNotifications(). Cela fonctionne exactement de la même manière que QObject::blockSignals(), mis à part que seuls les blocs de notification sont envoyés au nœud d'arrière-plan, pas le signal lui-même. Cela veut dire que d'autres connexions ou propriétés liées qui comptent sur vos signaux continueront de fonctionner.

Si vous bloquez les notifications par défaut, vous devrez envoyer votre propre notification pour garantir que le nœud d'arrière-plan a mis à jour l'information. Libre à vous d'utiliser les sous-classes dans la hiérarchie de Qt3DCore::QSceneChange et de les ployer à vos besoins. Une méthode commune est d'utiliser la sous-classe Qt3DCore::QStaticPropertyUpdatedChangeBase, qui gère le nom de la propriété et qui, de plus, ajoute un membre fortement typé pour la charge utile de la propriété. L'avantage de surplomber le mécanisme intégré est que cela évite l'usage de QVariant qui, lorsque qu'il y a trop de tâches parallèles, est ralenti - même si, généralement, la propriété d'arrière-plan ne change pas trop souvent.

VI - Résumé

Dans cet article, nous vous avons montré comment implémenter la plupart des nœuds d'arrière-plan ; comment enregistrer le mapper de nœuds avec l'aspect pour créer, consulter et détruire les nœuds d'arrière-plan ; comment initialiser le nœud d'arrière-plan depuis le nœud de premier plan de manière sûre et aussi comment synchroniser ses données avec le premier plan.

Dans le prochain article, nous achèverons notre aspect — il effectuera réellement son travail — et apprendrons comment récupérer les nœuds d'arrière-plan pour envoyer des mises à jour au nœud de premier plan (la valeur moyenne des FPS). Nous nous assurerons que la partie lourde des calculs soit exécutée par le groupe de fils d'exécution de Qt 3D, pour que vous puissiez avoir une idée de son ampleur. À la prochaine fois !

VII - Remerciements

Au nom de toute l'équipe Qt, j'aimerais adresser le plus grand remerciement à KDAB pour nous avoir autorisés à traduire cet article !

Je tiens à remercier Thibaut Cuvelier et Alexandre Laurent pour leurs conseils et relectures.