《牛来》火了?我用ThreeJs实现了一个简易版的,代码全送给你,文章最后附演示效果

最近火了一个电影哈!

可以说是拳打《蜘蛛侠》,脚踢《龙餐馆》,没错就是这个"土到极致就是潮"的"巨制"------《牛来》

我简单看了一下背景,这个电影据传就俩人的主创。

8月5号那天"悄悄"上线的,没预告、没路演、没海报,唯一的就是一张水墨画一样的宣发材料。

首日票房也是相当高,整整342块钱,是的,342元!

按理说这种片子基本上就影院"一周游",很快就下线了。

结果一个热搜把它送上了"神坛" ------ "牛来票房7352元没有万"。

第二天就炸了,全国排片从最低不足5场猛增至168场!

我从网上也扒了扒网友发的电影画面,不能说"简陋",只能说在AI盛行的今天,依旧有艺术家坚持手搓。

令人敬仰!

于是乎,我用ThreeJs简单做了一个小场景,以此来表达我个人的敬仰之情。

事先声明:

  • 此项目所有的外观、版权等均归属原创作团队,本人不享有任何版权权益,不承担任何侵权责任。
  • 此项目仅仅用于ThreeJs学习展示用途,没有任何嘲讽他人的意思。

好了,进入正题。

首先我们需要一个 3D 人物模型,大家可以在网上自己下载一个,我这里简单手搓了一个。

毕竟是"致敬",所以咱们不能马虎。

主要实现的功能有:

  • 创建地面、天空场景,及ThreeJs加载3D人物模型。
  • 使用方向键或WASD操作人物移动。
  • 使用空格键实现人物跳跃效果。
  • 使用Ctrl键实现人物蹲下效果。
  • 人物移动过程中随机在地面上出现草。

首先是创建一个基础的容器代码,ThreeJs 渲染在 Canvas 中。

html 复制代码
<template>
  <div class="scene-wrap">
    <div ref="container" class="canvas-container"></div>

    <div v-if="loading" class="loading">
      <div class="spinner"></div>
      <p>加载模型中... {{ progress }}%</p>
      <p class="tip">(niu.glb 约 45MB,首次加载稍慢)</p>
    </div>

    <div class="hint">
      <b>WASD / 方向键</b> 移动 &nbsp;·&nbsp; <b>空格</b> 跳跃 &nbsp;·&nbsp; <b>Ctrl</b> 蹲下
    </div>
  </div>
</template>

这里增加模型加载 Loading,让用户减少白屏等待。

js 复制代码
const container = ref<HTMLDivElement>()
const loading = ref(true)
// 加载进度百分比(0~100)
const progress = ref(0)
// 持有 GameWorld 实例,卸载时用于 dispose
let world: GameWorld | null = null

onMounted(() => {
  if (!container.value) return
  world = new GameWorld({
    container: container.value,
    // 加载进度回调:把 0~1 的比例转成百分比显示
    onProgress: (p) => {
      progress.value = Math.round(p * 100)
    },
    // 模型加载完成(成功或失败都触发):关闭 loading 遮罩
    onLoaded: () => {
      loading.value = false
    },
  })
})

// 组件卸载前销毁 3D 世界,释放 GPU 资源与事件监听
onBeforeUnmount(() => {
  world?.dispose()
})

核心方法通过 GameWorld 类进行了封装。

js 复制代码
// 引入所需资源
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'

首先实现场景渲染部分

js 复制代码
this.container = opts.container
this.onProgress = opts.onProgress
this.onLoaded = opts.onLoaded

// ---------- 渲染器 ----------
this.renderer = new THREE.WebGLRenderer({ antialias: true })
// 限制像素比上限为 2,避免高分屏下过度绘制拖慢帧率
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
// 开启阴影并选用软阴影(PCF),让人物在地上有柔和投影
this.renderer.shadowMap.enabled = true
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap
// 输出到 sRGB 色彩空间,保证纹理颜色显示正确(现代 Three.js 标准做法)
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.container.appendChild(this.renderer.domElement)

// ---------- 场景 ----------
this.scene = new THREE.Scene()
this.scene.background = new THREE.Color(SKY_COLOR)
// 远处加雾:让地面边缘"融"进天空,避免看到方形地面边界
this.scene.fog = new THREE.Fog(SKY_COLOR, 90, 220)

// ---------- 相机(第三人称跟随,初始在人物后上方) ----------
this.camera = new THREE.PerspectiveCamera(
    60, // 视场角 FOV
    this.container.clientWidth / this.container.clientHeight,
    0.1, // 近裁剪面
    1000 // 远裁剪面
)
this.camera.position.set(0, 6, 14)
this.camera.lookAt(0, 1, 0)

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

绘制地面

js 复制代码
/** 地面:一块铺满程序化土地纹理的平面,接收阴影 */
private setupGround() {
    const tex = this.makeGroundTexture()
    // 平面默认在 XY 面,绕 X 轴转 -90° 变成水平地面
    const geo = new THREE.PlaneGeometry(GROUND_HALF * 2, GROUND_HALF * 2, 1, 1)
    const mat = new THREE.MeshStandardMaterial({
        map: tex,
        color: 0xffffff,
        roughness: 1, // 土地不反光
        metalness: 0,
    })
    const ground = new THREE.Mesh(geo, mat)
    ground.rotation.x = -Math.PI / 2
    ground.receiveShadow = true // 让人物阴影投在地面上
    this.scene.add(ground)
}

/** 程序化生成土地纹理:土色底 + 大量随机斑块 + 零星草点,再平铺重复 */
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)
    // 土色斑块:随机颜色/位置/大小的半透明圆,叠出泥土质感
    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()
    }
    // 零星草点:在纹理上点几笔绿色,让土地看起来有草感(与实例化草叠加)
    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 // 允许平铺
    tex.repeat.set(40, 40) // 在一张地面上重复 40×40 次
    tex.colorSpace = THREE.SRGBColorSpace
    // 各向异性过滤:视角很低(贴地看)时纹理依然清晰,不糊
    tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
    return tex
}

生成草,这里用 InstancedMesh 一次性渲染最多 GRASS_MAX 株草。

一株草由 3 片交叉的竖直平面 PlaneGeometry 合并而成,省 draw call。

不论多少株草,都只提交一次几何体。

js 复制代码
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) // 把原点挪到草底部,方便以脚底为基准缩放/摆放
        g.rotateY((i / 3) * Math.PI) // 三片互成 60°,形成立体感
        g.rotateX(0.25) // 稍微外倾,像被风吹/有体积
        leaves.push(g)
    }
    const geo = mergeGeometries(leaves)! // 合并为单一几何体
    const mat = new THREE.MeshStandardMaterial({
        color: 0x4f8a36,
        side: THREE.DoubleSide, // 双面可见(平面无厚度)
        roughness: 1,
        metalness: 0,
    })
    this.grass = new THREE.InstancedMesh(geo, mat, GRASS_MAX)
    this.grass.instanceMatrix.setUsage(THREE.DynamicDrawUsage) // 矩阵会频繁更新
    this.grass.frustumCulled = false // 禁用视锥剔除,否则整片草可能被整体剔除
    // 初始全部隐藏:移到地下并把缩放趋近 0(实例矩阵里看不见即"不存在")
    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)
}

帧处理部分,每帧的输入处理 + 运动/物理积分。

整个交互的核心为:读取按键 → 计算移动向量 → 更新朝向/位置/跳跃/蹲下/弹跳。

js 复制代码
private handleInput(dt: number) {
    const k = this.keys
    // 把按键映射成"前/后/左/右"四个方向的有无(1/0)
    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

    // 蹲下 / 空格
    const crouching = !!(k['ControlLeft'] || k['ControlRight'])
    const space = !!k['Space']
    // 边沿检测
    if (space && !this.spacePrev && this.onGround) {
      this.vy = JUMP_SPEED
      this.onGround = false
    }
    this.spacePrev = space

    // 速度
    let speed = moving ? RUN_SPEED : 0
    if (crouching) speed *= 0.4
    if (!this.onGround) speed *= 0.6 // 空中减速

    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
    // 限制在地面范围内,别跑出地图
    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)

    // 离地后用重力做匀加速积分,落地归零
    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
      }
    }

    // 用缩放系数平滑过渡,并让缩放绕脚底(pivot 底部即 y=0)发生,
    const targetCrouch = crouching ? 0.55 : 1
    this.crouch += (targetCrouch - this.crouch) * Math.min(1, dt * 10)
    this.pivot.scale.y = this.crouch

    // 跑步上下弹跳效果
    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
    }
    // 最终 y = 跳跃高度 + 弹跳
    this.pivot.position.y = this.jumpY + this.bob

    // 随跑动在附近随机生成草:累计移动距离超过阈值就补一株
    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)
}

为了页面操作比较平滑,这里增加了相机跟随人物效果。

增加了跟随系数 a = 1 - 0.0015^dt。

dt 越大跟得越紧、越小越松,保证不同帧率下观感一致。

js 复制代码
private updateCamera(dt: number) {
    const p = this.pivot.position
    const tx = p.x
    const ty = p.y + 6 // 始终在人物头顶上方 6 单位
    const tz = p.z + 14 // 始终在人物身后 14 单位(第三人称视角)
    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) // 看向人物上半身
}

除了上述部分以外,最好在页面销毁的时候,将循环停止,相关事件全部解绑。

释放 GPU 资源,以及 Canvas 部分,避免出现内存泄漏。

演示效果给大家放一下,因为是 Gif 图片,可能会有点儿卡,实际效果要丝滑很多。

免责声明:

  1. 本项目基于 Three.js 开发,属于个人技术学习 Demo,仅供技术研究与交流学习,严禁用于任何商业场景、盈利用途
  2. Demo 内使用的电影角色形象、相关视觉素材的著作权、商标权全部归属原电影版权持有方。本人不对该影视 IP 享有任何版权。
  3. 本项目不存在任何商业推广、二次售卖、衍生盈利行为。
  4. 如果版权权利人认为本 Demo 构成侵权,请与我联系,我会立刻删除、下架全部相关内容。
  5. 任何人不得将本 Demo 内的形象、代码用于商用,若第三方擅自使用产生的一切法律责任,由使用方自行承担,与本项目作者无关。
相关推荐
世界哪有真情2 小时前
AI 写代码两年多,我发现自己越来越"看不进去"了
前端·后端·ai编程
星栈2 小时前
被 Rust async 纠正的三个异步认知
前端·后端·rust
前端_刘师兄2 小时前
前端开发工程师转FAE工程师路线规划
前端
禁止摆烂_才浅2 小时前
前端 AI 面试题
前端·面试·ai编程
程序员老赵2 小时前
Docker 部署 ZLMediaKit:轻松搭建高性能流媒体服务平台
前端·javascript·后端
Liora_Yvonne2 小时前
为什么每次发版,总有用户看到白屏?
前端
风月说与山鬼2 小时前
四、浏览器存储
前端·vue.js
hunterandroid2 小时前
[Android 从零到一] ViewPager2 与 Fragment 生命周期协同:从预加载到状态一致性
android·前端
cindershade2 小时前
别盯着 dist:Vite 应用的下载与执行成本治理闭环
前端