# Bottles

> Bottles of water and soda whose liquid sloshes, keeps its volume, ripples, foams at the glass and fizzes, turned from one profile and shaded with the shader composer.

Example `bottles`, running at https://nacatamalon.com/en/examples/bottles. The whole catalogue: https://nacatamalon.com/llms/examples.md.

Bottles of drink that slosh. The liquid keeps its volume however the bottle turns, its surface lies across the gravity it feels and overshoots on a spring, ripples while it moves and foams at the glass, and a soda fizzes: bubbles rise, gather on the surface and burst.

Each bottle is turned from one profile with `useLatheGeometry`, its shaders written once with the shader composer for both cards. The surface is one sheet seen from both sides (`cull: 'none'`), cut to the inside of the bottle by where each of its corners is in it, handed on with `carry`, and the liquid above its level is cut away with `alphaCutoff`. Drag to turn it; two fingers or the right button move it; the arrows switch bottles.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { WIDTH, HEIGHT } from './settings';
import { Room } from './scenes/demo';

const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    scaling: 'contain',
    smooth: true,
    background: '#0f1424'
});

// Declare Scenes
game({
    Room
});
```

`components/bottle.ts`:

```ts
import { createMaterial, createMesh, getColor, useCylinderGeometry, useLatheGeometry, usePlaneGeometry, useTransform, useSphereGeometry } from 'nacatamalon';
import { quatFromAxisAngle, quatMultiply, quatRotateVec3 } from 'nacatamalon/math';
import type { TMesh } from 'nacatamalon';
import type { TQuat } from 'nacatamalon/math';
import { outline, profile } from '../data/kinds';
import type { TKind, TShape } from '../data/kinds';
import { BUBBLE, FOAM_BUBBLE, glassShader, liquidShader, surfaceShader } from '../shaders/liquid';

// One bottle: glass, liquid, surface, cap and, for the drinks that have them, bubbles. The liquid's
// state is worked out here, every frame, and handed to the shaders through their knobs.

const GRAVITY = 60;

type V3 = { x: number; y: number; z: number; };
export type TPose = { x: number; y: number; z: number; q: TQuat; };

const conj = (q: TQuat): TQuat => [-q[0], -q[1], -q[2], q[3]];

const norm3 = (v: V3) => {
    const l = Math.hypot(v.x, v.y, v.z) || 1;

    return {
        x: v.x / l,
        y: v.y / l,
        z: v.z / l
    };
};

// Points spread through the liquid's shape, in the bottle's own space. The level is the height
// below which `fill` of them lie, so the amount of drink stays the same however it turns.
const sampleShape = (s: TShape, rnd: () => number) => {
    const pts: number[] = [];

    while (pts.length < 3600 * 3) {
        const y = s.bottom + rnd() * (s.top - s.bottom);
        const x = (rnd() * 2 - 1) * s.body;
        const z = (rnd() * 2 - 1) * s.body;
        const r = profile(y, s);

        if (x * x + z * z < r * r) {
            pts.push(x, y, z);
        }
    }

    const samples = new Float32Array(pts);
    const count = samples.length / 3;
    let cx = 0, cy = 0, cz = 0;

    for (let i = 0; i < count; i++) {
        cx += samples[i * 3];
        cy += samples[i * 3 + 1];
        cz += samples[i * 3 + 2];
    }

    return {
        samples,
        count,
        centroid: {
            x: cx / count,
            y: cy / count,
            z: cz / count
        }
    };
};

export const Bottle = ({ kind, rnd }: { kind: TKind; rnd: () => number; }) => {
    const place = useTransform({
        x: 0,
        y: 0,
        z: 0
    });

    const { samples, count, centroid } = sampleShape(kind.liquid, rnd);
    const projected = new Float32Array(count);

    const glass = createMaterial({
        ...glassShader(kind),
        name: 'glass',
        transparent: true,
        specular: '#ffffff',
        shininess: 90
    });

    const liquid = createMaterial({
        ...liquidShader(kind),
        name: 'liquid',
        transparent: true,
        specular: '#ffffff',
        shininess: 60,
        alphaCutoff: 0.005
    });

    const surface = createMaterial({
        ...surfaceShader(kind),
        name: 'surface',
        transparent: true,
        specular: '#ffffff',
        shininess: 70,
        alphaCutoff: 0.005,
        cull: 'none'
    });

    const meshes: TMesh[] = [
        createMesh({
            geometry: useLatheGeometry({
                profile: outline(kind.liquid),
                segments: 56
            }),
            material: liquid,
            zIndex: 0,
            castShadow: false
        }),
        createMesh({
            geometry: useLatheGeometry({
                profile: outline(kind.glass),
                segments: 56
            }),
            material: glass,
            zIndex: 4,
            castShadow: false
        }),
        createMesh({
            geometry: useCylinderGeometry({
                radius: kind.cap.radius,
                height: kind.cap.height,
                segments: 28
            }),
            tint: getColor(kind.cap.color),
            specular: '#ffffff',
            shininess: 40,
            transform: {
                y: kind.cap.y
            },
        }),
    ];

    // The surface lives inside the bottle, carried by the same placement as the liquid it caps.
    const sheet = createMesh({
        geometry: usePlaneGeometry({
            width: 4,
            depth: 4,
            widthSegments: 40,
            depthSegments: 40
        }),
        material: surface,
        zIndex: 2,
        castShadow: false
    });

    meshes.push(sheet);

    // Bubbles: a pool of little spheres that rise against the gravity the drink feels.
    const bubbleMat = createMaterial({
        ...BUBBLE,
        name: 'bubble',
        transparent: true,
        specular: '#ffffff',
        shininess: 120
    });

    const bubbleShape = useSphereGeometry({
        radius: 1,
        segments: 10,
        rings: 8
    });

    type TBubble = { mesh: TMesh; p: V3; r: number; speed: number; phase: number; alive: boolean; };

    const bubbles: TBubble[] = [];

    for (let i = 0; i < kind.bubbles; i++) {
        const mesh = createMesh({
            geometry: bubbleShape,
            material: bubbleMat,
            zIndex: 1,
            castShadow: false,
            visible: false
        });

        bubbles.push({
            mesh,
            p: {
                x: 0,
                y: 0,
                z: 0
            },
            r: 0.02,
            speed: 0.5,
            phase: rnd() * 6.28,
            alive: false
        });
    }

    // Bubbles on the surface: domes that sit on the liquid, drift to the glass and gather there,
    // as they do in a glass of soda, and burst after a moment. The ones rising from below become
    // these when they arrive, and shaking throws more of them up.
    const foamMat = createMaterial({
        ...FOAM_BUBBLE,
        name: 'foam-bubble',
        transparent: true,
        specular: '#ffffff',
        shininess: 120
    });

    type TFoam = { mesh: TMesh; p: V3; r: number; life: number; age: number; alive: boolean; };

    const foams: TFoam[] = [];

    for (let i = 0; i < (kind.bubbles > 0 ? 90 : 0); i++) {
        const mesh = createMesh({
            geometry: bubbleShape,
            material: foamMat,
            zIndex: 3,
            castShadow: false,
            visible: false
        });

        foams.push({
            mesh,
            p: {
                x: 0,
                y: 0,
                z: 0
            },
            r: 0.02,
            life: 1,
            age: 0,
            alive: false
        });
    }

    let visible = true;

    const addFoam = (p: V3, r: number) => {
        const free = foams.find((x) => !x.alive);

        if (!free) {
            return;
        }

        free.p = {
            ...p
        };
        free.r = r * (0.8 + rnd() * 0.5);
        free.life = 0.6 + rnd() * 2.2;
        free.age = 0;
        free.alive = true;
        free.mesh.visible = visible;
    };

    // Where streams of bubbles start: a few fixed spots low on the walls and the base, like the
    // scratches in a real glass that bubbles grow from.
    const sites: V3[] = [];

    for (let i = 0; i < 5; i++) {
        const y = kind.liquid.bottom + 0.12 + rnd() * 0.5;
        const a = rnd() * Math.PI * 2;
        const r = profile(y, kind.liquid) * (0.4 + rnd() * 0.5);

        sites.push({
            x: Math.cos(a) * r,
            y,
            z: Math.sin(a) * r
        });
    }

    // The liquid's state.
    let tiltX = 0, tiltZ = 0, tiltVX = 0, tiltVZ = 0;
    let prev: V3 = {
        x: 0,
        y: 0,
        z: 0
    };
    let vel: V3 = {
        x: 0,
        y: 0,
        z: 0
    };
    let first = true;
    let energy = 0;
    let fizz = 0;

    const setVisible = (on: boolean) => {
        visible = on;

        for (const m of meshes) {
            m.visible = on;
        }

        for (const b of bubbles) {
            b.mesh.visible = on && b.alive;
        }

        for (const b of foams) {
            b.mesh.visible = on && b.alive;
        }
    };

    const reset = () => {
        first = true;
        // Arriving is a little jolt, so the drink sloshes into place and a soda fizzes.
        tiltVX = (rnd() - 0.5) * 3;
        tiltVZ = (rnd() - 0.5) * 3;
        energy = 0.8;
        fizz = 0.6;
    };

    const spawn = (b: TBubble, nl: V3, level: number) => {
        const below = (p: V3) => p.x * nl.x + p.y * nl.y + p.z * nl.z < level - 0.2;
        let p: V3 | null = null;

        if (rnd() < 0.55) {
            const s = sites[Math.floor(rnd() * sites.length)];

            if (below(s)) {
                p = {
                    x: s.x + (rnd() - 0.5) * 0.04,
                    y: s.y,
                    z: s.z + (rnd() - 0.5) * 0.04
                };
            }
        }

        for (let tries = 0; !p && tries < 10; tries++) {
            const i = Math.floor(rnd() * count);

            const c = {
                x: samples[i * 3],
                y: samples[i * 3 + 1],
                z: samples[i * 3 + 2]
            };

            if (below(c)) {
                p = c;
            }
        }

        if (!p) {
            return;
        }

        b.p = p;
        b.r = 0.012 + rnd() * rnd() * 0.04;
        b.speed = 0.45 + b.r * 14 + rnd() * 0.2;
        b.alive = true;
        b.mesh.visible = visible;
    };

    const update = (dt: number, time: number, pose: TPose) => {
        const q = pose.q;

        place.x = pose.x;
        place.y = pose.y;
        place.z = pose.z;
        place.quaternion = q;

        // Where the drink's middle is in the world, and how hard it is being pushed.
        const c = quatRotateVec3(q, centroid);

        const pos = {
            x: pose.x + c.x,
            y: pose.y + c.y,
            z: pose.z + c.z
        };

        if (first) {
            prev = pos;
            vel = {
                x: 0,
                y: 0,
                z: 0
            };
            first = false;
        }

        const nv = {
            x: (pos.x - prev.x) / dt,
            y: (pos.y - prev.y) / dt,
            z: (pos.z - prev.z) / dt
        };

        const s = 1 - Math.exp(-dt * 25);

        const acc = {
            x: ((nv.x - vel.x) / dt) * s,
            y: ((nv.y - vel.y) / dt) * s,
            z: ((nv.z - vel.z) / dt) * s
        };

        vel = {
            x: vel.x + (nv.x - vel.x) * s,
            y: vel.y + (nv.y - vel.y) * s,
            z: vel.z + (nv.z - vel.z) * s
        };
        prev = pos;

        // The surface wants to lie across the gravity it feels; a spring makes it overshoot and slosh.
        const gy = Math.max(GRAVITY + acc.y, GRAVITY * 0.3);
        const clampT = (v: number) => Math.min(Math.max(v, -1.6), 1.6);
        const stiff = 70, damping = 3.2;

        tiltVX += (stiff * (clampT(acc.x / gy) - tiltX) - damping * tiltVX) * dt;
        tiltVZ += (stiff * (clampT(acc.z / gy) - tiltZ) - damping * tiltVZ) * dt;
        tiltX = clampT(tiltX + tiltVX * dt);
        tiltZ = clampT(tiltZ + tiltVZ * dt);

        const normal = norm3({
            x: tiltX,
            y: 1,
            z: tiltZ
        });

        // The level that keeps the volume.
        const inv = conj(q);
        const nl = quatRotateVec3(inv, normal);

        for (let i = 0; i < count; i++) {
            projected[i] = samples[i * 3] * nl.x + samples[i * 3 + 1] * nl.y + samples[i * 3 + 2] * nl.z;
        }

        projected.sort();

        const level = projected[Math.floor(count * kind.fill)];

        const point = {
            x: pose.x + normal.x * level,
            y: pose.y + normal.y * level,
            z: pose.z + normal.z * level
        };

        // A turn that lays the sheet's up on the normal.
        const capQ: TQuat = quatFromAxisAngle(normal.z, 0, -normal.x, Math.acos(Math.min(1, normal.y)));

        const u = quatRotateVec3(capQ, {
            x: 1,
            y: 0,
            z: 0
        });

        const v = quatRotateVec3(capQ, {
            x: 0,
            y: 0,
            z: 1
        });

        // Ripples and fizz grow with how much the drink is moving and fade as it settles.
        const motion = Math.hypot(tiltVX, tiltVZ) * 0.6 + Math.hypot(vel.x, vel.y, vel.z) * 0.08;

        energy += (Math.min(motion, 1.5) - energy) * (1 - Math.exp(-dt * (motion > energy ? 8 : 1.2)));
        fizz += (Math.min(motion * 0.8, 1) - fizz) * (1 - Math.exp(-dt * (motion > fizz ? 6 : 0.5)));

        // The liquid's knobs: the plane it is cut by, in the world, and the ripple.
        const lu = liquid.uniforms;

        lu.planePoint = [point.x, point.y, point.z];
        lu.planeNormal = [normal.x, normal.y, normal.z];
        lu.planeU = [u.x, u.y, u.z];
        lu.planeV = [v.x, v.y, v.z];
        lu.wave = [0.004 + energy * 0.035, 2.4, time, 0];

        // The sheet sits on the level, in the bottle; its knobs say where, for the corners to carry.
        const lp = quatRotateVec3(inv, {
            x: normal.x * level,
            y: normal.y * level,
            z: normal.z * level
        });

        const lq = quatMultiply(inv, capQ);

        sheet.transform.x = lp.x;
        sheet.transform.y = lp.y;
        sheet.transform.z = lp.z;
        sheet.transform.quaternion = lq;

        const su = surface.uniforms;

        su.wave = lu.wave;
        su.capTurn = [lq[0], lq[1], lq[2], lq[3]];
        su.capPlace = [lp.x, lp.y, lp.z];
        su.foam = kind.bubbles > 0 ? fizz : 0;

        // Bubbles rise along the normal (up for the drink), drift a little, stay inside the glass,
        // and pop when they reach the surface. Shaking it makes more of them.
        if (bubbles.length > 0) {
            const wanted = Math.min(bubbles.length, Math.round(26 + fizz * (bubbles.length - 26)));

            const side = norm3({
                x: nl.y,
                y: -nl.x,
                z: 0.3
            });

            for (let i = 0; i < bubbles.length; i++) {
                const b = bubbles[i];

                if (!b.alive) {
                    if (i < wanted && rnd() < dt * (3 + fizz * 30)) {
                        spawn(b, nl, level);
                    }

                    continue;
                }

                const wob = Math.sin(time * 7 + b.phase) * 0.06;
                const sp = b.speed * (1 + fizz * 0.8);

                b.p.x += (nl.x * sp + side.x * wob) * dt;
                b.p.y += (nl.y * sp + side.y * wob) * dt;
                b.p.z += (nl.z * sp + side.z * wob) * dt;

                const room = profile(b.p.y, kind.liquid) - b.r;
                const d = Math.hypot(b.p.x, b.p.z);
                const height = b.p.x * nl.x + b.p.y * nl.y + b.p.z * nl.z;

                if (room <= 0 || height > level - b.r * 0.5) {
                    if (room > 0) {
                        addFoam(b.p, b.r);
                    }

                    b.alive = false;
                    b.mesh.visible = false;
                    continue;
                }

                if (d > room) {
                    b.p.x *= room / d;
                    b.p.z *= room / d;
                }

                const t = b.mesh.transform;

                t.x = b.p.x;
                t.y = b.p.y;
                t.z = b.p.z;
                t.scaleX = t.scaleY = t.scaleZ = b.r;
            }

            // Shaking throws foam straight onto the surface too.
            const extra = fizz * fizz * 60 * dt;

            for (let k = 0; k < Math.floor(extra) + (rnd() < extra % 1 ? 1 : 0); k++) {
                const i = Math.floor(rnd() * count);

                addFoam({
                    x: samples[i * 3],
                    y: samples[i * 3 + 1],
                    z: samples[i * 3 + 2]
                }, 0.012 + rnd() * 0.025);
            }

            for (const fb of foams) {
                if (!fb.alive) {
                    continue;
                }

                fb.age += dt;

                // Kept on the surface wherever it moves: its middle on the plane, so half shows.
                const h = fb.p.x * nl.x + fb.p.y * nl.y + fb.p.z * nl.z - level;

                fb.p.x -= nl.x * h;
                fb.p.y -= nl.y * h;
                fb.p.z -= nl.z * h;

                // Drawn outwards across the surface, to gather at the glass.
                const d = Math.hypot(fb.p.x, fb.p.z) || 1e-4;
                let ox = fb.p.x / d, oz = fb.p.z / d, oy = 0;
                const along = ox * nl.x + oz * nl.z;

                ox -= nl.x * along;
                oy -= nl.y * along;
                oz -= nl.z * along;

                const drift = 0.25 * (1 + fizz);

                fb.p.x += ox * drift * dt;
                fb.p.y += oy * drift * dt;
                fb.p.z += oz * drift * dt;

                const room = profile(fb.p.y, kind.liquid) - fb.r * 0.6;
                const dd = Math.hypot(fb.p.x, fb.p.z);

                if (room <= 0 || fb.age > fb.life) {
                    fb.alive = false;
                    fb.mesh.visible = false;
                    continue;
                }

                if (dd > room) {
                    fb.p.x *= room / dd;
                    fb.p.z *= room / dd;
                }

                // Grows in as it arrives, swells a little and bursts at the end.
                const grow = Math.min(fb.age / 0.12, 1);
                const end = fb.life - fb.age;
                const burst = end < 0.08 ? 1 + (0.08 - end) * 4 : 1;
                const t = fb.mesh.transform;

                t.x = fb.p.x;
                t.y = fb.p.y;
                t.z = fb.p.z;
                t.scaleX = t.scaleY = t.scaleZ = fb.r * grow * burst;
            }
        }
    };

    return {
        update,
        setVisible,
        reset
    };
};
```

`components/showroom.ts`:

```ts
import { createSprite, createText, getColor, useKeyboard, usePointer, useSpawn, useUpdate } from 'nacatamalon';
import { quatFromAxisAngle, quatMultiply, quatNormalize } from 'nacatamalon/math';
import * as Settings from '../settings';
import { Bottle } from './bottle';
import type { TPose } from './bottle';
import { KINDS } from '../data/kinds';
import { createBottleRandom } from '../simulation/random';

export const Showroom = () => {
    const rnd = createBottleRandom();
    const makeBottle = useSpawn(Bottle, 'Bottle');

    const bottles = KINDS.map((kind) => makeBottle({
        kind,
        rnd
    }).api);

    let current = 0;

    bottles.forEach((b, i) => b.setVisible(i === current));

    const pose: TPose = {
        x: 0,
        y: 0,
        z: 0,
        q: quatNormalize(quatFromAxisAngle(0, 0, 1, 0.25))
    };

    // On-screen controls, drawn by the engine so they work with a finger too.
    const title = createText({
        text: KINDS[current].name,
        transform: {
            x: Settings.WIDTH / 2,
            y: 40
        },
        anchor: {
            x: 0.5,
            y: 0
        },
        style: {
            fontSize: 24
        },
        tint: '#dfe6ff'
    });

    const counter = createText({
        text: `1 / ${KINDS.length}`,
        transform: {
            x: Settings.WIDTH / 2,
            y: 74
        },
        anchor: {
            x: 0.5,
            y: 0
        },
        tint: '#6f7da6'
    });

    const button = (x: number, label: string) => {
        const pad = createSprite({
            width: 88,
            height: 88,
            tint: 'rgba(120,140,200,0.16)',
            transform: {
                x,
                y: Settings.HEIGHT - 120
            }
        });

        const text = createText({
            text: label,
            transform: {
                x,
                y: Settings.HEIGHT - 120
            },
            anchor: {
                x: 0.5,
                y: 0.5
            },
            style: {
                fontSize: 32
            },
            tint: '#dfe6ff'
        });

        return {
            pad,
            text
        };
    };

    const prevBtn = button(70, '<');
    const nextBtn = button(Settings.WIDTH - 70, '>');

    createText({
        text: 'DRAG TO TURN - TWO FINGERS OR RIGHT CLICK TO MOVE',
        transform: {
            x: Settings.WIDTH / 2,
            y: Settings.HEIGHT - 36
        },
        anchor: {
            x: 0.5,
            y: 0
        },
        tint: '#7f8fb8'
    });

    const show = (step: number) => {
        bottles[current].setVisible(false);
        current = (current + step + KINDS.length) % KINDS.length;
        bottles[current].setVisible(true);
        bottles[current].reset();
        title.text = KINDS[current].name;
        counter.text = `${current + 1} / ${KINDS.length}`;
    };

    const keys = useKeyboard();
    const pointer = usePointer();
    const last = new Map<number, { x: number; y: number; }>();
    const onUi = new Set<number>();
    let rightHeld = false;

    pointer.onDown((info) => {
        if (info.button === 'right') {
            rightHeld = true;
        }

        if (info.hits.includes(prevBtn.pad) || info.hits.includes(prevBtn.text)) {
            onUi.add(info.pointerId);
            show(-1);
        } else if (info.hits.includes(nextBtn.pad) || info.hits.includes(nextBtn.text)) {
            onUi.add(info.pointerId);
            show(1);
        }
    });
    pointer.onUp((info) => {
        if (info.button === 'right') {
            rightHeld = false;
        }

        onUi.delete(info.pointerId);
    });

    const worldPerPixel = (2 * Settings.CAMERA_Z * Math.tan((Settings.FOV * Math.PI) / 360)) / Settings.HEIGHT;
    let spinX = 0;
    let spinY = 0;

    useUpdate((delta, time) => {
        const dt = Math.max(delta, 1 / 240);

        if (keys.justPressed('ArrowRight')) {
            show(1);
        }

        if (keys.justPressed('ArrowLeft')) {
            show(-1);
        }

        // One pointer drags a turn, two (or the right button) drag the bottle about. A finger that
        // started on an arrow button only presses the button.
        const touches = pointer.touches().filter((t) => !onUi.has(t.pointerId));
        let dx = 0, dy = 0, n = 0;
        const seen = new Set<number>();

        for (const touch of touches) {
            seen.add(touch.pointerId);

            const before = last.get(touch.pointerId);

            if (before) {
                dx += touch.screenX - before.x;
                dy += touch.screenY - before.y;
                n++;
            }

            last.set(touch.pointerId, {
                x: touch.screenX,
                y: touch.screenY
            });
        }

        for (const id of [...last.keys()]) {
            if (!seen.has(id)) {
                last.delete(id);
            }
        }

        if (n > 0) {
            dx /= n;
            dy /= n;
        }

        let q = pose.q;

        if (touches.length >= 2 || (rightHeld && touches.length === 1)) {
            pose.x = Math.min(Math.max(pose.x + dx * worldPerPixel, -1.6), 1.6);
            pose.y = Math.min(Math.max(pose.y - dy * worldPerPixel, -2.6), 2.6);
            spinX *= 0.8;
            spinY *= 0.8;
        } else if (touches.length === 1) {
            const ax = dy * 0.012, ay = dx * 0.012;

            spinX = spinX * 0.5 + (ax / dt) * 0.5;
            spinY = spinY * 0.5 + (ay / dt) * 0.5;
            q = quatMultiply(quatFromAxisAngle(1, 0, 0, ax), q);
            q = quatMultiply(quatFromAxisAngle(0, 1, 0, ay), q);
        } else {
            const damp = Math.exp(-2.5 * dt);

            spinX *= damp;
            spinY *= damp;
            q = quatMultiply(quatFromAxisAngle(1, 0, 0, spinX * dt), q);
            q = quatMultiply(quatFromAxisAngle(0, 1, 0, spinY * dt), q);
        }

        pose.q = quatNormalize(q);

        for (const btn of [prevBtn, nextBtn]) {
            const pressed = pointer.isPressing(btn.pad) || pointer.isPressing(btn.text);

            btn.pad.tint = getColor(pressed ? 'rgba(160,180,240,0.34)' : 'rgba(120,140,200,0.16)');
        }

        bottles[current].update(dt, time, pose);
    });
};
```

`data/kinds.ts`:

```ts
// The bottles. Each is a profile turned about its axis: r(y), the radius at each height, written
// once here, turned into the bottle's shape by useLatheGeometry and, in the shaders, built from the
// same numbers by the shader composer.

export type RGB = [number, number, number];

export type TShape = {
    body: number; // radius of the body
    neck: number; // radius of the neck
    shoulderLo: number; // where the body starts narrowing
    shoulderHi: number; // where the neck is reached
    lip: number; // extra radius of the ring under the mouth
    lipA: number;
    lipB: number;
    waist: number; // how deep the waist is pinched (0 for none)
    waistY: number;
    waistW: number;
    top: number;
    bottom: number;
    inset: number; // how far inside the glass the liquid sits
    roundB: number;
    roundT: number;
};

export type TKind = {
    name: string;
    glass: TShape;
    liquid: TShape;
    fill: number;
    glassColor: RGB;
    glassRim: RGB;
    liquidColor: RGB;
    liquidGlow: RGB;
    liquidAlpha: number;
    surfaceColor: RGB;
    foamColor: RGB;
    foamBase: number; // how wide the foam ring is at rest
    speckle: number; // 0 for a clean surface, 1 for a foamy one
    surfaceAlpha: number;
    cap: { radius: number; height: number; y: number; color: string; };
    bubbles: number;
};

const waterGlass: TShape = {
    body: 0.75,
    neck: 0.22,
    shoulderLo: 0.0,
    shoulderHi: 0.7,
    lip: 0.045,
    lipA: 1.46,
    lipB: 1.52,
    waist: 0,
    waistY: 0,
    waistW: 1,
    top: 1.7,
    bottom: -1.7,
    inset: 0,
    roundB: 0.2,
    roundT: 0.07,
};

// A slim contour bottle: a long shoulder, a pinched waist and a bead under the cap.
const sodaGlass: TShape = {
    body: 0.58,
    neck: 0.19,
    shoulderLo: 0.15,
    shoulderHi: 1.1,
    lip: 0.05,
    lipA: 1.56,
    lipB: 1.62,
    waist: 0.08,
    waistY: -0.55,
    waistW: 0.38,
    top: 1.85,
    bottom: -1.8,
    inset: 0,
    roundB: 0.15,
    roundT: 0.05,
};

export const KINDS: TKind[] = [
    {
        name: 'WATER',
        glass: waterGlass,
        liquid: {
            ...waterGlass,
            top: 1.38,
            bottom: -1.64,
            inset: 0.055
        },
        fill: 0.55,
        glassColor: [0.80, 0.93, 1.0],
        glassRim: [0.55, 0.75, 0.95],
        liquidColor: [0.62, 0.90, 1.0],
        liquidGlow: [0.85, 0.97, 1.0],
        liquidAlpha: 0.22,
        surfaceColor: [0.55, 0.86, 1.0],
        foamColor: [0.9, 0.98, 1.0],
        foamBase: 0.05,
        speckle: 0,
        surfaceAlpha: 0.32,
        cap: {
            radius: 0.205,
            height: 0.5,
            y: 1.72,
            color: '#b07a4a'
        },
        bubbles: 0,
    },
    {
        name: 'SODA',
        glass: sodaGlass,
        liquid: {
            ...sodaGlass,
            top: 1.45,
            bottom: -1.74,
            inset: 0.05
        },
        fill: 0.74,
        glassColor: [0.72, 0.95, 0.82],
        glassRim: [0.45, 0.85, 0.65],
        liquidColor: [0.26, 0.08, 0.025],
        liquidGlow: [0.75, 0.32, 0.12],
        liquidAlpha: 0.62,
        surfaceColor: [0.45, 0.2, 0.08],
        foamColor: [0.96, 0.86, 0.70],
        foamBase: 0.09,
        speckle: 1,
        surfaceAlpha: 0.8,
        cap: {
            radius: 0.255,
            height: 0.24,
            y: 1.8,
            color: '#d8262f'
        },
        bubbles: 80,
    },
];

const smooth = (a: number, b: number, x: number) => {
    const t = Math.min(Math.max((x - a) / (b - a), 0), 1);

    return t * t * (3 - 2 * t);
};

// The radius at a height, 0 outside the bottle.
export const profile = (y: number, s: TShape) => {
    if (y > s.top || y < s.bottom) {
        return 0;
    }

    let r = s.neck + (s.body - s.neck) * (1 - smooth(s.shoulderLo, s.shoulderHi, y));

    r += s.lip * smooth(s.lipA, s.lipB, y);

    const w = (y - s.waistY) / s.waistW;

    r -= s.waist * Math.exp(-w * w);
    r -= s.inset;

    if (y < s.bottom + s.roundB) {
        const k = (s.bottom + s.roundB - y) / s.roundB;

        r *= Math.sqrt(Math.max(0, 1 - k * k));
    }

    if (y > s.top - s.roundT) {
        const k = (y - (s.top - s.roundT)) / s.roundT;

        r *= Math.sqrt(Math.max(0, 1 - k * k));
    }

    return Math.max(r, 0);
};

// The profile as the points a lathe turns, from the bottom up: it starts and ends on the axis.
export const outline = (s: TShape, points = 96) =>
    Array.from({
        length: points
    }, (_, i) => {
        const y = s.bottom + ((s.top - s.bottom) * i) / (points - 1);

        return {
            x: i === 0 || i === points - 1 ? 0 : profile(y, s),
            y
        };
    });
```

`scenes/demo.ts`:

```ts
import { cameraLookAt, createScene, useAmbientLight, useCamera3d, useDirectionalLight, usePointLight, useSpawn } from 'nacatamalon';

import * as Settings from '../settings';
import { Showroom } from '../components/showroom';

export const Room = () => {
    const camZ = Settings.CAMERA_Z;
    const fov = Settings.FOV;

    const camera = useCamera3d({
        projection: 'perspective',
        fov,
        transform: {
            y: 1.6,
            z: camZ
        }
    });

    cameraLookAt(camera, {
        x: 0,
        y: 0,
        z: 0
    });
    useAmbientLight({
        color: '#8fa7d6',
        intensity: 0.45
    });
    useDirectionalLight({
        intensity: 1.1,
        transform: {
            rotationX: -0.7,
            rotationY: 0.5
        }
    });
    usePointLight({
        color: '#ffd9a8',
        intensity: 1.2,
        range: 14,
        transform: {
            x: -3,
            y: 2,
            z: 4
        }
    });

    useSpawn(Showroom)();

    return createScene();
};
```

`settings.ts`:

```ts
export const WIDTH = 900;
export const HEIGHT = 960;
export const CAMERA_Z = 7.2;
export const FOV = 42;
```

`shaders/liquid.ts`:

```ts
import { compileShader, composerAdd, composerCarry, composerClamp, composerCross, composerDot, composerFloor, composerFract, composerFresnel, composerLength, composerLight, composerMax, composerMix, composerMul, composerPow, composerShine, composerSin, composerSmoothstep, composerSqrt, composerStep, composerSub, composerSwizzle, composerUniform, composerVec2, composerVec3, composerVec4, composerVertexPosition, composerW, composerWorldPos, composerX, composerY, composerZ } from 'nacatamalon/shader';
import type { TComposerNode } from 'nacatamalon/shader';
import type { RGB, TKind, TShape } from '../data/kinds';

// Every shader of the bottles, written once with the shader composer: each graph is compiled to
// both shader languages, so they draw the same on WebGPU and WebGL2.

const rgb = (c: RGB) => composerVec3(c[0], c[1], c[2]);

// The bottle's radius at a height, the same sum as profile() in kinds.ts, built as nodes. The
// rounding at both ends is a factor that is 1 away from them and falls to 0 at the very end, which
// is also what makes it 0 past them.
const radiusAt = (y: TComposerNode, s: TShape): TComposerNode => {
    const shoulder = composerSub(1, composerSmoothstep(s.shoulderLo, s.shoulderHi, y));
    const w = composerMul(composerSub(y, s.waistY), 1 / s.waistW);
    let r = composerAdd(s.neck, composerMul(s.body - s.neck, shoulder));

    r = composerAdd(r, composerMul(s.lip, composerSmoothstep(s.lipA, s.lipB, y)));
    r = composerSub(r, composerMul(s.waist, composerPow(Math.E, composerMul(-1, composerMul(w, w)))));
    r = composerSub(r, s.inset);

    const kb = composerMax(composerMul(composerSub(s.bottom + s.roundB, y), 1 / s.roundB), 0);
    const kt = composerMax(composerMul(composerSub(y, s.top - s.roundT), 1 / s.roundT), 0);
    const round = (k: TComposerNode) => composerSqrt(composerMax(composerSub(1, composerMul(k, k)), 0));

    return composerMax(composerMul(composerMul(r, round(kb)), round(kt)), 0);
};

// The ripple on the surface, in the surface's own coordinates (a across, b down). 'wave' is
// (height, how close the crests are, time, unused), the same knob on the liquid and its surface.
const wave = () => composerUniform('wave', [0, 2.4, 0, 0]);

const ripple = (a: TComposerNode, b: TComposerNode): TComposerNode => {
    const k = composerY(wave());
    const t = composerZ(wave());
    const one = composerSin(composerAdd(composerMul(a, k), composerMul(t, 3.1)));
    const two = composerMul(0.7, composerSin(composerSub(composerMul(composerMul(b, k), 1.37), composerMul(t, 2.3))));
    const three = composerMul(0.4, composerSin(composerAdd(composerMul(composerMul(composerAdd(a, b), k), 2.1), composerMul(t, 4.7))));

    return composerMul(composerX(wave()), composerAdd(composerAdd(one, two), three));
};

// A vector turned by a quaternion.
const turn = (q: TComposerNode, v: TComposerNode): TComposerNode => {
    const axis = composerSwizzle(q, 'xyz');
    const t = composerMul(composerCross(axis, v), 2);

    return composerAdd(composerAdd(v, composerMul(composerW(q), t)), composerCross(axis, t));
};

// The brightest channel of the shine: a glint.
const glint = () => composerMax(composerMax(composerX(composerShine()), composerY(composerShine())), composerZ(composerShine()));

export const glassShader = (k: TKind) => {
    const fres = composerFresnel(3);

    const color = composerAdd(
        composerAdd(composerMul(rgb(k.glassColor), composerAdd(composerMul(composerLight(), 0.35), 0.15)), composerMul(composerShine(), 1.4)),
        composerMul(rgb(k.glassRim), composerMul(fres, 0.7)),
    );

    const alpha = composerClamp(composerAdd(composerAdd(0.07, composerMul(fres, 0.6)), composerMul(glint(), 0.6)), 0, 0.95);

    return compileShader({
        shader: 'mesh3d',
        color: composerVec4(color, alpha)
    });
};

// The liquid: lit, brighter at its rim and its edge, and nothing above the level. How far above is
// measured in the world, from the plane the bottle sets every frame, ripples included; above, its
// alpha is 0, and alphaCutoff throws those pixels away.
export const liquidShader = (k: TKind) => {
    const rel = composerSub(composerWorldPos(), composerUniform('planePoint', [0, 0, 0]));

    const h = composerSub(
        composerDot(rel, composerUniform('planeNormal', [0, 1, 0])),
        ripple(composerDot(rel, composerUniform('planeU', [1, 0, 0])), composerDot(rel, composerUniform('planeV', [0, 0, 1]))),
    );

    const fres = composerFresnel(2);
    const edge = composerSmoothstep(-0.06, 0, h);

    const color = composerAdd(
        composerAdd(composerMul(rgb(k.liquidColor), composerAdd(composerMul(composerLight(), 0.75), 0.35)), composerShine()),
        composerMul(rgb(k.liquidGlow), composerAdd(composerMul(fres, 0.45), composerMul(edge, 0.5))),
    );

    const alpha = composerClamp(composerAdd(composerAdd(k.liquidAlpha, composerMul(fres, 0.4)), composerMul(edge, 0.35)), 0, 0.95);

    return compileShader({
        shader: 'mesh3d',
        color: composerVec4(color, composerMul(alpha, composerSub(1, composerStep(0, h))))
    });
};

// The surface: one flat sheet laid on the level, rippled at its corners, and seen from both sides.
// Each corner carries where it is in the bottle ('capTurn' and 'capPlace' are the sheet's place in
// it), and the colour cuts what lies past the glass at that height, rings it with foam and specks it.
export const surfaceShader = (k: TKind) => {
    const corner = composerVertexPosition();
    const moved = composerVec3(composerX(corner), ripple(composerX(corner), composerZ(corner)), composerZ(corner));
    const inBottle = composerAdd(turn(composerUniform('capTurn', [0, 0, 0, 1]), moved), composerUniform('capPlace', [0, 0, 0]));

    const local = composerSwizzle(composerCarry(), 'xyz');
    const across = composerVec2(composerX(local), composerZ(local));
    const r = radiusAt(composerY(local), k.liquid);
    const d = composerLength(across);
    const inside = composerSub(1, composerStep(r, d));
    const foam = composerUniform('foam', 0);
    const fres = composerFresnel(2);
    const rim = composerSmoothstep(composerSub(r, composerAdd(k.foamBase, composerMul(foam, 0.3))), r, d);
    const cell = composerFloor(composerMul(across, 60));
    const hash = composerFract(composerMul(composerSin(composerDot(cell, composerVec2(12.9898, 78.233))), 43758.5453));
    const speck = composerMul(composerMul(composerStep(composerSub(0.78, composerMul(foam, 0.35)), hash), k.speckle), composerAdd(0.35, foam));
    const amount = composerClamp(composerAdd(rim, speck), 0, 1);
    const base = composerAdd(composerMul(rgb(k.surfaceColor), composerAdd(composerMul(composerLight(), 0.7), 0.45)), composerMul(composerShine(), 1.3));

    const color = composerAdd(
        composerMix(base, composerMul(rgb(k.foamColor), composerAdd(composerMul(composerLight(), 0.5), 0.6)), amount),
        composerMul(fres, 0.25),
    );

    const alpha = composerClamp(composerAdd(composerAdd(k.surfaceAlpha, composerMul(fres, 0.3)), composerMul(amount, 0.5)), 0, 0.97);

    return compileShader({
        shader: 'mesh3d',
        position: moved,
        carry: inBottle,
        color: composerVec4(color, composerMul(alpha, inside)),
    });
};

// A bubble: clear in the middle, bright at its edge, the way a bubble in a drink reads. One on the
// surface is whiter and more solid, a dome of foam.
const bubble = (tint: RGB, light: number, shine: number, base: number, edge: number, power: number) => {
    const fres = composerFresnel(power);
    const color = composerAdd(composerMul(rgb(tint), composerAdd(composerMul(composerLight(), light), 1 - light)), composerMul(composerShine(), shine));

    return compileShader({
        shader: 'mesh3d',
        color: composerVec4(color, composerClamp(composerAdd(base, composerMul(fres, edge)), 0, 0.95))
    });
};

export const BUBBLE = bubble([1, 0.95, 0.88], 0.45, 2, 0.08, 0.85, 1.6);
export const FOAM_BUBBLE = bubble([1, 0.93, 0.82], 0.5, 2.2, 0.22, 0.75, 1.4);
```

`simulation/random.ts`:

```ts
export const createBottleRandom = () => {
    let seed = 1234567;
    const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;

    return rnd;
};
```
