ML Models#
Unity's Inference Engine (the package once called Sentis) runs ONNX models on the CPU or the GPU on every Unity platform. OneJS reaches it the way it reaches any C# API, through imports, so a model runs from your app's TypeScript with no C# of your own.
This page builds a handwritten digit reader on the MNIST model: draw a digit, read the model's answer.
Related: Type Generation, Building (link.xml)
Setup#
- Install the package. In the Package Manager, add
com.unity.ai.inference(2.6 or later) from the Unity Registry. It is licensed under the Unity terms, so each project installs it itself. It weighs about 6 MB in a web build and 43 MB in a Windows player, and it brings Unity's App UI package with it, which logs a warning when the editor starts. - Add the model. Put the
.onnxfile in aResourcesfolder beside your app, for exampleApp/Resources/mnist.onnx. Unity imports it as aModelAsset. - Generate its types. In the JSRunner inspector's Type Generation section, add
Unity.InferenceEngineunder Assemblies. The declarations land intypes/csharp.d.ts, and the import path isUnity/InferenceEngine.
Running the model#
import { Resources, SystemInfo } from "UnityEngine"
import { BackendType, ModelLoader, Tensor, TensorShape, Worker } from "Unity/InferenceEngine"
import type { ModelAsset } from "Unity/InferenceEngine"
import { Single } from "System"
const model = ModelLoader.Load(Resources.Load("mnist") as ModelAsset)
const worker = new Worker(model, BackendType.GPUCompute)
// Tensor<float>: a generic C# type is called with its type arguments.
const TensorFloat = Tensor(Single)
type FloatTensor = InstanceType<typeof TensorFloat>
const input = new TensorFloat(new TensorShape(1, 1, 28, 28), pixels, 0)
worker.Schedule(input)
const output = worker.PeekOutput() as FloatTensorA few things here differ from the C# you would write:
- Generic types are called with their type arguments.
Tensor<float>isTensor(Single), asList<int>isList(Int32)(C# Interop). The declarations type that call from OneJS 3.9.13 and unity-types 6000.5.5, so regenerate the types after updating. PeekOutput()is typed as the plainTensor. The object is aTensor<float>at runtime, and the cast gives you itsDownloadToArray().- A
Float32Arraypasses straight in where the constructor takes afloat[]. - Pass every constructor argument. OneJS 3.9.12 and earlier do not fill in a constructor's default arguments, so the
0fordataStartIndexis required there. Later versions fill them in, as they always have for methods.
Reading the result#
Read the output asynchronously: ask for it, wait a frame at a time until it is ready, then download it.
import { toArray } from "onejs-react"
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
output.ReadbackRequest()
while (!output.IsReadbackRequestDone()) await nextFrame()
const scores = toArray<number>(output.DownloadToArray())
input.Dispose()On a GPU backend the result takes a few frames: about 50 ms with MNIST on WebGPU in Chrome, and 30 ms in a Metal player. On the CPU backend the same loop finds it ready at once, in about 1 ms. DownloadToArray() on its own would also work on most backends, but it blocks until the GPU finishes, and WebGPU has no blocking readback at all. The C# shortcuts do not cross over: ReadbackAndCloneAsync returns an Awaitable, which OneJS does not turn into a Promise, and DownloadAsync<T> is a generic method, which OneJS cannot call.
Choosing a backend#
GPUCompute | GPUPixel | CPU | |
|---|---|---|---|
| Needs | compute shaders | any GPU | nothing |
| WebGPU | yes | wrong results | yes |
| WebGL2 | no compute shaders | yes | yes |
GPUPixel on WebGPU does not fail: it returns plausible scores that are wrong, and the only sign is a console line, "Texture Readback is not supported by WebGPU". Never choose it there. The simplest safe rule:
const backend = SystemInfo.supportsComputeShaders ? BackendType.GPUCompute : BackendType.CPUSmall models are often fastest on the CPU. MNIST takes about 0.06 ms per inference on a desktop CPU against 0.1 to 0.5 ms on its GPU, so measure your own model before assuming the GPU wins.
Warming up#
The first inference on a GPU backend prepares its shaders. Measured with MNIST: 0.15 to 0.9 s on WebGPU, 0.3 s on Windows D3D11, 5 s on Metal (macOS player), 5 to 6 s on D3D12 and Vulkan, and up to 26 s in an editor with a cold shader cache. The CPU backend starts in about 50 ms. Run one inference on a blank input while your app is loading, so the user never waits for it.
Cleaning up#
A Worker and every Tensor hold native memory until disposed. Dispose each input once its inference is read; the worker owns PeekOutput()'s tensor. Create the worker in an effect and dispose it in the effect's cleanup, which also runs on hot reload, so a reload never leaks the old one:
useEffect(() => {
const worker = new Worker(ModelLoader.Load(asset), backend)
return () => worker.Dispose()
}, [])Web builds#
IL2CPP strips C# that only JavaScript calls. At Unity's default stripping level the package survives, but at High the app fails to load with Generic type definition not found: Unity.InferenceEngine.Tensor1. Preserve the package in a link.xml under Assets/ ([Building](/docs/guides/building)); at High it adds about 4 MB to the uncompressed wasm:
mnist.ts` wraps the model. It runs one inference at a time, since each reads the worker's one output, and stops waiting if it is disposed mid-read:<linker>
<assembly fullname="Unity.InferenceEngine" preserve="all" />
</linker>
// mnist.ts
import { SystemInfo } from "UnityEngine"
import { BackendType, ModelLoader, Tensor, TensorShape, Worker } from "Unity/InferenceEngine"
import type { ModelAsset } from "Unity/InferenceEngine"
import { Single } from "System"
import { toArray } from "onejs-react"
// Sentis's Tensor<float>. OneJS binds a generic C# type by calling it with its
// type arguments.
const TensorFloat = Tensor(Single)
type FloatTensor = InstanceType<typeof TensorFloat>
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
/**
* Compute shaders where the GPU has them, else the CPU. Never GPUPixel: on
* WebGPU its readback is unsupported and the scores come back wrong.
*/
export function pickBackend(): BackendType {
return SystemInfo.supportsComputeShaders ? BackendType.GPUCompute : BackendType.CPU
}
/** MNIST: 28 by 28 pixels in, one score per digit out. */
export class DigitClassifier {
readonly backend: BackendType
private worker: Worker
private queue: Promise<unknown> = Promise.resolve()
private disposed = false
constructor(asset: ModelAsset, backend = pickBackend()) {
this.backend = backend
this.worker = new Worker(ModelLoader.Load(asset), backend)
}
/**
* Ten scores for 784 pixels, row by row, 0 for background and 255 for ink.
* Calls run one at a time, since each reads the worker's one output.
*/
classify(pixels: Float32Array): Promise<number[]> {
const run = this.queue.then(() => this.run(pixels))
this.queue = run.catch(() => {})
return run
}
dispose() {
this.disposed = true
this.worker.Dispose()
}
private async run(pixels: Float32Array): Promise<number[]> {
if (this.disposed) throw new Error("classifier disposed")
const input = new TensorFloat(new TensorShape(1, 1, 28, 28), pixels, 0)
try {
this.worker.Schedule(input)
const output = this.worker.PeekOutput() as FloatTensor
// A blocking download stalls the GPU, and WebGPU has none, so ask and wait.
output.ReadbackRequest()
while (!output.IsReadbackRequestDone()) {
await nextFrame()
if (this.disposed) throw new Error("classifier disposed")
}
return toArray<number>(output.DownloadToArray())
} finally {
input.Dispose()
}
}
}
/** Scores to probabilities. */
export function softmax(scores: number[]): number[] {
const max = Math.max(...scores)
const exps = scores.map((s) => Math.exp(s - max))
const sum = exps.reduce((a, b) => a + b, 0)
return exps.map((e) => e / sum)
}The app creates one classifier in an effect, warms it up, and classifies the pad's pixels when a stroke ends:
// index.tsx (the model part)
import { Resources } from "UnityEngine"
import type { ModelAsset } from "Unity/InferenceEngine"
import { DigitClassifier, softmax } from "./mnist"
function App() {
const [classifier, setClassifier] = useState<DigitClassifier | null>(null)
const [probs, setProbs] = useState<number[] | null>(null)
const pixels = useRef(new Float32Array(28 * 28))
useEffect(() => {
const c = new DigitClassifier(Resources.Load("mnist") as ModelAsset)
let live = true
c.classify(new Float32Array(28 * 28)).then(() => { if (live) setClassifier(c) })
return () => {
live = false
c.dispose()
}
}, [])
const predict = async () => {
if (classifier) setProbs(softmax(await classifier.classify(pixels.current)))
}
// The pad: pointer events ink pixels.current with a soft brush, ink 255 on 0,
// repaint it with useDrawing, and call predict() on pointer up.
}MNIST was trained on white digits on black that fill most of the 28 by 28 frame, so a digit drawn small or off center reads worse.