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:
-
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. - 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.
- 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.
- 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:OBJLoaderproduces non-indexed geometry, so callingcomputeVertexNormals()gives flat shading. The first version had a face covered in visible facets. RunningmergeVertices()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:
- Sky — a vertical gradient painted onto a canvas, used as the scene background, with the fog colour matched to the horizon so distant ground melts into it.
- Grass — a canvas texture built from 1,400 soft colour patches and 9,000 short strokes, tiled 26×26.
- Tufts — 900 small cones drawn as a single
InstancedMesh, so they cost one draw call. - Clouds — clusters of white spheres with fog disabled so they stay crisp.
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
- Snake model — "Snake" by Quaternius, CC0 1.0
- Rendering — three.js, MIT License
- Hosting — Cloudflare Pages