Code/Tensor/TensorBatch.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Sandbox;

namespace HumanoidMocapX.TensorInference;

/// <summary>Records tensor work in an engine graphics context and reads back its result.
/// Create, run and dispose on the main thread. One batch may be in flight per instance.</summary>
public sealed class TensorBatch : IDisposable
{
    readonly Scene scene;
    readonly CameraComponent camera;
    readonly SceneCustomObject renderObject;
    readonly Texture target;
    readonly Action<ReadOnlySpan<float>> receive;
    TaskCompletionSource<bool> completion;
    Action commands;
    GpuBuffer<float> result;
    Memory<float> destination;
    Exception recordingFailure;
    int recorded, readScheduled;
    bool disposed;

    /// <summary>Creates an isolated offscreen context. Throws when graphics is unavailable
    /// or engine resources cannot be created.</summary>
    public TensorBatch()
    {
        ThreadSafe.AssertIsMainThread();
        if (!Graphics.IsAvailable) throw new InvalidOperationException("Tensor inference requires an initialized graphics device.");
        scene = Scene.CreateEditorScene();
        try
        {
            camera = scene.CreateObject().Components.Create<CameraComponent>();
            camera.IsMainCamera = false; camera.EnablePostProcessing = false;
            renderObject = new SceneCustomObject(scene.SceneWorld);
            renderObject.Flags.IsOpaque = false; renderObject.Flags.IsTranslucent = true; renderObject.Flags.CastShadows = false;
            renderObject.RenderOverride = Record;
            // The public GPU readback API needs a render scope; standalone scopes are internal.
            target = Texture.CreateRenderTarget().WithSize(8, 8).Create();
            receive = Receive;
        }
        catch { scene.Destroy(); throw; }
    }

    /// <summary>Runs commands on the render thread, then copies the result prefix into
    /// destination. Keep buffers alive until completion. Cancellation after submission
    /// waits for readback before throwing; recording/readback failures also propagate.</summary>
    public async Task RunAsync(Action commands, GpuBuffer<float> result, Memory<float> destination,
        CancellationToken cancellation = default)
    {
        ThreadSafe.AssertIsMainThread();
        ObjectDisposedException.ThrowIf(disposed, this);
        ArgumentNullException.ThrowIfNull(commands);
        if (completion is not null) throw new InvalidOperationException("Await the current tensor batch before starting another.");
        if (result is null || !result.IsValid || destination.Length < 1 || destination.Length > result.ElementCount)
            throw new ArgumentException("Readback requires a valid buffer and a nonempty destination within its bounds.");
        cancellation.ThrowIfCancellationRequested();
        this.commands = commands; this.result = result; this.destination = destination;
        recordingFailure = null; recorded = 0; readScheduled = 0;
        completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
        Exception failure = null;
        try
        {
            if (!camera.RenderToTexture(target)) throw new InvalidOperationException("The engine could not submit the tensor batch view.");
        }
        catch (Exception error) { failure = error; }
        if (Interlocked.CompareExchange(ref readScheduled, 0, 0) != 0)
        {
            try { await completion.Task; }
            catch (Exception error) { failure ??= error; }
        }
        await GameTask.MainThread();
        completion = null; this.commands = null; this.result = null; this.destination = default;
        if (failure is not null) throw new InvalidOperationException("Tensor batch submission or readback failed.", failure);
        if (recordingFailure is OperationCanceledException && cancellation.IsCancellationRequested) cancellation.ThrowIfCancellationRequested();
        if (recordingFailure is not null) throw new InvalidOperationException("Tensor batch recording failed.", recordingFailure);
        if (Interlocked.CompareExchange(ref recorded, 0, 0) == 0)
            throw new InvalidOperationException("The engine did not execute the tensor batch render callback.");
        cancellation.ThrowIfCancellationRequested();
    }

    void Record(SceneObject unused)
    {
        if (Interlocked.Exchange(ref recorded, 1) != 0) return;
        try { commands(); }
        catch (Exception error) { recordingFailure = error; }
        try
        {
            // Even failed recording must finish queued GPU work before its buffers are disposed.
            result.GetDataAsync(receive, 0, destination.Length);
            Interlocked.Exchange(ref readScheduled, 1);
        }
        catch (Exception error) { recordingFailure ??= error; }
    }

    void Receive(ReadOnlySpan<float> values)
    {
        try { values.CopyTo(destination.Span); completion.TrySetResult(true); }
        catch (Exception error) { completion.TrySetException(error); }
    }

    /// <summary>Releases the isolated scene and target. Rejects disposal while a batch
    /// is in flight; await its completion even after cancellation.</summary>
    public void Dispose()
    {
        ThreadSafe.AssertIsMainThread();
        if (disposed) return;
        if (completion is not null) throw new InvalidOperationException("Await GPU readback before disposing the tensor batch.");
        disposed = true; renderObject.RenderOverride = null;
        scene.Destroy(); target.Dispose();
    }
}