XR & Spatial Computing / VR Bartending

VR Bartending Simulator

A full VR bartending training sim in Unity 6, built around real liquid physics and a live quality-evaluation loop.

01/03Liquid-pouring physics and procedural drink mixing

A commercial-grade VR training simulation where the core challenge is making liquid behave believably — pouring, mixing, overflowing, and blending color across multiple simultaneous sources — then grading the result against real service metrics.

Engineering highlights

Hover to preview · click to pin

PourController.cs

// ---------- Pouring (angle-driven) ----------
void PourUpdate(float dt)
{
    if (!CanPour)
        return;

    pouredThisFrame = 0f;
    if (dt <= 0f) 
    { 
        return; 
    }

    // --- 1. Tilt & threshold shared by all containers ---
    float fillRatio = Mathf.Clamp01(currentLiquid / Mathf.Max(1e-6f, maxLiquid));
    Transform t = bottle ? bottle : transform;

    currentTiltAngle = Vector3.Angle(t.up, Vector3.up); // 0 upright .. 180 upside down
    currentPourAngleThreshold = Mathf.Lerp(minPourAngle, basePourAngle, fillRatio);

    float overTilt01 = Mathf.InverseLerp(currentPourAngleThreshold, 180f, currentTiltAngle);
    overTilt01 = Mathf.Clamp01(overTilt01);

    float targetFlow01;
    float flowPerSec = 0f;

    // --- 2. Different behavior for bottles vs open containers ---
    if (containerType == ContainerType.Bottle)
    {
        // Bottles: near-constant flow once you're clearly past the threshold.
        // We use a gentle power curve so small tilts don't instantly pour full speed.
        targetFlow01 = Mathf.Pow(overTilt01, bottleAngleSharpness);

        if (targetFlow01 <= 0.001f)
        {
            // Snappy shut: once we’re back below the pour angle, kill the flow immediately
            currentFlow01 = 0f;
            _flowVel = 0f;
        }
        else
        {
            // Still in the pouring region: use smoothing so it ramps nicely
            currentFlow01 = Mathf.SmoothDamp(currentFlow01, targetFlow01, ref _flowVel, flowSmoothing);
        }

        flowPerSec = bottleFlowPerSecond * currentFlow01;
    }
    else // OpenContainer (cups, wine glasses, shaker inner cup)
    {
        // Open containers: tilt strongly controls how fast you dump.
        targetFlow01 = Mathf.Pow(overTilt01, openAngleSharpness);
        currentFlow01 = Mathf.SmoothDamp(currentFlow01, targetFlow01, ref _flowVel, flowSmoothing);

        if (currentFlow01 > 0.001f && currentLiquid > 0f)
        {
            // When FULL and FULLY tilted, we want to empty roughly in fullDrainTimeAtMaxTilt seconds.
            // Rate ~ (maxLiquid / time) scaled by:
            //  - angleFactor (how much past threshold)
            //  - volumeFactor (more full = slightly faster)
            float baseDrainRate = maxLiquid / Mathf.Max(0.01f, fullDrainTimeAtMaxTilt);

            float angleFactor = Mathf.Lerp(minOpenFlowFactor, 1f, currentFlow01);
            float volumeFactor = Mathf.Lerp(0.5f, 1f, fillRatio); // optional; makes very low fills a bit slower

            flowPerSec = baseDrainRate * angleFactor * volumeFactor;
        }
        else
        {
            flowPerSec = 0f;
        }
    }

    // --- 3. Apply viscosity (shared) ---
    flowPerSec /= Mathf.Max(0.01f, viscosity);

    // --- 4. Actually remove liquid this frame ---
    float request = Mathf.Max(0f, flowPerSec * dt);

    if (currentLiquid > 0f && request > 0f)
    {
        float outUnits = Mathf.Min(request, currentLiquid);
        if (outUnits > 0f)
        {
            RemoveAmount(outUnits);
            pouredThisFrame = outUnits;
        }
    }

    isPouring = pouredThisFrame > 0f;

    // --- 5. Particles / FX / self-replenish logic stays as-is ---
    if (pourParticleSystem)
    {
        var emission = pourParticleSystem.emission;
        emission.enabled = isPouring;
    }

    if (currentLiquid <= 1e-6f)
    {
        if (selfReplenishing && _originalMix != null && _originalMix.Count > 0)
        {
            ResetToOriginalMix();
        }
        else
        {
            if (!empty)
            {
                empty = true;
                OnEmptied?.Invoke();
            }
            currentLiquid = 0f;
        }
    }
    else empty = false;
}

Demo

Why liquid is the hard part

Most VR training sims can fake their props. A bartending sim cannot fake liquid — it is the thing the user is manipulating, judging, and being judged on. If the pour doesn’t read as believable, the whole simulation stops teaching.

So the liquid system carries the project. It runs as a multi-layer pipeline: a Shader Graph material driven by fill level, screen-space effects for the surface, and a color-blending model that composites hue, saturation, value, and opacity from every source currently contributing to the glass. Overflow is handled explicitly rather than clamped, because overflowing is a mistake the trainee needs to see and feel.

Grading the pour, not just simulating it

Underneath the visuals is a quality model. Every drink resolves to a score built from accuracy, timing, and service quality, and that score drives the tipping system — so the feedback loop is the same one a real bar runs on.

Built to be re-skinned

The recipe data lives in scriptable objects that can be edited and serialized at runtime. A venue can load its own menu into the simulation without touching the project, which is what makes this deployable as a training product rather than a tech demo.