跪拜 Guibai
← Back to the summary

A Three.js Tribute to Viral Zero-Budget Film 'Niu Lai'

Recently, a movie has gone viral!

You could say it punches Spider-Man and kicks Dragon Restaurant. That's right, it's this "so rustic it's chic" blockbuster — Niu Lai.

I took a quick look at the background. The film was reportedly made by a two-person crew.

It quietly went online on August 5th — no trailers, no roadshows, no posters, just a single ink-wash-style promotional image.

The opening-day box office was also quite high, a full 342 yuan. Yes, 342 yuan!

Normally, a film like this would basically have a "one-week run" in theaters and quickly disappear.

Then a trending topic sent it to the "altar" — "Niu Lai box office 7352 yuan, no 'ten-thousands'."

It exploded the next day. Nationwide screenings surged from a minimum of fewer than 5 to 168!

I also dug up some film stills shared by netizens. You can't just call it "crude"; you could say that in today's AI-prevalent era, there are still artists insisting on handcrafting.

Admirable!

So, I used Three.js to quickly build a small scene to express my personal admiration.

Disclaimer:

Alright, let's get to the point.

First, we need a 3D character model. You can download one online; I simply handcrafted one here.

After all, it's a "tribute," so we can't be sloppy.

The main implemented features are:

First, create a basic container code; Three.js renders inside a Canvas.

<template>
  <div class="scene-wrap">
    <div ref="container" class="canvas-container"></div>

    <div v-if="loading" class="loading">
      <div class="spinner"></div>
      <p>Loading model… {{ progress }}%</p>
      <p class="tip">(niu.glb is about 45MB, first load is a bit slow)</p>
    </div>

    <div class="hint">
      <b>WASD / Arrow Keys</b> Move · <b>Space</b> Jump · <b>Ctrl</b> Crouch
    </div>
  </div>
</template>

A model loading indicator is added here to reduce the user's blank-screen wait.

const container = ref<HTMLDivElement>()
const loading = ref(true)
// Loading progress percentage (0~100)
const progress = ref(0)
// Holds the GameWorld instance, used for disposal on unmount
let world: GameWorld | null = null

onMounted(() => {
  if (!container.value) return
  world = new GameWorld({
    container: container.value,
    // Loading progress callback: converts 0~1 ratio to percentage display
    onProgress: (p) => {
      progress.value = Math.round(p * 100)
    },
    // Model loading complete (triggers on success or failure): hides the loading overlay
    onLoaded: () => {
      loading.value = false
    },
  })
})

// Destroy the 3D world before component unmounts, freeing GPU resources and event listeners
onBeforeUnmount(() => {
  world?.dispose()
})

The core methods are encapsulated within the GameWorld class.

// Import required resources
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'

First, implement the scene rendering part.

this.container = opts.container
this.onProgress = opts.onProgress
this.onLoaded = opts.onLoaded

// ---------- Renderer ----------
this.renderer = new THREE.WebGLRenderer({ antialias: true })
// Cap pixel ratio at 2 to avoid excessive drawing slowing frame rate on high-DPI screens
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
// Enable shadows and use soft shadows (PCF) for a soft character shadow on the ground
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
// Output to sRGB color space to ensure correct texture color display (modern Three.js standard practice)
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.container.appendChild(this.renderer.domElement)

// ---------- Scene ----------
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(SKY_COLOR)
// Add fog in the distance: makes the ground edge "melt" into the sky, avoiding a visible square ground boundary
this.scene.fog = new THREE.Fog(SKY_COLOR, 90, 220)

// ---------- Camera (third-person follow, initially behind and above the character) ----------
this.camera = new THREE.PerspectiveCamera(
    60, // Field of view (FOV)
    this.container.clientWidth / this.container.clientHeight,
    0.1, // Near clipping plane
    1000 // Far clipping plane
)
this.camera.position.set(0, 6, 14)
this.camera.lookAt(0, 1, 0)

// ---------- Listeners ----------
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
window.addEventListener('resize', this.onResize)

Draw the ground.

/** Ground: a plane covered with a procedural dirt texture, receiving shadows */
private setupGround() {
    const tex = this.makeGroundTexture()
    // Plane defaults to XY plane; rotate -90° around X-axis to make it horizontal ground
    const geo = new THREE.PlaneGeometry(GROUND_HALF * 2, GROUND_HALF * 2, 1, 1)
    const mat = new THREE.MeshStandardMaterial({
        map: tex,
        color: 0xffffff,
        roughness: 1, // Dirt is not reflective
        metalness: 0,
    })
    const ground = new THREE.Mesh(geo, mat)
    ground.rotation.x = -Math.PI / 2
    ground.receiveShadow = true // Allow character shadow to cast onto the ground
    this.scene.add(ground)
}

/** Procedurally generate a dirt texture: brown base + many random patches + scattered grass dots, then tiled */
private makeGroundTexture(): THREE.Texture {
    const c = document.createElement('canvas')
    c.width = c.height = 512
    const ctx = c.getContext('2d')!
    ctx.fillStyle = '#7a5532'
    ctx.fillRect(0, 0, 512, 512)
    // Dirt patches: semi-transparent circles with random color/position/size, layered for a dirt feel
    for (let i = 0; i < 2600; i++) {
        const x = Math.random() * 512
        const y = Math.random() * 512
        const r = 2 + Math.random() * 16
        const col =
        Math.random() > 0.5
            ? `rgba(${(110 + Math.random() * 40) | 0}, ${(78 + Math.random() * 30) | 0}, ${(46 + Math.random() * 20) | 0}, ${0.12 + Math.random() * 0.22})`
            : `rgba(${(60 + Math.random() * 30) | 0}, ${(42 + Math.random() * 20) | 0}, ${(26 + Math.random() * 14) | 0}, ${0.12 + Math.random() * 0.22})`
        ctx.fillStyle = col
        ctx.beginPath()
        ctx.arc(x, y, r, 0, Math.PI * 2)
        ctx.fill()
    }
    // Scattered grass dots: a few green strokes on the texture for a grassy feel (overlaid with instanced grass)
    for (let i = 0; i < 400; i++) {
        const x = Math.random() * 512
        const y = Math.random() * 512
        ctx.fillStyle = `rgba(90,140,60,${0.15 + Math.random() * 0.25})`
        ctx.fillRect(x, y, 2, 4)
    }
    const tex = new THREE.CanvasTexture(c)
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping // Allow tiling
    tex.repeat.set(40, 40) // Repeat 40x40 times across one ground plane
    tex.colorSpace = THREE.SRGBColorSpace
    // Anisotropic filtering: texture stays sharp even at very low viewing angles (looking across the ground)
    tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
    return tex
}

Generate grass. Here, InstancedMesh is used to render up to GRASS_MAX blades of grass in one go.

A single blade of grass is composed of 3 intersecting vertical planes (PlaneGeometry) merged together, saving draw calls.

No matter how many blades of grass, only one geometry is submitted.

private setupGrass() {
    const leaves: THREE.BufferGeometry[] = []
    for (let i = 0; i < 3; i++) {
        const g = new THREE.PlaneGeometry(0.14, 0.6)
        g.translate(0, 0.3, 0) // Move origin to the bottom of the grass, making it easier to scale/position from the base
        g.rotateY((i / 3) * Math.PI) // Three planes at 60° to each other, creating a 3D feel
        g.rotateX(0.25) // Slight outward tilt, like being blown by wind or having volume
        leaves.push(g)
    }
    const geo = mergeGeometries(leaves)! // Merge into a single geometry
    const mat = new THREE.MeshStandardMaterial({
        color: 0x4f8a36,
        side: THREE.DoubleSide, // Visible from both sides (planes have no thickness)
        roughness: 1,
        metalness: 0,
    })
    this.grass = new THREE.InstancedMesh(geo, mat, GRASS_MAX)
    this.grass.instanceMatrix.setUsage(THREE.DynamicDrawUsage) // Matrix will be updated frequently
    this.grass.frustumCulled = false // Disable frustum culling, otherwise the whole grass field might be culled
    // Initially hide all: move underground and scale near 0 (invisible in the instance matrix means "non-existent")
    for (let i = 0; i < GRASS_MAX; i++) {
        this.dummy.position.set(0, -1000, 0)
        this.dummy.scale.setScalar(0.0001)
        this.dummy.updateMatrix()
        this.grass.setMatrixAt(i, this.dummy.matrix)
    }
    this.grass.instanceMatrix.needsUpdate = true
    this.scene.add(this.grass)
}

Frame processing section: per-frame input handling + motion/physics integration.

The core of the interaction is: read keys → calculate movement vector → update orientation/position/jump/crouch/bob.

private handleInput(dt: number) {
    const k = this.keys
    // Map keys to presence (1/0) of "forward/back/left/right" directions
    const fwd = k['KeyW'] || k['ArrowUp'] ? 1 : 0
    const back = k['KeyS'] || k['ArrowDown'] ? 1 : 0
    const left = k['KeyA'] || k['ArrowLeft'] ? 1 : 0
    const right = k['KeyD'] || k['ArrowRight'] ? 1 : 0
    const ix = right - left
    const iz = back - fwd
    const moving = ix !== 0 || iz !== 0

    // Crouch / Space
    const crouching = !!(k['ControlLeft'] || k['ControlRight'])
    const space = !!k['Space']
    // Edge detection
    if (space && !this.spacePrev && this.onGround) {
      this.vy = JUMP_SPEED
      this.onGround = false
    }
    this.spacePrev = space

    // Speed
    let speed = moving ? RUN_SPEED : 0
    if (crouching) speed *= 0.4
    if (!this.onGround) speed *= 0.6 // Slow down in the air

    if (moving) {
      const target = Math.atan2(ix, iz) + FACING_OFFSET
      this.heading = lerpAngle(this.heading, target, Math.min(1, dt * 12))
    }
    this.pivot.rotation.y = this.heading

    this.pivot.position.x += ix * speed * dt
    this.pivot.position.z += iz * speed * dt
    // Clamp within the ground area, don't run off the map
    const lim = GROUND_HALF - 2
    this.pivot.position.x = THREE.MathUtils.clamp(this.pivot.position.x, -lim, lim)
    this.pivot.position.z = THREE.MathUtils.clamp(this.pivot.position.z, -lim, lim)

    // After leaving the ground, apply gravity for uniform acceleration integration; reset on landing
    if (!this.onGround) {
      this.vy -= GRAVITY * dt
      this.jumpY += this.vy * dt
      if (this.jumpY <= 0) {
        this.jumpY = 0
        this.vy = 0
        this.onGround = true
      }
    }

    // Smoothly transition the crouch scale factor, making scaling occur around the feet (pivot bottom is y=0)
    const targetCrouch = crouching ? 0.55 : 1
    this.crouch += (targetCrouch - this.crouch) * Math.min(1, dt * 10)
    this.pivot.scale.y = this.crouch

    // Running bob effect
    if (moving && this.onGround) {
      this.runTime += dt * 16
      this.bob = Math.abs(Math.sin(this.runTime)) * 0.12
    } else {
      this.bob *= 0.82
      if (Math.abs(this.bob) < 0.001) this.bob = 0
    }
    // Final y = jump height + bob
    this.pivot.position.y = this.jumpY + this.bob

    // Randomly generate grass nearby as the character runs: spawn one when accumulated distance exceeds threshold
    const moved = this.pivot.position.distanceTo(this.prevPos)
    this.distAccum += moved
    if (this.distAccum > 1.6) {
      this.distAccum = 0
      this.spawnGrass()
    }
    this.prevPos.copy(this.pivot.position)
}

For smoother page operation, a camera-follow effect is added here.

A follow coefficient a = 1 - 0.0015^dt is used.

The larger dt is, the tighter the follow; the smaller, the looser, ensuring consistent feel across different frame rates.

private updateCamera(dt: number) {
    const p = this.pivot.position
    const tx = p.x
    const ty = p.y + 6 // Always 6 units above the character's head
    const tz = p.z + 14 // Always 14 units behind the character (third-person perspective)
    const a = 1 - Math.pow(0.0015, dt)
    this.camera.position.x += (tx - this.camera.position.x) * a
    this.camera.position.y += (ty - this.camera.position.y) * a
    this.camera.position.z += (tz - this.camera.position.z) * a
    this.camera.lookAt(p.x, p.y + 1.3, p.z) // Look at the character's upper body
}

Besides the above, it's best to stop the loop and unbind all related events when the page is destroyed.

Release GPU resources and the Canvas part to avoid memory leaks.

Here's the demo effect. Since it's a GIF, it might look a bit choppy; the actual effect is much smoother.

Disclaimer:

  1. This project is developed based on Three.js and is a personal technical learning demo, intended solely for technical research and exchange. Any commercial use or profit-making purpose is strictly prohibited.
  2. The copyrights and trademark rights of the movie character images and related visual materials used in the Demo belong entirely to the original movie copyright holder. I do not hold any copyright over this film IP.
  3. This project does not involve any commercial promotion, secondary sales, or derivative profit-making activities.
  4. If the copyright holder believes this Demo constitutes infringement, please contact me, and I will immediately delete and remove all related content.
  5. No one may use the images or code in this Demo for commercial purposes. Any legal liability arising from unauthorized use by third parties shall be borne by the user, and is unrelated to the author of this project.
Comments

Top 3 from juejin.cn, machine-translated. The original thread is authoritative.

用户24171602916

Can you make it walk with both feet? I really need this

PBitW

[facepalm]

cbw100

First post, thumbs up