Unity Sentis: corriendo modelos ONNX en runtime
Sentis es el runtime de ML de Unity. Importas un .onnx, lo corres en CPU o GPU sin servidor, y obtienes inferencia local en milisegundos. Punto de entrada al ML aplicado.
Publicado: · Por Juanjo "Banyo" López
0. Introducción
Tienes un modelo entrenado. Quizá es un clasificador de imágenes para detectar qué tipo de bioma está mirando la cámara, un detector de pose para que el avatar imite al jugador desde una webcam, o una policy de Reinforcement Learning que decide qué hace tu NPC cada frame. El modelo existe, está en formato .onnx, y ahora quieres correrlo dentro de Unity. En la máquina del jugador. Sin un servidor externo. Sin latencia de red. En milisegundos.
Hasta hace poco, la respuesta era Barracuda. Hoy Unity la marcó como deprecated y la reemplazó con Sentis (oficialmente Unity Inference Engine). Sentis lee modelos ONNX directamente desde un asset, los compila para CPU o GPU, y te devuelve la inferencia como un tensor que tú procesas en C#.
No es magia. Es un wrapper sobre operadores de redes neuronales mapeados a compute shaders o a Burst, con una API estable y pensada para shippear. Lo que tú haces es: cargas el modelo, creas un Worker, le pasas un tensor de entrada, y lees el tensor de salida.
En este tutorial vas a ver:
- Qué es ONNX y por qué Sentis lo usa como formato de entrada.
- El pipeline mínimo:
ModelLoader→Worker→Schedule→PeekOutput. - Cuándo Sentis es la herramienta correcta y cuándo necesitas algo distinto (Ollama, una API en la nube).
1. Demo
2. Concepto y arquitectura
Unity Sentis es un runtime de inferencia de redes neuronales que corre modelos en formato ONNX directamente dentro del engine, sin servidor. Soporta CPU y GPU mediante compute shaders. Reemplaza a Barracuda y se enfoca en juegos shippeables: modelos pequeños y medianos, latencia baja, integración nativa con assets de Unity.
2.1 ¿Qué es ONNX y por qué lo usa Sentis?
ONNX es Open Neural Network Exchange, un formato abierto para representar redes neuronales. Lo definió un consorcio liderado por Microsoft y Facebook en 2017 con una idea simple: que el modelo que entrenaste en PyTorch lo puedas correr en TensorFlow, ONNX Runtime, CoreML, o cualquier otro runtime sin reentrenar nada.
Un archivo .onnx contiene dos cosas:
- El grafo de operaciones (capas, activaciones, multiplicaciones de matrices, convoluciones).
- Los pesos ya entrenados de cada capa.
Casi cualquier framework moderno exporta a ONNX: PyTorch con torch.onnx.export, TensorFlow con tf2onnx, scikit-learn con skl2onnx. Sentis lee el .onnx como un asset de Unity y lo compila para el backend que elijas. Tú no escribes el grafo a mano; lo entrenas afuera y lo importas.
2.2 ¿Qué puede correr Sentis y qué no?
Sentis es para modelos pequeños y medianos task-specific. Hablamos de redes feedforward, convolucionales, MLPs, ResNets compactas, detectores de pose, clasificadores de imagen, policies de RL. Cosas que entran en cientos de MB como máximo y que pueden ejecutar inferencia en pocos milisegundos.
Sentis no es para:
- LLMs de 7B o más parámetros. Esos modelos pesan gigabytes, requieren cuantización agresiva y kernels específicos. Usa Ollama, llama.cpp o una API en la nube.
- Operadores muy custom. Algunas redes de investigación usan ops que no están en el set soportado. Si el import falla por una op no soportada, tienes que reescribir esa parte del modelo o esperar a que Unity la agregue.
- Entrenamiento. Sentis solo hace inferencia (forward pass). Entrenar sigue siendo tarea de PyTorch o TensorFlow en GPU dedicada.
2.3 Pipeline básico
El flujo entero, ignorando detalles, es esto:
El asset .onnx pasa por ModelLoader, se ata a un Worker con un backend, y al schedulearlo con un tensor de entrada devuelve un tensor de salida.
Cada paso tiene un equivalente directo en código C#. Vas a ver el snippet completo en la sección 4.
2.4 Tensores: la moneda de cambio
Sentis habla en tensores. Un tensor es un arreglo N-dimensional con una shape y un tipo. Para una imagen de 224x224 RGB la shape típica es (1, 3, 224, 224): un batch de uno, tres canales (R, G, B), 224 de alto, 224 de ancho. Para features planos (vida del NPC, distancia al jugador, ángulo) la shape es (1, F) donde F es el número de features.
En C# trabajas con Tensor<float> o Tensor<int>. Construyes uno pasándole una TensorShape y un array, y lo lees con DownloadToArray() o iterando sus celdas. Si tu input es una Texture2D, Sentis trae utilidades (TextureConverter) para convertirla en tensor sin que tú escribas el loop pixel a pixel.
Una Texture2D RGBA se convierte en un Tensor float de 4 dimensiones: batch, canales, alto, ancho. A la salida, un tensor 2D de logits que se descarga como float[].
2.5 CPU vs GPU backend
Sentis te deja elegir el backend cuando creas el Worker. Los tres principales:
BackendType.CPU. Ejecuta sobre el CPU con Burst. Portable, no necesita compute shaders, suficiente para modelos pequeños o cuando el GPU está saturado renderizando.BackendType.GPUCompute. Compute shaders en la GPU. Típicamente 5x a 20x más rápido que CPU en redes convolucionales. Esta es la opción por defecto si tu modelo lo aprovecha.BackendType.GPUPixel. Fallback en hardware sin compute shaders (algunos móviles viejos). Más lento que GPUCompute, pero corre en casi todo.
Dos pipelines paralelos. La GPU avanza con flujo rápido sobre redes convolucionales; el CPU avanza con flujo lento. La diferencia se acentúa cuando el modelo crece.
La regla rápida: empieza con GPUCompute, perfila con el Profiler, y baja a CPU solo si mides un beneficio claro (por ejemplo, modelos diminutos donde la transferencia GPU domina el tiempo).
2.6 ¿Cuándo usar Sentis vs una API de LLM en la nube?
Son herramientas para problemas distintos:
- Sentis brilla cuando tienes un modelo entrenado específicamente para tu problema. Detector de pose para tu juego de baile, clasificador de bioma para tu generador procedural, policy de RL entrenada con ML-Agents para tu NPC. El modelo es tuyo, vive en el cliente, no paga por inferencia y no manda datos del jugador a ningún servidor.
- API en la nube (OpenAI, Anthropic, Google) brilla cuando necesitas un modelo de propósito general y enorme: texto libre, razonamiento, multimodalidad. Pagas por token, hay latencia de red, y el modelo no es tuyo.
No compiten. Un juego maduro puede usar ambos: Sentis para visión local y NPC behavior, una API en la nube para diálogo abierto del NPC con el jugador.
2.7 Async scheduling: por qué Schedule no bloquea
Si llamas worker.Schedule(input) y al frame siguiente lees PeekOutput(), la GPU tuvo tiempo de hacer el trabajo en paralelo con el render del frame. Sentis no fuerza un stall (bloqueo sincrónico) salvo que lo pidas explícitamente con CompleteAllPendingOperations() o al leer el tensor de forma síncrona.
En la práctica: encolas la inferencia al inicio del frame, sigues con tu lógica de juego, y al final lees el resultado. Si el modelo es lo bastante rápido, ni notas la inferencia. Si es lento, puedes dividir la inferencia por frames: ejecutas una porción del grafo cada frame con ScheduleIterable para amortizarlo.
2.8 ¿Cómo evito leaks de memoria GPU con tensores?
Los tensores son IDisposable. Si creas uno y no lo liberas, se queda en memoria de GPU hasta que el GC lo recoja (puede ser mucho después) o hasta que el juego cierre. Después de unas cuantas inferencias mal liberadas, ves la VRAM crecer hasta que el driver tira un error.
Reglas duras:
- Cada
Tensor<T>que tú construyes va dentro de un bloqueusingo lo liberas con.Dispose()explícito. worker.PeekOutput()devuelve un tensor propiedad del worker. No lo liberes tú; si lo necesitas más allá del próximoSchedule, clónalo conDownloadToArray()oReadbackAndClone().- El
Workertambién esIDisposable. Llama aworker.Dispose()enOnDestroydel MonoBehaviour.
3. Pseudocódigo
function loadModel(path: String) -> Model
return ModelLoader.load(path)
function createWorker(model: Model, backend: BackendType) -> Worker
return new Worker(model, backend)
function infer(worker: Worker, input: Tensor) -> Tensor
worker.schedule(input)
return worker.peekOutput() # ojo: ownership del worker, no liberar
function imageToTensor(img: Image, shape: Shape) -> Tensor
pixels = img.getPixels()
normalized = pixels.map(p => p / 255.0)
return new Tensor(normalized, shape)
function classifyImage(worker: Worker, img: Image) -> ClassResult
input = imageToTensor(img, [1, 3, 224, 224])
output = infer(worker, input)
scores = output.toArray() # clonamos al CPU para usarlo
classIdx = argmax(scores)
input.dispose()
return ClassResult(classIdx, scores[classIdx])
function shutdown(worker: Worker)
worker.dispose()
Tres ideas: el input lo creas tú y lo liberas tú; la salida es del worker y no se libera (se clona si la quieres más allá); el worker se dispone al final de la vida del componente.
4. Implementación en Unity / C#
Un componente mínimo que carga un .onnx desde el inspector, crea un worker en GPU, y expone un método Run que recibe un array de floats y devuelve la salida como array. Pensado para que lo extiendas: visión, clasificación, RL policy, lo que sea.
using System;
using Unity.Sentis;
using UnityEngine;
public class SentisRunner : MonoBehaviour {
[Tooltip("Arrastra aqui tu archivo .onnx importado como ModelAsset.")]
public ModelAsset modelAsset;
[Tooltip("GPUCompute es lo recomendado para inferencia pesada.")]
public BackendType backend = BackendType.GPUCompute;
Model model;
Worker worker;
void Start() {
if (modelAsset == null) {
Debug.LogError("[Sentis] Falta asignar el ModelAsset.");
enabled = false;
return;
}
model = ModelLoader.Load(modelAsset);
worker = new Worker(model, backend);
}
/// <summary>
/// Corre una inferencia. inputData debe coincidir con la shape esperada
/// del modelo (ej: 1*3*224*224 para una imagen ImageNet).
/// </summary>
public float[] Run(float[] inputData, TensorShape shape) {
// Tensor de entrada: lo construimos nosotros, lo disponemos nosotros.
using var input = new Tensor<float>(shape, inputData);
worker.Schedule(input);
// PeekOutput es propiedad del worker: NO lo disponemos.
// Clonamos a CPU para poder usarlo despues de la proxima inferencia.
using var output = worker.PeekOutput() as Tensor<float>;
return output.DownloadToArray();
}
void OnDestroy() {
// Sin esto, leak de VRAM al recargar la escena.
worker?.Dispose();
}
}
Cuarenta líneas. Esto ya corre cualquier modelo ONNX que reciba un vector de floats y devuelva otro. Para visión, lo único que añades es la conversión Texture2D → tensor con TextureConverter.ToTensor.
5. En otros engines
- Godot: ONNX Runtime via GDExtension (proyecto
godot-onnx) o llamadas directas al ONNX Runtime de Microsoft compilando un binding. Más manual que Sentis, pero el formato del modelo es el mismo. - Unreal: tiene NNE (Neural Network Engine) nativo desde UE5.3, con soporte directo de ONNX. API distinta a Sentis (más orientada a interfaces), pero el flujo conceptual es idéntico: carga modelo, crea instancia, ejecuta.
- JavaScript / Web:
onnxruntime-webcorre ONNX en WASM con backend WebGL/WebGPU opcional. Para Unity WebGL builds, considera exportar aonnxruntime-weben lugar de cargar Sentis dentro del WASM de Unity.
La parte que cambia entre engines es la API del runtime. El modelo .onnx, los tensores y la lógica de pre/postprocesado son portables.
6. Quiz
Pon a prueba lo que entendiste
Responde una por una. La explicación aparece al elegir, correcta o no.
Tu Worker no libera memoria entre escenas y la VRAM crece partida tras partida. ¿Qué te falta?
El frame se cae a 15 fps justo cuando llamas Schedule. ¿Qué cambias primero?
Sentis no puede cargar tu .onnx por una op no soportada. ¿Qué chequeas primero?
Quieres correr un LLM de 7B parámetros dentro de Unity con Sentis. ¿Buena idea?