About 牛來

What this is, and how the tricky parts work.

牛來 (NIU LAI) is a small 3D toy that runs in your browser. You tap a cow; it moves, opens its mouth, and moos. There is a second mode where cows walk in from every direction and you have to tap them before they reach the snake in the middle.

There is no download, no account, and no server. Everything runs locally in your browser using three.js and WebGL. The whole thing is about 4.8 MB, which is roughly one photo.

The mouth was the hard part

The cow model is a static OBJ file. It has no skeleton, no blend shapes, and — this was the real problem — no mouth cavity. The lips are just a crease on a closed surface. There is nothing inside the head to reveal.

So opening the mouth had to be done by deforming the mesh directly, every frame:

  1. Find the lip line. Rendering the model through an orthographic camera and raycasting against it gave the exact 3D coordinates: the mouth seam runs from (z 0.75, y 0.78) at the front, angling back and up to (z 0.05, y 0.95) under the ear. That line becomes a hinge plane.
  2. Rotate the jaw. Vertices below that plane and on the front half of the face rotate downward around the hinge, weighted with a smoothstep falloff so the neck and chest stay put.
  3. Carve a cavity. Rotating alone just makes the chin longer — it reads as a stretched face, not an open mouth. So vertices near the seam are also pushed inward along −Z. The push follows a Gaussian whose sigma narrows toward the corners, which gives an almond shape rather than a rectangle.
  4. Darken it. The recessed area is tinted dark through vertex colors. Without this it reads as a dent, not a mouth.
One trap worth knowing: OBJLoader produces non-indexed geometry, so calling computeVertexNormals() gives flat shading. The first version had a face covered in visible facets. Running mergeVertices() first (100,464 → 17,704 vertices) fixes it. When the mouth closes completely the original normals are restored, so the resting pose is pixel-identical to the untouched model.

The lip sync is real

The mouth is not playing a canned animation. An AnalyserNode measures the RMS amplitude of the audio that is actually playing and drives the jaw angle from it, with a fast attack and a slower release. The mouth follows the syllables of the sound. Muted, it falls back to a synthetic envelope so the cow still looks like it is talking.

Deforming only the ~1,600 affected vertices and recomputing normals costs a median of 1.8 ms per frame, and only while the mouth is actually moving.

The snake

The snake coiled in the middle of the arena is a CC0 model by Quaternius, with four skeletal animations. It idles, slowly turns to face whichever cow is closest, and strikes when one gets through.

Another trap: Box3.setFromObject() on a skinned mesh measures the bone nodes, not the rendered shape. This model reported 318 units when it actually renders at 3.2. Scaling from that number shrank the snake to 0.018 units — invisible. The fix is to apply the bone transforms yourself, and to do it after the object is in the scene graph and its world matrices have been updated.

The grassland is generated, not painted

There are no background images in this game. Everything is drawn at load time:

The mown circle sits 0.09 units above the field. Without that gap the two coplanar surfaces z-fight and the arena fills with radial stripes.

Size

The textures started as 7.1 MB of PNG. Converted to JPEG — diffuse at 2048, normal at 1024, roughness at 512 — they came to 0.25 MB, a 96% reduction. An A/B pixel comparison over the cow's face showed a mean difference of 1.62/255, which is invisible. Load time matters more than texture resolution for something people arrive at from a link.

Credits

▶ Play 牛來