Canvas 2D · Generative art · Tutorial

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.

A 20-minute read · 15 live demos · every code block on this page is the code that runs
The finished piece. By the end of this article you'll have built every part of it: the lamp, the carved leather, the jointed puppets, the rods, and the timeline.

皮影戏 (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.

lamp (size L) puppet + rod screen umbrapenumbra audience → D d

Four facts follow, and each one becomes a technique:

  1. 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.
  2. 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.
  3. 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.
  4. Distance from the screen changes the shadow. A puppet at distance d from the screen, with the lamp at D, casts a shadow magnified by D / (D − d), with a soft edge about L·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.

An empty screen. Toggle each finishing pass to see what it adds, and drag the lamp slider to watch the colour temperature fall as the flame dims.

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.

The same four translucent pieces, composited two ways. With multiply, overlaps deepen and the screen’s glow survives inside each colour.

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.

Carving on the main canvas punches through to the page (the checkerboard). Carving on the figure layer punches through to the lamp.

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.

The same gourd-shaped piece at each of the three steps: dye only, dyed and carved, and finished with its inked edge.

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.

The five cuts on identical shield shapes. Change the dye to see how each one reads against a different colour.

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:

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.

The warrior’s head at 5× scale. The crosshair is the local origin (the neck rivet); the dots are the anchor and control points of the face path.

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:

Shoulder → elbow → hand. Each piece is drawn in the coordinate system its parent leaves behind.

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.

Drag anywhere to move the target. When you let go, it wanders on its own. The elbow toggle flips the solution.

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:

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 “pieces” to assemble the warrior in paint order. “Explode” slides every riveted piece outward along its joint, and “pivots” marks each rivet.

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.

A specimen board. The staff on the right rotates continuously, but its tassel keeps falling toward the ground, because it asks the transform which way is 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.

  1. 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.
  2. 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.
  3. 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 with lerp(rest, beat, k).
  4. 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.

Three performances of the same rig. Every value is a function of time; switch beats at any moment and nothing breaks.

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.

The ground scrolls at the gait's speed while the puppet stays roughly in place, the way a puppeteer walks a horse across a painted landscape.

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.

Slide the warrior from the paper toward the lamp: larger, softer, paler. The rods are always far from the screen, so their shadows are always soft.

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.

The same show as the opening banner, with a scrubber. Pause and drag through the lamp cue between scenes, or through the flip at 14 seconds.

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

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.