Evaluation des algorithmes possibles pour la sélection dynamique des niveaux de détails dans une scène sur DPO-Voyager
Contexte
Les scènes Voyager ont déjà la possibilité de stocker 5 niveaux de détails : Thumb, Low, Medium, High, Highest. Dans la pratique, le niveau Highestest utilisé uniquement à des fins de contrôle. Dans le système actuel, le niveau de détail est appliqué à l’échelle de la scène, en fonction des capacités maximales déclarées du contexte webGL (GL_MAX_TEXTURE_SIZE).
Dans la pratique, la valeur maximale choisie étant assez basse, le modèle High est sélectionné dans tous les cas pour un matériel récent.
Additionellement, ce système n’est pas du tout adapté à une scène multi-modèle ou on est rapidement dans l’incapacité de charger le niveau de détail maximal de l’ensemble des modèles, mais pour laquelle on est théoriquement capable de prioritiser les modèles que l’on souhaite mieux définir.
L’algorithme permettant de matérialiser cette prioritisation est le sujet de ce fil.
const camForward = cameraComponent.camera.getWorldDirection(new Vector3());
const yfov= cameraComponent.ins.fov.value * Math.PI / 180;
for(let model of this.getGraphComponents(CVModel2)){
const scale = Math.pow(10, model.ins.localUnits.value);
let {center:position, radius: size} = model.localBoundingBox.getBoundingSphere(new Sphere());
position.multiplyScalar(scale);
size = size*scale;
//Try to approximate how much screen space the model is taking up
//This should also look whether the object is "in screen" or outside of it
const distance = camPos.distanceTo(position);
// Size relative to viewport in 0..1 range
const relSize = size* Math.max(...model.transform.ins.scale.value) / (2*Math.tan(yfov/2)*distance);
//View direction modification
position.sub(camPos);
const camAngle = camForward.angleTo(position);
const inView = camAngle < yfov/2; /** @fixme Ideally check against both FOV angles */
/** @fixme also check if fully or partially in view */
//Arbitrarily set: 10% Low, 25% Medium, 50% High
const current = model.ins.quality.value;
let bestMatchQuality :EDerivativeQuality = current;
if(relSize == 0){
continue;
}else if(relSize < 0.05){
bestMatchQuality = EDerivativeQuality.Thumb;
}else if(relSize < 0.1 && EDerivativeQuality.Low < current){
bestMatchQuality = EDerivativeQuality.Low;
}else if(relSize < 0.15){
bestMatchQuality = EDerivativeQuality.Low;
}else if(relSize < 0.2 && EDerivativeQuality.Medium < current){
bestMatchQuality = EDerivativeQuality.Medium;
}else if(relSize < 0.30){
bestMatchQuality = EDerivativeQuality.Medium;
}else if(relSize < 40){
bestMatchQuality = EDerivativeQuality.High;
}
//Check for cases where we won't update quality
if(bestMatchQuality == current) continue;
else if(current < bestMatchQuality && !inView) continue; //Don't upgrade when not visible, but do downgrade
const bestMatchDerivative = model.derivatives.select(EDerivativeUsage.Web3D, bestMatchQuality);
if(bestMatchDerivative && bestMatchDerivative.data.quality != current ){
console.debug("Set quality for ", model.ins.name.value, " from ", current, " to ", bestMatchDerivative.data.quality);
model.ins.quality.setValue(bestMatchDerivative.data.quality);
}
}
Plusieurs problèmes d’optimisation évidents :
L’utilisation d’une boundingSphere ne prend pas en compte l’orientation du modèle dans le calcul de sa taille.
Les modèles « centraux » devraient être priorisés par rapport aux modèles périphériques, en particulier si ils sont partiellement hors du champ de vision
Le calcul de visibilité est effectué sur le centre du modèle. un modèle partiellement visible pourra être considéré comme « hors du champ »
Le calcul de distance est fait sur le centre du modèle. Un modèle qui a une grande étendue en direction de la camera aura sa taille relative faussée.
Solutions envisagées
1. et 4. peuvent probablement être fixées en faisant une projection 2D en screen-space de la BoundingBox. 3. nécessite probablement l’utilisation d’un Frustum. Pourrait aussi aider pour 2.?
Problèmes à aborder ensuite
Les tests de différentes scènes sur cette « solution naïve » ont fait apparaître le besoin de coupler une sélection du niveau de détail en fonction de la taille à l’écran (démontrée ici) à un mécanisme de limite (Éventuellement basé sur GL_MAX_TEXTURE_SIZE) dans le cas de scènes présentant un grand nombre d’objets.
Dans un premier temps, en abordant les points 1. à 4. mentionnés précédemment, il faudrait établir un premier système de prioritisation permettant de décharger les modèles moins prioritaires en cas de contention.
Permet de raisonner plus facilement sur la taille perçue de l’objet en espace utilisateur.
Ajout de primitives de pondération selon le positionnement de l’objet, en plus de sa taille affichée.
Prototype de quality-downgrade en cas de contention de ressources, peu satisfaisant tant en structure qu’en qualité.
Les tests plus poussés font apparaître le besoin de gérer l’annulation de requêtes en cours pour éviter une saturation du réseau en cas de mouvements rapides et répétés.
Voyager utilisant fetch et intégrant déjà la sérialisation des changements de derivatives, il suffirait probablement d’ajouter un AbortController aux procédures existantes. Possiblement, nécessité de rebaser sur #284 pour éviter d’éditer le sous-module libs/ff-core inutilement
Voir les techniques de sélection des niveaux de détails de 3D tiles. Le concept de « screen space error » pourrait être backporté vers voyager pour un algorithme plus lisible.
On ne peut pas se baser uniquement sur la « taille perçue », aussi fins que soient les calculs: On aura toujours des situations avec beaucoup trop de modèles en « High ».
Un algorithme qui limite la quantité de modèles autorisés à s’upgrade en High/Medium/Low à un maximum (eg: Les 2 plus gros en High, puis 4 plus gros en Medium, etc…) obtiens de bons résultats. Il est théoriquement possible d’ensuite de modifier cette valeur dynamiquement en fonction des performances de rendu.
Implémenter un hystérésis sur la solution ci-dessus n’est pas évident, mais nécessaire pour apporter une certaine stabilité.
Par ailleurs, les optimisations de performance qui rendent cela possible sont en cours de publication :
L’idée est simple : Idéalement on veut charger les meilleurs qualités possibles, mais sans aller au delà de ce que le client peut supporter.
Côté selection de qualité, c’est assez facile de faire un algorithme de régression avec un système de « budget » à ne pas dépasser. Une fois qu’on a déterminé l’importance relative de chaque modèle, il suffit de downgrader un par un en commençant par le moins important.
Problème: déterminer les capacités du client. C’est beaucoup moins simple qu’on ne se l’imagine.
#1: identifier le materiel
Idéalement, on demande « quelle puissance tu as? » au client, il nous répond, on s’adapte.
Mais le navigateur est (à raison!) très parcimonieux dans les informations qu’il nous donne:
navigator.hardwareConcurrency: nombre de coeurs logiques disponibles (imprécis)
navigator.deviceMemory: taille de la RAM, clamp entre0.25et 8
GL_MAX_TEXTURE_SIZE Presque toujours 16k ou 32k.
Rien qui permettre de façon très fiable de déterminer la puissance réellement disponible
Fausse bonne idée : le temps de rendu
On s’appuie sur le temps passé à executer la fonction WebGLRenderer.render, ça nous donne une bonne idée de la complexité de la scène vis à vis de la puissance disponible pour le client.
Problème: les CPU/GPU modernes sont tellement puissants en « burst » que même des téléphones d’entrée de gamme rendent à toute vitesse des scènes clairement beaucoup trop lourdes pour eux.