Cliente de Realidad Virtual y Realidad Mixta para la teleoperación inmersiva de vehículos autónomos (UGV).
Aplicación desarrollada en Unity 6 LTS para visores Meta Quest 3, que se comunica bidireccionalmente con un backend en Python (Flask) conectado a ROS 2. Permite al operador controlar el vehículo, ver sus cámaras y monitorizar su estado desde un entorno VR inmersivo.
Desarrollado para el Instituto Universitario de Investigación del Automóvil (INSIA) — Universidad Politécnica de Madrid.
| Característica | Descripción |
|---|---|
| Teleoperación Inmersiva | Control total del vehículo (aceleración, frenado y dirección) utilizando los joysticks de los mandos de Meta Quest. |
| Streaming Multi-Cámara (MJPEG) | Visualización simultánea de hasta 5 flujos de vídeo de baja latencia directamente desde el vehículo, con carga escalonada y reconexión automática. |
| Mapa GPS Nativo (C#) | Mapa de calles renderizado 100% en C# nativo con tiles de CartoDB Voyager, proyección Web Mercator exacta, rastro GPS azul y flecha de orientación del vehículo. Sin dependencias Android externas. |
| Realidad Mixta (Passthrough) | Intercala entre entorno 100% virtual y el modo "Passthrough" para que el piloto no pierda noción de su entorno físico real. |
| Auto-Descubrimiento UDP | Las gafas detectan automáticamente el servidor Flask del vehículo en la red local mediante broadcast UDP en puerto 5555. |
| Interfaz Generada por Código | Toda la escena 3D (paneles, botones, HUD, cámaras) se genera automáticamente desde código C# (SceneBuilder.cs), replicando el diseño CSS de la interfaz web HTML. |
| Arquitectura Desacoplada | Comunicación vía HTTP REST (JSON) y flujos MJPEG. La IP del vehículo se configura externamente, facilitando el cambio sin recompilar. |
┌─────────────────────────────────────────────────────────────────────────────┐
│ META QUEST 3 (Unity 6 / C#) │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Quest │ │ FlaskApiClient│ │ MjpegStream │ │
│ │ Controller │──▶│ (Singleton │ │ Receiver (x5) │ │
│ │ Handler │ │ HTTP Client) │ │ (Hilos separados)│ │
│ └──────────────┘ └───────┬───────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌──────────────┐ ┌───────┴───────┐ ┌────────┴─────────┐ │
│ │ Server │ │ ControlPanel │ │ NativeGpsMap │ │
│ │ Discovery │ │ UI │ │ Screen │ │
│ │ (UDP:5555) │ │ (728 líneas) │ │ (CartoDB + Merc.)│ │
│ └──────┬───────┘ └───────────────┘ └────────┬─────────┘ │
│ │ │ │
└─────────┼─────────────────────────────────────────┼─────────────────────────┘
│ WiFi / Ethernet │
│ │ HTTPS (tiles CartoDB)
┌─────────┼─────────────────────────────────────────┼─────────────────────────┐
│ ▼ ▼ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ UDP Broadcast│ │ Flask Server │ │ /video_feed/{id} │ │
│ │ Puerto 5555 │ │ (web_node.py) │ │ MJPEG Streams │ │
│ └──────────────┘ └───────┬───────┘ └──────────────────┘ │
│ │ │
│ ┌───────┴───────┐ ┌──────────────────┐ │
│ │ ROS 2 │ │ CartoDB Voyager │ │
│ │ (Topics, │ │ (tiles de mapa) │ │
│ │ Services) │ │ basemaps.carto.. │ │
│ └───────┬───────┘ └──────────────────┘ │
│ │ ↑ HTTPS │
│ ┌───────┴───────┐ │ │
│ │ VEHÍCULO │ Las gafas descargan │
│ │ (Motores, │ tiles directamente │
│ │ Cámaras, │ de Internet │
│ │ Sensores) │ │
│ └───────────────┘ │
│ │
│ JETSON / PC CON ROS 2 (Python + Flask) │
└─────────────────────────────────────────────────────────────────────────────┘
| Flujo | Protocolo | Dirección | Descripción |
|---|---|---|---|
| Telemetría | HTTP GET (JSON) | Quest ← Flask | Velocidad, modo, inclinación, waypoints, emergencia |
| Comandos | HTTP POST (JSON) | Quest → Flask | Joystick VR, cambio de modo, emergencia, rutas |
| Vídeo | MJPEG sobre HTTP | Quest ← Flask | Hasta 5 streams simultáneos (/video_feed/{1..5}) |
| Discovery | UDP Broadcast | Quest ← Flask | Auto-detección de IP del servidor (puerto 5555) |
| Mapa GPS | HTTP GET (JSON) | Quest ← Flask | Coordenadas GPS y orientación via /get_full_state, tiles de CartoDB Voyager via HTTPS |
| Componente | Detalle |
|---|---|
| Meta Quest 3 | Visor VR principal. Meta Quest 2 también funciona con limitaciones de rendimiento. |
| Cable USB-C | Para conectar las gafas al PC de desarrollo (depuración ADB + Build & Run). |
| PC de desarrollo | Ubuntu/Linux recomendado. Windows también funciona pero no se ha probado extensamente. |
| Vehículo + Jetson/PC | Ordenador embarcado con ROS 2 ejecutando el servidor Flask y las cámaras. |
| Red WiFi compartida | Las gafas y el vehículo deben estar en la misma red WiFi/Ethernet. |
| Software | Versión | Notas |
|---|---|---|
| Unity Hub | Última versión | Gestor de versiones de Unity |
| Unity 6 LTS | 6000.0.77f1 | ⚠️ Versión exacta obligatoria. Ver sección de problemas |
| Android Build Support | Incluido en Unity | Debe marcarse durante la instalación de Unity |
| ADB | Incluido con Unity | Android Debug Bridge para comunicación con las gafas |
| Python 3 + Flask | 3.8+ | Lado del vehículo (servidor backend) |
| ROS 2 | Humble/Jazzy | Lado del vehículo (middleware robótico) |
⚠️ IMPORTANTE: Sigue estos pasos en orden. Cada paso depende del anterior. Si te saltas un paso, es probable que tengas errores difíciles de diagnosticar.
Durante el desarrollo se descubrió que la versión 6000.5.x (Preview/Beta) de Unity contiene cambios radicales en sus APIs internas que rompen por completo el SDK oficial de Meta Quest. Funciones como GetInstanceID fueron eliminadas o reemplazadas, causando más de 50 errores CS0619 imposibles de parchear. La versión LTS (Long Term Support) 6000.0.77f1 es la última versión estable compatible.
Instala Unity Hub desde unity.com/download si no lo tienes.
Descarga Unity 6000.0.77f1 desde el archivo oficial de Unity:
6000.0.77f1 (es la rama estable 6000.0)Marca los módulos necesarios durante la instalación:
Espera a que termine la instalación (~5-10 GB de descarga).
git clone <URL_DEL_REPOSITORIO>
MetaQuestVehcileControl/.Nota: Todos los paquetes (Meta SDK, TLab WebView, XR Interaction Toolkit, etc.) se instalan automáticamente porque están declarados en
Packages/manifest.json.
Al abrir el proyecto por primera vez en Linux, aparecerán errores rojos en la consola de Unity:
error CS1032: Cannot define/undefine preprocessor symbols after first token in file
¿Por qué ocurre? Es un bug conocido del SDK de Meta. El archivo RuntimeOptimizerPlugin.cs tiene directivas #define / #undef en una posición que el compilador de Mono en Linux no tolera (en Windows no da problemas).
Solución:
Navega en Unity al archivo problemático. La ruta es:
Packages/com.meta.xr.sdk.core/Scripts/RuntimeOptimizer/Core/RuntimeOptimizerPlugin.cs
Abre el archivo y busca las líneas #define o #undef que estén después de la primera línea de código.
Comenta esas líneas con // al principio:
// #define SOME_SYMBOL ← comentar
// #undef SOME_SYMBOL ← comentar
Guarda el archivo y vuelve a Unity. Los errores desaparecerán.
Nota: Este parche no afecta a la funcionalidad. Solo silencia un conflicto de sintaxis específico de la compilación en Linux.
File > Build Settings.Ve a Edit > Project Settings > XR Plug-in Management.
Haz clic en la pestaña Android (icono del robot verde).
Marca la casilla OpenXR.
Expande la sección OpenXR que aparece debajo:
Ve a Project Validation (justo debajo de OpenXR en el menú lateral).
Haz clic en Fix All — esto corrige automáticamente configuraciones como:
Nota: Si algún warning no se arregla con "Fix All", generalmente se puede ignorar (son recomendaciones, no errores bloqueantes).
Para que los textos de la interfaz y los tiles del mapa GPS se vean nítidos en las gafas, es necesario activar la resolución dinámica:
Edit > Project Settings > Quality.¿Por qué es necesario? Sin resolución dinámica, los textos de TextMeshPro y las imágenes de los tiles del mapa se ven borrosos y pixelados en las Meta Quest 3, especialmente a la distancia de lectura de 2.5 metros a la que se colocan los paneles.
El proyecto usa ambos sistemas de input de Unity (el antiguo Input.GetKeyDown para atajos de teclado en PC, y el nuevo InputSystem para XR).
Edit > Project Settings > Player.Si no haces este paso, aparecerá el error:
InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package in Player Settings.
Todos los paquetes ya vienen declarados en Packages/manifest.json y se instalan automáticamente. Puedes verificarlos en Window > Package Manager:
| Paquete | Versión | Fuente | Para qué sirve |
|---|---|---|---|
| Meta XR All-in-One SDK | 203.0.0 | Unity Registry | SDK principal de Meta Quest (OVRManager, OVRInput, Passthrough) |
| XR Interaction Toolkit | 3.0.11 | Unity Registry | Sistema de interacción XR (rays, grabs, XR UI) |
| XR Hands | 1.7.3 | Unity Registry | Tracking de manos (opcional, para futuro uso) |
| OpenXR | 1.16.1 | Unity Registry | Backend de XR multiplataforma |
| Input System | 1.19.0 | Unity Registry | Nuevo sistema de input de Unity |
| TLab WebView | git | GitHub | Navegador web embebido Android-only (legacy, ya no usado para GPS) |
| TLab VKeyboard | git | GitHub | Teclado virtual en VR (dependencia de TLab WebView) |
| Universal Render Pipeline | 17.0.4 | Unity Registry | Pipeline de render moderno |
| TextMesh Pro | built-in | Unity | Texto de alta calidad en la UI |
Si necesitas reinstalar manualmente TLab WebView o VKeyboard:
Window > Package Managerhttps://github.com/TLabAltoh/TLabWebView.githttps://github.com/TLabAltoh/TLabVKeyborad.gitNota: Este paso ya NO es necesario. La pantalla del mapa GPS ahora usa un renderizado 100% nativo en C# (
NativeGpsMapScreen.cs) que descarga tiles de CartoDB Voyager directamente, sin necesidad de TLab WebView ni de copiar prefabs delPackageCache.El paquete TLab WebView sigue declarado en
manifest.jsonpor si se necesita en el futuro para otros usos, pero el mapa GPS funciona sin él.
La interfaz 3D completa se genera desde código, no manualmente. Esto asegura que la escena es siempre reproducible y consistente.
INSIA > Crear Sala de Control (Rediseño Web).[OK] Sala de Control (Calco HTML + Fase 4) creada en: Assets/Scenes/MainControlRoom.unity
El SceneBuilder.cs habrá creado automáticamente:
NativeGpsMapScreen, máscara de recorte Image + Mask, y contenedor para tiles de CartoDB)FlaskApiClient, QuestControllerHandler y ServerDiscoveryMjpegStreamReceiver adjuntos a cada panel de cámaraInputSystemUIInputModule⚠️ Este paso es MANUAL y OBLIGATORIO. Sin él, los mandos no podrán hacer clic en los botones de la interfaz.
Después de que SceneBuilder genere la escena, hay que añadir los componentes de interacción XR:
Meta XR Tools > Building Blocks.Si no encuentras "Building Blocks" en el menú, también puedes buscarlo en
Window > Meta XR > Building Blocks.
Para que el rayo del mando pueda pulsar botones en los canvas 3D:
Main_Web_Interface.XR > Make Canvas Interactable (o utiliza Building Blocks para añadir un "XR Interactable Canvas").[GPS Map Panel].Esto añade automáticamente los componentes necesarios (TrackedDeviceGraphicRaycaster, etc.) para que la UI responda al rayo XR.
Tras añadir todo, la consola NO debe mostrar errores rojos. Si ves warnings amarillos, generalmente son informativos y no bloqueantes.
Por defecto, Linux no permite a usuarios normales acceder a dispositivos USB de desarrollo. Sin esta configuración, tendrás que usar sudo cada vez que conectes las gafas:
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0666", GROUP="plugdev"' | \
sudo tee /etc/udev/rules.d/51-oculus.rules > /dev/null && \
sudo udevadm control --reload-rules && \
sudo udevadm trigger
2833es el Vendor ID de Meta/Oculus en USB.
adb devices
Debe mostrar algo como:
List of devices attached
1WMHH815T10XXX device
Si no aparece: desconecta y reconecta el cable. Si sigue sin funcionar, ejecuta:
adb kill-server && adb devices
Las gafas detectan automáticamente el servidor Flask del vehículo gracias al auto-descubrimiento UDP implementado en ServerDiscovery.cs. No necesitas configurar ninguna IP manualmente.
web_node.py, línea 1674) arranca un hilo que emite un broadcast UDP cada 2 segundos en el puerto 5555 con un paquete JSON:
{"service": "vehicle_control", "vehicle": "<namespace_ROS2>", "ip": "<ip_real_del_servidor>", "port": <puerto_flask>}
Donde:
vehicle = el namespace de ROS 2 del nodo activo (ej: MUTT8x8)ip = la IP real de red del servidor (auto-detectada)port = el puerto del servidor Flask (normalmente 5050)ServerDiscovery.cs) y, al recibir un paquete con "service": "vehicle_control", auto-configuran la IP y el puerto de FlaskApiClient.PlayerPrefs para que, en el próximo arranque, las gafas conecten directamente sin esperar al broadcast.Requisito: Las gafas y el vehículo deben estar en la misma red WiFi. Si están en redes distintas, el broadcast UDP no llegará.
Si por alguna razón el auto-descubrimiento no funciona (redes separadas, firewall bloqueando UDP, etc.), puedes configurar la IP a mano:
[Managers].Flask Api Client.10.170.183.110) y Server Port (por defecto 5050).Build And Run.Nota: Esta IP se hardcodea en la escena, así que si cambias de vehículo/red tendrás que modificarla y recompilar. Por eso el auto-descubrimiento UDP es mucho más práctico.
File > Build Settings.Assets/Scenes/MainControlRoom.unity está en la lista Scenes In Build. Si no está, haz clic en Add Open Scenes..apk. Elige cualquier carpeta.Nota: El archivo
.apkgenerado pesa ~90 MB. El.gitignoredel proyecto ya excluye los.apkdel control de versiones.
Para que las gafas tengan información que mostrar, el servidor Flask debe estar corriendo:
# En tu PC/Jetson con ROS 2 (asegúrate de hacer source al workspace):
source ~/ros2_ws/install/setup.bash
ros2 launch web_interface_pkg_8x8 <tu_launch_file>.launch.py
Si quieres simular cámaras para probar sin hardware real:
python3 src/mock_cameras.py
MetaQuestVehcileControl/
├── Assets/
│ ├── Editor/
│ │ └── SceneBuilder.cs ← Generador automático de la escena 3D
│ ├── Scripts/
│ │ ├── Networking/
│ │ │ ├── FlaskApiClient.cs ← Singleton HTTP (comunicación con Flask)
│ │ │ ├── MjpegStreamReceiver.cs← Decodificador de vídeo MJPEG
│ │ │ └── ServerDiscovery.cs ← Auto-descubrimiento UDP
│ │ ├── Input/
│ │ │ └── QuestControllerHandler.cs ← Mapeo de mandos Quest
│ │ ├── UI/
│ │ │ ├── ControlPanelUI.cs ← Lógica de la interfaz (botones, HUD)
│ │ │ ├── CameraPanelController.cs ← Contenedor de panel de cámara
│ │ │ ├── GpsMapScreen.cs ← Controller legacy para TLab WebView (no activo)
│ │ │ ├── LoadingAnimator.cs ← Animaciones de carga (rotación, pulso)
│ │ │ ├── NativeGpsMapScreen.cs ← Mapa GPS nativo C# (tiles CartoDB + Web Mercator)
│ │ │ ├── NoDragScrollRect.cs ← ScrollRect sin drag/scroll por joystick
│ │ │ └── VRKeyboardFocus.cs ← Teclado nativo Android en VR
│ │ └── XR/
│ │ └── LazyFollowUI.cs ← HUD dinámico "Lazy Follow"
│ ├── Oculus/ ← Configuración de OVR (auto-generado)
│ ├── Plugins/ ← Plugins nativos Android
│ ├── Resources/
│ │ └── TLab/WebView/Browser.prefab ← Prefab del navegador web (legacy, no requerido para GPS)
│ ├── Samples/ ← Samples de XR Interaction Toolkit
│ ├── Scenes/
│ │ └── MainControlRoom.unity ← Escena principal (generada por SceneBuilder)
│ ├── Settings/ ← Configuración de Input Actions
│ ├── StreamingAssets/ ← Vacío (la IP se auto-detecta por UDP)
│ ├── TextMesh Pro/ ← Assets de TextMesh Pro
│ ├── XR/ ← Configuración de XR
│ └── XRI/ ← Configuración de XR Interaction Toolkit
├── Packages/
│ ├── manifest.json ← Lista de TODOS los paquetes (Meta SDK, TLab, etc.)
│ └── com.meta.xr.sdk.core/ ← SDK de Meta (local, para poder parchear CS1032)
├── ProjectSettings/
│ └── ProjectVersion.txt ← Versión de Unity: 6000.0.77f1
└── .gitignore ← Excluye Library/, Builds/, APKs, logs
Assets/Editor/SceneBuilder.cs — Generador de Escena (805 líneas)¿Qué hace? Genera automáticamente toda la jerarquía 3D de la sala de control desde código C#. Se ejecuta desde la barra de menú de Unity: INSIA > Crear Sala de Control (Rediseño Web).
¿Por qué existe? Permite recrear la escena desde cero en cualquier momento, garantizando consistencia. Si se modifica el layout o se añaden elementos, basta con volver a ejecutar el script.
Qué crea:
OVRCameraRig y lo instancia. Si no lo encuentra, crea una cámara básica a 1.6m de altura. Configura OVRManager con Passthrough habilitado y OVRPassthroughLayer oculto por defecto (arranca en modo VR).NativeMapContainer con máscara de recorte (Image + Mask) para que los tiles no se salgan del panel, y NativeGpsMapScreen que gestiona la descarga de tiles, la flecha y el rastroFlaskApiClient, QuestControllerHandler y ServerDiscoveryLazyFollowUI para seguimiento de cabezaInputSystemUIInputModule para compatibilidad con el nuevo Input SystemColores y diseño: Los colores están tomados directamente del CSS de index_new.html de la interfaz web HTML del vehículo (por ejemplo, #0f1117 para el fondo, #248446 para botones verdes, #d34233 para botones rojos). Esto asegura que la interfaz VR tenga el mismo aspecto visual que la web.
Layout: Todos los paneles se posicionan en un plano a DIST = 2.5 metros del usuario (definido en FlatPos(x, y) → Vector3(x, EYE + y, 2.5)). La distribución exacta es:
FlatPos(0, 0)FlatPos(-1.4, 0.1)FlatPos(1.4, 0.1)FlatPos(-0.5, -0.8)FlatPos(0.5, -0.8)FlatPos(0, 0.88)Assets/Scripts/Networking/FlaskApiClient.cs — Cliente HTTP (487 líneas)Singleton central que gestiona TODA la comunicación HTTP con el servidor Flask de ROS 2.
Polling (GET): Cada 0.5 segundos consulta al servidor:
/get_telemetry — Velocidad del vehículo/get_mode_mission_status — Modo de operación (0=StandBy, 1=Auto, 2=Tele, 4=Nav2, 6=VR)/get_land_meter_status — Roll/Pitch del inclinómetro/get_waypoint_info — Información de waypoints/get_route_progress — Porcentaje de ruta completado/get_ui_state — Estado sincronizado de la interfaz (botones, toggles, emergencia)/get_num_cameras — Número de cámaras disponibles (solo al inicio)/get_available_maps — Lista de mapas/rutas grabadas (solo al inicio)Comandos (POST): Envía acciones al vehículo cuando el usuario interactúa:
Gestión de conexión: Detecta pérdida de conexión tras 6 fallos consecutivos y emite eventos OnConnectionLost / OnConnectionRestored que la UI usa para mostrar feedback visual.
Assets/Scripts/Networking/MjpegStreamReceiver.cs — Vídeo MJPEG (352 líneas)Decodificador de streams MJPEG que corre en un hilo secundario para no bloquear el hilo principal de Unity.
Funcionamiento:
FlaskApiClient esté conectado (no intenta streams a una IP incorrecta)/video_feed/{cam_id}FF D8 = inicio, FF D9 = final) separados por boundaries --frame_hasNewFrameTexture2D.LoadImage() y lo aplica al RawImageOptimizaciones críticas:
ServicePointManager.DefaultConnectionLimit = 10: Sin esto, .NET/Mono limita a 2 conexiones simultáneas a la misma IP. Las cámaras 3, 4 y 5 se quedarían atascadas para siempre.(id - 1) * 0.6 segundos antes de conectarse, para no saturar la CPU al cargar las 5 a la vez.MIN_DECODE_INTERVAL = 1/15 para no sobrecargar el procesador del Quest.Assets/Scripts/Networking/ServerDiscovery.cs — Auto-Descubrimiento UDP (134 líneas)Escucha broadcast UDP en el puerto 5555 para detectar automáticamente el servidor Flask en la red.
Funcionamiento:
{"service":"vehicle_control","ip":"192.168.1.50","port":5050,"vehicle":"MUTT_8x8"}, extrae la IP y el puertoUpdate()), actualiza FlaskApiClient.Instance.serverIp y guarda en PlayerPrefs para el próximo arranqueAssets/Scripts/Input/QuestControllerHandler.cs — Controles del Mando (191 líneas)Mapea los controles físicos de los mandos Meta Quest a acciones del vehículo.
Separación importante: Los botones están divididos en dos grupos:
HandleLocalButtons() — Se ejecutan SIEMPRE (no necesitan conexión):
HandleNetworkButtons() — Solo se ejecutan si hay conexión al servidor:
Joystick: Lee OVRInput.RawAxis2D.RThumbstick en las gafas, o Input.GetAxis("Horizontal/Vertical") en PC. Aplica deadzone de 0.15 y envía a 20 Hz. Solo envía si VR Mode está activo y no hay emergencia.
Passthrough: Busca OVRPassthroughLayer en la escena y alterna su visibilidad. Cambia Camera.main.backgroundColor entre transparente (Passthrough ON) y oscuro (Passthrough OFF).
Assets/Scripts/UI/ControlPanelUI.cs — Lógica de UI (728 líneas)Script central que conecta los botones generados por SceneBuilder con FlaskApiClient. Es una réplica exacta del JavaScript de index_new.html de la interfaz web.
Inicialización: Busca todos los Button en la jerarquía por nombre (btn_standby, btn_auto, btn_tele, btn_nav2, btn_follow_person, btn_follow_vehicle, btn_record, btn_stop_rec, btn_play_map, btn_stop_map, btn_lock, btn_stop_emerg, tgl_dynamic_speed, tgl_follow_me, tgl_override_speed) y les asigna listeners.
Sincronización bidireccional: Cuando el usuario pulsa un botón, se envía el comando al backend. Simultáneamente, el polling de FlaskApiClient trae el estado del backend, y UpdateUI() actualiza los colores de los botones, el HUD (velocidad, roll/pitch, emergencia, progreso de ruta) y los toggles.
Mapa GPS: Detecta el [GPS Map Panel] y delega la visualización al NativeGpsMapScreen, que espera a que FlaskApiClient esté conectado antes de descargar los tiles y la flecha.
Assets/Scripts/UI/NativeGpsMapScreen.cs — Mapa GPS Nativo C# (319 líneas)Renderizador 100% C# nativo que reemplaza al antiguo TLab WebView para mostrar el mapa GPS del vehículo. Descarga tiles de mapa de CartoDB Voyager, dibuja el rastro azul del recorrido y muestra la flecha de orientación del vehículo, todo sin dependencias Android externas.
Arquitectura:
RawImage hijo del contenedor del mapa, con un tamaño de 600×600 píxeles UI. Se usa CartoDB Voyager en lugar de OpenStreetMap porque los servidores de OSM bloquean las peticiones desde Unity con error 403 Forbidden.LatLonToMercator()) para convertir coordenadas GPS a píxeles de mapa. Esto garantiza que la posición del vehículo cuadre exactamente con la calle/edificio correcto del tile.arrow.png del servidor Flask que usa la interfaz web. La flecha rota según el heading del vehículo (orientation del JSON de /get_full_state). Si la descarga falla (sin conexión), muestra un fallback verde.RawImage estirados y rotados) conectando todos los puntos del position_history del servidor. Color #3b82f6 (azul de la web), grosor 12px, opacidad 70%. Los segmentos se reciclan para evitar creación continua de GameObjects.Polling:
Cada 0.5 segundos consulta /get_full_state al servidor Flask para obtener:
position_history — Array de coordenadas [lat, lon] del recorridoorientation — Heading del vehículo en gradosSincronización con auto-discovery:
El script espera a que FlaskApiClient.isConnected == true antes de intentar descargar la flecha o los tiles. Esto evita que arranque con una IP incorrecta cuando el auto-descubrimiento UDP aún no ha terminado. Las peticiones HTTP tienen un timeout de 2 segundos para no colgarse si la IP cambia en mitad de la misión.
Máscara de recorte:
El contenedor padre (NativeMapContainer) en SceneBuilder lleva un componente Image + Mask que recorta cualquier tile o segmento de línea que se salga del panel del mapa.
Assets/Scripts/UI/GpsMapScreen.cs — Controller Legacy WebView (105 líneas)⚠️ Script legacy. No se usa activamente. Ha sido reemplazado por
NativeGpsMapScreen.cs.
Controller original que gestionaba la inicialización de TLab WebView para renderizar la página HTML del GPS (/gps_only) dentro de Unity. Se mantiene en el repositorio como referencia y por si en el futuro se necesita un WebView para otros usos.
Funcionamiento (legacy):
TLab.WebView.Browser en el GameObjectFlaskApiClient estuviera conectadohttp://{ip}:{port}/gps_onlyUpdateFrame() para renderizar la web en la textura de UnityMotivo del reemplazo: TLab WebView dependía de GeckoView (motor de Firefox para Android), que causaba inestabilidad y crashes frecuentes en las Meta Quest 3. Además, era completamente incompatible con el Editor de Unity en PC (solo funcionaba en Android), impidiendo las pruebas en el escritorio.
Assets/Scripts/UI/CameraPanelController.cs — Panel de Cámara (49 líneas)Componente simple que almacena el ID de la cámara y la referencia al RawImage donde se muestra el vídeo. Sirve de puente entre el layout visual creado por SceneBuilder y el MjpegStreamReceiver que decodifica el stream.
Assets/Scripts/UI/LoadingAnimator.cs — Animaciones de Carga (52 líneas)Proporciona dos efectos visuales para los overlays de "Esperando conexión..." sin necesidad de GIFs externos:
SceneBuilder lo overridea a -150f)Assets/Scripts/UI/NoDragScrollRect.cs — Anti-Scroll (11 líneas)Problema que resuelve: En VR, el joystick del mando genera eventos de scroll que hacen que el panel de control se desplace involuntariamente cuando el rayo apunta al panel.
Solución: Hereda de ScrollRect y bloquea OnBeginDrag, OnDrag, OnEndDrag y OnScroll. La scrollbar lateral sigue funcionando porque es un componente Scrollbar independiente que mueve la posición por drag directo, sin pasar por estos métodos.
Assets/Scripts/UI/VRKeyboardFocus.cs — Teclado Nativo VR (43 líneas)Al seleccionar un campo de texto (TMP_InputField) con el rayo del mando, abre el teclado nativo de Android (Meta System Keyboard) usando TouchScreenKeyboard.Open(). Sincroniza lo que el usuario escribe de vuelta al campo en cada frame.
Requiere que
requiresSystemKeyboard = trueesté configurado enOVRProjectConfig.asset(lo haceSceneBuilderautomáticamente).
Assets/Scripts/XR/LazyFollowUI.cs — HUD Dinámico (101 líneas)Los paneles de la interfaz siguen al usuario de forma suave y natural:
| Parámetro | Valor | Efecto |
|---|---|---|
maxPositionDifference | 0.15m (15cm) | Zona muerta de traslación — si la cabeza se mueve menos de 15cm, los paneles no se mueven (evita temblor) |
maxAngleDifference | 40° | Zona muerta de rotación — puedes girar hasta 40° sin que los paneles se muevan |
positionLerpSpeed | 6 | Velocidad de interpolación de posición (lerp) |
rotationLerpSpeed | 4 | Velocidad de interpolación de rotación (lerp) |
Nota técnica: El script declara
distanceFromHead = 2.5fpero no se utiliza en el cálculo de posición (ver comentario en línea 86-88 del código). La distancia real entre el usuario y los paneles la defineDIST = 2.5fenSceneBuilder.FlatPos(), que posiciona los paneles hijos a Z=2.5 en la jerarquía.LazyFollowUIsolo mueve el contenedor padre[UI Panels]para igualar la posición X,Y,Z de la cabeza con zona muerta, sin añadir offset adicional.
Comportamiento: Si estás quieto, los paneles no se mueven. Si caminas y te separas más de 15cm de donde estaban, te siguen suavemente. Si giras la cabeza más de 40°, los paneles se arrastran para volver a centrarse frente a ti.
| Control | Acción | Requiere Conexión |
|---|---|---|
| Joystick Derecho (arriba/abajo) | Acelerar / Frenar | ✅ Sí |
| Joystick Derecho (izq./der.) | Girar el volante | ✅ Sí |
| Botón A (mano derecha) | Toggle Passthrough (VR ↔ MR) | ❌ No (local) |
| Botón B (mano derecha) | Toggle Modo VR ON/OFF | ✅ Sí |
| Grip (gatillo inferior) | Toggle Parada de Emergencia | ✅ Sí |
| Trigger (gatillo superior) | Click en botones de la UI | ❌ No (local) |
| Tecla | Acción | Equivale a |
|---|---|---|
| ↑ / ↓ / ← / → (o WASD) | Acelerar, Frenar, Girar | Joystick |
| V | Toggle Modo VR | Botón B |
| P | Toggle Passthrough | Botón A |
| Barra Espaciadora | Toggle Parada de Emergencia | Grip |
Nota: Los controles de teclado usan el sistema antiguo de Input (
Input.GetKeyDown) y requieren que el Input System esté configurado en Both (ver Paso 6).
Todos los endpoints que FlaskApiClient.cs consume del servidor Flask (web_node.py):
| Endpoint | Respuesta | Usado por |
|---|---|---|
/get_telemetry | {"status": {"speed": 12.5, "steering": 0.3}} | HUD de velocidad |
/get_mode_mission_status | {"status": 2} | Botones de modo (StandBy/Auto/Tele/Nav2) |
/get_land_meter_status | {"status": {"x": 1.2, "y": -0.5}} | Medidores Roll/Pitch |
/get_waypoint_info | {"status": "Waypoint 3 de 10"} | Info de waypoints en HUD |
/get_route_progress | {"progress": 45.2} | Porcentaje de ruta |
/get_ui_state | {...estados de toggles, emergencia, modo...} | Sincronización bidireccional de UI |
/get_num_cameras | {"num_cameras": 5} | Configuración inicial |
/get_available_maps | {"map_names": ["ruta1", "ruta2"]} | Dropdown de mapas |
| Endpoint | Body JSON | Acción |
|---|---|---|
/publish_modo_mision | {"mode": 2} | Cambiar modo de misión |
/publish_stop_brakes | {"value": true} | Activar/desactivar freno de emergencia HMI |
/api/control | {"boton":"joystick","x":0.5,"y":0.8} | Enviar joystick VR |
/api/control | {"boton":"vr_mode","estado":true} | Toggle VR Mode |
/api/control | {"boton":"vr_emergency","estado":true} | Emergencia desde VR |
/publish_signal_override | {"value": true} | Override de señal de velocidad |
/publish_followme | {"type": "person"} | Seleccionar Follow Person/Vehicle |
/publish_track_name | {"name":"ruta1","speed_type":true,"speed":10} | Grabar ruta |
/publish_map | {"map_name":"ruta1","mission":1,"mode":-1} | Reproducir mapa |
/api/update_ui_state | {"key":"speedTypeChecked","value":"true"} | Sincronizar estado UI |
| Endpoint | Tipo | Descripción |
|---|---|---|
/video_feed/1 | MJPEG | Stream de cámara principal |
/video_feed/2 a /video_feed/5 | MJPEG | Streams de cámaras secundarias |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 1 | Errores CS0619 masivos (50+ errores) al importar Meta SDK | Unity 6000.5.x (Preview/Beta) rompe APIs internas del SDK | Usar Unity 6000.0.77f1 (LTS). Ver Paso 1 |
| 2 | CS1032: Cannot define/undefine preprocessor symbols | Bug del SDK de Meta en compiladores de Linux | Comentar las líneas #define/#undef en RuntimeOptimizerPlugin.cs. Ver Paso 3 |
| 3 | InvalidOperationException: Input using UnityEngine.Input class | Input System configurado solo en "New" | Cambiar a Both en Project Settings > Player > Active Input Handling. Ver Paso 6 |
| 4 | Package Manager Window Error: auth code | Bug intermitente de Unity Hub en Linux (token de autenticación caducado) | Ignorar. No afecta al proyecto. Dar clic en "Clear" en la consola |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 5 | Panel GPS con fondo gris oscuro sin mapa (en PC o gafas) | Las gafas/PC no tienen conexión a internet, o el servidor Flask no envía GPS | Verificar conexión WiFi con internet. El mapa necesita descargar tiles de basemaps.cartocdn.com |
| 6 | DllNotFoundException: OVRPlugin (errores rojos en Linux) | El SDK de Meta busca DLLs nativas de Windows/Android que no existen en Linux | Ignorar. Todo funcionará correctamente al compilar el APK para las gafas |
| 7 | Solo se ven 2 de 5 cámaras | .NET/Mono limita a 2 conexiones HTTP simultáneas a la misma IP por defecto | MjpegStreamReceiver ya lo soluciona con ServicePointManager.DefaultConnectionLimit = 10 |
| 8 | Joystick hace scroll en el panel de control | El ScrollRect intercepta eventos del eje vertical del joystick XR | NoDragScrollRect bloquea OnScroll(). Ya está implementado |
| 9 | Botón A (Passthrough) no funciona sin conexión al servidor | El guard clause bloqueaba TODOS los botones | Ya solucionado: separación en HandleLocalButtons() y HandleNetworkButtons() |
| 10 | Botón B (VR Mode) no se refleja en la interfaz web | Usaba el endpoint incorrecto (/publish_modo_mision en vez de /api/control) | Ya solucionado: SendVrMode() ahora usa /api/control con "boton":"vr_mode" |
| 11 | Textos borrosos o pixelados en VR | Limitación de los atlas de fuentes estáticos de TextMeshPro | Activar Resolución Dinámica en Project Settings > Quality > Render Pipeline Asset > Dynamic Resolution. También se puede generar un Font Asset SDF con mayor resolución/padding. |
| 12 | Mapa GPS con error 403 Forbidden (tiles no cargan) | OpenStreetMap bloquea peticiones desde Unity (User-Agent no reconocido) | Ya solucionado: se usa CartoDB Voyager (basemaps.cartocdn.com) que permite CORS libre |
| 13 | Flecha del GPS se ve como cuadrado verde | No hay conexión al servidor Flask (la imagen arrow.png no se pudo descargar) | Verificar que el auto-descubrimiento UDP funciona y que FlaskApiClient.isConnected == true |
| 14 | Mapa GPS se sale del panel | Falta la máscara de recorte en el contenedor | Ya solucionado: SceneBuilder añade Image + Mask al NativeMapContainer para recortar |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 15 | ADB no detecta las gafas sin sudo | Falta regla udev para el Vendor ID de Meta (2833) | Crear regla udev. Ver Paso 11 |
| 16 | Meta XR Simulator no funciona en Linux | Solo disponible para Windows/Mac | Usar atajos de teclado (V, P, Espacio) + Scene View del editor |
| 17 | Las gafas no se conectan al servidor Flask | Las gafas y el PC del vehículo no están en la misma red WiFi | Verificar que ambos dispositivos están conectados a la misma red. Comprobar con ping |
La versión 6000.5 es una release Preview/Beta de Unity 6 que contiene cambios drásticos en sus APIs internas. Durante el desarrollo se instaló por error y se descubrió que:
GetInstanceID fueron eliminadas o movidas de namespaceCS0619Consejo: Si al abrir Unity Hub ves versiones como 6000.5.x, 6001.x o similares, NO las instales para este proyecto. Usa siempre la rama 6000.0.x (LTS).
Puedes probar parcialmente el proyecto sin necesidad de las Meta Quest:
WASD / Flechas → Joystick (solo funciona si VR Mode está activo)V → Toggle VR Mode (equivale a Botón B)P → Toggle Passthrough (equivale a Botón A)Espacio → Toggle Emergency Stop (equivale a Grip)Limitaciones en PC:
OVRPlugin en la consola son normales en LinuxBuild And Run (Paso 13).Para comprobar que el servidor Flask funciona, abre un navegador en tu PC y visita:
http://<IP_SERVIDOR>:5050/get_telemetry → Debería devolver JSON con velocidadhttp://<IP_SERVIDOR>:5050/video_feed/1 → Debería mostrar el vídeo MJPEG de la cámara 1http://<IP_SERVIDOR>:5050/gps_only → Debería mostrar la página del mapa GPSEl backend que alimenta a las gafas es un servidor Flask integrado con ROS 2. El código está en los paquetes:
web_interface_pkg — Para vehículos de 3 cámarasweb_interface_pkg_8x8 — Para el vehículo 8x8 de 5 cámaras# 1. Source del workspace de ROS 2
source ~/ros2_ws/install/setup.bash
# 2. Lanzar el nodo Flask (ajusta el launch file según tu vehículo)
ros2 launch web_interface_pkg_8x8 <nombre>.launch.py
El servidor Flask ya incluye código que emite automáticamente un broadcast UDP cada pocos segundos con la información de conexión:
{
"service": "vehicle_control",
"vehicle": "MUTT_8x8",
"ip": "192.168.1.50",
"port": 5050
}
Las gafas escuchan en el puerto UDP 5555 y auto-configuran la IP al recibir este paquete.
Si no tienes cámaras reales conectadas:
python3 src/mock_cameras.py
Esto lanza un servidor MJPEG que genera imágenes de test en los endpoints /video_feed/1 a /video_feed/5.
Si modificas SceneBuilder.cs (por ejemplo, para cambiar el layout, añadir un panel o ajustar colores):
INSIA > Crear Sala de Control (Rediseño Web).ControlPanelUI.cs busca botones por nombre (GameObject.name). Si añades un botón nuevo en SceneBuilder, usa estos IDs:
| ID del Botón | Función |
|---|---|
btn_standby | Modo StandBy (0) |
btn_auto | Modo Autónomo (1) |
btn_tele | Modo Teleoperado (2) |
btn_nav2 | Modo Nav2 (4) |
btn_follow_person | Follow Person (3) |
btn_follow_vehicle | Follow Vehicle (3) |
btn_record | Grabar ruta |
btn_stop_rec | Detener grabación |
btn_play_map | Reproducir mapa |
btn_stop_map | Detener reproducción |
btn_lock | Candado de vídeo |
btn_stop_emerg | Parada de emergencia UI |
btn_map_hand | Toggle interacción libre del mapa |
tgl_dynamic_speed | Toggle velocidad dinámica/estática |
tgl_follow_me | Toggle Follow Me waypoints |
tgl_override_speed | Toggle override de señal |
En SceneBuilder.cs, añade una nueva llamada a BuildSideCam() con un camId de 6 (o el que corresponda):
BuildSideCam(root.transform, "CAM 6", 6, FlatPos(x, y));
El SceneBuilder ya asigna automáticamente un MjpegStreamReceiver con el cameraId correcto a cada panel que tenga CameraPanelController.
En el backend Flask, asegúrate de que existe el endpoint /video_feed/6 para la nueva cámara.
El proyecto usa Universal Render Pipeline (URP) 17.0.4. Si necesitas cambiar materiales o shaders, asegúrate de que sean compatibles con URP (no con Built-in ni HDRP).
El proyecto tiene configurado InputSystem_Actions.inputactions en Assets/ para las acciones de XR Interaction Toolkit. Los controles del vehículo (joystick, botones) se leen directamente via OVRInput (en gafas) o Input.GetKeyDown (en PC), no a través de este archivo de acciones.
Desarrollado para el Instituto Universitario de Investigación del Automóvil (INSIA) — Universidad Politécnica de Madrid.
| Dependencia | Licencia | URL |
|---|---|---|
| Meta XR All-in-One SDK | Meta Platform License | developer.meta.com |
| TLab WebView | MIT License | github.com/TLabAltoh/TLabWebView |
| TLab VKeyboard | MIT License | github.com/TLabAltoh/TLabVKeyborad |
| XR Interaction Toolkit | Unity Companion License | docs.unity3d.com |
| Universal Render Pipeline | Unity Companion License | docs.unity3d.com |
C#
95.7%
ShaderLab
2.0%
Python
1.7%
Cliente de Realidad Virtual y Realidad Mixta para la teleoperación inmersiva de vehículos autónomos (UGV).
Aplicación desarrollada en Unity 6 LTS para visores Meta Quest 3, que se comunica bidireccionalmente con un backend en Python (Flask) conectado a ROS 2. Permite al operador controlar el vehículo, ver sus cámaras y monitorizar su estado desde un entorno VR inmersivo.
Desarrollado para el Instituto Universitario de Investigación del Automóvil (INSIA) — Universidad Politécnica de Madrid.
| Característica | Descripción |
|---|---|
| Teleoperación Inmersiva | Control total del vehículo (aceleración, frenado y dirección) utilizando los joysticks de los mandos de Meta Quest. |
| Streaming Multi-Cámara (MJPEG) | Visualización simultánea de hasta 5 flujos de vídeo de baja latencia directamente desde el vehículo, con carga escalonada y reconexión automática. |
| Mapa GPS Nativo (C#) | Mapa de calles renderizado 100% en C# nativo con tiles de CartoDB Voyager, proyección Web Mercator exacta, rastro GPS azul y flecha de orientación del vehículo. Sin dependencias Android externas. |
| Realidad Mixta (Passthrough) | Intercala entre entorno 100% virtual y el modo "Passthrough" para que el piloto no pierda noción de su entorno físico real. |
| Auto-Descubrimiento UDP | Las gafas detectan automáticamente el servidor Flask del vehículo en la red local mediante broadcast UDP en puerto 5555. |
| Interfaz Generada por Código | Toda la escena 3D (paneles, botones, HUD, cámaras) se genera automáticamente desde código C# (SceneBuilder.cs), replicando el diseño CSS de la interfaz web HTML. |
| Arquitectura Desacoplada | Comunicación vía HTTP REST (JSON) y flujos MJPEG. La IP del vehículo se configura externamente, facilitando el cambio sin recompilar. |
┌─────────────────────────────────────────────────────────────────────────────┐
│ META QUEST 3 (Unity 6 / C#) │
│ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ Quest │ │ FlaskApiClient│ │ MjpegStream │ │
│ │ Controller │──▶│ (Singleton │ │ Receiver (x5) │ │
│ │ Handler │ │ HTTP Client) │ │ (Hilos separados)│ │
│ └──────────────┘ └───────┬───────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌──────────────┐ ┌───────┴───────┐ ┌────────┴─────────┐ │
│ │ Server │ │ ControlPanel │ │ NativeGpsMap │ │
│ │ Discovery │ │ UI │ │ Screen │ │
│ │ (UDP:5555) │ │ (728 líneas) │ │ (CartoDB + Merc.)│ │
│ └──────┬───────┘ └───────────────┘ └────────┬─────────┘ │
│ │ │ │
└─────────┼─────────────────────────────────────────┼─────────────────────────┘
│ WiFi / Ethernet │
│ │ HTTPS (tiles CartoDB)
┌─────────┼─────────────────────────────────────────┼─────────────────────────┐
│ ▼ ▼ │
│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ UDP Broadcast│ │ Flask Server │ │ /video_feed/{id} │ │
│ │ Puerto 5555 │ │ (web_node.py) │ │ MJPEG Streams │ │
│ └──────────────┘ └───────┬───────┘ └──────────────────┘ │
│ │ │
│ ┌───────┴───────┐ ┌──────────────────┐ │
│ │ ROS 2 │ │ CartoDB Voyager │ │
│ │ (Topics, │ │ (tiles de mapa) │ │
│ │ Services) │ │ basemaps.carto.. │ │
│ └───────┬───────┘ └──────────────────┘ │
│ │ ↑ HTTPS │
│ ┌───────┴───────┐ │ │
│ │ VEHÍCULO │ Las gafas descargan │
│ │ (Motores, │ tiles directamente │
│ │ Cámaras, │ de Internet │
│ │ Sensores) │ │
│ └───────────────┘ │
│ │
│ JETSON / PC CON ROS 2 (Python + Flask) │
└─────────────────────────────────────────────────────────────────────────────┘
| Flujo | Protocolo | Dirección | Descripción |
|---|---|---|---|
| Telemetría | HTTP GET (JSON) | Quest ← Flask | Velocidad, modo, inclinación, waypoints, emergencia |
| Comandos | HTTP POST (JSON) | Quest → Flask | Joystick VR, cambio de modo, emergencia, rutas |
| Vídeo | MJPEG sobre HTTP | Quest ← Flask | Hasta 5 streams simultáneos (/video_feed/{1..5}) |
| Discovery | UDP Broadcast | Quest ← Flask | Auto-detección de IP del servidor (puerto 5555) |
| Mapa GPS | HTTP GET (JSON) | Quest ← Flask | Coordenadas GPS y orientación via /get_full_state, tiles de CartoDB Voyager via HTTPS |
| Componente | Detalle |
|---|---|
| Meta Quest 3 | Visor VR principal. Meta Quest 2 también funciona con limitaciones de rendimiento. |
| Cable USB-C | Para conectar las gafas al PC de desarrollo (depuración ADB + Build & Run). |
| PC de desarrollo | Ubuntu/Linux recomendado. Windows también funciona pero no se ha probado extensamente. |
| Vehículo + Jetson/PC | Ordenador embarcado con ROS 2 ejecutando el servidor Flask y las cámaras. |
| Red WiFi compartida | Las gafas y el vehículo deben estar en la misma red WiFi/Ethernet. |
| Software | Versión | Notas |
|---|---|---|
| Unity Hub | Última versión | Gestor de versiones de Unity |
| Unity 6 LTS | 6000.0.77f1 | ⚠️ Versión exacta obligatoria. Ver sección de problemas |
| Android Build Support | Incluido en Unity | Debe marcarse durante la instalación de Unity |
| ADB | Incluido con Unity | Android Debug Bridge para comunicación con las gafas |
| Python 3 + Flask | 3.8+ | Lado del vehículo (servidor backend) |
| ROS 2 | Humble/Jazzy | Lado del vehículo (middleware robótico) |
⚠️ IMPORTANTE: Sigue estos pasos en orden. Cada paso depende del anterior. Si te saltas un paso, es probable que tengas errores difíciles de diagnosticar.
Durante el desarrollo se descubrió que la versión 6000.5.x (Preview/Beta) de Unity contiene cambios radicales en sus APIs internas que rompen por completo el SDK oficial de Meta Quest. Funciones como GetInstanceID fueron eliminadas o reemplazadas, causando más de 50 errores CS0619 imposibles de parchear. La versión LTS (Long Term Support) 6000.0.77f1 es la última versión estable compatible.
Instala Unity Hub desde unity.com/download si no lo tienes.
Descarga Unity 6000.0.77f1 desde el archivo oficial de Unity:
6000.0.77f1 (es la rama estable 6000.0)Marca los módulos necesarios durante la instalación:
Espera a que termine la instalación (~5-10 GB de descarga).
git clone <URL_DEL_REPOSITORIO>
MetaQuestVehcileControl/.Nota: Todos los paquetes (Meta SDK, TLab WebView, XR Interaction Toolkit, etc.) se instalan automáticamente porque están declarados en
Packages/manifest.json.
Al abrir el proyecto por primera vez en Linux, aparecerán errores rojos en la consola de Unity:
error CS1032: Cannot define/undefine preprocessor symbols after first token in file
¿Por qué ocurre? Es un bug conocido del SDK de Meta. El archivo RuntimeOptimizerPlugin.cs tiene directivas #define / #undef en una posición que el compilador de Mono en Linux no tolera (en Windows no da problemas).
Solución:
Navega en Unity al archivo problemático. La ruta es:
Packages/com.meta.xr.sdk.core/Scripts/RuntimeOptimizer/Core/RuntimeOptimizerPlugin.cs
Abre el archivo y busca las líneas #define o #undef que estén después de la primera línea de código.
Comenta esas líneas con // al principio:
// #define SOME_SYMBOL ← comentar
// #undef SOME_SYMBOL ← comentar
Guarda el archivo y vuelve a Unity. Los errores desaparecerán.
Nota: Este parche no afecta a la funcionalidad. Solo silencia un conflicto de sintaxis específico de la compilación en Linux.
File > Build Settings.Ve a Edit > Project Settings > XR Plug-in Management.
Haz clic en la pestaña Android (icono del robot verde).
Marca la casilla OpenXR.
Expande la sección OpenXR que aparece debajo:
Ve a Project Validation (justo debajo de OpenXR en el menú lateral).
Haz clic en Fix All — esto corrige automáticamente configuraciones como:
Nota: Si algún warning no se arregla con "Fix All", generalmente se puede ignorar (son recomendaciones, no errores bloqueantes).
Para que los textos de la interfaz y los tiles del mapa GPS se vean nítidos en las gafas, es necesario activar la resolución dinámica:
Edit > Project Settings > Quality.¿Por qué es necesario? Sin resolución dinámica, los textos de TextMeshPro y las imágenes de los tiles del mapa se ven borrosos y pixelados en las Meta Quest 3, especialmente a la distancia de lectura de 2.5 metros a la que se colocan los paneles.
El proyecto usa ambos sistemas de input de Unity (el antiguo Input.GetKeyDown para atajos de teclado en PC, y el nuevo InputSystem para XR).
Edit > Project Settings > Player.Si no haces este paso, aparecerá el error:
InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package in Player Settings.
Todos los paquetes ya vienen declarados en Packages/manifest.json y se instalan automáticamente. Puedes verificarlos en Window > Package Manager:
| Paquete | Versión | Fuente | Para qué sirve |
|---|---|---|---|
| Meta XR All-in-One SDK | 203.0.0 | Unity Registry | SDK principal de Meta Quest (OVRManager, OVRInput, Passthrough) |
| XR Interaction Toolkit | 3.0.11 | Unity Registry | Sistema de interacción XR (rays, grabs, XR UI) |
| XR Hands | 1.7.3 | Unity Registry | Tracking de manos (opcional, para futuro uso) |
| OpenXR | 1.16.1 | Unity Registry | Backend de XR multiplataforma |
| Input System | 1.19.0 | Unity Registry | Nuevo sistema de input de Unity |
| TLab WebView | git | GitHub | Navegador web embebido Android-only (legacy, ya no usado para GPS) |
| TLab VKeyboard | git | GitHub | Teclado virtual en VR (dependencia de TLab WebView) |
| Universal Render Pipeline | 17.0.4 | Unity Registry | Pipeline de render moderno |
| TextMesh Pro | built-in | Unity | Texto de alta calidad en la UI |
Si necesitas reinstalar manualmente TLab WebView o VKeyboard:
Window > Package Managerhttps://github.com/TLabAltoh/TLabWebView.githttps://github.com/TLabAltoh/TLabVKeyborad.gitNota: Este paso ya NO es necesario. La pantalla del mapa GPS ahora usa un renderizado 100% nativo en C# (
NativeGpsMapScreen.cs) que descarga tiles de CartoDB Voyager directamente, sin necesidad de TLab WebView ni de copiar prefabs delPackageCache.El paquete TLab WebView sigue declarado en
manifest.jsonpor si se necesita en el futuro para otros usos, pero el mapa GPS funciona sin él.
La interfaz 3D completa se genera desde código, no manualmente. Esto asegura que la escena es siempre reproducible y consistente.
INSIA > Crear Sala de Control (Rediseño Web).[OK] Sala de Control (Calco HTML + Fase 4) creada en: Assets/Scenes/MainControlRoom.unity
El SceneBuilder.cs habrá creado automáticamente:
NativeGpsMapScreen, máscara de recorte Image + Mask, y contenedor para tiles de CartoDB)FlaskApiClient, QuestControllerHandler y ServerDiscoveryMjpegStreamReceiver adjuntos a cada panel de cámaraInputSystemUIInputModule⚠️ Este paso es MANUAL y OBLIGATORIO. Sin él, los mandos no podrán hacer clic en los botones de la interfaz.
Después de que SceneBuilder genere la escena, hay que añadir los componentes de interacción XR:
Meta XR Tools > Building Blocks.Si no encuentras "Building Blocks" en el menú, también puedes buscarlo en
Window > Meta XR > Building Blocks.
Para que el rayo del mando pueda pulsar botones en los canvas 3D:
Main_Web_Interface.XR > Make Canvas Interactable (o utiliza Building Blocks para añadir un "XR Interactable Canvas").[GPS Map Panel].Esto añade automáticamente los componentes necesarios (TrackedDeviceGraphicRaycaster, etc.) para que la UI responda al rayo XR.
Tras añadir todo, la consola NO debe mostrar errores rojos. Si ves warnings amarillos, generalmente son informativos y no bloqueantes.
Por defecto, Linux no permite a usuarios normales acceder a dispositivos USB de desarrollo. Sin esta configuración, tendrás que usar sudo cada vez que conectes las gafas:
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0666", GROUP="plugdev"' | \
sudo tee /etc/udev/rules.d/51-oculus.rules > /dev/null && \
sudo udevadm control --reload-rules && \
sudo udevadm trigger
2833es el Vendor ID de Meta/Oculus en USB.
adb devices
Debe mostrar algo como:
List of devices attached
1WMHH815T10XXX device
Si no aparece: desconecta y reconecta el cable. Si sigue sin funcionar, ejecuta:
adb kill-server && adb devices
Las gafas detectan automáticamente el servidor Flask del vehículo gracias al auto-descubrimiento UDP implementado en ServerDiscovery.cs. No necesitas configurar ninguna IP manualmente.
web_node.py, línea 1674) arranca un hilo que emite un broadcast UDP cada 2 segundos en el puerto 5555 con un paquete JSON:
{"service": "vehicle_control", "vehicle": "<namespace_ROS2>", "ip": "<ip_real_del_servidor>", "port": <puerto_flask>}
Donde:
vehicle = el namespace de ROS 2 del nodo activo (ej: MUTT8x8)ip = la IP real de red del servidor (auto-detectada)port = el puerto del servidor Flask (normalmente 5050)ServerDiscovery.cs) y, al recibir un paquete con "service": "vehicle_control", auto-configuran la IP y el puerto de FlaskApiClient.PlayerPrefs para que, en el próximo arranque, las gafas conecten directamente sin esperar al broadcast.Requisito: Las gafas y el vehículo deben estar en la misma red WiFi. Si están en redes distintas, el broadcast UDP no llegará.
Si por alguna razón el auto-descubrimiento no funciona (redes separadas, firewall bloqueando UDP, etc.), puedes configurar la IP a mano:
[Managers].Flask Api Client.10.170.183.110) y Server Port (por defecto 5050).Build And Run.Nota: Esta IP se hardcodea en la escena, así que si cambias de vehículo/red tendrás que modificarla y recompilar. Por eso el auto-descubrimiento UDP es mucho más práctico.
File > Build Settings.Assets/Scenes/MainControlRoom.unity está en la lista Scenes In Build. Si no está, haz clic en Add Open Scenes..apk. Elige cualquier carpeta.Nota: El archivo
.apkgenerado pesa ~90 MB. El.gitignoredel proyecto ya excluye los.apkdel control de versiones.
Para que las gafas tengan información que mostrar, el servidor Flask debe estar corriendo:
# En tu PC/Jetson con ROS 2 (asegúrate de hacer source al workspace):
source ~/ros2_ws/install/setup.bash
ros2 launch web_interface_pkg_8x8 <tu_launch_file>.launch.py
Si quieres simular cámaras para probar sin hardware real:
python3 src/mock_cameras.py
MetaQuestVehcileControl/
├── Assets/
│ ├── Editor/
│ │ └── SceneBuilder.cs ← Generador automático de la escena 3D
│ ├── Scripts/
│ │ ├── Networking/
│ │ │ ├── FlaskApiClient.cs ← Singleton HTTP (comunicación con Flask)
│ │ │ ├── MjpegStreamReceiver.cs← Decodificador de vídeo MJPEG
│ │ │ └── ServerDiscovery.cs ← Auto-descubrimiento UDP
│ │ ├── Input/
│ │ │ └── QuestControllerHandler.cs ← Mapeo de mandos Quest
│ │ ├── UI/
│ │ │ ├── ControlPanelUI.cs ← Lógica de la interfaz (botones, HUD)
│ │ │ ├── CameraPanelController.cs ← Contenedor de panel de cámara
│ │ │ ├── GpsMapScreen.cs ← Controller legacy para TLab WebView (no activo)
│ │ │ ├── LoadingAnimator.cs ← Animaciones de carga (rotación, pulso)
│ │ │ ├── NativeGpsMapScreen.cs ← Mapa GPS nativo C# (tiles CartoDB + Web Mercator)
│ │ │ ├── NoDragScrollRect.cs ← ScrollRect sin drag/scroll por joystick
│ │ │ └── VRKeyboardFocus.cs ← Teclado nativo Android en VR
│ │ └── XR/
│ │ └── LazyFollowUI.cs ← HUD dinámico "Lazy Follow"
│ ├── Oculus/ ← Configuración de OVR (auto-generado)
│ ├── Plugins/ ← Plugins nativos Android
│ ├── Resources/
│ │ └── TLab/WebView/Browser.prefab ← Prefab del navegador web (legacy, no requerido para GPS)
│ ├── Samples/ ← Samples de XR Interaction Toolkit
│ ├── Scenes/
│ │ └── MainControlRoom.unity ← Escena principal (generada por SceneBuilder)
│ ├── Settings/ ← Configuración de Input Actions
│ ├── StreamingAssets/ ← Vacío (la IP se auto-detecta por UDP)
│ ├── TextMesh Pro/ ← Assets de TextMesh Pro
│ ├── XR/ ← Configuración de XR
│ └── XRI/ ← Configuración de XR Interaction Toolkit
├── Packages/
│ ├── manifest.json ← Lista de TODOS los paquetes (Meta SDK, TLab, etc.)
│ └── com.meta.xr.sdk.core/ ← SDK de Meta (local, para poder parchear CS1032)
├── ProjectSettings/
│ └── ProjectVersion.txt ← Versión de Unity: 6000.0.77f1
└── .gitignore ← Excluye Library/, Builds/, APKs, logs
Assets/Editor/SceneBuilder.cs — Generador de Escena (805 líneas)¿Qué hace? Genera automáticamente toda la jerarquía 3D de la sala de control desde código C#. Se ejecuta desde la barra de menú de Unity: INSIA > Crear Sala de Control (Rediseño Web).
¿Por qué existe? Permite recrear la escena desde cero en cualquier momento, garantizando consistencia. Si se modifica el layout o se añaden elementos, basta con volver a ejecutar el script.
Qué crea:
OVRCameraRig y lo instancia. Si no lo encuentra, crea una cámara básica a 1.6m de altura. Configura OVRManager con Passthrough habilitado y OVRPassthroughLayer oculto por defecto (arranca en modo VR).NativeMapContainer con máscara de recorte (Image + Mask) para que los tiles no se salgan del panel, y NativeGpsMapScreen que gestiona la descarga de tiles, la flecha y el rastroFlaskApiClient, QuestControllerHandler y ServerDiscoveryLazyFollowUI para seguimiento de cabezaInputSystemUIInputModule para compatibilidad con el nuevo Input SystemColores y diseño: Los colores están tomados directamente del CSS de index_new.html de la interfaz web HTML del vehículo (por ejemplo, #0f1117 para el fondo, #248446 para botones verdes, #d34233 para botones rojos). Esto asegura que la interfaz VR tenga el mismo aspecto visual que la web.
Layout: Todos los paneles se posicionan en un plano a DIST = 2.5 metros del usuario (definido en FlatPos(x, y) → Vector3(x, EYE + y, 2.5)). La distribución exacta es:
FlatPos(0, 0)FlatPos(-1.4, 0.1)FlatPos(1.4, 0.1)FlatPos(-0.5, -0.8)FlatPos(0.5, -0.8)FlatPos(0, 0.88)Assets/Scripts/Networking/FlaskApiClient.cs — Cliente HTTP (487 líneas)Singleton central que gestiona TODA la comunicación HTTP con el servidor Flask de ROS 2.
Polling (GET): Cada 0.5 segundos consulta al servidor:
/get_telemetry — Velocidad del vehículo/get_mode_mission_status — Modo de operación (0=StandBy, 1=Auto, 2=Tele, 4=Nav2, 6=VR)/get_land_meter_status — Roll/Pitch del inclinómetro/get_waypoint_info — Información de waypoints/get_route_progress — Porcentaje de ruta completado/get_ui_state — Estado sincronizado de la interfaz (botones, toggles, emergencia)/get_num_cameras — Número de cámaras disponibles (solo al inicio)/get_available_maps — Lista de mapas/rutas grabadas (solo al inicio)Comandos (POST): Envía acciones al vehículo cuando el usuario interactúa:
Gestión de conexión: Detecta pérdida de conexión tras 6 fallos consecutivos y emite eventos OnConnectionLost / OnConnectionRestored que la UI usa para mostrar feedback visual.
Assets/Scripts/Networking/MjpegStreamReceiver.cs — Vídeo MJPEG (352 líneas)Decodificador de streams MJPEG que corre en un hilo secundario para no bloquear el hilo principal de Unity.
Funcionamiento:
FlaskApiClient esté conectado (no intenta streams a una IP incorrecta)/video_feed/{cam_id}FF D8 = inicio, FF D9 = final) separados por boundaries --frame_hasNewFrameTexture2D.LoadImage() y lo aplica al RawImageOptimizaciones críticas:
ServicePointManager.DefaultConnectionLimit = 10: Sin esto, .NET/Mono limita a 2 conexiones simultáneas a la misma IP. Las cámaras 3, 4 y 5 se quedarían atascadas para siempre.(id - 1) * 0.6 segundos antes de conectarse, para no saturar la CPU al cargar las 5 a la vez.MIN_DECODE_INTERVAL = 1/15 para no sobrecargar el procesador del Quest.Assets/Scripts/Networking/ServerDiscovery.cs — Auto-Descubrimiento UDP (134 líneas)Escucha broadcast UDP en el puerto 5555 para detectar automáticamente el servidor Flask en la red.
Funcionamiento:
{"service":"vehicle_control","ip":"192.168.1.50","port":5050,"vehicle":"MUTT_8x8"}, extrae la IP y el puertoUpdate()), actualiza FlaskApiClient.Instance.serverIp y guarda en PlayerPrefs para el próximo arranqueAssets/Scripts/Input/QuestControllerHandler.cs — Controles del Mando (191 líneas)Mapea los controles físicos de los mandos Meta Quest a acciones del vehículo.
Separación importante: Los botones están divididos en dos grupos:
HandleLocalButtons() — Se ejecutan SIEMPRE (no necesitan conexión):
HandleNetworkButtons() — Solo se ejecutan si hay conexión al servidor:
Joystick: Lee OVRInput.RawAxis2D.RThumbstick en las gafas, o Input.GetAxis("Horizontal/Vertical") en PC. Aplica deadzone de 0.15 y envía a 20 Hz. Solo envía si VR Mode está activo y no hay emergencia.
Passthrough: Busca OVRPassthroughLayer en la escena y alterna su visibilidad. Cambia Camera.main.backgroundColor entre transparente (Passthrough ON) y oscuro (Passthrough OFF).
Assets/Scripts/UI/ControlPanelUI.cs — Lógica de UI (728 líneas)Script central que conecta los botones generados por SceneBuilder con FlaskApiClient. Es una réplica exacta del JavaScript de index_new.html de la interfaz web.
Inicialización: Busca todos los Button en la jerarquía por nombre (btn_standby, btn_auto, btn_tele, btn_nav2, btn_follow_person, btn_follow_vehicle, btn_record, btn_stop_rec, btn_play_map, btn_stop_map, btn_lock, btn_stop_emerg, tgl_dynamic_speed, tgl_follow_me, tgl_override_speed) y les asigna listeners.
Sincronización bidireccional: Cuando el usuario pulsa un botón, se envía el comando al backend. Simultáneamente, el polling de FlaskApiClient trae el estado del backend, y UpdateUI() actualiza los colores de los botones, el HUD (velocidad, roll/pitch, emergencia, progreso de ruta) y los toggles.
Mapa GPS: Detecta el [GPS Map Panel] y delega la visualización al NativeGpsMapScreen, que espera a que FlaskApiClient esté conectado antes de descargar los tiles y la flecha.
Assets/Scripts/UI/NativeGpsMapScreen.cs — Mapa GPS Nativo C# (319 líneas)Renderizador 100% C# nativo que reemplaza al antiguo TLab WebView para mostrar el mapa GPS del vehículo. Descarga tiles de mapa de CartoDB Voyager, dibuja el rastro azul del recorrido y muestra la flecha de orientación del vehículo, todo sin dependencias Android externas.
Arquitectura:
RawImage hijo del contenedor del mapa, con un tamaño de 600×600 píxeles UI. Se usa CartoDB Voyager en lugar de OpenStreetMap porque los servidores de OSM bloquean las peticiones desde Unity con error 403 Forbidden.LatLonToMercator()) para convertir coordenadas GPS a píxeles de mapa. Esto garantiza que la posición del vehículo cuadre exactamente con la calle/edificio correcto del tile.arrow.png del servidor Flask que usa la interfaz web. La flecha rota según el heading del vehículo (orientation del JSON de /get_full_state). Si la descarga falla (sin conexión), muestra un fallback verde.RawImage estirados y rotados) conectando todos los puntos del position_history del servidor. Color #3b82f6 (azul de la web), grosor 12px, opacidad 70%. Los segmentos se reciclan para evitar creación continua de GameObjects.Polling:
Cada 0.5 segundos consulta /get_full_state al servidor Flask para obtener:
position_history — Array de coordenadas [lat, lon] del recorridoorientation — Heading del vehículo en gradosSincronización con auto-discovery:
El script espera a que FlaskApiClient.isConnected == true antes de intentar descargar la flecha o los tiles. Esto evita que arranque con una IP incorrecta cuando el auto-descubrimiento UDP aún no ha terminado. Las peticiones HTTP tienen un timeout de 2 segundos para no colgarse si la IP cambia en mitad de la misión.
Máscara de recorte:
El contenedor padre (NativeMapContainer) en SceneBuilder lleva un componente Image + Mask que recorta cualquier tile o segmento de línea que se salga del panel del mapa.
Assets/Scripts/UI/GpsMapScreen.cs — Controller Legacy WebView (105 líneas)⚠️ Script legacy. No se usa activamente. Ha sido reemplazado por
NativeGpsMapScreen.cs.
Controller original que gestionaba la inicialización de TLab WebView para renderizar la página HTML del GPS (/gps_only) dentro de Unity. Se mantiene en el repositorio como referencia y por si en el futuro se necesita un WebView para otros usos.
Funcionamiento (legacy):
TLab.WebView.Browser en el GameObjectFlaskApiClient estuviera conectadohttp://{ip}:{port}/gps_onlyUpdateFrame() para renderizar la web en la textura de UnityMotivo del reemplazo: TLab WebView dependía de GeckoView (motor de Firefox para Android), que causaba inestabilidad y crashes frecuentes en las Meta Quest 3. Además, era completamente incompatible con el Editor de Unity en PC (solo funcionaba en Android), impidiendo las pruebas en el escritorio.
Assets/Scripts/UI/CameraPanelController.cs — Panel de Cámara (49 líneas)Componente simple que almacena el ID de la cámara y la referencia al RawImage donde se muestra el vídeo. Sirve de puente entre el layout visual creado por SceneBuilder y el MjpegStreamReceiver que decodifica el stream.
Assets/Scripts/UI/LoadingAnimator.cs — Animaciones de Carga (52 líneas)Proporciona dos efectos visuales para los overlays de "Esperando conexión..." sin necesidad de GIFs externos:
SceneBuilder lo overridea a -150f)Assets/Scripts/UI/NoDragScrollRect.cs — Anti-Scroll (11 líneas)Problema que resuelve: En VR, el joystick del mando genera eventos de scroll que hacen que el panel de control se desplace involuntariamente cuando el rayo apunta al panel.
Solución: Hereda de ScrollRect y bloquea OnBeginDrag, OnDrag, OnEndDrag y OnScroll. La scrollbar lateral sigue funcionando porque es un componente Scrollbar independiente que mueve la posición por drag directo, sin pasar por estos métodos.
Assets/Scripts/UI/VRKeyboardFocus.cs — Teclado Nativo VR (43 líneas)Al seleccionar un campo de texto (TMP_InputField) con el rayo del mando, abre el teclado nativo de Android (Meta System Keyboard) usando TouchScreenKeyboard.Open(). Sincroniza lo que el usuario escribe de vuelta al campo en cada frame.
Requiere que
requiresSystemKeyboard = trueesté configurado enOVRProjectConfig.asset(lo haceSceneBuilderautomáticamente).
Assets/Scripts/XR/LazyFollowUI.cs — HUD Dinámico (101 líneas)Los paneles de la interfaz siguen al usuario de forma suave y natural:
| Parámetro | Valor | Efecto |
|---|---|---|
maxPositionDifference | 0.15m (15cm) | Zona muerta de traslación — si la cabeza se mueve menos de 15cm, los paneles no se mueven (evita temblor) |
maxAngleDifference | 40° | Zona muerta de rotación — puedes girar hasta 40° sin que los paneles se muevan |
positionLerpSpeed | 6 | Velocidad de interpolación de posición (lerp) |
rotationLerpSpeed | 4 | Velocidad de interpolación de rotación (lerp) |
Nota técnica: El script declara
distanceFromHead = 2.5fpero no se utiliza en el cálculo de posición (ver comentario en línea 86-88 del código). La distancia real entre el usuario y los paneles la defineDIST = 2.5fenSceneBuilder.FlatPos(), que posiciona los paneles hijos a Z=2.5 en la jerarquía.LazyFollowUIsolo mueve el contenedor padre[UI Panels]para igualar la posición X,Y,Z de la cabeza con zona muerta, sin añadir offset adicional.
Comportamiento: Si estás quieto, los paneles no se mueven. Si caminas y te separas más de 15cm de donde estaban, te siguen suavemente. Si giras la cabeza más de 40°, los paneles se arrastran para volver a centrarse frente a ti.
| Control | Acción | Requiere Conexión |
|---|---|---|
| Joystick Derecho (arriba/abajo) | Acelerar / Frenar | ✅ Sí |
| Joystick Derecho (izq./der.) | Girar el volante | ✅ Sí |
| Botón A (mano derecha) | Toggle Passthrough (VR ↔ MR) | ❌ No (local) |
| Botón B (mano derecha) | Toggle Modo VR ON/OFF | ✅ Sí |
| Grip (gatillo inferior) | Toggle Parada de Emergencia | ✅ Sí |
| Trigger (gatillo superior) | Click en botones de la UI | ❌ No (local) |
| Tecla | Acción | Equivale a |
|---|---|---|
| ↑ / ↓ / ← / → (o WASD) | Acelerar, Frenar, Girar | Joystick |
| V | Toggle Modo VR | Botón B |
| P | Toggle Passthrough | Botón A |
| Barra Espaciadora | Toggle Parada de Emergencia | Grip |
Nota: Los controles de teclado usan el sistema antiguo de Input (
Input.GetKeyDown) y requieren que el Input System esté configurado en Both (ver Paso 6).
Todos los endpoints que FlaskApiClient.cs consume del servidor Flask (web_node.py):
| Endpoint | Respuesta | Usado por |
|---|---|---|
/get_telemetry | {"status": {"speed": 12.5, "steering": 0.3}} | HUD de velocidad |
/get_mode_mission_status | {"status": 2} | Botones de modo (StandBy/Auto/Tele/Nav2) |
/get_land_meter_status | {"status": {"x": 1.2, "y": -0.5}} | Medidores Roll/Pitch |
/get_waypoint_info | {"status": "Waypoint 3 de 10"} | Info de waypoints en HUD |
/get_route_progress | {"progress": 45.2} | Porcentaje de ruta |
/get_ui_state | {...estados de toggles, emergencia, modo...} | Sincronización bidireccional de UI |
/get_num_cameras | {"num_cameras": 5} | Configuración inicial |
/get_available_maps | {"map_names": ["ruta1", "ruta2"]} | Dropdown de mapas |
| Endpoint | Body JSON | Acción |
|---|---|---|
/publish_modo_mision | {"mode": 2} | Cambiar modo de misión |
/publish_stop_brakes | {"value": true} | Activar/desactivar freno de emergencia HMI |
/api/control | {"boton":"joystick","x":0.5,"y":0.8} | Enviar joystick VR |
/api/control | {"boton":"vr_mode","estado":true} | Toggle VR Mode |
/api/control | {"boton":"vr_emergency","estado":true} | Emergencia desde VR |
/publish_signal_override | {"value": true} | Override de señal de velocidad |
/publish_followme | {"type": "person"} | Seleccionar Follow Person/Vehicle |
/publish_track_name | {"name":"ruta1","speed_type":true,"speed":10} | Grabar ruta |
/publish_map | {"map_name":"ruta1","mission":1,"mode":-1} | Reproducir mapa |
/api/update_ui_state | {"key":"speedTypeChecked","value":"true"} | Sincronizar estado UI |
| Endpoint | Tipo | Descripción |
|---|---|---|
/video_feed/1 | MJPEG | Stream de cámara principal |
/video_feed/2 a /video_feed/5 | MJPEG | Streams de cámaras secundarias |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 1 | Errores CS0619 masivos (50+ errores) al importar Meta SDK | Unity 6000.5.x (Preview/Beta) rompe APIs internas del SDK | Usar Unity 6000.0.77f1 (LTS). Ver Paso 1 |
| 2 | CS1032: Cannot define/undefine preprocessor symbols | Bug del SDK de Meta en compiladores de Linux | Comentar las líneas #define/#undef en RuntimeOptimizerPlugin.cs. Ver Paso 3 |
| 3 | InvalidOperationException: Input using UnityEngine.Input class | Input System configurado solo en "New" | Cambiar a Both en Project Settings > Player > Active Input Handling. Ver Paso 6 |
| 4 | Package Manager Window Error: auth code | Bug intermitente de Unity Hub en Linux (token de autenticación caducado) | Ignorar. No afecta al proyecto. Dar clic en "Clear" en la consola |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 5 | Panel GPS con fondo gris oscuro sin mapa (en PC o gafas) | Las gafas/PC no tienen conexión a internet, o el servidor Flask no envía GPS | Verificar conexión WiFi con internet. El mapa necesita descargar tiles de basemaps.cartocdn.com |
| 6 | DllNotFoundException: OVRPlugin (errores rojos en Linux) | El SDK de Meta busca DLLs nativas de Windows/Android que no existen en Linux | Ignorar. Todo funcionará correctamente al compilar el APK para las gafas |
| 7 | Solo se ven 2 de 5 cámaras | .NET/Mono limita a 2 conexiones HTTP simultáneas a la misma IP por defecto | MjpegStreamReceiver ya lo soluciona con ServicePointManager.DefaultConnectionLimit = 10 |
| 8 | Joystick hace scroll en el panel de control | El ScrollRect intercepta eventos del eje vertical del joystick XR | NoDragScrollRect bloquea OnScroll(). Ya está implementado |
| 9 | Botón A (Passthrough) no funciona sin conexión al servidor | El guard clause bloqueaba TODOS los botones | Ya solucionado: separación en HandleLocalButtons() y HandleNetworkButtons() |
| 10 | Botón B (VR Mode) no se refleja en la interfaz web | Usaba el endpoint incorrecto (/publish_modo_mision en vez de /api/control) | Ya solucionado: SendVrMode() ahora usa /api/control con "boton":"vr_mode" |
| 11 | Textos borrosos o pixelados en VR | Limitación de los atlas de fuentes estáticos de TextMeshPro | Activar Resolución Dinámica en Project Settings > Quality > Render Pipeline Asset > Dynamic Resolution. También se puede generar un Font Asset SDF con mayor resolución/padding. |
| 12 | Mapa GPS con error 403 Forbidden (tiles no cargan) | OpenStreetMap bloquea peticiones desde Unity (User-Agent no reconocido) | Ya solucionado: se usa CartoDB Voyager (basemaps.cartocdn.com) que permite CORS libre |
| 13 | Flecha del GPS se ve como cuadrado verde | No hay conexión al servidor Flask (la imagen arrow.png no se pudo descargar) | Verificar que el auto-descubrimiento UDP funciona y que FlaskApiClient.isConnected == true |
| 14 | Mapa GPS se sale del panel | Falta la máscara de recorte en el contenedor | Ya solucionado: SceneBuilder añade Image + Mask al NativeMapContainer para recortar |
| # | Problema | Causa | Solución |
|---|---|---|---|
| 15 | ADB no detecta las gafas sin sudo | Falta regla udev para el Vendor ID de Meta (2833) | Crear regla udev. Ver Paso 11 |
| 16 | Meta XR Simulator no funciona en Linux | Solo disponible para Windows/Mac | Usar atajos de teclado (V, P, Espacio) + Scene View del editor |
| 17 | Las gafas no se conectan al servidor Flask | Las gafas y el PC del vehículo no están en la misma red WiFi | Verificar que ambos dispositivos están conectados a la misma red. Comprobar con ping |
La versión 6000.5 es una release Preview/Beta de Unity 6 que contiene cambios drásticos en sus APIs internas. Durante el desarrollo se instaló por error y se descubrió que:
GetInstanceID fueron eliminadas o movidas de namespaceCS0619Consejo: Si al abrir Unity Hub ves versiones como 6000.5.x, 6001.x o similares, NO las instales para este proyecto. Usa siempre la rama 6000.0.x (LTS).
Puedes probar parcialmente el proyecto sin necesidad de las Meta Quest:
WASD / Flechas → Joystick (solo funciona si VR Mode está activo)V → Toggle VR Mode (equivale a Botón B)P → Toggle Passthrough (equivale a Botón A)Espacio → Toggle Emergency Stop (equivale a Grip)Limitaciones en PC:
OVRPlugin en la consola son normales en LinuxBuild And Run (Paso 13).Para comprobar que el servidor Flask funciona, abre un navegador en tu PC y visita:
http://<IP_SERVIDOR>:5050/get_telemetry → Debería devolver JSON con velocidadhttp://<IP_SERVIDOR>:5050/video_feed/1 → Debería mostrar el vídeo MJPEG de la cámara 1http://<IP_SERVIDOR>:5050/gps_only → Debería mostrar la página del mapa GPSEl backend que alimenta a las gafas es un servidor Flask integrado con ROS 2. El código está en los paquetes:
web_interface_pkg — Para vehículos de 3 cámarasweb_interface_pkg_8x8 — Para el vehículo 8x8 de 5 cámaras# 1. Source del workspace de ROS 2
source ~/ros2_ws/install/setup.bash
# 2. Lanzar el nodo Flask (ajusta el launch file según tu vehículo)
ros2 launch web_interface_pkg_8x8 <nombre>.launch.py
El servidor Flask ya incluye código que emite automáticamente un broadcast UDP cada pocos segundos con la información de conexión:
{
"service": "vehicle_control",
"vehicle": "MUTT_8x8",
"ip": "192.168.1.50",
"port": 5050
}
Las gafas escuchan en el puerto UDP 5555 y auto-configuran la IP al recibir este paquete.
Si no tienes cámaras reales conectadas:
python3 src/mock_cameras.py
Esto lanza un servidor MJPEG que genera imágenes de test en los endpoints /video_feed/1 a /video_feed/5.
Si modificas SceneBuilder.cs (por ejemplo, para cambiar el layout, añadir un panel o ajustar colores):
INSIA > Crear Sala de Control (Rediseño Web).ControlPanelUI.cs busca botones por nombre (GameObject.name). Si añades un botón nuevo en SceneBuilder, usa estos IDs:
| ID del Botón | Función |
|---|---|
btn_standby | Modo StandBy (0) |
btn_auto | Modo Autónomo (1) |
btn_tele | Modo Teleoperado (2) |
btn_nav2 | Modo Nav2 (4) |
btn_follow_person | Follow Person (3) |
btn_follow_vehicle | Follow Vehicle (3) |
btn_record | Grabar ruta |
btn_stop_rec | Detener grabación |
btn_play_map | Reproducir mapa |
btn_stop_map | Detener reproducción |
btn_lock | Candado de vídeo |
btn_stop_emerg | Parada de emergencia UI |
btn_map_hand | Toggle interacción libre del mapa |
tgl_dynamic_speed | Toggle velocidad dinámica/estática |
tgl_follow_me | Toggle Follow Me waypoints |
tgl_override_speed | Toggle override de señal |
En SceneBuilder.cs, añade una nueva llamada a BuildSideCam() con un camId de 6 (o el que corresponda):
BuildSideCam(root.transform, "CAM 6", 6, FlatPos(x, y));
El SceneBuilder ya asigna automáticamente un MjpegStreamReceiver con el cameraId correcto a cada panel que tenga CameraPanelController.
En el backend Flask, asegúrate de que existe el endpoint /video_feed/6 para la nueva cámara.
El proyecto usa Universal Render Pipeline (URP) 17.0.4. Si necesitas cambiar materiales o shaders, asegúrate de que sean compatibles con URP (no con Built-in ni HDRP).
El proyecto tiene configurado InputSystem_Actions.inputactions en Assets/ para las acciones de XR Interaction Toolkit. Los controles del vehículo (joystick, botones) se leen directamente via OVRInput (en gafas) o Input.GetKeyDown (en PC), no a través de este archivo de acciones.
Desarrollado para el Instituto Universitario de Investigación del Automóvil (INSIA) — Universidad Politécnica de Madrid.
| Dependencia | Licencia | URL |
|---|---|---|
| Meta XR All-in-One SDK | Meta Platform License | developer.meta.com |
| TLab WebView | MIT License | github.com/TLabAltoh/TLabWebView |
| TLab VKeyboard | MIT License | github.com/TLabAltoh/TLabVKeyborad |
| XR Interaction Toolkit | Unity Companion License | docs.unity3d.com |
| Universal Render Pipeline | Unity Companion License | docs.unity3d.com |
C#
95.7%
ShaderLab
2.0%
Python
1.7%