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
| Emote | Game moment | Motion recipe |
|---|---|---|
| 🫨 Shake | taking damage | 6 jitters ±3.4°, each ~0.6× the last, 70 ms apiece |
| 🪦 Hit the floor | knockdown / death | pitch −85° in 150 ms → 520 ms stunned hold → rise + re-center over 1.6 s |
| ⚔️ Lunge | attack / dash | hfov punch −36° in 130 ms, brief hold, 650 ms ease back |
| 😵 Dizzy | stun / poison | slow yaw-pitch circle, +8° hfov blur-out, refocus |
| 👀 Scan | spawn / wake | −60° sweep, +120° sweep, settle forward |
| 🤕 Flinch | near miss | pitch +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:
- In fast, out slow. Impacts are 90–150 ms; recoveries are 500–1600 ms.
Reversed, the same keyframes read as floaty nonsense.
- Shakes decay. Constant-amplitude jitter reads as a broken gimbal;
multiplying amplitude by ~0.6 per cycle reads as absorbed impact.
- 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.)
- End at rest. Every timeline's last frame returns to the base gaze or
to a deliberate HOME. Never strand the camera mid-emote.
- 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.
- 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:
- /components/emotes/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt