Oculus SDK Intermédiaire

Maîtriser l'Oculus SDK : Architectures Avancées et Patterns Professionnels

Plongez dans les architectures robustes de l'Oculus SDK en explorant les patterns éprouvés en production et les bonnes pratiques qui font la différence. Un cours mêlant théorie fondamentale et implémentations réelles pour développer des expériences VR performantes et scalables.

Preparetoi.academy 30 min

1. Architecture du Système de Rendu Oculus : Fondations et Optimisations

Définition
L'architecture du système de rendu Oculus est la structure sous-jacente qui gère l'affichage stéréoscopique, la latence minimale et la compensation de mouvement pour créer une expérience VR immersive. Elle comprend les couches de rendu, la gestion des framebuffer et les mécanismes de reprojection.

Explication Détaillée
L'Oculus SDK utilise une architecture en couches où chaque eye (œil) possède son propre framebuffer de rendu. Le système effectue un rendu à 90 FPS minimum pour éviter le motion sickness. La reprojection asynchrone permet au système de corriger les erreurs de suivi en temps réel. Le TimeWarp (déformation temporelle) compense les délais entre le rendu et l'affichage en déformant les images rendues selon les données de suivi de tête actuelles. Cette architecture exige une compréhension profonde de la pile graphique, notamment la gestion des textures stéréo, la profondeur de champ et l'optimisation des shaders pour le rendu double. Les développeurs doivent maîtriser le contexte d'exécution Oculus et les API de bas niveau pour éviter les goulots d'étranglement.

#include <OVR_CAPI.h>
#include <OVR_CAPI_GL.h>
#include <GL/gl.h>

class OculusRenderSystem {
private:
    ovrSession session;
    ovrGraphicsLuid luid;
    GLuint eyeRenderTexture[2];
    GLuint eyeRenderFBO[2];
    ovrTextureSwapChain textureChain[2];
    
public:
    bool InitializeRenderSystem() {
        ovrInitParams initParams = {
            ovrInit_RequestVersion,
            OVR_MINOR_VERSION,
            NULL,
            0,
            0
        };
        
        if (!ovr_Initialize(&initParams)) {
            return false;
        }
        
        ovrSessionStatus sessionStatus;
        if (OVR_FAILURE(ovr_Create(&session, &luid))) {
            return false;
        }
        
        ovrHmdDesc hmdDesc = ovr_GetHmdDesc(session);
        
        for (int eye = 0; eye < 2; eye++) {
            ovrSizei idealSize = ovr_GetFovTextureSize(
                session, 
                (ovrEyeType)eye, 
                hmdDesc.DefaultEyeFov[eye], 
                1.0f
            );
            
            CreateSwapChain(idealSize, eye);
            CreateFramebuffer(idealSize, eye);
        }
        
        return true;
    }
    
    void RenderFrame() {
        double frameTime = ovr_GetTimeInSeconds();
        
        ovrPosef eyePoses[2];
        ovrTrackingState trackingState = ovr_GetTrackingState(
            session, 
            frameTime, 
            ovrTrue
        );
        
        ovr_CalcEyePoses(
            trackingState.HeadPose.ThePose,
            hmdDesc.DefaultEyeFov,
            eyePoses
        );
        
        for (int eye = 0; eye < 2; eye++) {
            RenderToEye(eye, eyePoses[eye]);
        }
        
        ovrLayerEyeFov ld_eyes = {};
        ld_eyes.Header.Type = ovrLayerType_EyeFov;
        ld_eyes.Header.Flags = ovrLayerFlag_TextureOriginAtBottomLeft;
        
        for (int eye = 0; eye < 2; eye++) {
            ld_eyes.ColorTexture[eye] = textureChain[eye];
            ld_eyes.Viewport[eye] = OVR_RECTI(0, 0, idealSize[eye].w, idealSize[eye].h);
            ld_eyes.Fov[eye] = hmdDesc.DefaultEyeFov[eye];
            ld_eyes.RenderPose[eye] = eyePoses[eye];
            ld_eyes.SensorSampleTime = frameTime;
        }
        
        ovrLayerHeader* layers = &ld_eyes.Header;
        ovr_SubmitFrame(session, 0, &layers, 1);
    }
    
private:
    void CreateSwapChain(ovrSizei size, int eye) {
        ovrTextureSwapChainDesc desc = {};
        desc.Type = ovrTexture_2D;
        desc.ArraySize = 1;
        desc.Width = size.w;
        desc.Height = size.h;
        desc.MipLevels = 1;
        desc.Format = OVR_FORMAT_R8G8B8A8_UNORM_SRGB;
        desc.SampleCount = 1;
        desc.StaticImage = ovrFalse;
        
        ovr_CreateTextureSwapChainGL(session, &desc, &textureChain[eye]);
    }
    
    void RenderToEye(int eye, const ovrPosef& eyePose) {
        glBindFramebuffer(GL_FRAMEBUFFER, eyeRenderFBO[eye]);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        
        // Calcul de la matrice de projection
        ovrMatrix4f projection = ovrMatrix4f_Projection(
            hmdDesc.DefaultEyeFov[eye],
            0.01f,
            10000.0f,
            ovrProjection_RightHanded
        );
        
        // Rendu de la scène
        RenderScene(projection, eyePose);
        
        glBindFramebuffer(GL_FRAMEBUFFER, 0);
    }
};
Composant Responsabilité FPS Critique
TimeWarp Compensation de latence 90+ FPS
Eye Tracking Suivi des yeux 90 FPS min
Reprojection Correction des images Asynchrone
Swap Chain Gestion des buffers Double buffering
Layer Submission Soumission des frames Synchrone

Astuce Professionnelle
Implémentez un système de monitoring de FPS avec logs détaillés. Utilisez les GPU markers pour identifier les goulots d'étranglement. Testez systématiquement sur du matériel minimum pour valider la latence cible de 20ms par œil.

Attention Critique
Ne jamais dépasser 90 FPS ni descendre en dessous. La rupture de frame cause le motion sickness immédiat. Les erreurs dans le TimeWarp déstabilisent complètement l'expérience. Toujours valider les données de suivi avant utilisation.


2. Gestion Avancée du Suivi et de l'Input dans l'Oculus SDK

Définition
La gestion du suivi et de l'input dans l'Oculus SDK englobe le suivi 6-DOF (six degrés de liberté) des contrôleurs et de la tête, la détection des gestes, la pose estimation et l'intégration des données de suivi temps réel dans la logique applicative.

Explication Détaillée
L'Oculus SDK intègre des systèmes sophistiqués de tracking utilisant des caméras infrarouges et des capteurs IMU (accéléromètres, gyroscopes) pour fournir une pose précise à chaque frame. Le tracking 6-DOF comprend la position (X, Y, Z) et la rotation (quaternions). Les contrôleurs Oculus transmettent également des données d'entrée analogiques et digitales. Le système maintient un historique de poses pour permettre la prédiction et l'interpolation. Il est crucial de comprendre la latence de suivi inhérente au système et d'implémenter les mécanismes de prédiction appropriés. Les gestes de main (hand tracking) offrent une alternative aux contrôleurs avec reconnaissance d'os et de doigts en temps réel. L'intégration appropriée de ces données crée l'illusion d'immersion totale.

#include <OVR_CAPI_Util.h>

class AdvancedTrackingManager {
private:
    ovrSession session;
    ovrHmdDesc hmdDesc;
    ovrPosef lastHeadPose;
    ovrPosef predictedHeadPose;
    double lastPredictionTime;
    
public:
    struct TrackingData {
        ovrPosef headPose;
        ovrPosef controllerPose[2];
        ovrInputState inputState;
        float predictedLatency;
    };
    
    TrackingData GetTrackingDataWithPrediction(double predictAheadSeconds) {
        TrackingData data = {};
        
        // Obtenir l'état de suivi avec prédiction
        ovrTrackingState trackingState = ovr_GetTrackingState(
            session,
            ovr_GetTimeInSeconds() + predictAheadSeconds,
            ovrTrue
        );
        
        data.headPose = trackingState.HeadPose.ThePose;
        data.predictedLatency = (float)predictAheadSeconds;
        
        // Capturer l'état des contrôleurs
        ovr_GetInputState(session, ovrControllerType_Touch, &data.inputState);
        
        // Extraire les poses des contrôleurs
        ExtractControllerPoses(trackingState, data);
        
        return data;
    }
    
    void HandleControllerInput(const ovrInputState& inputState) {
        // Boutons digitaux
        if (inputState.Buttons & ovrButton_RThumb) {
            HandleThumbstickPress(ovrHand_Right);
        }
        
        if (inputState.Buttons & ovrButton_A) {
            HandleButtonPress('A');
        }
        
        // Triggers analogiques
        float rightTrigger = inputState.IndexTrigger[ovrHand_Right];
        float rightGrip = inputState.HandTrigger[ovrHand_Right];
        
        if (rightTrigger > 0.1f) {
            HandleTriggerPress(ovrHand_Right, rightTrigger);
        }
        
        // Thumbsticks
        ovrVector2f rightThumb = inputState.Thumbstick[ovrHand_Right];
        if (fabs(rightThumb.x) > 0.2f || fabs(rightThumb.y) > 0.2f) {
            HandleThumbstickMovement(ovrHand_Right, rightThumb);
        }
    }
    
    bool InitializeHandTracking() {
        ovrInitParams initParams = {};
        initParams.Flags |= ovrInit_RequestVersion;
        
        ovr_Initialize(&initParams);
        ovr_Create(&session, NULL);
        
        // Déterminer les capacités disponibles
        unsigned int supportedCapabilities = 0;
        if (OVR_SUCCESS(ovr_GetHandTrackingCapabilities(
            session,
            &supportedCapabilities
        ))) {
            if (supportedCapabilities & ovrHandCapabilities_Physics) {
                // Hand tracking avec physique disponible
                return true;
            }
        }
        
        return false;
    }
    
    void ProcessHandTracking() {
        ovrHandPose handPoses[2];
        ovrHandState handStates[2];
        
        double displayTime = ovr_GetTimeInSeconds();
        
        for (int hand = 0; hand < 2; hand++) {
            ovrResult result = ovr_GetHandPose(
                session,
                (ovrHand)hand,
                displayTime,
                &handPoses[hand]
            );
            
            if (OVR_SUCCESS(result)) {
                ProcessSingleHandPose(handPoses[hand], hand);
            }
        }
    }
    
    void ProcessGestures(const ovrHandPose& handPose) {
        // Détection de pincement (thumb + index)
        float thumbIndexDistance = CalculateFingerDistance(
            handPose, 
            ovrHandFinger_Thumb, 
            ovrHandFinger_Index
        );
        
        if (thumbIndexDistance < 0.02f) {
            OnPinchDetected();
        }
        
        // Détection de paume ouverte
        bool isPalmOpen = CalculatePalmOpennessScore(handPose) > 0.8f;
        
        // Détection de poing fermé
        bool isFistClosed = CalculateFistClosednessScore(handPose) > 0.8f;
    }
    
private:
    void ExtractControllerPoses(
        const ovrTrackingState& trackingState, 
        TrackingData& data
    ) {
        for (int hand = 0; hand < 2; hand++) {
            if (trackingState.HandStatusFlags[hand] & ovrStatus_OrientationTracked) {
                data.controllerPose[hand].Orientation = 
                    trackingState.HandPoses[hand].ThePose.Orientation;
            }
            
            if (trackingState.HandStatusFlags[hand] & ovrStatus_PositionTracked) {
                data.controllerPose[hand].Position = 
                    trackingState.HandPoses[hand].ThePose.Position;
            }
        }
    }
    
    float CalculateFingerDistance(
        const ovrHandPose& pose, 
        ovrHandFinger finger1, 
        ovrHandFinger finger2
    ) {
        // Calcul basé sur les données osseuses
        ovrVector3f pos1 = pose.BoneRotations[finger1].Position;
        ovrVector3f pos2 = pose.BoneRotations[finger2].Position;
        
        return sqrt(
            pow(pos1.x - pos2.x, 2) +
            pow(pos1.y - pos2.y, 2) +
            pow(pos1.z - pos2.z, 2)
        );
    }
    
    float CalculatePalmOpennessScore(const ovrHandPose& pose) {
        // Score basé sur l'écartement des doigts
        return 0.0f; // Implémentation simplifiée
    }
    
    float CalculateFistClosednessScore(const ovrHandPose& pose) {
        // Score basé sur la fermeture des doigts
        return 0.0f; // Implémentation simplifiée
    }
};
Type de Suivi Latence Précision Cas d'Usage
Head Tracking 20ms ±1mm Positionnement caméra
Controller 6-DOF 20ms ±5mm Interactions précises
Hand Tracking 30ms ±2cm Gestes naturels
Eye Tracking 15ms ±1° UI intégrative
Gesture Recognition 100ms Contextuel Actions complexes

Astuce Professionnelle
Implémentez un système de dead zones pour les thumbsticks (20% minimum). Utilisez la prédiction de pose pour les interactions critiques. Testez la détection de gestes avec plusieurs utilisateurs pour valider la robustesse.

Attention Critique
La perte de suivi momentaire doit être gérée gracieusement. Ne jamais afficher de discontinuités dans le mouvement. Valider que les quaternions sont normalisés avant chaque utilisation.


3. Performance Optimization et Profiling en VR

Définition
L'optimisation de performance en VR consiste à maintenir un taux de frame constant (90 FPS minimum) tout en maximisant la qualité visuelle. Le profiling VR requiert des outils spécialisés pour mesurer la latence, la consommation GPU/CPU et identifier les goulots d'étranglement critiques.

Explication Détaillée
La VR impose des contraintes de performance extrêmement strictes comparées au gaming traditionnel. Chaque milliseconde de latence additionnelle augmente le risque de cybersickness. Le profiling en VR nécessite de mesurer plusieurs métriques : le CPU frame time, le GPU frame time, la latence de présentation, la latence de mouvement-to-photon et la consommation mémoire. Les outils Oculus incluent le Performance HUD qui affiche en temps réel les FPS, la latence du TimeWarp et les avertissements de performance. L'optimisation requiert une compréhension approfondie du rendering pipeline, du batching de drawcalls, de l'occlusion culling et de la gestion des shaders. Les développeurs doivent implémenter des LOD (Level of Detail) agressifs et des techniques de reprojection asynchrone. La budgétisation GPU est critique : on doit généralement préserver 20-30% de la capacité GPU pour les opérations système.

#include <OVR_CAPI_GL.h>
#include <chrono>

class PerformanceProfiler {
private:
    struct FrameMetrics {
        double cpuFrameTime;
        double gpuFrameTime;
        double totalLatency;
        int drawCalls;
        int trianglesRendered;
        int batchCount;
        timestamp frameStart;
    };
    
    std::vector<FrameMetrics> frameHistory;
    static const int HISTORY_SIZE = 300;
    
public:
    void BeginFrameProfile() {
        FrameMetrics metrics = {};
        metrics.frameStart = std::chrono::high_resolution_clock::now();
        frameHistory.push_back(metrics);
    }
    
    void EndFrameProfile() {
        if (frameHistory.empty()) return;
        
        FrameMetrics& metrics = frameHistory.back();
        auto now = std::chrono::high_resolution_clock::now();
        
        metrics.cpuFrameTime = std::chrono::duration<double, std::milli>(
            now - metrics.frameStart
        ).count();
        
        if (frameHistory.size() > HISTORY_SIZE) {
            frameHistory.erase(frameHistory.begin());
        }
    }
    
    void OptimizeRenderingPipeline(SceneRenderer& renderer) {
        // Frustum culling agressif
        renderer.EnableFrustumCulling(true);
        
        // Occlusion culling pour réduire les overdraw
        renderer.EnableOcclusionCulling(true);
        
        // Batching des objets statiques
        renderer.BatchStaticGeometry();
        
        // Réduction des shaders complexes
        OptimizeShaders(renderer);
        
        // LOD progressive
        renderer.EnableLODSystem(true);
        renderer.SetLODBias(0.5f); // Plus agressif en VR
    }
    
    void ImplementLODSystem(SceneObject& object, const ovrVector3f& cameraPos) {
        float distance = GetDistance(object.position, cameraPos);
        
        // LOD 0: Haute qualité, <= 5m
        if (distance <= 5.0f) {
            object.SetDetailLevel(0);
            object.EnableDetailNormals(true);
            object.SetTriangleCount(65536); // Haute résolution
        }
        // LOD 1: Qualité moyenne, 5-15m
        else if (distance <= 15.0f) {
            object.SetDetailLevel(1);
            object.EnableDetailNormals(false);
            object.SetTriangleCount(16384);
        }
        // LOD 2: Basse qualité, 15-50m
        else if (distance <= 50.0f) {
            object.SetDetailLevel(2);
            object.SetTriangleCount(4096);
        }
        // LOD 3: Très basse qualité, >50m
        else {
            object.SetDetailLevel(3);
            object.SetTriangleCount(1024);
        }
    }
    
    void OptimizeShaders(SceneRenderer& renderer) {
        // Désactiver les features non essentiels
        renderer.DisableFeature(ShaderFeature::AdvancedNormals);
        renderer.DisableFeature(ShaderFeature::ParallaxMapping);
        
        // Utiliser les shaders pré-compilés optimisés
        renderer.UseOptimizedShaderVariants();
        
        // Réduire la complexité des calculs
        renderer.SetMaxLightsPerPixel(2);
        renderer.EnableLightCulling(true);
    }
    
    void MonitorGPUPerformance(ovrSession session) {
        // Utilisation du Performance HUD d'Oculus
        ovr_SetInt(session, "PerfHudMode", (int)ovrPerfHud_LatencyTiming);
        
        // Lecture des metrics GPU via l'API
        unsigned int gpuFrameCount = 0;
        ovr_GetIntArray(session, "GPUFrameCount", &gpuFrameCount, 1);
    }
    
    void ImplementTextureCompression() {
        // Utiliser BC4/BC5 pour les normales
        // Utiliser ASTC pour les textures diffuses
        // Réduire les résolutions de texture en VR
        
        TextureCompressionSettings settings = {};
        settings.format = TextureFormat::BC4;
        settings.maxResolution = 1024; // Au lieu de 2048-4096
        settings.mipLevels = 8;
        
        ApplyTextureCompressionGlobally(settings);
    }
    
    void ImplementDynamicResolution() {
        // Ajustement dynamique de la résolution de rendu
        // basé sur la charge GPU
        
        static float targetGPULoad = 0.85f;
        static ovrSizei currentRenderSize = {1024, 1024};
        
        float currentGPULoad = GetGPULoad();
        
        if (currentGPULoad > 0.95f) {
            // Réduire la résolution
            currentRenderSize.w = (int)(currentRenderSize.w * 0.9f);
            currentRenderSize.h = (int)(currentRenderSize.h * 0.9f);
        } else if (currentGPULoad < 0.75f) {
            // Augmenter la résolution
            currentRenderSize.w = (int)(currentRenderSize.w * 1.05f);
            currentRenderSize.h = (int)(currentRenderSize.h * 1.05f);
        }
    }
    
    void PrintPerformanceReport() {
        if (frameHistory.size() < 60) return;
        
        double avgCPUTime = 0, avgGPUTime = 0;
        double maxCPUTime = 0, maxGPUTime = 0;
        
        for (const auto& metric : frameHistory) {
            avgCPUTime += metric.cpuFrameTime;
            avgGPUTime += metric.gpuFrameTime;
            maxCPUTime = std::max(maxCPUTime, metric.cpuFrameTime);
            maxGPUTime = std::max(maxGPUTime, metric.gpuFrameTime);
        }
        
        avgCPUTime /= frameHistory.size();
        avgGPUTime /= frameHistory.size();
        
        printf("=== Performance Report ===\n");
        printf("Average CPU Frame Time: %.2f ms (target: 11.11 ms @ 90 FPS)\n", avgCPUTime);
        printf("Average GPU Frame Time: %.2f ms\n", avgGPUTime);
        printf("Max CPU Frame Time: %.2f ms\n", maxCPUTime);
        printf("Max GPU Frame Time: %.2f ms\n", maxGPUTime);
        printf("FPS: %.1f\n", 1000.0 / (avgCPUTime + avgGPUTime));
    }
    
private:
    float GetGPULoad() {
        // Implémentation simplifiée
        return 0.0f;
    }
    
    float GetDistance(const ovrVector3f& p1, const ovrVector3f& p2) {
        return sqrt(
            pow(p1.x - p2.x, 2) +
            pow(p1.y - p2.y, 2) +
            pow(p1.z - p2.z, 2)
        );
    }
};
Métrique Seuil Critique Seuil d'Alerte Unité
FPS < 80 < 85 images/sec
CPU Frame Time > 12.5 > 11.5 ms
GPU Frame Time > 12.5 > 11.5 ms
Latence Motion-to-Photon > 50 > 40 ms
Overdraw > 3x > 2x facteur
Drawcalls/frame > 2000 > 1500 appels

Astuce Professionnelle
Créez un budget de performance: allouez 50% au rendu, 30% à la logique, 20% aux systèmes. Utilisez le GPU Performance Counter dans Oculus Profiler pour identifier les shaders bottleneck. Testez sur du matériel mobile (Quest) pour valider la portabilité.

Attention Critique
Ne jamais sacrifier la stabilité FPS pour la qualité visuelle. Une chute FPS de 90 à 45 (reprojection) est perceptible immédiatement. Toujours monitorer la latence motion-to-photon, pas juste le FPS.


4. Implémentation d'Interfaces Utilisateur Immersives en 3D

Définition
Les interfaces utilisateur 3D (UI immersive) en VR transcendent les traditionnels écrans plats en créant des éléments interactifs positionnés dans l'espace 3D. Elles requièrent des patterns spécifiques d'interaction, de positionnement et de feedback pour maintenir l'immersion.

Explication Détaillée
Contrairement aux interfaces 2D écran, les UI VR doivent être positionnées dans l'espace 3D et rester visibles sans causer la fatigue oculaire. Les patterns courants incluent les UI attachées à la tête (head-locked UI), les UI en monde (world-locked UI) et les UI gestuelles. Les éléments doivent répondre aux interactions des contrôleurs avec un feedback haptique et sonore immédiat. La lisibilité est critique : les éléments doivent être dimensionnés correctement pour la vision périférique VR (FOV limité). L'implémentation efficace requiert la compréhension des shaders 2D/3D hybrides, du raycasting 3D pour la détection de collision UI et de l'optimisation du rendu UI. Les patterns éprouvés incluent les menus radiaux, les dashboards flottants et les UI attachées aux contrôleurs.

#include <OVR_CAPI.h>
#include <glm/glm.hpp>

class ImmersiveUISystem {
private:
    struct UIElement {
        glm::vec3 position;
        glm::quat rotation;
        glm::vec2 scale;
        bool isHeadLocked;
        bool isInteractable;
        std::function<void()> onSelect;
        std::function<void()> onHover;
    };
    
    std::vector<UIElement> uiElements;
    ovrSession session;
    
public:
    void CreateHeadLockedMenu() {
        // Menu attaché à la tête, suit le regard
        UIElement menu;
        menu.position = glm::vec3(0, 0, -1.5f); // 1.5m devant
        menu.isHeadLocked = true;
        menu.scale = glm::vec2(0.5f, 0.5f);
        
        // Créer les boutons du menu
        CreateMenuButton("Play", glm::vec3(-0.3f, 0.2f, 0), menu);
        CreateMenuButton("Settings", glm::vec3(0, 0.2f, 0), menu);
        CreateMenuButton("Quit", glm::vec3(0.3f, 0.2f, 0), menu);
    }
    
    void CreateWorldLockedDashboard(glm::vec3 worldPosition) {
        // Dashboard positionné dans le monde, reste en place
        UIElement dashboard;
        dashboard.position = worldPosition;
        dashboard.isHeadLocked = false;
        dashboard.scale = glm::vec2(1.0f, 0.75f);
        
        CreateDashboardElements(dashboard);
        uiElements.push_back(dashboard);
    }
    
    void UpdateUIInteraction(
        const ovrPosef& controllerPose, 
        const ovrInputState& inputState
    ) {
        // Raycasting depuis le contrôleur
        glm::vec3 rayOrigin = PoseToVec3(controllerPose.Position);
        glm::vec3 rayDirection = GetControllerForwardDirection(controllerPose);
        
        // Vérifier les intersections avec tous les éléments UI
        for (auto& element : uiElements) {
            if (!element.isInteractable) continue;
            
            bool isHit = CheckRayAABBIntersection(
                rayOrigin, 
                rayDirection, 
                element.position, 
                element.scale
            );
            
            if (isHit) {
                element.onHover();
                
                // Détection du clic
                if (inputState.Buttons & ovrButton_Trigger) {
                    element.onSelect();
                    PlayHapticFeedback(0.5f);
                }
            }
        }
    }
    
    void RenderUI(const ovrPosef& headPose, const GLuint& screenTexture) {
        glEnable(GL_BLEND);
        glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
        
        for (const auto& element : uiElements) {
            if (element.isHeadLocked) {
                // UI attachée à la tête : position relative à la caméra
                glm::mat4 headMatrix = GetHeadTransformMatrix(headPose);
                glm::vec3 screenPos = glm::vec3(headMatrix * glm::vec4(
                    element.position, 1.0f
                ));
                
                RenderUIQuad(screenPos, element.rotation, element.scale);
            } else {
                // UI positionnée dans le monde
                RenderUIQuad(element.position, element.rotation, element.
Accédez à des centaines d'examens QCM — Découvrir les offres Premium