Leather, Lamp, and Canvas
How to draw and animate Chinese shadow puppetry in code, step by step, from a glowing sheet of paper to a riveted horse and rider.
皮影戏 (píyǐng xì, “leather-shadow theatre”) is more than two thousand years old. A performer holds flat, jointed figures cut from donkey or ox hide against a paper screen. A lamp burns behind them, and the audience on the other side watches coloured shadows. The hide is scraped so thin that it turns translucent. It's dyed red, green and ochre, then carved with thousands of tiny holes, so each figure glows like stained glass and is laced with pinpricks of light.
I recently made a shadow-puppet animation of the Tang poem 古意 entirely in code, with no images or libraries. This article walks through the technique in the order you'd build it. We start with an empty lit screen and add one idea at a time. We'll end with puppets built as trees of carved parts, a horse that gallops, soft rod shadows, and a small timeline that runs the show.
Step 0What we are simulating
Before writing any code, it helps to be precise about the physics, because every visual decision later follows from it. There are three objects: a lamp, a puppet and a screen. The audience sees only the screen.
Four facts follow, and each one becomes a technique:
- The screen emits light, and shadows only take light away. A shadow can darken the screen or tint it, but it can never be brighter than the lamp behind it. In compositing terms that's multiply.
- Leather is translucent and dyed. A red piece doesn't paint red over the screen. It filters the lamp, so a red piece is brighter near the hot-spot and darker at the edges.
- Carving removes material. A hole in the leather lets the full lamp through, so holes show exactly what the screen shows, including its gradient and grain.
- Distance from the screen changes the shadow. A puppet at distance
dfrom the screen, with the lamp atD, casts a shadow magnified byD / (D − d), with a soft edge aboutL·d / (D − d)wide. Pressed flat against the paper, it's sharp and life-size. Pulled back toward the lamp, it grows, softens and fades.
Keep those four facts in mind. Almost every line of code below implements one of them.
Step 1The screen is the light
We'll need a little noise throughout for flicker, sway and grain. A 1-D value noise is enough: hash the integers, then smoothly interpolate between them. It's cheap, deterministic and smooth, which is the texture of a hand that's never quite still.
The screen is a radial gradient that's hot near the lamp and falls off to a deep umber at the edges. Two details make it feel alive. The hot-spot drifts a few pixels, driven by slow noise, because a real flame moves. After the scene is drawn we multiply in a sheet of paper grain, a vignette, and a warm grey whose brightness flickers. That grey multiply also serves as our dimmer, and a dimmer flame is a redder flame, so the blue channel falls fastest.
Step 2Shadows multiply
The obvious first attempt is to paint shapes on top of the screen in colour. With the default source-over compositing, a semi-transparent red leaf blends toward a flat red. It sits on the screen like a sticker, and it looks the same whether it's in the hot-spot or in the dim corner.
Physically, the leather filters the light: every channel of the screen is scaled by the leather's transmittance. That's exactly what globalCompositeOperation = 'multiply' computes. The result is screen × leather per channel, so a filter can never brighten the screen, overlapping pieces grow darker and richer, and the lamp's gradient shows through every piece. Switch the mode in the demo and watch the leaves as they drift across the hot-spot.
One consequence is useful: multiply is commutative. It doesn't matter whether the horse or the mountain is multiplied first, so we're free to group things into layers by blur instead of by stacking order. We'll lean on that in Step 14.
Step 3A layer to cut into
Carving means erasing. The Canvas tool for erasing is destination-out: wherever you draw, existing pixels become transparent. If we carve directly on the main canvas, we erase the screen as well as the leather. The hole shows whatever is behind the <canvas> element, which in the demo below is a grey checkerboard.
The fix is structural. Every puppet is drawn onto an offscreen, transparent figure layer. Holes cut there reveal nothing, so they're transparent. We then multiply the whole layer onto the screen, and the holes let the lamp through untouched. The same function takes a blur radius, which later becomes our depth of field.
Two small things in makeStage matter. We draw in CSS-pixel “stage units” and let toStage apply the device-pixel ratio, so every coordinate in this article is resolution-independent. And begin/end bracket each frame: the screen first, then the finish passes last. From now on a frame looks like S.begin(t), a few S.layer(...) calls, then S.end(t).
Step 4One piece of leather
Real pi ying figures are assembled from dozens of separate pieces. Each piece goes through the same three steps: it's dyed, it's carved, and its silhouette reads as a crisp dark edge where the hide is thickest. That becomes the one drawing primitive everything else is built on:
Two design choices here pay off later. A carve is simply a function that draws, so any Canvas drawing code becomes a carving tool. The clip keeps holes inside the piece, so a carving pattern can be lazy and cover a whole bounding box. The order also matters: the outline is stroked after carving, so holes that run into the edge are closed off by it, the way a carver always leaves a rim of hide.
Look at the colours in HIDE. They're dyes, not paints. face has an alpha of 0.08, which is almost clear leather. That's how pi ying “hollow faces” (空脸) work: a heroic or young character's face is carved away entirely except for a fine profile line.
Step 5A carver’s vocabulary
Traditional carvers work with a small set of cuts: rows of round punches, overlapping fish-scale arcs for armour, lattice for windows, spiral “cloud” curls, and long slits that follow a contour. Each one becomes a pattern factory, a function that takes a region and returns a carve function. Factories compose with pat.all.
Because holes pass the lamp straight through, a carved piece is always brighter than an uncarved one of the same dye. Carving is how pi ying makers controlled value. Heavy armour cuts turn a dark hide into a glittering mid-tone, and a black horse becomes readable through carved swirls on its shoulder and hip.
Step 6Shapes as paths
Where do the silhouettes come from? I author every piece as an SVG path string and hand it to Path2D, which accepts SVG path syntax directly. Path strings are compact, easy to tweak by hand, and they render with the browser's native path code. They're also immutable, so parsing each string once and caching the result is free performance:
The most important decision isn't a function, though. It's a convention for local coordinates, used by every piece:
- Figures face right (+x), and y points down. To face left, flip with
scale(-1, 1). - A piece's origin
(0, 0)is the rivet that attaches it to its parent. A forearm's origin is the elbow, and a head's origin is the base of the neck. - A limb at rest hangs down the +y axis, so a rotation of 0 means “hanging straight down”.
With that convention, rotating a piece is just c.rotate(angle), and it swings about the correct joint without any offset arithmetic. The demo shows the warrior's head, which we'll use shortly, with its origin at the neck and the anchor points of its face path overlaid.
That face path is only a dozen curve segments, and the profile line does all the characterisation. Pi ying faces are a study in economy: a straight forehead-to-nose line, one long eye slit that sweeps back toward the temple, and a single stroke for a brow.
Step 7Joints and rivets
A real puppet's pieces are held together with knotted thread through a hole, so each joint is a free pivot. In Canvas terms a joint is three calls: translate to the joint, rotate by the joint angle, then draw the child in its own local space. Because the transform stack composes, a forearm drawn inside the upper arm's transform automatically follows the shoulder. Here is an arm written out by hand:
Notice the torso line in the source: c.translate(330, 60); c.scale(3.2, 3.2). It places the whole assembly with one transform, and nothing inside the arm knows or cares. Also notice the tiny 0.04 * Math.sin(t * 1.3) added to the shoulder. That's the first piece of animation in this article, and we'll come back to that kind of tiny, constant motion in Step 12.
Step 8Reaching: two-bone IK
Setting angles by hand is fine for a flailing arm, but acting usually starts from a target: the hand should be on the pipa's neck, on the spear shaft, or at the lips holding a flute. Two-bone inverse kinematics solves this exactly with the law of cosines. With upper length L1, lower length L2, and distance d from shoulder to target, the elbow's interior angle comes straight out of the triangle.
Two details are specific to our conventions. First, base = atan2(-dx, dy) rather than the usual atan2(dy, dx), because a limb at angle 0 hangs down +y instead of pointing along +x. Second, the distance is clamped to the reachable range. A puppet's arm should straighten toward an unreachable target rather than produce NaN and vanish. Drag the target in the demo, including outside the dashed reach circle.
Step 9A puppet is a tree
Writing translate/rotate/draw by hand is fine for three pieces. A pi ying general has over thirty: four back flags, two pheasant plumes, a helmet, a brim, a neck guard, a chest mirror, a belt, fish-scale armour, and more. The arm example contains the answer already. Each piece has a parent, a rivet position in the parent's space, an angle, and children. That's a tree, and once a puppet is data, one short recursive function can draw it.
A few ideas are packed into those thirty lines:
- Pose is separate from structure. The tree never changes. Animation means producing a new
poseobject,{ nearArm: -0.4, head: 0.1, … }, keyed by node name. One rig can be posed a hundred ways, and one pose function can drive any rig that uses the same names. backandkidsencode depth. In a flat puppet, z-order is paint order. The far arm is painted before the torso, and the near arm after it. Putting both lists on every node lets a child sit behind its own parent, which you need for a head whose plumes sweep behind the helmet.drawis the escape hatch. Some pieces aren't fixed paths: feathers that bend in the wind, a tassel that always hangs down. A node can supply a function instead, and it still gets the joint transform for free.- Debug views are free. The
explode,budgetandmarksoptions exist purely so we can look at the construction. That's the next demo.
Step 10Assembling a warrior
Here is the full general as data. Read it top to bottom and you're reading the paint order. The hips are the root. Behind them are the back flags, the far arm and both legs. In front of them are the skirt, torso, chest mirror, belt, head, shoulder guard, and finally the near arm, which holds the spear.
Look at the last function. warriorPose is where animation stops being about angles and becomes about intentions. The caller says where the hand should be and how the spear should lean. IK turns the hand position into arm angles, and one line solves a subtle problem. The spear is a child of the hand, so it inherits the shoulder and elbow rotations. To keep it at the angle we asked for, we subtract them: spear − nearArm − nearFore. Any prop that's held but has its own orientation, like a fan, a cup or a flute, needs the same counter-rotation.
Scrub slowly through the first dozen pieces and the value of paint order becomes concrete. The back flags go on first, and at that stage they're just four pennants floating in space. The far arm and far leg follow. They're darker than their near twins because they're dyed with darker hides, which is how the craft fakes depth without perspective. Only then do the skirt and torso cover their tops. Try exploding the figure fully: every piece slides away from its parent along the direction of its own rivet, like a museum mount.
Step 11Pieces made of math
The warrior references five functions we haven't written: feathers, backFlags, tassel, and the strip and localDir helpers underneath them. They're the parts that can't be a fixed path, because their shape is their motion.
Gravity in the puppet’s own frame
A tassel hangs toward the ground. But a tassel drawn inside the spear's transform lives in a coordinate system that has been rotated by the shoulder, the elbow and the spear, and possibly mirrored if the warrior faces left. Which way is “down” in that frame? We ask the transform directly: invert the current matrix and push the world vector (0, 1) through it. This one idea makes every dangling thing on every puppet correct, whatever its parents are doing.
Travelling waves
strip is the workhorse for anything made of cloth. It walks along a centre-line one segment at a time, and at each step it bends the heading a little by sin(t·freq − i·0.55). The − i·0.55 is the whole trick. Each segment sees the same wave a little later than the one before it, so the motion travels from root to tip the way a flag ripples, instead of the whole strip wobbling in unison. The amplitude grows toward the tip, (0.3 + k), because the root is held and the tail is free. grav pulls the heading gently toward straight down.
Step 12Bringing it to life
We now have a posable puppet, and the remaining question is which poses, over time. Everything I animate is a pure function of time, pose = f(t), with no stored state or physics integration. That makes any moment reproducible, lets you scrub or skip, and keeps frame-rate hiccups from accumulating. Within that rule there are four ingredients, and nearly every motion in the finished piece is a sum of them.
- Cycles are
sin(t·ω)for anything periodic, like breathing, walking and rippling. Use a different frequency for each part, so the parts never line up and nothing looks mechanical. - Noise is
snoise(t·ω)for anything held. A puppeteer's hand is never perfectly still, and a few pixels of low-frequency noise on the rod hand is the difference between a puppet and a sprite. - Beats are eased ramps:
ease((t − start) / duration). An acting beat rises, holds and settles, and you build it by multiplying a rising ramp by a falling one. Blend any pose value toward the beat pose withlerp(rest, beat, k). - Secondary motion is the tassels, feathers and flags from Step 11. They respond to the same wind value and lag behind the body, so you get follow-through without extra work.
There's also one move that's pure shadow theatre, the turn. A puppeteer flips a flat figure to face the other way, and for a moment the audience sees it as a sliver. We do the same with scale(cos(π·k), 1) as k eases from 0 to 1.
The salute beat is worth reading closely: ease(b / 0.6) * (1 − ease((b − 2.4) / 0.9)). It rises in 0.6 s, holds, then settles over 0.9 s. Fast in and slow out is the classic shape of a deliberate gesture. The turn guards against facing reaching exactly zero, because a zero scale makes the matrix non-invertible and localDir would return garbage for one frame.
Step 13Puppets inside puppets
The tree pays off most here. A horse is another rig, with far legs in back, a body, a neck riveted at the withers, and near legs in kids. One of its nodes is a saddle whose draw function calls drawRig again with the warrior. A rider is simply a subtree grafted onto the horse. He inherits every bob and pitch of the gallop without a line of coordination code, and he keeps his own pose table.
The gait functions are the most “tuned” code in the project, so it's worth explaining how they came about. Each leg gets two angles, the upper segment and the lower one, driven by one stride phase. The upper segment swings sinusoidally. The lower segment folds only during the forward swing, which is where cos(p) is positive, and it straightens as the leg reaches. That's why the fold term is wrapped in max(0, …). The four legs share the stride but take different phase offsets. A walk uses quarter-cycle offsets, giving four beats. The gallop uses a tight fan of offsets, so the front pair reaches together while the hind pair pushes, which is the stylised “flying gallop” of Chinese horse art. The body bob and pitch, and the neck's counter-nod, are all tied to the same phase, which is why it reads as one animal instead of five parts.
One line in the demo source deserves a highlight: hp.rider.hips = 0.3. The rider's root node is named hips, so setting that key rotates the entire warrior forward at the saddle. He leans into the gallop, and his flags, feathers and spear go with him. Because a pose is just a dictionary, you can add or override one key without touching anything else.
Step 14Depth, blur, and control rods
Back to fact four from the start. A shadow's softness depends on how far the object is from the screen. This is where layer(blur, …) earns its first argument, and where the commutativity of multiply from Step 2 matters: we can group elements by distance, blur each group once, and composite in any order.
For scenery that means a few flat layers: far hills blurred heavily and faintly dyed, nearer hills less so, and the puppets crisp. For a puppet that the performer pulls away from the screen, the physics gives three coupled changes. It grows by D / (D − d), its edge softens by roughly L·d / (D − d), and it fades as the penumbra spreads the same shadow over a larger area. The slider below moves the warrior along that path.
Look again at rod() and drawRods() in stage.js. As each rig is drawn, any node with a rod asks the current transform where its attachment point lands on the stage, using getTransform().transformPoint(), the same trick as localDir. The points are collected, and after all the layers, they're drawn together as lines running off the bottom of the frame, in one layer blurred by 2.4 px. Rods are held away from the screen by the performer's hands, so they should always be the softest shadows in the frame. Their faint presence tells the audience, without saying so, that a person is working these figures.
Step 15Directing a scene
The last layer of abstraction is time at the scale of a performance. A show is a list of scenes. Each scene has a duration, a screen tint, fade times and a draw(S, lt, t) that receives local time lt, measured from the start of the scene, and global time t, for things that shouldn't reset like flicker and wind. Between scenes the lamp dims almost to black, exactly as a theatre would, and that dimness hides the scene change. The text is inked one character at a time down a vertical column, each character sharpening from a blur and settling a few pixels, like ink soaking into paper.
Because every pose, ripple and flicker is a pure function of time, the scrubber just works. There's no replay and no hidden state; runShow(S, 14.2) always draws the same frame. That's what made the full piece manageable. Seven scenes, each timed against its lines of verse, could be jumped into, paused and screenshotted at any second while I tuned them.
AfterwordPerformance and pitfalls
- Reuse the figure layer. One offscreen canvas per stage, cleared and reused for every layer. A full-canvas
drawImagewith multiply is fast on the GPU, while allocating canvases every frame is not. - Blur a layer, not a piece.
ctx.filter = 'blur()'costs roughly the same whether the layer contains one mountain or thirty soldiers, so group by depth. Blur radii are in device pixels, so multiply by the DPR. Wherectx.filterisn't supported, drawing the layer into a smaller canvas and scaling it back up is an acceptable fake. - Cap the device-pixel ratio. Rendering at 3× on a phone quadruples the fill cost for detail nobody can see through a paper screen. I cap at 2.
- Cache static paths, build dynamic ones.
P()caches every fixed silhouette. Feathers, strips and manes are rebuilt each frame, and that's fine because they're small. - Carving is cheap until it isn't. Each carve is a
clipplusdestination-outdrawing. Thirty soldiers with fish-scale armour means thousands of arcs. For distant, blurred crowds, pass a simpler carve or none, since nobody can see scales through four pixels of blur. - Never let a matrix collapse. A turn that scales to exactly 0 makes
inverse()return NaNs, and every tassel on that puppet disappears for a frame. - Only animate what's on screen. The demos on this page share one
requestAnimationFrameloop and pause when scrolled out of view, using anIntersectionObserver.
CodaWhy this works
Looking back, the result depends much less on drawing skill than on a handful of honest choices. The screen is the only source of light. Colour is a filter, so everything multiplies. Carving is subtraction, so it happens in a separate layer. Every piece swings about its own rivet, so a puppet is a tree and a pose is a table. Distance becomes blur, and time is the only input. Once those are in place, a new character is mostly a few path strings and a list of parts. The medium's constraints, flat pieces, dyed hide, one lamp, happen to be very good constraints for code.
If you build something with this, start the way the craft does: cut one piece, hold it up to the light, and see how it glows.