# Volumetric fog

> Ground fog that behaves like a fluid: 48 see-through sheets share one 3D material that reads a fluid simulated on the CPU.

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

Ground fog that behaves like a fluid. WASD or the arrows roll the glowing ball through it, space jumps, and T shows the fluid underneath. The ball pushes the fog aside, the air keeps moving when it stops, it flows round the pillars, and a landing blasts it outward and lifts it. Left alone, the ball drives itself. V, or the button at the top right, switches views: the fog, its sheets in wireframe (they never move: all the motion is in the shader), and the fluid as a mesh of lines whose points follow the air.

The fog is 48 see-through sheets stacked from the floor up, sharing one `mesh3d` material written for both cards. Each one reads a 3D noise at its height and turns density into alpha, so together they add up the volume as a raymarch would, and the pillars hide it through the engine's own depth test. How it moves is a 96 by 96 fluid simulated on the CPU, in steps of exactly 1/60 (`step: 60`): it reaches the shader as two pictures from `createPixels`, refreshed each frame with `updatePixelTexture` and read as `maps`, and the shader reads its noise where the air carried it, so the wisps travel with the flow.

## Code

`index.ts`:

```ts
import { createGame } from 'nacatamalon';
import { Temple } from './scenes/temple';
import { ACTIONS, HEIGHT, WIDTH } from './settings';

// Ground fog that behaves like a fluid: a glowing ball pushes it aside, the air keeps moving when
// the ball stops, it flows round the pillars, and a landing blasts it outward and lifts it.
//
//   WASD / arrows   move
//   space           jump
//   T               show the fluid
//   V               switch views: the fog, its sheets, the fluid as a mesh (or the button)
//
// Left alone, the ball drives itself.
const game = createGame('#app', {
    width: WIDTH,
    height: HEIGHT,
    background: '#0b0f1a',
    scaling: 'contain',
    step: 60,
    actions: ACTIONS
});

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

`components/ball.ts`:

```ts
import { createMesh, useAction, useDirection, usePointLight, useSphereGeometry, useUpdate } from 'nacatamalon';
import { useRandom } from 'nacatamalon/math';
import { ALTAR, BALL, IDLE_SECONDS, PILLARS, PILLAR_RADIUS } from '../settings';
import type { TFog } from './fog';

/**
 * What the ball bumps into on the floor: each pillar, and the altar as a circle round it.
 */
const OBSTACLES = [
    ...PILLARS.map(([x, z]) => ({
        x,
        z,
        radius: PILLAR_RADIUS + 0.07
    })),
    {
        x: 0,
        z: 0,
        radius: ALTAR.width / 2 + 0.1
    }
];

/**
 * The glowing ball that stirs the fog. It rolls, jumps and lands, carries its light, and tells the
 * fog where it is and when it hits the floor. Left alone it drives itself.
 */
export const Ball = ({ fog }: { fog: TFog; }) => {
    const mesh = createMesh({
        geometry: useSphereGeometry({
            radius: BALL.radius,
            segments: 24,
            rings: 14
        }),
        tint: '#ffd9a0',
        emissive: '#ff9a3c',
        transform: {
            x: BALL.start.x,
            y: BALL.radius,
            z: BALL.start.z
        }
    });

    const lamp = usePointLight({
        color: '#ffa860',
        intensity: 2.2,
        range: 6,
        transform: {
            x: BALL.start.x,
            y: 1,
            z: BALL.start.z
        }
    });

    const move = useDirection('left', 'right', 'up', 'down');
    const jump = useAction('jump');
    const random = useRandom();

    const velocity = {
        x: 0,
        y: 0,
        z: 0
    };

    let idleFor = IDLE_SECONDS;
    let autoTime = 0;
    let autoJump = 2.5;

    useUpdate((delta, time) => {
        const at = mesh.transform;
        const grounded = at.y <= BALL.radius + 0.001;

        // Up on the stick or the keys goes away from the camera, along -z.
        let { x: ix, y: iz } = move();
        let jumping = jump.justPressed();

        idleFor = ix !== 0 || iz !== 0 || jumping ? 0 : idleFor + delta;

        if (idleFor > IDLE_SECONDS) {
            autoTime += delta * 0.55;
            ix = Math.sin(autoTime) * 5.2 - at.x;
            iz = Math.sin(autoTime * 2) * 2.6 + 1.2 - at.z;
            autoJump -= delta;

            if (autoJump <= 0) {
                jumping = true;
                autoJump = random.rand(5, 8);
            }

            const length = Math.hypot(ix, iz);

            if (length > 1) {
                ix /= length;
                iz /= length;
            }
        }

        // It stops almost dead when let go: the ball stops, the air it was pushing does not.
        const steering = ix !== 0 || iz !== 0;

        velocity.x += ix * BALL.accel * delta;
        velocity.z += iz * BALL.accel * delta;

        const drag = Math.exp(-(steering ? BALL.drag : BALL.stopDrag) * delta);

        velocity.x *= drag;
        velocity.z *= drag;

        const takeoff = jumping && grounded;

        if (takeoff) {
            velocity.y = BALL.jump;
        }

        velocity.y -= BALL.gravity * delta;
        at.x += velocity.x * delta;
        at.y += velocity.y * delta;
        at.z += velocity.z * delta;

        let impact = 0;

        if (at.y < BALL.radius) {
            impact = -velocity.y;
            at.y = BALL.radius;
            velocity.y = 0;
        }

        at.x = Math.min(Math.max(at.x, -BALL.limit), BALL.limit);
        at.z = Math.min(Math.max(at.z, -BALL.limit), BALL.limit);

        for (const obstacle of OBSTACLES) {
            const dx = at.x - obstacle.x;
            const dz = at.z - obstacle.z;
            const d = Math.hypot(dx, dz);
            const min = BALL.radius + obstacle.radius;

            if (d < min && d > 0.0001) {
                at.x = obstacle.x + (dx / d) * min;
                at.z = obstacle.z + (dz / d) * min;

                const dot = (velocity.x * dx + velocity.z * dz) / d;

                if (dot < 0) {
                    velocity.x -= (dx / d) * dot * 1.6;
                    velocity.z -= (dz / d) * dot * 1.6;
                }
            }
        }

        at.rotationY += delta * (0.6 + Math.hypot(velocity.x, velocity.z));
        lamp.transform.x = at.x;
        lamp.transform.y = at.y + 0.4;
        lamp.transform.z = at.z;
        lamp.intensity = 2.0 + Math.sin(time * 7) * 0.15;

        fog.ballAt(at, velocity);

        // Taking off already blows; landing blows far more, by how hard it comes down.
        if (takeoff) {
            fog.blast(at.x, at.z, BALL.radius * 4.5, BALL.jump * 3, 0.3);
        }

        if (impact > 2) {
            fog.blast(at.x, at.z, BALL.radius * 7, impact * 5, Math.min(1, impact / 9));
        }
    });

    return {
        mesh
    };
};
```

`components/fog.ts`:

```ts
import { createMaterial, createMesh, useAction, usePlaneGeometry, useUpdate } from 'nacatamalon';
import { createPixels, createPixelTexture, updatePixelTexture } from 'nacatamalon/pixels';
import { FOG_GLSL, FOG_WGSL, type TFogKnobs } from '../shaders/fog';
import { createFluid } from '../simulation/fluid';
import { ALTAR, BALL, FLOW, FLUID, FOG, PILLARS, PILLAR_RADIUS } from '../settings';

type TPoint = {
    x: number;
    y: number;
    z: number;
};

const HALF = FOG.area / 2;
const CELL = FOG.area / FLUID.grid;

/**
 * Where a place on the floor falls on the fluid's grid, along one axis.
 */
const toCell = (world: number) => (world + HALF) / CELL;

/**
 * The fog: the fluid that moves it, the two pictures it is read through, and the sheets that draw
 * it. What stirs it (the ball) tells it where it is and when it lands, through what this returns.
 */
export const Fog = () => {
    // The pillars and the altar are walls the air has to go round.
    const fluid = createFluid(FLUID.grid, CELL);

    for (const [x, z] of PILLARS) {
        fluid.markSolidCircle(toCell(x), toCell(z), (PILLAR_RADIUS + 0.04) / CELL);
    }

    fluid.markSolidRect(toCell(-ALTAR.width / 2), toCell(-ALTAR.depth / 2), toCell(ALTAR.width / 2), toCell(ALTAR.depth / 2));

    const fluidPixels = createPixels(FLUID.grid, FLUID.grid, '#000000');
    const liftPixels = createPixels(FLUID.grid, FLUID.grid, '#000000');
    const fluidMap = createPixelTexture(fluidPixels);
    const liftMap = createPixelTexture(liftPixels);

    const knobs: TFogKnobs = {
        ball: [BALL.start.x, BALL.radius, BALL.start.z],
        radius: BALL.radius,
        area: [-HALF, -HALF, FOG.area, FOG.area],
        bottom: FOG.bottom,
        top: FOG.top,
        step: (FOG.slicesTop - FOG.bottom) / FOG.slices,
        sigma: FOG.sigma,
        offsetRange: FOG.offsetRange,
        liftHeight: FOG.liftHeight,
        glow: FOG.glow,
        fogColor: FOG.color,
        glowColor: FOG.glowColor,
        debug: 0
    };

    // One material for every sheet: they differ only in their height, which the shader reads.
    const material = createMaterial({
        shader: 'mesh3d',
        name: 'volumetric fog sheet',
        transparent: true,
        cull: 'none',
        fragment: FOG_WGSL,
        fragmentGlsl: FOG_GLSL,
        maps: {
            fluid: {
                texture: fluidMap,
                wrap: 'clamp',
                smooth: true
            },
            lift: {
                texture: liftMap,
                wrap: 'clamp',
                smooth: true
            }
        },
        uniforms: knobs
    });

    const sheet = usePlaneGeometry({
        width: FOG.area,
        depth: FOG.area
    });

    for (let i = 0; i < FOG.slices; i++) {
        createMesh({
            geometry: sheet,
            material,
            castShadow: false,
            transform: {
                x: 0,
                y: FOG.bottom + (FOG.slicesTop - FOG.bottom) * (i + 0.5) / FOG.slices,
                z: 0
            }
        });
    }

    const ball: TPoint = {
        x: BALL.start.x,
        y: BALL.radius,
        z: BALL.start.z
    };

    const ballVelocity: TPoint = {
        x: 0,
        y: 0,
        z: 0
    };

    const showFluid = useAction('fluid');

    // The game steps at exactly 60 a second (`step` in index.ts), so one update is one step of the
    // fluid, and it behaves the same on any screen.
    useUpdate((delta) => {
        if (showFluid.justPressed()) {
            knobs.debug = knobs.debug > 0 ? 0 : 1;
        }

        // How deep the ball is in the fog: at the top of a jump it touches nothing.
        const inside = Math.min(1, Math.max(0, (FOG.top + 0.2 - (ball.y - BALL.radius)) / 0.8));

        fluid.pushBall(toCell(ball.x), toCell(ball.z), BALL.radius / CELL, ballVelocity.x / CELL, ballVelocity.z / CELL, inside, Math.max(0, ballVelocity.y));
        fluid.step(delta);
        fluid.write(fluidPixels.data, liftPixels.data, FOG.offsetRange);
        updatePixelTexture(fluidMap);
        updatePixelTexture(liftMap);
    });

    return {
        /**
         * Where the ball is and how it moves, each frame.
         */
        ballAt: (at: TPoint, velocity: TPoint) => {
            ball.x = at.x;
            ball.y = at.y;
            ball.z = at.z;
            ballVelocity.x = velocity.x;
            ballVelocity.y = velocity.y;
            ballVelocity.z = velocity.z;
            knobs.ball[0] = at.x;
            knobs.ball[1] = at.y;
            knobs.ball[2] = at.z;
        },
        /**
         * A blow at a place on the floor: the air thrown outward within `radius` world units, and the
         * fog lifted by `raise`, 0 to 1.
         */
        blast: (x: number, z: number, radius: number, strength: number, raise: number) => {
            fluid.blast(toCell(x), toCell(z), radius / CELL, strength, raise);
        },
        /**
         * One cell of the fluid as it is now, by its place on the grid.
         */
        cell: fluid.cell,
        /**
         * Thins the fog to a veil, or brings it back.
         */
        veil: (thin: boolean) => {
            knobs.sigma = thin ? FLOW.veilSigma : FOG.sigma;
        }
    };
};

export type TFog = ReturnType<typeof Fog>;
```

`components/hall.ts`:

```ts
import { createMesh, useCubeGeometry, useCylinderGeometry, usePlaneGeometry } from 'nacatamalon';
import { createPixels, createPixelTexture, fillNoise, fillRect } from 'nacatamalon/pixels';
import { ALTAR, PILLARS, PILLAR_RADIUS } from '../settings';

/**
 * A stone slab, painted in code: noise, and the joints that cut it into four.
 */
const paintFloor = () => {
    const tile = createPixels(32, 32);

    fillNoise(tile, {
        seed: 3,
        scale: 6,
        octaves: 3,
        from: '#2a2f3d',
        to: '#4a5164'
    });
    fillRect(tile, 0, 0, 32, 1, '#191c26');
    fillRect(tile, 0, 16, 32, 1, '#191c26');
    fillRect(tile, 0, 0, 1, 32, '#191c26');
    fillRect(tile, 16, 0, 1, 32, '#191c26');

    return createPixelTexture(tile);
};

/**
 * The temple the fog lies in: a floor, the pillars it flows round and is hidden by, and the altar.
 */
export const Hall = () => {
    // The floor in pieces of 2 by 2, so each one is lit by the lights near it.
    const slab = paintFloor();

    const piece = usePlaneGeometry({
        width: 2,
        depth: 2,
        widthSegments: 4,
        depthSegments: 4
    });

    for (let x = -11; x <= 11; x += 2) {
        for (let z = -11; z <= 11; z += 2) {
            createMesh({
                geometry: piece,
                texture: slab,
                transform: {
                    x,
                    y: 0,
                    z
                }
            });
        }
    }

    const column = useCylinderGeometry({
        radius: PILLAR_RADIUS,
        height: 3.2,
        segments: 12
    });

    const capital = useCubeGeometry({
        width: 1.1,
        height: 0.3,
        depth: 1.1
    });

    for (const [x, z] of PILLARS) {
        createMesh({
            geometry: column,
            tint: '#8c8aa0',
            transform: {
                x,
                y: 1.6,
                z
            }
        });
        createMesh({
            geometry: capital,
            tint: '#a6a3b8',
            transform: {
                x,
                y: 3.35,
                z
            }
        });
        createMesh({
            geometry: capital,
            tint: '#6f6d82',
            transform: {
                x,
                y: 0.15,
                z
            }
        });
    }

    createMesh({
        geometry: useCubeGeometry(ALTAR),
        tint: '#5d5a70',
        transform: {
            x: 0,
            y: ALTAR.height / 2,
            z: 0
        }
    });
};
```

`components/views.ts`:

```ts
import { createSprite, createText, listen, useAction, useLines, useUpdate } from 'nacatamalon';
import { FLOW, FLUID, FOG, VIEWS, WIDTH } from '../settings';
import type { TFog } from './fog';

const HALF = FOG.area / 2;
const CELL = FOG.area / FLUID.grid;

/**
 * Seven numbers per corner of a line, as `useLines` takes them: x, y, z, r, g, b, a.
 */
const CORNER = 7;

/**
 * The height of each sheet, the same as the fog component lays them at.
 */
const sheetHeight = (i: number) => FOG.bottom + (FOG.slicesTop - FOG.bottom) * (i + 0.5) / FOG.slices;

/**
 * The sheets as lines: each one is two triangles, its outline and a diagonal, and never moves.
 */
const sheetOutlines = () => {
    const vertices: number[] = [];
    const corners = [[-HALF, -HALF], [HALF, -HALF], [HALF, HALF], [-HALF, HALF]];

    const push = (x: number, y: number, z: number, alpha: number) => {
        vertices.push(x, y, z, 0.55, 0.75, 1, alpha);
    };

    for (let i = 0; i < FOG.slices; i++) {
        const y = sheetHeight(i);
        const alpha = 0.35 + 0.5 * (1 - i / FOG.slices);

        for (let k = 0; k < 4; k++) {
            const next = corners[(k + 1) % 4];

            push(corners[k][0], y, corners[k][1], alpha);
            push(next[0], y, next[1], alpha);
        }

        push(-HALF, y, -HALF, alpha * 0.6);
        push(HALF, y, HALF, alpha * 0.6);
    }

    return new Float32Array(vertices);
};

/**
 * The three ways to look at the fog, switched with V or the button at the top right: FOG as it is,
 * SHEETS in wireframe, and FLOW, the fluid underneath as a mesh. In FLOW the fog thins to a veil.
 */
export const Views = ({ fog }: { fog: TFog; }) => {
    let view = 0;

    const button = createSprite({
        width: 150,
        height: 18,
        tint: '#1c2338',
        anchor: {
            x: 1,
            y: 0
        },
        zIndex: 100,
        transform: {
            x: WIDTH - 8,
            y: 5
        }
    });

    const label = createText({
        text: '',
        tint: '#ffd9a0',
        anchor: {
            x: 0.5,
            y: 0
        },
        zIndex: 101,
        transform: {
            x: WIDTH - 83,
            y: 10
        }
    });

    const [sheetLines, setSheetLines] = useLines();
    const [flowLines, setFlowLines] = useLines();

    setSheetLines(sheetOutlines());

    // The fluid's mesh: one cell in `FLOW.every` each way, made again each frame it shows. Each point
    // goes where the fog that was there has been carried, rises with what was thrown up, and goes
    // from blue at rest to orange where the air hurries.
    const across = Math.floor(FLUID.grid / FLOW.every);
    const points = new Float32Array(across * across * CORNER);
    const mesh = new Float32Array(across * (across - 1) * 2 * 2 * CORNER);

    const buildFlow = () => {
        for (let j = 0; j < across; j++) {
            for (let i = 0; i < across; i++) {
                const cell = fog.cell(i * FLOW.every + 1, j * FLOW.every + 1);
                const o = (j * across + i) * CORNER;
                const hurry = Math.min(1, cell.speed / 30);

                points[o] = -HALF + (i * FLOW.every + 1.5) * CELL - cell.offX;
                points[o + 1] = 0.05 + Math.min(1, cell.lift) * FOG.liftHeight + (cell.density - 1) * 0.35;
                points[o + 2] = -HALF + (j * FLOW.every + 1.5) * CELL - cell.offZ;
                points[o + 3] = 0.35 + 0.65 * hurry;
                points[o + 4] = 0.75 - 0.2 * hurry;
                points[o + 5] = 1 - 0.7 * hurry;
                points[o + 6] = 0.25 + 0.65 * Math.min(1, cell.density);
            }
        }

        let written = 0;

        const copy = (point: number) => {
            for (let k = 0; k < CORNER; k++) {
                mesh[written++] = points[point * CORNER + k];
            }
        };

        // Each row and each column joined point to point.
        for (let j = 0; j < across; j++) {
            for (let i = 0; i < across - 1; i++) {
                copy(j * across + i);
                copy(j * across + i + 1);
                copy(i * across + j);
                copy((i + 1) * across + j);
            }
        }

        setFlowLines(mesh);
    };

    const show = () => {
        label.text = `[V] VIEW: ${VIEWS[view]}`;
        sheetLines.visible = VIEWS[view] === 'SHEETS';
        flowLines.visible = VIEWS[view] === 'FLOW';
        fog.veil(VIEWS[view] === 'FLOW');
    };

    const next = () => {
        view = (view + 1) % VIEWS.length;
        show();
    };

    listen(button, {
        onClick: next
    });
    listen(label, {
        onClick: next
    });
    show();

    const switchView = useAction('view');

    useUpdate(() => {
        if (switchView.justPressed()) {
            next();
        }

        if (VIEWS[view] === 'FLOW') {
            buildFlow();
        }
    });
};
```

`scenes/temple.ts`:

```ts
import { cameraLookAt, createScene, createText, useAmbientLight, useCamera3d, useDirectionalLight, useFog, useSpawn, useUpdate } from 'nacatamalon';
import { Ball } from '../components/ball';
import { Fog } from '../components/fog';
import { Hall } from '../components/hall';
import { Views } from '../components/views';
import { HEIGHT } from '../settings';

/**
 * A dark temple with fog on the floor. The fog is made before the ball, which is handed what the
 * fog returns so it can stir it.
 */
export const Temple = () => {
    const camera = useCamera3d({
        projection: 'perspective',
        fov: 50,
        transform: {
            x: 0,
            y: 7.5,
            z: 8
        }
    });

    // The engine's distance fog only darkens the far end; the fog on the floor is components/fog.ts.
    useFog({
        color: '#0b0f1a',
        near: 9,
        far: 24
    });
    useAmbientLight({
        color: '#6a7aa8',
        intensity: 0.35
    });
    useDirectionalLight({
        color: '#b8c8ff',
        intensity: 0.55,
        transform: {
            rotationX: -1.0,
            rotationY: 0.6
        }
    });

    useSpawn(Hall)();

    const fog = useSpawn(Fog)();

    const ball = useSpawn(Ball)({
        fog: fog.api
    });

    useSpawn(Views)({
        fog: fog.api
    });

    createText({
        text: 'WASD / ARROWS: MOVE   SPACE: JUMP   T: SHOW FLUID',
        tint: '#9fb0d8',
        zIndex: 100,
        transform: {
            x: 8,
            y: 8
        }
    });
    createText({
        text: 'VOLUMETRIC FOG · NACATAMALON',
        tint: '#8090b8',
        zIndex: 100,
        transform: {
            x: 8,
            y: HEIGHT - 16
        }
    });

    // The camera follows the ball from above, a little behind it.
    useUpdate((delta) => {
        const at = ball.api.mesh.transform;
        const follow = 1 - Math.exp(-2.5 * delta);

        camera.transform.x += (at.x * 0.6 - camera.transform.x) * follow;
        camera.transform.z += (at.z * 0.6 + 9 - camera.transform.z) * follow;
        cameraLookAt(camera, {
            x: at.x * 0.6,
            y: 0.4,
            z: at.z * 0.6
        });
    });

    return createScene();
};
```

`settings.ts`:

```ts
import { INPUT, type TActionDef } from 'nacatamalon';

/**
 * The game's size, in its own pixels.
 */
export const WIDTH = 640;
export const HEIGHT = 360;

/**
 * Moving and jumping as a platformer reads them (WASD or the arrows, space, a pad), T to see the
 * fluid that moves the fog, and V to switch views.
 */
export const ACTIONS: TActionDef[] = [
    ...INPUT.ACTION_PRESETS.platformer,
    {
        name: 'fluid',
        bindings: [
            {
                type: 'key',
                key: 't'
            }
        ]
    },
    {
        name: 'view',
        bindings: [
            {
                type: 'key',
                key: 'v'
            }
        ]
    }
];

/**
 * What V and the button at the top right show: the fog as it is, the sheets in wireframe (they
 * never move: all the motion is in the shader), and the fluid as a mesh whose points follow the air.
 */
export const VIEWS = ['FOG', 'SHEETS', 'FLOW'] as const;

/**
 * The fluid's mesh in the FLOW view takes one cell in `every`. While it shows, the fog thins to a
 * veil of this thickness so the mesh can be seen.
 */
export const FLOW = {
    every: 2,
    veilSigma: 1.2
};

/**
 * The fog: a square of `area` by `area` world units round the middle, drawn as `slices` see-through
 * sheets from `bottom` up to `slicesTop`. At rest it reaches `top`; the sheets go higher so there is
 * room for what a landing throws up, up to `liftHeight` more.
 */
export const FOG = {
    area: 22,
    slices: 48,
    bottom: 0.02,
    top: 1.7,
    slicesTop: 3.4,
    liftHeight: 1.6,
    /**
     * How thick it is: how much light a unit of it takes.
     */
    sigma: 5.5,
    /**
     * How far, in world units, the air can carry the fog's wisps from where they started.
     */
    offsetRange: 6,
    color: [0.62, 0.68, 0.82],
    /**
     * The ball's light in the fog round it.
     */
    glow: 0.4,
    glowColor: [1.0, 0.55, 0.25]
};

/**
 * The fluid under the fog: a grid of `grid` by `grid` cells over the same square.
 */
export const FLUID = {
    grid: 96
};

/**
 * The glowing ball, in world units and seconds. `limit` is how far from the middle it may roll.
 */
export const BALL = {
    radius: 0.5,
    start: {
        x: 2,
        z: 2
    },
    accel: 22,
    gravity: 22,
    jump: 8.5,
    limit: 7.5,
    /**
     * How fast it slows: gently while it is steered, almost at once when let go.
     */
    drag: 3.5,
    stopDrag: 9
};

/**
 * Left alone this many seconds, the ball drives itself in a figure of eight and jumps now and then.
 */
export const IDLE_SECONDS = 3;

/**
 * The temple's pillars, where they stand on the floor (x, z), and the altar in the middle.
 */
export const PILLARS: readonly (readonly [number, number])[] = [[-3.5, -3.5], [3.5, -3.5], [-3.5, 3.5], [3.5, 3.5], [0, -5.5], [-5.5, 0], [5.5, 0]];

export const PILLAR_RADIUS = 0.38;

export const ALTAR = {
    width: 1.6,
    height: 0.6,
    depth: 1.0
};
```

`shaders/fog.ts`:

```ts
// The fog, layer by layer: see-through horizontal sheets, each reading a 3D noise at its height in
// the world. Together they add up the density as a raymarch would, and the engine's depth test is
// free: anything in front of the fog hides it, with no depth to read.
//
// How it moves is not the shader's to decide: the fluid simulated on the CPU decides, and it comes
// in two maps. The shader only reads from them how much fog there is, where the air carried it and
// how high it was thrown, and draws it in 3D.
//
// Both languages say the same, line for line: WGSL for WebGPU, GLSL for WebGL2.

/**
 * The knobs the shader reads (`mu.<name>`), changed from the game each frame: where the ball is,
 * and T's view of the fluid.
 */
export type TFogKnobs = {
    ball: number[];
    radius: number;
    area: number[];
    bottom: number;
    top: number;
    step: number;
    sigma: number;
    offsetRange: number;
    liftHeight: number;
    glow: number;
    fogColor: number[];
    glowColor: number[];
    debug: number;
};

export const FOG_WGSL = /* wgsl */ `
fn fogHash(p: vec3<f32>) -> f32 {
    let q = fract(p * 0.3183099 + vec3<f32>(0.1, 0.2, 0.3)) * 17.0;
    return fract(q.x * q.y * q.z * (q.x + q.y + q.z));
}

fn fogNoise(p: vec3<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let a = mix(fogHash(i + vec3<f32>(0.0, 0.0, 0.0)), fogHash(i + vec3<f32>(1.0, 0.0, 0.0)), u.x);
    let b = mix(fogHash(i + vec3<f32>(0.0, 1.0, 0.0)), fogHash(i + vec3<f32>(1.0, 1.0, 0.0)), u.x);
    let c = mix(fogHash(i + vec3<f32>(0.0, 0.0, 1.0)), fogHash(i + vec3<f32>(1.0, 0.0, 1.0)), u.x);
    let d = mix(fogHash(i + vec3<f32>(0.0, 1.0, 1.0)), fogHash(i + vec3<f32>(1.0, 1.0, 1.0)), u.x);
    return mix(mix(a, b, u.y), mix(c, d, u.y), u.z);
}

fn fogFbm(p: vec3<f32>) -> f32 {
    return (fogNoise(p) * 0.5 + fogNoise(p * 2.03 + vec3<f32>(5.2, 1.3, 7.1)) * 0.3
        + fogNoise(p * 4.01 + vec3<f32>(2.7, 9.1, 3.3)) * 0.2);
}

fn effect(surface: vec4<f32>, ctx: FragContext) -> vec4<f32> {
    let p = ctx.worldPos;
    let uv = (p.xz - mu.area.xy) / mu.area.zw;

    // What the fluid says here: how much fog, where the air carried it, and how high it was thrown.
    let fluid = sampleMap_fluid(uv);
    let raised = sampleMap_lift(uv);
    let amount = fluid.r * 2.0;
    let drift = (fluid.gb - vec2<f32>(0.5)) * mu.offsetRange;
    let lift = raised.r;

    // The layer grows upward where it was thrown, and thins a little underneath.
    let top = mu.top + lift * mu.liftHeight;
    let h = clamp((p.y - mu.bottom) / (top - mu.bottom), 0.0, 1.0);
    let profile = smoothstep(0.0, 0.06, h) * (1.0 - smoothstep(0.45, 1.0, h)) * (1.0 - lift * 0.45 * (1.0 - h));

    // The noise is read where the air has carried this fog: the wisps travel with the current.
    let wind = vec3<f32>(mu.time * 0.08, mu.time * 0.03, mu.time * 0.05);
    let q = vec3<f32>(p.x + drift.x, p.y - lift * 0.9, p.z + drift.y) * vec3<f32>(0.55, 1.4, 0.55) + wind;
    let n = fogFbm(q);
    var density = (0.35 + clamp((n - 0.36) * 2.2, 0.0, 1.0)) * profile * amount;

    let edge = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));
    density = density * smoothstep(0.0, 0.18, edge + (n - 0.5) * 0.12);

    // The ball takes up its room in height too (the fluid only knows about the floor).
    let toBall = p - mu.ball;
    let dist = length(toBall) + (n - 0.5) * 0.35;
    density = density * smoothstep(mu.radius * 0.9, mu.radius * 1.6, dist);

    let thickness = mu.step / max(abs(ctx.viewDir.y), 0.2);
    let alpha = 1.0 - exp(-density * mu.sigma * thickness);

    let glow = mu.glowColor * (mu.glow * exp(-length(toBall) * 1.15));
    let tone = mix(0.55, 1.05, h) * (0.85 + n * 0.3) * (1.0 + raised.g * 0.25);
    let color = mu.fogColor * tone + glow;

    // T shows the fluid: red the fog, green and blue where it moved.
    let shown = mix(color, vec3<f32>(fluid.r, fluid.g, fluid.b), mu.debug);
    return vec4<f32>(shown, clamp(max(alpha, mu.debug * 0.35 * step(0.5, h) * step(h, 0.55)), 0.0, 1.0));
}
`;

export const FOG_GLSL = /* glsl */ `
float fogHash(vec3 p) {
    vec3 q = fract(p * 0.3183099 + vec3(0.1, 0.2, 0.3)) * 17.0;
    return fract(q.x * q.y * q.z * (q.x + q.y + q.z));
}

float fogNoise(vec3 p) {
    vec3 i = floor(p);
    vec3 f = fract(p);
    vec3 u = f * f * (3.0 - 2.0 * f);
    float a = mix(fogHash(i + vec3(0.0, 0.0, 0.0)), fogHash(i + vec3(1.0, 0.0, 0.0)), u.x);
    float b = mix(fogHash(i + vec3(0.0, 1.0, 0.0)), fogHash(i + vec3(1.0, 1.0, 0.0)), u.x);
    float c = mix(fogHash(i + vec3(0.0, 0.0, 1.0)), fogHash(i + vec3(1.0, 0.0, 1.0)), u.x);
    float d = mix(fogHash(i + vec3(0.0, 1.0, 1.0)), fogHash(i + vec3(1.0, 1.0, 1.0)), u.x);
    return mix(mix(a, b, u.y), mix(c, d, u.y), u.z);
}

float fogFbm(vec3 p) {
    return fogNoise(p) * 0.5 + fogNoise(p * 2.03 + vec3(5.2, 1.3, 7.1)) * 0.3
        + fogNoise(p * 4.01 + vec3(2.7, 9.1, 3.3)) * 0.2;
}

vec4 effect(vec4 surface, FragContext ctx) {
    vec3 p = ctx.worldPos;
    vec2 uv = (p.xz - mu.area.xy) / mu.area.zw;

    vec4 fluid = sampleMap_fluid(uv);
    vec4 raised = sampleMap_lift(uv);
    float amount = fluid.r * 2.0;
    vec2 drift = (fluid.gb - vec2(0.5)) * mu.offsetRange;
    float lift = raised.r;

    float top = mu.top + lift * mu.liftHeight;
    float h = clamp((p.y - mu.bottom) / (top - mu.bottom), 0.0, 1.0);
    float profile = smoothstep(0.0, 0.06, h) * (1.0 - smoothstep(0.45, 1.0, h)) * (1.0 - lift * 0.45 * (1.0 - h));

    vec3 wind = vec3(mu.time * 0.08, mu.time * 0.03, mu.time * 0.05);
    vec3 q = vec3(p.x + drift.x, p.y - lift * 0.9, p.z + drift.y) * vec3(0.55, 1.4, 0.55) + wind;
    float n = fogFbm(q);
    float density = (0.35 + clamp((n - 0.36) * 2.2, 0.0, 1.0)) * profile * amount;

    float edge = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));
    density *= smoothstep(0.0, 0.18, edge + (n - 0.5) * 0.12);

    vec3 toBall = p - mu.ball;
    float dist = length(toBall) + (n - 0.5) * 0.35;
    density *= smoothstep(mu.radius * 0.9, mu.radius * 1.6, dist);

    float thickness = mu.step / max(abs(ctx.viewDir.y), 0.2);
    float alpha = 1.0 - exp(-density * mu.sigma * thickness);

    vec3 glow = mu.glowColor * (mu.glow * exp(-length(toBall) * 1.15));
    float tone = mix(0.55, 1.05, h) * (0.85 + n * 0.3) * (1.0 + raised.g * 0.25);
    vec3 color = mu.fogColor * tone + glow;

    vec3 shown = mix(color, fluid.rgb, mu.debug);
    return vec4(shown, clamp(max(alpha, mu.debug * 0.35 * step(0.5, h) * step(h, 0.55)), 0.0, 1.0));
}
`;
```

`simulation/fluid.ts`:

```ts
// A 2D fluid (Jos Stam's stable fluids) on a grid that covers the floor under the fog. Plain code on
// the CPU, nothing of the engine: the fog component reads it into two pictures for the shader.
//
// What the grid holds, per cell:
// - u, v: the air's speed, in cells per second. This is what has inertia: when the ball stops dead,
//   the air keeps moving and slows down by itself.
// - density: how much fog there is (1 normal, 0 none, up to 2 piled up).
// - offX, offZ: how far the current has carried the fog's "stuff", in world units. The shader moves
//   its noise by this, so the wisps travel with the air.
// - lift: how high the fog has been thrown (jumps, landings), which travels with the air too.
//
// Each step: vorticity confinement so the swirls are not lost, advection, projection (the air does
// not compress, so it flows round the ball and the pillars), and then everything else travels with
// that speed.

export type TFluid = ReturnType<typeof createFluid>;

export const createFluid = (size: number, cellWorld: number) => {
    const n = size * size;
    let u = new Float32Array(n);
    let v = new Float32Array(n);
    let u2 = new Float32Array(n);
    let v2 = new Float32Array(n);
    let density = new Float32Array(n).fill(1);
    let density2 = new Float32Array(n);
    let offX = new Float32Array(n);
    let offZ = new Float32Array(n);
    let offX2 = new Float32Array(n);
    let offZ2 = new Float32Array(n);
    let lift = new Float32Array(n);
    let lift2 = new Float32Array(n);
    // The shock wave: air thrown outward. An incompressible fluid would erase it when it projects
    // (it is all divergence), so it is kept apart: it travels with itself, fades fast, and carries
    // the fog, its stuff and its height just as the ordinary air does.
    let su = new Float32Array(n);
    let sv = new Float32Array(n);
    let su2 = new Float32Array(n);
    let sv2 = new Float32Array(n);
    const pressure = new Float32Array(n);
    const divergence = new Float32Array(n);
    const curl = new Float32Array(n);
    const solid = new Uint8Array(n);

    const at = (x: number, y: number) => y * size + x;

    // Bilinear read, held at the edge.
    const sample = (field: Float32Array, x: number, y: number) => {
        x = Math.min(Math.max(x, 0), size - 1.001);
        y = Math.min(Math.max(y, 0), size - 1.001);

        const x0 = x | 0;
        const y0 = y | 0;
        const fx = x - x0;
        const fy = y - y0;
        const i = y0 * size + x0;
        const a = field[i] + (field[i + 1] - field[i]) * fx;
        const b = field[i + size] + (field[i + size + 1] - field[i + size]) * fx;

        return a + (b - a) * fy;
    };

    const markSolidCircle = (cx: number, cy: number, radius: number) => {
        for (let y = 0; y < size; y++) {
            for (let x = 0; x < size; x++) {
                if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) < radius) {
                    solid[at(x, y)] = 1;
                }
            }
        }
    };

    const markSolidRect = (x0: number, y0: number, x1: number, y1: number) => {
        for (let y = Math.max(0, Math.floor(y0)); y < Math.min(size, Math.ceil(y1)); y++) {
            for (let x = Math.max(0, Math.floor(x0)); x < Math.min(size, Math.ceil(x1)); x++) {
                solid[at(x, y)] = 1;
            }
        }
    };

    /**
     * The ball as a moving obstacle: where it is, the air goes at its speed, and the fog that was
     * there is pushed aside and piles up round it. `weight`, 0 to 1, is how deep it is in the fog
     * (in the air it pushes nothing); `rising` is how fast it goes up.
     */
    const pushBall = (cx: number, cy: number, radius: number, velX: number, velY: number, weight: number, rising: number) => {
        if (weight <= 0) {
            return;
        }

        const reach = radius * 1.35;
        const ring = radius * 2.4;
        let removed = 0;
        let ringCells = 0;
        const x0 = Math.max(1, Math.floor(cx - ring));
        const x1 = Math.min(size - 2, Math.ceil(cx + ring));
        const y0 = Math.max(1, Math.floor(cy - ring));
        const y1 = Math.min(size - 2, Math.ceil(cy + ring));

        for (let y = y0; y <= y1; y++) {
            for (let x = x0; x <= x1; x++) {
                const i = at(x, y);
                const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);

                if (d < reach) {
                    const w = (1 - (d / reach) ** 2) * weight;

                    u[i] += (velX - u[i]) * w;
                    v[i] += (velY - v[i]) * w;

                    const taken = density[i] * w * 0.9;

                    density[i] -= taken;
                    removed += taken;
                    // Going up inside the fog, it takes a little of it up too.
                    lift[i] = Math.min(1, lift[i] + rising * w * 0.08);
                } else if (d < ring && !solid[i]) {
                    ringCells++;
                }
            }
        }

        if (ringCells === 0 || removed === 0) {
            return;
        }

        const share = removed / ringCells;

        for (let y = y0; y <= y1; y++) {
            for (let x = x0; x <= x1; x++) {
                const i = at(x, y);
                const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);

                if (d >= reach && d < ring && !solid[i]) {
                    density[i] = Math.min(2, density[i] + share);
                }
            }
        }
    };

    /**
     * A shock wave: on landing (or taking off) the air is thrown outward, the fog in the middle goes
     * to the ring, and the layer is lifted. Part of the blow goes into the ordinary air as well,
     * where the pillars turn it into swirls.
     */
    const blast = (cx: number, cy: number, radius: number, strength: number, raise: number) => {
        const x0 = Math.max(1, Math.floor(cx - radius));
        const x1 = Math.min(size - 2, Math.ceil(cx + radius));
        const y0 = Math.max(1, Math.floor(cy - radius));
        const y1 = Math.min(size - 2, Math.ceil(cy + radius));

        for (let y = y0; y <= y1; y++) {
            for (let x = x0; x <= x1; x++) {
                const dx = x + 0.5 - cx;
                const dy = y + 0.5 - cy;
                const d = Math.hypot(dx, dy);
                const i = at(x, y);

                if (d >= radius || d < 0.001 || solid[i]) {
                    continue;
                }

                const k = d / radius;
                const falloff = Math.sin(k * Math.PI) * (1 - k * 0.4);

                su[i] += (dx / d) * strength * falloff;
                sv[i] += (dy / d) * strength * falloff;
                u[i] += (dx / d) * strength * falloff * 0.35;
                v[i] += (dy / d) * strength * falloff * 0.35;
                lift[i] = Math.min(1, lift[i] + raise * (1 - k) ** 1.5);
            }
        }
    };

    const confineVortices = (dt: number, epsilon: number) => {
        for (let y = 1; y < size - 1; y++) {
            for (let x = 1; x < size - 1; x++) {
                const i = at(x, y);

                curl[i] = (v[i + 1] - v[i - 1] - (u[i + size] - u[i - size])) * 0.5;
            }
        }

        for (let y = 2; y < size - 2; y++) {
            for (let x = 2; x < size - 2; x++) {
                const i = at(x, y);
                const gx = (Math.abs(curl[i + 1]) - Math.abs(curl[i - 1])) * 0.5;
                const gy = (Math.abs(curl[i + size]) - Math.abs(curl[i - size])) * 0.5;
                const length = Math.hypot(gx, gy) + 1e-5;
                const w = curl[i];

                u[i] += epsilon * (gy / length) * w * dt;
                v[i] -= epsilon * (gx / length) * w * dt;
            }
        }
    };

    const project = (iterations: number) => {
        for (let y = 1; y < size - 1; y++) {
            for (let x = 1; x < size - 1; x++) {
                const i = at(x, y);

                divergence[i] = (u[i + 1] - u[i - 1] + v[i + size] - v[i - size]) * 0.5;
            }
        }

        for (let k = 0; k < iterations; k++) {
            for (let y = 1; y < size - 1; y++) {
                for (let x = 1; x < size - 1; x++) {
                    const i = at(x, y);

                    pressure[i] = (pressure[i - 1] + pressure[i + 1] + pressure[i - size] + pressure[i + size] - divergence[i]) * 0.25;
                }
            }
        }

        for (let y = 1; y < size - 1; y++) {
            for (let x = 1; x < size - 1; x++) {
                const i = at(x, y);

                u[i] -= (pressure[i + 1] - pressure[i - 1]) * 0.5;
                v[i] -= (pressure[i + size] - pressure[i - size]) * 0.5;
            }
        }
    };

    const enforceWalls = () => {
        for (let i = 0; i < n; i++) {
            if (solid[i]) {
                u[i] = 0;
                v[i] = 0;
            }
        }

        for (let k = 0; k < size; k++) {
            u[at(0, k)] = 0;
            u[at(size - 1, k)] = 0;
            v[at(k, 0)] = 0;
            v[at(k, size - 1)] = 0;
        }
    };

    const step = (dt: number) => {
        confineVortices(dt, 9);
        enforceWalls();

        // The speed travels with itself and fades little by little: the inertia.
        const keep = Math.exp(-0.55 * dt);

        for (let y = 0; y < size; y++) {
            for (let x = 0; x < size; x++) {
                const i = at(x, y);
                const bx = x - u[i] * dt;
                const by = y - v[i] * dt;

                u2[i] = sample(u, bx, by) * keep;
                v2[i] = sample(v, bx, by) * keep;
            }
        }

        [u, u2] = [u2, u];
        [v, v2] = [v2, v];
        enforceWalls();
        // The last step's pressure is where this one starts, so a few rounds are enough.
        project(24);
        enforceWalls();

        // The shock wave travels outward with itself and dies out in a little over a second.
        const fade = Math.exp(-2.0 * dt);

        for (let y = 0; y < size; y++) {
            for (let x = 0; x < size; x++) {
                const i = at(x, y);

                if (solid[i]) {
                    su2[i] = 0;
                    sv2[i] = 0;
                    continue;
                }

                const bx = x - su[i] * dt;
                const by = y - sv[i] * dt;

                su2[i] = sample(su, bx, by) * fade;
                sv2[i] = sample(sv, bx, by) * fade;
            }
        }

        [su, su2] = [su2, su];
        [sv, sv2] = [sv2, sv];

        // Everything else is carried by the air, the ordinary one plus the blow's.
        const refill = 1 - Math.exp(-0.3 * dt);
        // What piled up spreads out sooner than what was emptied comes back.
        const spill = 1 - Math.exp(-0.8 * dt);
        const relax = Math.exp(-0.18 * dt);
        const settle = Math.exp(-0.7 * dt);

        for (let y = 0; y < size; y++) {
            for (let x = 0; x < size; x++) {
                const i = at(x, y);
                const wx = u[i] + su[i];
                const wy = v[i] + sv[i];
                const bx = x - wx * dt;
                const by = y - wy * dt;
                // Continuity: where the blow opens the air the fog empties, where it closes it piles up.
                const inside = x > 0 && y > 0 && x < size - 1 && y < size - 1;
                const spread = inside ? (su[i + 1] - su[i - 1] + sv[i + size] - sv[i - size]) * 0.5 : 0;
                const d = Math.min(2, sample(density, bx, by) * Math.exp(-spread * dt * 0.7));

                density2[i] = d + (1 - d) * (d > 1 ? spill : refill);
                offX2[i] = Math.max(-3, Math.min(3, (sample(offX, bx, by) - wx * dt * cellWorld) * relax));
                offZ2[i] = Math.max(-3, Math.min(3, (sample(offZ, bx, by) - wy * dt * cellWorld) * relax));
                lift2[i] = sample(lift, bx, by) * settle;
            }
        }

        [density, density2] = [density2, density];
        [offX, offX2] = [offX2, offX];
        [offZ, offZ2] = [offZ2, offZ];
        [lift, lift2] = [lift2, lift];
    };

    /**
     * Writes the state into the two pictures the shader reads: `fluidMap` with how much fog (red)
     * and how far it was carried (green and blue), `liftMap` with how high it was thrown (red) and
     * how fast the air moves (green).
     */
    const write = (fluidMap: Uint8Array, liftMap: Uint8Array, offsetRange: number) => {
        for (let i = 0; i < n; i++) {
            const o = i * 4;

            fluidMap[o] = Math.min(255, Math.max(0, Math.round((density[i] / 2) * 255)));
            fluidMap[o + 1] = Math.min(255, Math.max(0, Math.round((offX[i] / offsetRange + 0.5) * 255)));
            fluidMap[o + 2] = Math.min(255, Math.max(0, Math.round((offZ[i] / offsetRange + 0.5) * 255)));
            fluidMap[o + 3] = 255;

            const speed = Math.hypot(u[i] + su[i], v[i] + sv[i]);

            liftMap[o] = Math.round(Math.min(1, lift[i]) * 255);
            liftMap[o + 1] = Math.round(Math.min(1, speed / 40) * 255);
            liftMap[o + 2] = 0;
            liftMap[o + 3] = 255;
        }
    };

    /**
     * One cell as it is now: how much fog, how far the air carried it, how high it was thrown, and
     * how fast the air moves there.
     */
    const cell = (x: number, y: number) => {
        const i = at(x, y);

        return {
            density: density[i],
            offX: offX[i],
            offZ: offZ[i],
            lift: lift[i],
            speed: Math.hypot(u[i] + su[i], v[i] + sv[i])
        };
    };

    return {
        size,
        markSolidCircle,
        markSolidRect,
        pushBall,
        blast,
        step,
        write,
        cell
    };
};
```
