A quick-sheet of directed camera motions — shake, knockdown, lunge — that make the 360° POV feel like a body, not a tripod

Camera Emotes

A quick-sheet of directed camera motions — shake, knockdown, lunge — that make the 360° POV feel like a body, not a tripod


Why direct the camera?

Fly-to points the camera at things. Emotes are the other half of embodiment: they move the camera as a thing. A specimen that takes a hit should shake; one that gets slammed should hit the floor, lie stunned, and pull itself back up; a dash forward should kick the field of view the way speed does. Trigger these at the right game moments and the viewer stops feeling like a tripod in a skybox and starts feeling like a head that belongs to something.

Technically an emote is nothing new: it's a keyframe timeline of lookAt writes, played client-side. No component changes, no server in the loop — just the imperative camera API sequenced with intent.

The quick sheet

Look anywhere first — every emote plays relative to your current gaze, then comes to rest (back at your gaze, or re-centered "home" for the knockdown — a body rights itself):

# File: docs/emotes/emotes_example.py

import dash_mantine_components as dmc
from dash import Input, Output, State, clientside_callback, html

from dash_pannellum import DashPannellum

# The quick sheet — keys must match window.CAM_EMOTES.SHEET
# (assets/camera_emotes.js), which owns the keyframe timelines.
EMOTES = {
    "shake": "🫨 Shake",
    "knockdown": "🪦 Hit the floor",
    "lunge": "⚔️ Lunge",
    "dizzy": "😵 Dizzy",
    "scan": "👀 Scan",
    "flinch": "🤕 Flinch",
}

component = html.Div(
    [
        DashPannellum(
            id="emote-pano",
            tour={
                "default": {"firstScene": "springhouse"},
                "scenes": {
                    "springhouse": {
                        "title": "Spring House",
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/bma-0.jpg",
                        "autoLoad": True,
                        "yaw": 5,
                        "pitch": 0,
                        "hfov": 95,
                        # Leave headroom for the emotes' hfov kicks
                        "minHfov": 45,
                        "maxHfov": 115,
                    }
                },
            },
            autoLoad=True,
            width="100%",
            height="420px",
        ),
        dmc.Group(
            [
                dmc.Button(
                    label,
                    id=f"emote-btn-{name}",
                    variant="light",
                    size="xs",
                )
                for name, label in EMOTES.items()
            ],
            mt="md",
            gap="xs",
            wrap="wrap",
        ),
        dmc.Alert(
            "Look anywhere in the scene, then trigger an emote — every motion "
            "plays relative to your current gaze.",
            id="emote-status",
            color="gray",
            mt="sm",
        ),
        dmc.Code("camera idle", id="emote-readout", mt="xs"),
    ]
)

# One clientside callback for the whole sheet: the triggered button picks the
# emote; the pano's reported pitch/yaw/hfov States are the motion's base.
# Playback is pure lookAt sequencing — the server is never involved.
clientside_callback(
    """async function(n1, n2, n3, n4, n5, n6, pitch, yaw, hfov) {
        const trig = window.dash_clientside.callback_context.triggered_id;
        if (!trig) { return window.dash_clientside.no_update; }
        const name = String(trig).replace('emote-btn-', '');
        return await window.CAM_EMOTES.play('emote-pano', name, {
            pitch: pitch, yaw: yaw, hfov: hfov,
        });
    }""",
    Output("emote-status", "children"),
    [Input(f"emote-btn-{name}", "n_clicks") for name in EMOTES],
    State("emote-pano", "pitch"),
    State("emote-pano", "yaw"),
    State("emote-pano", "hfov"),
    prevent_initial_call=True,
)


# Live camera state, so the motion is legible while it plays.
clientside_callback(
    """function(pitch, yaw, hfov) {
        const f = (v, d) => (typeof v === 'number' ? v.toFixed(d) : '0');
        return 'pitch ' + f(pitch, 1) + '\\u00b0 \\u00b7 yaw ' + f(yaw, 1)
            + '\\u00b0 \\u00b7 hfov ' + f(hfov, 0) + '\\u00b0';
    }""",
    Output("emote-readout", "children"),
    Input("emote-pano", "pitch"),
    Input("emote-pano", "yaw"),
    Input("emote-pano", "hfov"),
)

:defaultExpanded: false :withExpandedButton: true

EmoteGame momentMotion recipe
🫨 Shaketaking damage6 jitters ±3.4°, each ~0.6× the last, 70 ms apiece
🪦 Hit the floorknockdown / deathpitch −85° in 150 ms → 520 ms stunned hold → rise + re-center over 1.6 s
⚔️ Lungeattack / dashhfov punch −36° in 130 ms, brief hold, 650 ms ease back
😵 Dizzystun / poisonslow yaw-pitch circle, +8° hfov blur-out, refocus
👀 Scanspawn / wake−60° sweep, +120° sweep, settle forward
🤕 Flinchnear misspitch +7° & hfov +10° in 90 ms, 480 ms recover

The motion-design rules

The sheet (assets/camera_emotes.js) encodes a few principles worth stealing for your own emotes:

  1. In fast, out slow. Impacts are 90–150 ms; recoveries are 500–1600 ms.

Reversed, the same keyframes read as floaty nonsense.

  1. Shakes decay. Constant-amplitude jitter reads as a broken gimbal;

multiplying amplitude by ~0.6 per cycle reads as absorbed impact.

  1. Speed is field-of-view, not rotation. A lunge that yaws feels like a

turn; a lunge that punches hfov in and eases it back feels like acceleration. (Racing games have abused this forever.)

  1. End at rest. Every timeline's last frame returns to the base gaze or

to a deliberate HOME. Never strand the camera mid-emote.

  1. Stay under ~2.5 s. Longer stops being a reflex and starts being a

cutscene — which is fine, but then you're back to fly-to territory.

  1. One at a time. The player has a busy-guard; queuing or blending

emotes is how cameras get motion-sick.

Anatomy of a keyframe

// assets/camera_emotes.js — knockdown, the "hit the floor & rise" emote
frames(b) {            // b = {pitch, yaw, hfov} at trigger time
    return [
        {pitch: -85, hfov: b.hfov + 14, ms: 150},          // slam DOWN, fast
        {pitch: -85, ms: 60, hold: 520},                   // stunned beat
        {pitch: -22, yaw: HOME.yaw, hfov: b.hfov, ms: 950}, // rise, stage 1
        {pitch: 0,   yaw: HOME.yaw, ms: 700},               // upright, centered
    ];
}

Each frame becomes one set_props(id, {lookAt: {...view, animated: ms}}), awaited for ms + hold. Omitted axes hold their current value — lookAt semantics — so a frame that only touches hfov leaves your gaze alone.

Triggering from game moments

The example uses buttons, but the trigger is just any Dash event. The pattern mirrors fly-to: pick the emote server-side if the game logic lives there, or fire it straight from a clientside callback if the moment is already in the browser (a collision in your game loop, a websocket push):

# server-side game logic decides, client plays it
clientside_callback(
    """async function(hit, pitch, yaw, hfov) {
        if (!hit) { return window.dash_clientside.no_update; }
        const name = hit.fatal ? 'knockdown' : (hit.grazed ? 'flinch' : 'shake');
        return await window.CAM_EMOTES.play('game-pano', name, {pitch, yaw, hfov});
    }""",
    Output("emote-log", "children"),
    Input("hit-store", "data"),          # written by your turn resolver
    State("game-pano", "pitch"), State("game-pano", "yaw"), State("game-pano", "hfov"),
    prevent_initial_call=True,
)

Emotes compose with everything else in the package: they're lookAt-only, so they run identically over a static panorama, a tour scene, or a live dynamic-canvas arena — shake the camera while the world redraws under it. A good combo to try: fly-to a contact, then lunge on arrival.

The base gaze comes from the component's reported pitch/yaw/hfov States (throttled to 4/s) — close enough for reflex moves. If emote choreography gets heavier (chained sequences, eased curves, camera paths), the package-level answer would be a playSequence prop so timelines run inside the component against the live viewer state. Wishlist material for 0.4.0.


Source: /components/emotes

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: