All articles

Building Natural ASL Animation

How English text becomes an ASL performance: parsing, grammar reordering, phonological synthesis, fingerspelling, non-manual markers, gaze, interpolation and the cleanup passes that keep hands out of the chest.

Muhammad Aamir18 min readAug 4, 2026
The natural ASL animation pipeline: English text through grammar reordering, phonological synthesis, and interpolation into bone quaternions rendered in three.js

This is the article about the actual animation system. Nine stages, from an English string to bone quaternions, and the parts where each stage can go wrong.

The whole pipeline:

text
English text
   |  gloss.ts        rule-based ASL grammar (compromise for POS/lemma)
   v
ASL gloss + non-manual markers
   |  synthesize.ts   ASL-LEX phonology -> articulatory trajectory
   v
keyframed body poses  (wrist target + palm orientation + handshape + face)
   |  player.ts       interpolation, blinks, breathing, sway
   v
rig.ts               analytic two-bone IK + calibrated finger FK + morphs
   v
the character, in three.js

Every stage hands off a plain data structure. That is what makes the system debuggable: when a hand ends up in the wrong place, you need to know which stage put it there.

1. English parsing

The input is an English sentence. The first job is finding out what the words are: part of speech, lemma, whether something is a proper noun.

This uses compromise, a small in-browser NLP library.

Why a rule-based POS tagger and not an LLM: it runs in the browser in microseconds, it is deterministic, and it has no API dependency. The project brief was explicitly local-only. An LLM call per sentence would have been slower, non-deterministic, and a network dependency for a system that otherwise has none.

What it gives us: normalised word forms, POS tags, and lemmas. Enough to know that went is the past tense of go, and that Muhammad is a proper noun.

TypeScript
interface Word {
  raw: string;
  norm: string;
  tags: string[];
  isProper: boolean;
  isTime: boolean;
  isWh: boolean;
}

Three of those six fields are project-specific flags, and each drives a grammar rule below.

2. ASL grammar

ASL is not signed English. It has its own syntax, and signing English word order with ASL vocabulary is not ASL.

The rules implemented, from the module docstring:

text
- determiners and the copula are dropped (ASL has neither)
- time is established first  ("YESTERDAY ME GO STORE")
- topic-comment order, with the topic marked by raised brows
- wh-words move to the end of the clause and carry furrowed brows
- yes/no questions raise the brows over the whole clause
- negation becomes NOT plus a headshake spanning the predicate
- tense is lexical: FINISH for completed, WILL for future
- anything with no lexical sign is fingerspelled, names always

Dropping function words

TypeScript
const DROP = new Set([
  'a', 'an', 'the', 'is', 'am', 'are', 'was', 'were', 'be', 'been', 'being',
  'do', 'does', 'did', 'of', 'to', 'there', 'that', 'as', 'at', 'by',
  'shall', 'would', 'could', 'may', 'might', 'must', 'ought',
]);

Why: ASL has no articles and no copula. Hi, my name is Muhammad becomes HELLO MY NAME fs-MUHAMMAD. The is is gone, because signing it would be an error, not a nicety.

Reordering

TypeScript
/**
 * Order a clause the way ASL does: TIME first, then the topic, then the
 * comment, with wh-words pushed to the end.
 */
function reorder(words: Word[]): { ordered: Word[]; topicCount: number } {
  const time = words.filter((w) => w.isTime);
  const wh = words.filter((w) => w.isWh);
  const rest = words.filter((w) => !w.isTime && !w.isWh);
  return { ordered: [...time, ...rest, ...wh], topicCount: time.length };
}

Why time first: ASL establishes a time frame and then everything after it is understood to happen then. There is no tense inflection to carry it. Yesterday I went to school becomes YESTERDAY ME GO SCHOOL.

Why wh-words go last: in ASL, wh-questions typically place the wh-word at the end of the clause. How are you becomes YOU HOW.

Note the topicCount return. It is not cosmetic: the fronted time words become the topic, and the topic carries raised brows:

TypeScript
if (i < topicCount) tokenNmm.topic = true;

Grammar and face are produced together, because in ASL they are together. The brow raise is not decoration on the topic; it is part of marking it.

Lexical tense

TypeScript
clause.push({ gloss: 'FINISH', signId: id, source: '(past)', nmm: {} });

ASL marks completed aspect with FINISH rather than inflecting the verb. He was late glosses as FINISH IX-3 LATE.

Third person is spatial

TypeScript
/**
 * Third-person reference is spatial in ASL - the signer points at a locus - so
 * HE/SHE/THEY become an index point rather than a lexical sign.
 */
const THIRD_PERSON: Record<string, 'ix3' | 'ix3_plural'> = {
  he: 'ix3', him: 'ix3', his: 'ix3',
  she: 'ix3', her: 'ix3', hers: 'ix3',
  ...
};

There is no sign for he. You point at a location in the signing space, and that location now means that person. This is one of the places where ASL is structurally unlike English and a word-for-word approach breaks completely.

A bug worth documenting

Thank you came out fingerspelled as fs-T-H-A-N-K.

The proper-noun guard tested lex.index[mapped] (an index keyed by English words) against a value that had already been mapped to a sign ID. LEXICAL_MAP sends thank to thank_you, and no English word contains an underscore. So the lookup failed, the word looked unknown, and a capitalised sentence-initial word got fingerspelled.

The fix was ordering: resolve the sign first, then decide about fingerspelling.

TypeScript
// Resolve to a sign first, then decide about fingerspelling.
//
// This used to test `lex.index[mapped]`, which is keyed by English words,
// against a value that may already be a *sign id* - LEXICAL_MAP sends
// "thank" to `thank_you`, and no English word is spelled with an
// underscore.
const id = lookup(lex, [mapped, ...lemmas(w)]);

3. Phonological synthesis

Now each gloss token becomes movement. This is where ASL-LEX earns its place.

The mapping:

text
handshape  -> finger flexion / spread / thumb   (handshapes.ts)
location   -> wrist target + orientation        (locations.ts)
movement   -> the path between the two          (synthesize.ts)
sign type  -> what the non-dominant hand does   (synthesize.ts)

A Handshape is a normalised parameter set, not a pose:

TypeScript
export interface Handshape {
  /** thumb chain: [CMC, MCP, IP] */
  thumb: Curl3;
  index: Curl3;
  middle: Curl3;
  ring: Curl3;
  pinky: Curl3;
  /** 0 = fingers touching, 1 = fully splayed */
  spread: number;
  /** 0 = thumb alongside the palm plane, 1 = rotated across the palm */
  thumbOpp: number;
  /** 0 = thumb tucked to the index, 1 = thumb swung away from the hand */
  thumbAbd: number;
}

The gap: orientation

ASL-LEX does not annotate palm orientation. It gives location and handshape, not which way the palm faces.

Orientation is often the difference between a correct sign and a nonsense one. So it comes from per-location defaults plus an explicit override table in overrides.ts for the signs where the default is wrong, currently hello, my, me, you, name, love, nice, meet, thank_you, ix3, ix3_plural.

This is a real limitation, stated plainly. 2,719 signs use a reasonable default; the ones actually tested got hand-checked. It is the weakest link in the linguistic chain.

A data bug: 417 signs with no movement

417 ASL-LEX entries have movement: None, LOVE among them. These are held signs: you make a shape and hold it.

The synthesiser emitted a single keyframe for them, which collapsed the sign to 0.08 seconds. It flashed and vanished.

TypeScript
path.length ? [start, ...path] : [start, { ...start }]

Why two identical frames: a keyframe is an instant. Duration lives between keyframes. A sign with one keyframe has no duration by definition. Emitting the start twice gives the hold somewhere to exist.

4. Fingerspelling

Names get fingerspelled. Always: that is correct ASL behaviour for proper nouns, whether or not a sign exists.

The module docstring describes the whole design:

text
Unknown words - names above all - are fingerspelled rather than dropped.
The hand sits in the dominant-side "fingerspelling window" near the shoulder
with the palm toward the addressee, letters flow with a small forward drift,
doubled letters bounce sideways, and J and Z carry their citation movement.

Four details in there, each a real property of fingerspelling.

The window

TypeScript
export function spellWindow(anchors: Anchors, dom: Side): Vec3 {
  const s = dom === 'L' ? 1 : -1;
  const chin = anchors.chin;
  return [s * 0.82, chin[1] - 0.32, chin[2] + 0.72];
}

Fingerspelling happens in a small, consistent space near the dominant shoulder. It does not wander. Anchoring to chin rather than absolute coordinates means it scales with the character.

Drift and doubled letters

TypeScript
const doubled = i > 0 && letters[i - 1] === ch;
// letters drift very slightly forward across the word, doubles step sideways
const drift = (i / Math.max(1, letters.length - 1)) * 0.10;
const lateral = doubled ? -s * 0.16 : 0;

if (doubled) {
  // small re-articulation so the two letters read as two
  keys.push({ t: t - ctx.rate * 0.35, ease: 'easeOut', pose: ... });
}

Why doubles need special handling: MUHAMMAD has a double M. Two identical handshapes in sequence with nothing between them interpolate to no motion at all: the hand just sits there and the word reads as having one M.

Real fingerspellers handle this with a small lateral bounce. The code adds the sideways offset plus an intermediate re-articulation keyframe so the two letters read as two.

This is the kind of detail that separates technically correct from readable. The handshapes were right before this fix; the word still was not legible.

Moving letters

TypeScript
if (MOVING_LETTERS.has(ch)) {
  const path = ch === 'j' ? jPath(base, s) : zPath(base, s);
  ...
}

J and Z are not static handshapes. J traces a hook with the pinky; Z draws a zigzag with the index. A static pose for either is simply wrong.

Lead-in

TypeScript
// lead-in so the hand arrives in the window before the first letter
const lead = 0.16;

Why: without it, the first letter is being formed while the hand is still travelling to the window, and it is unreadable. 160ms of approach fixes it.

5. Facial expressions

In ASL the face carries grammar. This is not optional colour.

Non-manual markers

TypeScript
if (nmm.ynq) {
  morphs.browRaise = 0.9 * env;
  morphs.eyeWide = 0.3 * env;
  head = [0.09 * env, 0, 0];
}
if (nmm.whq) {
  morphs.browFurrow = 0.85 * env;
  morphs.squint = 0.25 * env;
  head = [0.07 * env, 0, 0.05 * env];
}
if (nmm.neg) {
  morphs.browFurrow = Math.max(morphs.browFurrow ?? 0, 0.5 * env);
  head = [head[0], Math.sin(phase * Math.PI * 4) * 0.16 * env, head[2]];
}

Brows up for yes/no questions. Brows furrowed for wh-questions. A headshake spanning negation, note it is a sin driven by phase, so it oscillates across the sign rather than being a static pose.

The envelope:

TypeScript
// ramp the marker in and out so it does not pop
const env = Math.min(1, Math.sin(Math.PI * Math.min(1, Math.max(0, phase))) * 1.8);

A half-sine, multiplied by 1.8 and clamped. It rises fast, plateaus for most of the sign, and falls fast. Without it, markers snap on and off at sign boundaries.

Mouth morphemes

ASL has adverbial mouth morphemes: the mouth carries meaning the hands do not.

TypeScript
export const MOUTH_MORPHEMES: Record<string, Record<string, number>> = {
  oo: { mouthPucker: 0.85 },
  ee: { mouthWide: 0.8 },
  mm: { mouthPress: 0.7 },
  cha: { cheekPuff: 0.85, jawOpen: 0.3 },
  puff: { cheekPuff: 0.9, mouthPress: 0.45 },
  th: { mouthTH: 0.8 },
  ah: { jawOpen: 0.55 },
  pah: { jawOpen: 0.7, eyeWide: 0.5 },
  smile: { mouthSmile: 0.7 },
};

The same manual sign for WRITE means write normally with mm and write carelessly with th. Only the face distinguishes them.

This table existed from the start and was dead code. Nothing ever assigned nmm.mouth except a smile on warm signs, so every other entry was unreachable. The fix was a lexical map from sign ID to adverbial in gloss.ts.

And then a second bug: the new map emitted puff (for MANY, MUCH, FAT) but the synthesiser table had no puff entry. It resolved to undefined and produced no mouth at all, silently. The test suite now asserts that every morpheme the grammar can emit resolves to a shape and moves the face at mid-sign.

Antagonistic morphs must not stack

TypeScript
/**
 * An adverbial mouth morpheme owns the mouth for the length of its sign.
 *
 * The affect and effort passes below also reach for the mouth, and they use
 * Math.max, so without this the pursed `oo` of SMALL would be layered under a
 * warm sentence's smile - two antagonistic shapes driven at once, which on a
 * blendshape face reads as a grimace rather than as either expression.
 */
const mouthTaken = !!shape && nmm.mouth !== 'smile';

Affect

TypeScript
// The face carries the attitude of the utterance continuously.  A warm sign
// lifts the cheeks, brows and mouth together; a negative one draws the brows
// in, narrows the eyes and wrinkles the nose.  Half-envelope so the affect
// lingers across the sign rather than pulsing with it.

Why affect is continuous while markers pulse: grammatical markers scope over specific constituents. Attitude persists. The affect envelope is 0.55 + 0.45 * env, so it never fully decays between signs.

Why this matters linguistically: ASL has no tone of voice. A blank face on a warm sentence does not read as neutral, it reads as indifference or sarcasm.

6. Eye gaze

Blinks and gaze are not grammar, but a face without them is a mannequin. The design notes:

text
- spontaneous blink rate is roughly 15-20/min at rest and *drops* while
  concentrating, which for a signer means while actually signing
- a blink closes faster than it opens (~70 ms down, ~110 ms up)
- the two lids are not perfectly synchronous - a few milliseconds apart
- some blinks are partial: the lid drops ~40% and comes straight back
- saccades are ballistic: ~30-60 ms of movement between fixations that
  last a few hundred milliseconds, not a smooth drift
TypeScript
const BASE_INTERVAL_IDLE = 3.4;
const BASE_INTERVAL_SIGNING = 5.2;

And the linguistic hook:

text
Signers blink at prosodic boundaries the way speakers pause, and gaze
carries reference: looking at a locus in space is how a pronoun gets its
antecedent.

Sign boundaries are passed into the player as prosodic boundaries, and blinks are biased toward them.

7. Body movement

Head and torso are channels on every pose:

TypeScript
/** head rotation in radians: pitch (nod +down), yaw (+left), roll (+tilt left) */
head: Vec3;
/** torso rotation, same convention; used for role shift and body lean */
torso: Vec3;

Torso rotation is there for role shift, the ASL device where a signer turns their body slightly to indicate they are now quoting or embodying a different referent.

There is also an idle layer: breathing and small postural sway, applied continuously. A character that is perfectly still between signs looks paused, not attentive.

8. Timing

Sign duration comes from ASL-LEX, clamped:

TypeScript
const MIN_SIGN = 0.34;
const MAX_SIGN = 1.5;

const total = Math.min(MAX_SIGN, Math.max(MIN_SIGN, (sign.dur || 700) / 1000)) / speed;

Why clamp: the database has outliers, and a sign lasting 3 seconds or 80ms is unreadable either way. 340ms is about the floor for a legible sign; 1.5s is about the ceiling before it reads as deliberate emphasis.

Why || 700: a sensible default for entries with no duration.

Speed is a divisor, so the UI slider scales everything coherently.

Speed limits

TypeScript
/**
 * Fastest average wrist speed, in model units per second.  Easing doubles the
 * peak, so this corresponds to roughly 2 m/s at the fastest point - brisk
 * signing, but not a blur.  Transitions that would exceed it are stretched.
 */
const MAX_AVG_SPEED = 6.5;
/**
 * Fastest average hand rotation, in radians per second.  Easing roughly doubles
 * the peak, so 5.2 rad/s averages ~300 deg/s and peaks near 600 - inside what a
 * signer's hand actually does.  It used to be 7.3, which let peaks reach
 * ~920 deg/s; combined with a near-180-degree turn that reads as the hand
 * tumbling rather than travelling.
 */
const MAX_ANGULAR_SPEED = 5.2;

Note both comments reason about the peak, not the average. Easing roughly doubles peak velocity relative to mean. Setting a limit on the average without accounting for that lets the peak reach twice what you intended, which is exactly the 7.3 to 5.2 story.

Transitions that exceed the limit are stretched in time rather than clipped. The sign still happens; it takes longer.

9. Interpolation and smoothing

This is where the visible quality lives, and where the worst bug was.

The bug

The first implementation used the uniform Catmull-Rom formula. Keyframes are not uniformly spaced: a repeated sign puts four knots 0.145s apart between much longer transitions. It also fed the eased parameter into the spline, shaping velocity twice, and disabled smoothing next to hold keyframes, so a spline span met an eased-lerp span with mismatched velocity at the join.

Three bugs stacked. The symptom was a stutter during SCHOOL, a repeated palm-on-palm clap.

The fix

TypeScript
function hermite(
  p0: number, p1: number, p2: number, p3: number,
  dtPrev: number, dt: number, dtNext: number, s: number,
): number {
  // Secants either side of each knot.
  const d1 = (p1 - p0) / dtPrev;
  const d2 = (p2 - p1) / dt;
  const d3 = (p3 - p2) / dtNext;

  // Monotone (Fritsch-Carlson) tangents.  A plain finite difference overshoots
  // wherever the motion reverses, and a repeated sign reverses constantly -
  // SCHOOL claps down, up, down, up - so the hand would sail past the palm at
  // each extreme and come back.
  const limit = (a: number, b: number) => {
    if (a * b <= 0) return 0;                   // turning point: flat
    const m = (a + b) * 0.5;
    const cap = 3 * Math.min(Math.abs(a), Math.abs(b));
    return Math.sign(m) * Math.min(Math.abs(m), cap);
  };
  const m1 = limit(d1, d2);
  const m2 = limit(d2, d3);

  const s2 = s * s;
  const s3 = s2 * s;
  const h00 = 2 * s3 - 3 * s2 + 1;
  const h10 = s3 - 2 * s2 + s;
  const h01 = -2 * s3 + 3 * s2;
  const h11 = s3 - s2;
  return h00 * p1 + h10 * dt * m1 + h01 * p2 + h11 * dt * m2;
}

Three corrections:

  • Divide by real dt: Tangents are now correct on non-uniform knots.

  • Evaluate on raw time: Easing shapes velocity once, not twice.

  • Limit the tangents: Flat at a turning point, capped at 3x the smaller neighbouring secant. A clap decelerates into the palm instead of overshooting through it.

Measured: stutter ratio on SCHOOL went from 319 to 4.3. A ratio of 2 to 5 is ordinary eased velocity variation.

Easing

TypeScript
function easeInOut(t: number) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function easeOut(t: number) { return 1 - Math.pow(1 - t, 3); }

case 'hold': return t < HOLD_FRACTION
  ? 0
  : easeInOut((t - HOLD_FRACTION) / (1 - HOLD_FRACTION));

hold is the interesting one: it stays at zero for a fraction of the interval and then eases. That is how a sign's final position is held before moving on, which is how signs are actually produced.

Rotation

Positions interpolate as scalars per axis. Orientations do not: they slerp.

TypeScript
const ORIENT_CACHE = new WeakMap<HandTarget, Quaternion>();
export function handOrientation(h: HandTarget): Quaternion { ... }

10. The transition system

Between one sign and the next, the hands have to get somewhere. That path is not in ASL-LEX: it is a motor-planning problem, and it is where hands end up inside the chest.

The cleanup passes, and the order is deliberate:

TypeScript
// --- cleanup passes ------------------------------------------------------
// Order matters: slow the fast moves down first (which is what makes hands
// shoot through the body in the first place), then route whatever still
// crosses the body around it.
isolateHandTargets(keys);
if (opts.body?.valid) liftOutOfBody(keys, opts);
openCrowdedHands(keys);
const remap = enforceMaxSpeed(keys, opts.speed);
const routed = opts.body?.valid ? routeAroundBody(keys, opts) : keys;
// Routing inserts displaced waypoints, which can make an interval fast again,
// so the speed limit is re-applied afterwards.
const remap2 = enforceMaxSpeed(routed, opts.speed);

isolateHandTargets first, and here is why:

TypeScript
// The cleanup passes below mutate hand positions in place, and buildSign
// legitimately reuses one HandTarget across many keyframes (the idle hand of
// a one-handed sign is a single object).  Without this every pass would apply
// its correction once per referencing keyframe and the offsets would compound.

A shared-object aliasing bug. The idle hand of a one-handed sign is one object referenced by many keyframes. Mutating it once per reference multiplied the correction. Classic, and invisible until you look at the numbers.

Why speed-limiting comes before routing: a hand shooting across the body at high speed is what produces a path through the chest. Slow it down first and there is less to route.

Why speed is re-applied after routing: routing inserts waypoints, which shortens intervals, which can make them fast again. Two passes.

Note enforceMaxSpeed returns a remap function: the timeline entries are remapped through it so the gloss chips in the UI stay aligned with the animation after time is stretched.

How it all fits together

Tracing Hi, my name is Muhammad end to end:

text
"Hi, my name is Muhammad"
   |
   |  compromise: POS tags, lemmas, proper-noun detection
   v
[hi][my][name][is][Muhammad]
   |
   |  DROP removes "is" (copula)
   |  LEXICAL_MAP: hi -> hello
   |  "Muhammad" is a proper noun -> fingerspell
   v
HELLO   MY   NAME   fs-MUHAMMAD
  |      |     |         |
  |      |     |         +- fingerspell.ts: window, drift,
  |      |     |            doubled M bounce, 0.16s lead-in
  |      |     |
  |      |     +- ASL-LEX: two-handed, contact, H handshape
  |      |        -> handsTouch = true
  |      |
  |      +- body-anchored possessive, overrides.ts orientation
  |
  +- WARM_SIGNS -> mouth: smile, affect 0.6
   |
   v  synthesize.ts: keyframes with wrist targets, orientations,
   |  handshapes, morphs, head/torso
   v
   |  cleanup: isolate -> lift -> open -> speed -> route -> speed
   v
   |  player.ts: monotone Hermite on position, slerp on orientation,
   |  blinks at sign boundaries, idle sway
   v
   |  rig.ts: two-bone analytic IK + swivel, calibrated finger FK,
   |  collision resolvers, morph application
   v
bone quaternions + morph influences -> three.js

Measured result on this sentence: 3 flicker events at 60fps, zero at 120fps, and a maximum wobble of 1.64 px on a 1028x1706 canvas. The remaining events are NAME's own repeated tap, a genuine direction reversal, not an artifact.

Honest limitations

  • Palm orientation is defaulted: For most of the 2,719 signs, because ASL-LEX does not annotate it. This is the weakest link.

  • No classifier constructions: ASL uses classifier handshapes to depict shape, movement and spatial relationships. Not implemented.

  • Spatial agreement is minimal: Third-person indexing works, but verbs that inflect for subject and object (GIVE, ASK) are not spatially modulated.

  • No Deaf signer has validated the output: The artifact numbers say it is geometrically clean and phonologically grounded. They do not say it is fluent.

Key Takeaways

  1. 1

    ASL is not signed English: Drop the copula, front time, move wh-words to the end, and mark topics with raised brows.

  2. 2

    Grammar and face are produced together: Because in ASL they are the same thing. The topic marker is the brow raise.

  3. 3

    A phonological database is the right abstraction: Handshape, location, movement, contact are parameters a synthesiser can consume.

  4. 4

    Normalise handshapes to 0 to 1: And convert to rotations using values measured from the rig, so the same data drives any character.

  5. 5

    Doubled letters need re-articulation: Two identical handshapes in sequence interpolate to no motion at all.

  6. 6

    Antagonistic blendshapes must compete, not sum: A smile and a pucker at 0.7 each is a grimace.

  7. 7

    Non-uniform keyframes need time-aware interpolation: Uniform Catmull-Rom is wrong when knots are not evenly spaced.

  8. 8

    Limit tangents at turning points: A repeated sign reverses constantly, and plain finite differences overshoot through the contact.

  9. 9

    Reason about peak velocity, not average: Easing roughly doubles it.

  10. 10

    Order your cleanup passes deliberately: Slow down first, then route; re-apply the speed limit after routing inserts waypoints.

  11. 11

    Watch for shared-object aliasing when passes mutate in place: One reused HandTarget compounded corrections invisibly.

Resources

  • ASL-LEX 2.0: Sevcikova Sehyr, Z., Caselli, N., Cohen-Goldberg, A. M., and Emmorey, K. (2021). JDSDE 26(2). CC BY-NC 4.0. Approximately 2,723 signs on 22 phonological dimensions.

  • compromise: github.com/spencermountain/compromise (https://github.com/spencermountain/compromise). The in-browser NLP used for POS and lemmas.

  • Fritsch, F. N., and Carlson, R. E. (1980): Monotone Piecewise Cubic Interpolation. SIAM J. Numer. Anal. 17(2).

  • Liddell, S. K. (2003): Grammar, Gesture, and Meaning in American Sign Language. On spatial reference and indexing.

  • Valli, C., and Lucas, C.: Linguistics of American Sign Language. Standard reference for ASL phonology and non-manual markers.

  • three.js: Quaternion.slerp, SkinnedMesh, morph target influences.

The rest of the series

This is Part 6 of a 10-part series. The other parts:

  1. 1

    How I Built an AI-Powered ASL Sign Character with Claude Code

  2. 2

    How Blender MCP Works with Claude Code

  3. 3

    Building a Production Ready Sign Language Character

  4. 4

    Designing the Goal Prompt

  5. 5

    Loop Engineering Explained

  6. 6

    Project Folder Architecture

  7. 7

    Every Prompt Used During Development

  8. 8

    Common Problems We Solved

  9. 9

    Lessons Learned

Frequently Asked Questions

How do you convert English to ASL grammar?

Rule-based transformation on a POS-tagged sentence: drop determiners and the copula, move time words to the front (they become the topic and carry a brow raise), push wh-words to the end, express tense lexically with FINISH or WILL, and convert third-person pronouns into spatial index points. ASL has no articles and no copula, so signing them would be an error.

Why fingerspell names instead of using a sign?

Because that is what ASL does. Proper nouns are fingerspelled regardless of whether a sign exists. It is also the only way an open-vocabulary system can handle a name it has never seen, which is precisely what you need sign language for in an introduction.

How do you animate doubled letters like the MM in MUHAMMAD?

With a lateral bounce and an intermediate re-articulation keyframe. Two identical handshapes in sequence interpolate to no motion, so the hand sits still and the word reads as having one M. The handshapes were correct before this fix; the word still was not legible.

What are non-manual markers?

Grammatical information carried on the face and head. Raised brows mark yes/no questions and topics; furrowed brows mark wh-questions; a headshake spans negation. They are not expression, they are syntax. A signer with a still face is producing ungrammatical ASL.

What is a mouth morpheme?

An adverbial carried on the mouth. The same manual sign for WRITE means write normally with mm and write carelessly with th. The hands are identical; only the face distinguishes them.

Signers blink at prosodic boundaries the way speakers pause, so blink placement carries phrasing. Beyond that, blink rate drops under concentration, so a character blinking at a constant rate looks wrong in a way viewers notice without being able to name.

How do you stop hands passing through the body during transitions?

A pipeline of cleanup passes at synthesis time: isolate shared targets, lift out of the body, open crowded hands, enforce a speed limit, route around the body, then enforce the speed limit again. Order matters: excessive speed is what drives hands through the chest, so slowing down comes before routing, and routing inserts waypoints that need re-limiting.

Why use monotone cubic interpolation instead of Catmull-Rom?

Two reasons. Keyframes are not uniformly spaced in time, so the uniform Catmull-Rom formula computes wrong tangents. And repeated signs reverse direction constantly (a clap goes down, up, down, up) where plain finite-difference tangents overshoot and the hand sails through the palm. Fritsch-Carlson limiting flattens the tangent at turning points. It took the measured stutter ratio on SCHOOL from 319 to 4.3.

How accurate is the ASL?

Geometrically clean and phonologically grounded, not validated by a Deaf signer. Palm orientation is defaulted for most signs because ASL-LEX does not annotate it, and classifier constructions and verb agreement are not implemented. Treat it as an engineering demonstration, not a translation tool.

If you are building accessibility software or any product where the visible quality lives in the details (interpolation, blendshape competition, motion planning) that is exactly the kind of AI product engineering we ship at ETechViral. The next part of the series covers the folder architecture that made this system maintainable.

Tags
  • ASL
  • Procedural Animation
  • NLP
  • Inverse Kinematics
  • Interpolation
  • Three.js
  • Linguistics
  • Accessibility