gestures - fswolf/luna-local-agent GitHub Wiki
How Luna's 3D portrait moves its body, how she decides when to move it, and how to write your own gestures in a few lines of JavaScript.
Everything here is about VRM (3D) models. Live2D models have their own motions and don't use any of this.
- What a gesture is
- From her reply to her body
- How she picks one
- What happens every frame
- The skeleton and its numbers
- Anatomy of a gesture file
- Writing one, step by step
- Common mistakes
- Code or a .vrma clip?
A gesture is a short list of key poses in time. A pose says where some of her bones point: "right upper arm raised, forearm bent up, hand turned so the palm faces you". The portrait eases smoothly from one key to the next and blends the whole thing over whatever she's already doing (breathing, looking around, talking), so a gesture never snaps on or off.
Each gesture is one file in portrait/gestures/:
portrait/
├── viewer/gestures.js the player: loads the files, blends them, plays them
└── gestures/
├── _pose.js shared helpers (rest pose, mirroring, fingers)
├── wave.js
├── hug.js
├── wink.js
└── ... one file per gesture
The file name is the gesture's name: wink.js is wink. Files starting
with _ are helpers or notes and are never loaded as gestures.
you: "I missed you"
│
▼
Luna's prompt offers her the tags (gestures.py reads every gesture
[wave] [hug] [wink] ... file with an "about" line)
│
▼
she replies "I missed you too [hug]"
│
├── the screen, the voice and her history get "I missed you too"
│ (the tag is cut out before anything is shown or spoken)
│
└── the live monitor sends {"t": "gesture", "name": "hug"}
│
▼
the portrait page finds hug.js (already loaded) and plays it
- The portrait asks the server for
gestures/index.json: a list of every.jsfile inportrait/gestures/except the_ones. - It loads each file and reads its settings.
left: truemakes a mirrored copy called<name>_left,idle: trueadds it to her idle moves,private: truekeeps it away from stream chat. - The viewer (
./viewer.sh) makes a button for every gesture it found. A file with a mistake in it gets a pink line in the panel saying which file and what's wrong, instead of a missing button.
Files are read fresh on every page load, so after editing a gesture, F5 is enough.
| Trigger | How |
|---|---|
| She chooses | In tags mode (the default) her prompt lists every gesture that has an about: line, and she writes the tag where it fits. Private ones are only offered in your own conversations |
| Meaning | In meaning mode the embedding model compares what was said with each gesture's description |
| Keywords | A word from a gesture's keywords inside an action she writes (*beckons you closer*), or its emoji anywhere. Also you saying hi or asking for a hug |
| Chat | Viewers' !wave, !hug, a first "hi", "lol" (never private gestures) |
| You |
/portrait wink, or the button in the viewer |
| On her own | Gestures with idle: true, every 25 to 55 seconds while she's idle |
| Events | She waves when she wakes up and shrugs when a tool fails |
/portrait gestures <mode>, or the gestures row in the tools pane,
switches between tags, meaning, keywords and off. See
Portrait for the details of each.
There are three ways, and you choose which with /portrait gestures <mode>
or the gestures row in the tools pane. Every gesture file and clip
takes part in all three; the same two lines in a file feed them:
about: "sultry come-hither, teasing, inviting", // tags and meaning
keywords: ["beckon", "come hither", "crooks a finger"], // keyword fallbackHer system prompt gets one line listing every gesture that has an about:
Body: you have an animated 3D body on screen. When a gesture fits what you say, put one tag where it happens: [wave] (hello, goodbye), [hug] (affection, comfort), [sultry_beckon] (sultry come-hither, teasing, inviting), ... At most one per reply, most replies need none.
The language model reads the conversation and, when it fits, writes the
tag into its reply like any other word. Nothing else chooses: it's the
same reasoning that picks her words, so it understands the moment, not
just the vocabulary. That's why it suits role-play: "come a little
closer..." in a teasing scene gets [sultry_beckon] with no keyword in
sight.
As the reply streams in, the tag is cut out before anything is shown, spoken or saved, and the portrait is told to play it. Private gestures are left out of the line in chat rooms, so she can't pick them there.
The about text is the whole interface: write it as "when to use this",
short and concrete. A vague about gets picked at odd times; no about
means she never picks it herself.
Cost: about 60 tokens of prompt (cached by llama.cpp, so read once) plus a few output tokens when she uses one.
After she answers, the embedding model (the one that recalls her memories) turns two things into vectors, lists of numbers that capture meaning:
- the exchange: "They said: ... She answered: ...";
- a short description of each gesture. The built-ins have hand-written
ones; every other gesture or clip with an
aboutgets "A moment for the gesture sultry beckon: sultry come-hither, teasing, inviting."
It compares the exchange with every description (cosine similarity, 0 to
- and also with a few "ordinary reply" descriptions (facts, a finished
task, an explanation). A gesture plays only if it beats the best
"ordinary" score by
portrait.meaning_margin(0.04 by default), so most replies get nothing./portrait meaning <text>shows the scores for any line, and the log records them for every reply.
It runs after the reply, off the turn's path (10 to 30 ms), needs the embedding server, and is fuzzier than tags. Private gestures only take part in your own conversations.
Underneath both modes (and alone in keywords mode), the portrait watches
what she writes. A word from a gesture's keywords inside an action
(*beckons*, *puts a finger to her lips*) plays it; an emoji in the list
(🤫) plays it anywhere. A keyword matches the start of a word, so hug
catches hugs and hugging. A tag she wrote herself always wins over a
keyword, and the earliest match in the reply wins over later ones.
Keywords only look inside *actions*, never at ordinary sentences, so
"I hugged a tree today" doesn't make her hug.
For a .vrma clip, put about and keywords in clips.json.
About 60 times a second the portrait builds her pose in layers:
-
Her own body language.
portrait.jssets her head and neck (where she's looking), chest and shoulders (breathing), spine (sway). - Rest pose. Arms down, elbows soft, fingers a little curled, legs straight. Any other bone a gesture has touched goes back to straight.
-
Gestures on top. For each playing gesture, the player:
- works out a weight from 0 to 1: rising over the fade-in, 1 while it plays, falling over the fade-out;
- finds the two keys around the current time and eases between them (smoothstep, so motion starts and stops gently);
- turns each bone towards that pose by the weight (a quaternion slerp),
or, for
+bones, adds the rotation on top of what's there.
-
Expressions. The gesture's
exprvalues are applied: smiles, winks, blush, tears. - Physics. three-vrm runs the spring bones (hair, ears, tail), so they swing from whatever the body just did.
Two consequences worth knowing:
- A gesture never has to return to rest itself. The fade-out blends it back into her normal pose.
- Starting a new gesture fades the old one out while the new one fades in, so you can interrupt freely.
Gestures run on real time, not frame count, so they last the same on a slow machine and a fast one.
Every VRM model has the same named bones (the VRM humanoid standard):
hips, spine, chest, upperChest, neck, head, leftShoulder,
leftUpperArm, leftLowerArm, leftHand, the finger bones, and the legs,
each with a left and a right. That's why one gesture works on every VRM
model.
Rotations are [x, y, z] in radians (π ≈ 3.14 is half a turn, 1.57
a quarter turn, 0.5 is about 30°). They're measured from the T-pose: all
zeros means arms straight out to the sides, palms down.
| Bone | x | y | z |
|---|---|---|---|
rightUpperArm |
rolls the whole arm | + swings it forward, − back | 0 straight out, + lowers (1.22 hangs at her side), − raises above the shoulder |
rightLowerArm |
(use the 4th number instead) | + bends the forearm forward, towards her chest | − bends it up (−2.4 brings the hand near the shoulder), + down |
rightHand |
rolls the palm | swings the hand side to side | flexes the wrist up or down |
'+head', '+neck'
|
+ nods down | turns left or right | tilts towards a shoulder |
'+spine', '+chest'
|
+ leans forward | twists | leans sideways |
rightUpperLeg |
− lifts the leg forward | ||
rightLowerLeg |
+ bends the knee | ||
rightFoot |
tilts the foot (keeps it flat when the knee bends) |
Use the right side's numbers with y and z flipped:
rightUpperArm: [0, 0.8, 1.15] // right hand to her chest
leftUpperArm: [0, -0.8, -1.15] // the same thing, left handYou rarely need to type the left side: both({...}) mirrors a right-side
pose onto both arms, and left: true makes a whole left-handed copy.
rightLowerArm: [0, 2.0, -0.55, -0.4]: the 4th number turns the forearm
about its own length, like turning a doorknob, before the bend. It's how a
palm comes to face the right way without the wrist bending unnaturally.
Combine it with the hand's x for the rest of the turn.
-
Plain (
rightUpperArm): "point this bone here". Used for arms, hands, fingers and legs. -
With
+('+head','+spine','+chest'): "add this to what she's already doing". Used for bones the portrait animates itself (her head follows her gaze, her chest breathes), so the gesture adds a nod or a lean without stopping the rest.
hips: [x, y, z] is a position in metres, not a rotation:
hips: [0, 0.15, 0] lifts her 15 cm (the jump uses it), [-0.025, 0, 0]
shifts her weight onto one leg.
Here's wink.js, a whole gesture:
// wink: a playful wink with a little head tilt and a smile
export default {
about: "playful, teasing, a little flirty",
dur: 1.6, fade: [0.25, 0.4],
keys: [
[0.0, { '+head': [0.02, 0, 0] }],
[0.35, { '+head': [0.04, -0.06, -0.16] }],
[1.1, { '+head': [0.04, -0.06, -0.17] }],
[1.6, { '+head': [0.01, 0, -0.04] }],
],
expr: { blinkRight: [[0, 0], [0.3, 1], [0.95, 1], [1.2, 0]],
relaxed: [[0, 0], [0.3, 0.55], [1.6, 0.4]], blush: 0.25 },
};| Key | What it does |
|---|---|
dur |
How long it plays, in seconds |
fade |
[in, out]: seconds to blend in and back out |
keys |
[[time, pose], ...], times in seconds from the start |
expr |
Her face while it plays (below) |
osc |
A wobble on top: [[bone, axis, amount, speed], ...]. The wave uses ['rightHand', 1, 0.3, 2.3]: hand, y axis, 0.3 radians, 2.3 times a second |
add |
Poses added on top for the whole gesture, like {'+head': [0, 0, 0.14]}
|
hips |
(inside a pose) Her position, metres |
sob |
true: little shoulder hitches, used by cry
|
then |
'pant': another gesture that fades in as this one ends, used by jog
|
| Setting | What it does |
|---|---|
about: "..." |
When to use it. Offers it to her as a tag in tags mode, and is what meaning mode compares with. Without it she won't choose it herself, but it still plays from buttons, commands and idle |
keywords: [...] |
Words that play it when she writes them inside an *action*, and emoji that play it anywhere |
idle: true |
One of the things she does on her own when nothing's happening |
private: true |
Never triggered from stream chat, and only offered to her in your own conversations |
left: true |
Also make a mirrored copy, <name>_left
|
You don't have to repeat every bone in every key. For each bone, the player looks for the nearest keys that mention that bone on either side of the current time and eases between them. A bone missing from the later keys simply holds its last value.
So: put the full pose on the first and the last key, and in between
list only what changes. sultryBeckon.js does exactly that: the arms are
in the first and last keys, and the keys in between only curl the fingers.
A number holds an expression for the whole gesture; a list of
[time, value] pairs animates it:
expr: { happy: 0.6 } // smile throughout
expr: { blush: [[0, 0], [0.8, 1], [3, 1], [3.6, 0]] } // blush rises, holds, fades| Name | What it is |
|---|---|
happy, sad, angry, relaxed, surprised
|
Moods. VRoid's happy opens the mouth into a grin; relaxed is a softer smile with the eyes eased |
blink, blinkLeft, blinkRight
|
Eyes. blinkRight: 1 closes her right eye (a wink) |
aa, ih, ou, ee, oh
|
Mouth shapes. ou is a pucker |
blush, tears
|
Luna's own overlays, 0 to 1 |
A wink's eye opens again by itself when the gesture ends.
import { both, fingers, open } from './_pose.js';| Helper | What it gives you |
|---|---|
both({ right side }) |
The same pose on both arms, left mirrored |
fingers('right', 0.5) |
Fingers curled: 0 straight, 0.5 relaxed, 1 a fist |
open('right') |
Fingers flat |
REST |
Her normal standing pose, if you want to return to it explicitly |
For one finger different from the rest (pointing, shh), set its three bones yourself after the helper:
...fingers('right', 0.9), // curl them all
rightIndexProximal: [0, 0, 0], rightIndexIntermediate: [0, 0, 0], rightIndexDistal: [0, 0, 0],Say you want her to put a hand on her heart.
1. Start with one bone. Make portrait/gestures/hand_on_heart.js:
export default {
dur: 3, keys: [[0, { rightUpperArm: [0, 0.8, 1.15] }], [3, { rightUpperArm: [0, 0.8, 1.15] }]],
};Run ./viewer.sh, click hand on heart, drag round to look from the
side. The upper arm should come down and forward.
2. Add the forearm. Bend it towards her chest:
rightLowerArm: [0, 2.15, -0.15],F5, click, look. Nudge one number at a time by about 0.1 to 0.3 until the hand sits where you want.
3. Fix the hand. If the palm faces the wrong way, change rightHand's
x, or add a 4th number to rightLowerArm. Check from the side.
4. Give it life. A second key a little different from the first, so she isn't frozen, a head tilt, a face:
import { fingers } from './_pose.js';
const HAND = { rightUpperArm: [0, 0.8, 1.15], rightLowerArm: [0, 2.15, -0.15], rightHand: [0, 0.2, 0] };
export default {
about: "touched, grateful",
dur: 3.0, fade: [0.5, 0.6],
keys: [
[0.0, { ...HAND, ...fingers('right', 0.2), '+head': [0.06, 0, 0.08] }],
[1.5, { '+head': [0.09, 0, 0.12] }],
[3.0, { ...HAND, ...fingers('right', 0.25), '+head': [0.06, 0, 0.1] }],
],
expr: { relaxed: 0.4, blush: [[0, 0], [1, 0.5]] },
};5. Let her use it. The about line is all it takes. Within about 20
seconds she has [hand_on_heart] in her prompt, and /portrait hand_on_heart works.
| You see | Why | Fix |
|---|---|---|
| No button for your gesture | A typo in the file, or a viewer started before an update | Look for a pink line under the buttons; restart ./viewer.sh once after updating Luna |
| Her left arm goes the wrong way | Left uses the right's numbers with y and z flipped | Flip the signs of y and z, or write the right side and use both()
|
| She leans over like a falling tower |
'+hips' rotates her whole body from the hips |
Use plain hips: [x, y, z] (metres) to move her |
| Palm faces the wrong way | The forearm and wrist need a roll | Change rightHand x, or add a 4th number to rightLowerArm; steps of 0.3 |
| An arm points somewhere odd mid-gesture | A bone missing from the first key falls back to a later key, or vice versa | Put the full pose on the first and last keys |
| A hand goes through her body | The pose is fine from the front only | Drag round to her side in the viewer and adjust y |
| It snaps instead of easing |
fade too short, or keys too close together |
fade: [0.4, 0.6] is a good start |
| Gesture in code |
.vrma clip |
|
|---|---|---|
| Made with | A text editor and the viewer | XR Animator (webcam), Blender, or a download |
| Good for | Poses, holds, small expressive moves | Fluid full-body motion: dances, walks, anything acted out |
| Lives in | portrait/gestures/ |
portrait/animations/ |
| Settings | Inside the file | portrait/animations/clips.json |
Both play through the same blend, both can carry expr (blush, tears,
smiles), and a clip named like a gesture (wave.vrma) replaces it. See
Portrait for clips.