用 Three-js 做個兔吉寶箱給大家拜個年

介紹

不知不覺兔年已經來到,今年用什麼形式慶賀新春呢,思來想去,就準備用 Three.js 做個拜年寶箱動畫,寶箱落下後點擊就可以打開,一直萌萌噠的小兔吉就給我拜年啦,每次說出的賀詞都是不同的,所以我把這個寶箱命名爲兔吉寶箱~

演示

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

源碼地址:gitee.com/jsmask/rabb…[3]

正文

基礎搭建

本項目將使用 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"></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();
        }
    }
}    
複製代碼

模型動畫

還是以寶箱模型打開動畫爲例,當初模型加載完成的時候,已經把模型信息裏的動畫存儲下來放到了 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);
    }
}
複製代碼

緩動動畫

這個 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
        );
    }
}
複製代碼

賀詞動效

賀詞是做 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 去遍歷每一個文字,然後設置他們的偏移角度和動畫的延遲等,可以輕輕鬆鬆完成這個賀詞動畫。

結語

本篇算是比較基礎的帶小夥伴們進入 web 的 3d 世界的搭建和交互,介紹了一些庫的組合與使用,希望各位會喜歡,也希望各位也發揮想象力實現更加驚豔的效果。

這是今年的第一篇文章,希望大家多點贊多鼓勵,來年爭取將更好的作品帶來。新的一年,希望大家健健康康,闔家歡樂,兔年大吉。

作者:jsmask

https://juejin.cn/post/7185413265789288507

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/i2ZZjQmUnBQ_Husnzp6e6w