用Three.js做个兔吉宝箱给大家拜个年

8,644 阅读6分钟

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

介绍

不知不觉兔年已经来到,今年用什么形式庆贺新春呢,思来想去,就准备用Three.js做个拜年宝箱动画,宝箱落下后点击就可以打开,一直萌萌哒的小兔吉就给我拜年啦,每次说出的贺词都是不同的,所以我把这个宝箱命名为兔吉宝箱~

演示

演示.gif

演示地址: jsmask.gitee.io/rabbit-luck…

源码地址:gitee.com/jsmask/rabb…

正文

基础搭建

本项目将使用vite4来实现:

yarn create vite

起好项目名,选择vue3后,就构建成功一个基础项目了。

再配置一下 vite.config.js 文件,拿些文件的时候至少得要个别名吧,因为项目也比较小,目前就简单配置了。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from "path"

// https://vitejs.dev/config/
export default defineConfig({
  base:"./",
  server: {
    host: '0.0.0.0',
    open: false,
  },
  resolve: {
    alias: {
      "@": resolve(__dirname, "src")
    }
  },
  plugins: [vue()],
})

当然这还远远不够,我们还要安装scss来为更好的书写样式:

yarn add scss -D

这里我还安装了 reset.css ,目的是清除一些浏览器的默认样式。

yarn add reset.css

而且导入在 style.scss 里,然后可以在里面写一些定义的公共样式。最后把 style.scss 直接再导入到 main.js 中。

// style.scss
@import url("reset.css");
// ...

为了更方便的获取一些资源,我们还要把资源文件放置到 public 文件夹中,这样我们就可以直接用音频,图片,模型这些资源了。

目录.png

场景搭建

这个项目一共分两个场景,一个是刚进来默认的初始确认场景,一个是3D动画的主场景,主要实现的业务代码还是比较多的,详细请看上面的源码。

初始.png

初始场景就是非常普通页面,就是用css写一个闪动的文字动画,然后监听键盘和鼠标当按下后,闪动加快一段时间后就跳入到3D动画的场景中。

加入这个初始默认场景的主要目的有三个,第一让用户点击后实现了交互激活音效功能,第二让用户有个准备不要一上来就开始了失去了趣味,第三点因为里面的音效有四个资源这里希望在默认来的时候先加载他们不至于后面播放不出来。

// audio.js
export let AUDIODATA = {
    BGMMAIN: "assets/audio/bgm.mp3",
    PRESS:"assets/audio/press.mp3",
    OPEN: "assets/audio/open.mp3",
    FADE:"assets/audio/fade.mp3",
}

let bgm = new Audio();
bgm.src = AUDIODATA.BGMMAIN

export function playBGM(continuate = true) {
    if (!continuate) bgm.currentTime = 0;
    bgm.volume = 60 / 100;
    bgm.loop = true;
    bgm.play();
    return bgm
}

export function stopBGM() {
    if (!bgm) return;
    bgm.paused();
    bgm.currentTime = 0;
}

// ...

动画场景.png

接下来就是3D动画的场景,要做3D首先安装 three.js

yarn add three

然后写一个脚本文件 index.js 引入将three.js 其中,这个文件也是我们的主逻辑脚本,其中会实现一个 Game 的类,在这个里面我们将实现场景初始化,引入摄像机灯光模型等 ,后面会将这个类实例化,传入到显示容器中。

// game/index.js
import * as THREE from "three"
export default class Game {
    constructor(parentEl) {
        this.parentEl = parentEl;
        this.init();
    }
    init(){
        this.renderer = new THREE.WebGLRenderer({
            antialias: true,
        });
        this.renderer.outputEncoding = THREE.sRGBEncoding;
        this.renderer.gammaFactor = 3;
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        this.renderer.domElement.id = "game-canvas"
        this.parentEl.appendChild(this.renderer.domElement);
        // ...
    }
    // ...
}
<!--MainScene.vue-->
<template>
	<div ref="gameRef" class="main-game"></div>
</template>

<script setup>
    import { ref, onMounted, onUnmounted } from "vue"
    import { playBGM } from "@/game/audio"
    const gameRef = ref(null)
    let game;
    onMounted(() => {
        playBGM();
        game = new Game(gameRef.value)
    })
    onUnmounted(() => {
        game.destroy()
    })
</script>    

场景切换

场景有了但是我们这么快速的切换呢,此时很多人都直接使用vue-router来切换。但是考虑目前项目只有两个场景到是可以不使用,这样减少了一个库,减少了资源的使用。二来路由切换后地址栏会有杂质。而且我们还没有对游戏一些自定义配置需要用到状态管理类的库,不如就用状态管理暂时充当路由使用,反正就俩场景,用 v-if 控制好了。

<!--App.vue-->
<script setup>
import PressScene from "./view/PressScene.vue"
import MainScene from "./view/MainScene.vue"
import { useSystemStore } from "@/store/system";
const store = useSystemStore();
</script>

<template>
    <press-scene v-if="store.scene == 'press'" />
    <main-scene v-if="store.scene == 'play'" />
</template>

当然,可以看到系统管理我们用到了 pinia

先安装一下:

yarn add pinia

再写一个专门管理系统状态的文件:

// system.js
import { defineStore } from 'pinia'

const defaultState = {
    scene: "press",
}

export const useSystemStore = defineStore('system', {
    state: () => {
        return {
            ...defaultState
        };
    },
    actions: {
        changeScene(sceneName) {
            this.scene = sceneName;
        },
    }
})

目前还是比较简单,就是单纯的控制场景是哪一个,当然你还可以加一些别的配置比如音量或者播放速度的控制等等。

每次切换场景只要通知一些系统,状态要改变了,场景就会发生变化。

// PressScene.vue
window.addEventListener("keydown", handleClick)
function handleClick() {
    store.changeScene("play")
    window.removeEventListener("keydown", handleClick)
}

模型加载

加载和导入模型,我们以宝箱为例:

import * as THREE from "three"
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import bus from "./bus"

export default class Chest {
    constructor(game) {
        this.game = game;
        this.scene = game.scene;
        this.camera = game.camera;
        this.target = null;
        this.isOpen = false;
        this.state = "wait"
        this.init();
    }
    init() {
        let loader = new GLTFLoader();
        loader.load("./assets/mod/chest/scene.gltf", gltf => {
            this.target = gltf.scene;
            this.animations = gltf.animations;
            this.target.scale.set(.005, .005, .005);
            this.target.position.set(0, 5, 0)
            this.target.traverse(c => {
                c.castShadow = true;
                c.receiveShadow = true;
                if (c.material && c.material.map) {
                    c.material.map.encoding = THREE.sRGBEncoding;
                }
            });
            this.mixer = new THREE.AnimationMixer(this.target);
            this.mixer.addEventListener('finished', this.finishedAnimation.bind(this));
            this.scene.add(this.target);
            bus.$emit("loaded","chest")
        }
    }
    // ...
}

因为宝箱模型是gltf格式的,所以通过实例化 GLTFLoader 来实现一个Loader。通过load方法来传入地址来加载它,这里要注意地址要设置成相对路径。加载成功后会就可以拿到模型信息,此时你可以设置该模型的大小位置方向等等,当然这个模型是有动画的所有我们还要保存一下它动画的信息。

模型对象有 traverse 可以对此模型进行遍历,这里是对其添加一些阴影设置,当然如果有需要还可以更换材质节点等等操作。

最后,你会发现我们这里自定义了一个bus作为来发布订阅一些消息,这里是把该模型的加载的消息发出去。然后主逻辑获取这些信息。

发布订阅我们使用了 mitt.js 库来实现。

// bus.js
import mitt from "mitt";

const bus = {};
const emitter = mitt();

bus.$on = emitter.on;
bus.$off = emitter.off;
bus.$emit = emitter.emit;

export default bus;

这样在主逻辑脚本中,可以接收到加载完的消息从而通知界面逻辑。

// game/index.js
const loadModNameList = []
bus.$on("loaded", (name) => {
    loadModNameList.push(name)
    bus.$emit("progress", ~~(loadModNameList.length / 3 * 100))
    if (loadModNameList.length >= 3) {
        setTimeout(()=>{
            this.chest.playGame()
        },500)
    }
})

出来.png

宝箱选中

这个3D动画其中一个交互是你要点击宝箱后才能打开,小兔吉出来拜年,那么怎么才能判断你点中了宝箱模型呢?其实非常简单,只要你绑定好点击事件,点击画面后就可以拿到坐标将其变成三维坐标,再根据摄像机位置,实例出 THREE.Raycaster 在场景中发出一道射线会捕获到经过的物体,然后根据这些物体遍历,如果其中有的物体属于宝箱的,那么就意味着刚才的点击就选中了宝箱模型。然后就可以对其发出打开等指令操作了。

export default class Chest {  
	bindEvent() {
        window.addEventListener("mouseup", this.handleClick.bind(this));
        window.addEventListener("touchend", this.handleClick.bind(this))
    }
    handleClick(e) {
        let vector = new THREE.Vector3();
        vector.set(
            (e.clientX / window.innerWidth) * 2 - 1,
            -(e.clientY / window.innerHeight) * 2 + 1,
            0.5);
        vector.unproject(this.camera);
        let raycaster = new THREE.Raycaster(this.camera.position, vector.sub(this.camera.position).normalize());
        let intersects = raycaster.intersectObjects(this.scene.children);
        let isActive = false;
        // 遍历射线是否经过宝箱
        for (const item of intersects) {
            this.target.traverse(c => {
                if (c == item.object) isActive = true;
            })
        }
        // 如果选中并且没有打开就直接打开指令打开宝箱
        if (isActive && this.state == "wait" && !this.isOpen) {
            this.open();
        }
    }
}    

1.gif

模型动画

还是以宝箱模型打开动画为例,当初模型加载完成的时候,已经把模型信息里的动画存储下来放到了 animations 中。

export default class Chest {  
	open() {
        console.log("open begin")
        this.isOpen = true;
        bus.$emit("open")
        playSeOpen()
        this.mixer.stopAllAction();
        let anim = this.animations[0]
        let curAction = this.mixer.clipAction(anim);
        curAction.enabled = true;
        curAction.time = 0.0;
        curAction.clampWhenFinished = true;
        curAction.setEffectiveTimeScale(1.0);
        curAction.setEffectiveWeight(1.0);
        curAction.setLoop(THREE.LoopOnce, 1);
        curAction.play();
    }   
}

我们先拿到所需要的打开动画 anim ,在动画混合器 mixer中,设置当前动画的动作。因为打开动画是只播放一次不需要去循环播放,所以就要设置它循环次数为1,当然还有很多细节上的设置要去调整,之后就可以使用 play 方法播放了。

最后别忘了,动画每一帧都是需要更新才会有效果的。

export default class Chest {     
	update(delta) {
        this.mixer && this.mixer.update(delta);
    }
}

2.gif

缓动动画

这个3d世界中所有缓动动画比如宝箱下落回弹,摄像机视角的前进,都是使用gsap.js来实现的,所以先安装一下:

yarn add gsap

这里用到了gsap的时间线动画,非常简单就是实例化 gsap.timeline ,在某个阶段用什么缓动效果持续多久实现某个动画,结束之后会怎么都可以轻松设置。

import gsap, { Bounce } from "gsap"
export default class Chest {  
	playGame(){
        this.runTimeLine();
    }
    runTimeLine() {
        this.timer = new gsap.timeline({
            defaults: { duration: 0 },
        });
        playSeFade()
        this.timer.to(
            this.target.position,
            {
                duration: 1,
                y: 0,
                ease: Bounce.easeOut,
                onComplete: () => {
                    this.rabbit.setVisible(true)
                },
            }
        );
        this.timer.to(
            this.camera.position,
            {
                duration: 1.2,
                z: 2.4,
                onComplete: () => {
                    this.bindEvent();
                },
            },
            1.5
        );
    }
}

4.gif

贺词动效

贺词是做css来实现,因为打开宝箱后镜头会固定住完全可以在指定位置做文字动画,又不用再引入3D字体模型来增加资源消耗。

贺词.png

当然,你会发现这些文字动画,每个文字都带了些角度偏移,从而整个贺词形成拱形。这里就不得不夸赞用scss来快速实现这个样式了。

@use 'sass:math';
$color: rgb(255, 201, 101);
$border-width: 2px;
$border-color: #000;
$num: 9;
$deg: 12deg;
$delay: 350ms;

h1 {
        position: relative;
        display: flex;
        justify-content: center;
        align-items: center;
        color: $color;
        letter-spacing: #{$border-width * 1.5};
        -webkit-text-stroke-color: $border-color;
        -webkit-text-stroke-width: $border-width;
        font-size: 12px;
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -235px);

        &>span {
            position: absolute;
            font-size: 4.2em;
            font-weight: bolder;
            transform-origin: 50% #{$num * .85em};

            @for $i from 0 through $num {
                &:nth-child(#{$i + 1}) {
                    transform: rotate(#{$deg * $i - (floor(math.div($num,2))+0.1) * $deg});
                    z-index: #{$num - $i};
                    animation: show 0.4s backwards;
                    animation-delay: #{$i * $delay + 1500};
                }
            }

            @keyframes show {
                0% {
                    font-size: 6em;
                    filter: blur(0.1em);
                    opacity: 0;
                }

                80% {
                    font-size: 3.6em;
                    filter: blur(0.001em);
                    opacity: 1;
                }

                100% {
                    font-size: 4.2em;
                    filter: blur(0);
                    opacity: 1;
                }
            }
        }
}

通过 scss@for 去遍历每一个文字,然后设置他们的偏移角度和动画的延迟等,可以轻轻松松完成这个贺词动画。

3.gif

结语

本篇算是比较基础的带小伙伴们进入web的3d世界的搭建和交互,介绍了一些库的组合与使用,希望各位会喜欢,也希望各位也发挥想象力实现更加惊艳的效果。

这是今年的第一篇文章,希望大家多点赞多鼓励,来年争取将更好的作品带来。新的一年,希望大家健健康康,阖家欢乐,兔年大吉。