自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

2024 抖音歡笑中國年之招財(cái)神龍互動技術(shù)揭秘

開發(fā)
字節(jié)跳動旗下的抖音等 App 在 2024 年春節(jié)期間推出了歡笑中國年系列活動,在實(shí)現(xiàn)增長業(yè)務(wù)目標(biāo)的同時(shí),為用戶帶來了全新的體驗(yàn)和樂趣。「招財(cái)神龍」是其中的一個(gè)重要玩法。

前言

本次春節(jié)活動,使用到了字節(jié)內(nèi)的主要前端、跨端、互動技術(shù)產(chǎn)品。主要涉及:

  • 跨端框架 提供了首屏直出的方案使其具有較短的首屏?xí)r間,能夠大大提升業(yè)務(wù)加載成功率??缍丝蚣芤蔡峁┝?Canvas 作為 SAR Creator 等渲染引擎的運(yùn)行環(huán)境。
  • SAR Creator 是抖音前端架構(gòu)自研的一款基于 TypeScript 的高性能互動解決方案。SAR Creator 提供面向設(shè)計(jì)和研發(fā)同學(xué)的工作流,內(nèi)置常見 2D / 3D 渲染能力、動效、粒子、物理等效果支持。

活動中,主要支持了 5 個(gè)互動玩法:“招財(cái)神龍”、“神龍?zhí)綄殹?、“搖福簽”、“保衛(wèi)現(xiàn)金”和“紅包雨”,如下所示。

圖片

我們會通過系列文章,介紹春節(jié)玩法用到的互動技術(shù)。文章所說的互動技術(shù)指以圖形 API(如:WebGL)為基礎(chǔ),結(jié)合前端工程化、圖形渲染、引擎技術(shù)、交互能力和跨平臺能力,面向前端技術(shù)棧的動效和游戲化技術(shù),如下圖所示。

圖片

在活動開發(fā)中,前端 UI 如:滑動列表、頁面布局,可以用成熟的前端框架(React)。需要圖形繪制的地方,如:渲染 3D 模型,就要用到互動解決方案(SAR Creator)?;铀玫降膱D形繪制部分往往是頁面中的一個(gè)區(qū)域,我們會把互動部分封裝成一個(gè) SDK,通過使用 SDK API 和前端進(jìn)行通信。

本文作為系列開篇,主要從“招財(cái)神龍”玩法視角,分享團(tuán)隊(duì)前端互動玩法的相關(guān)開發(fā)經(jīng)驗(yàn)。

活動玩法介紹

下面是招財(cái)神龍玩法示意,用戶可以點(diǎn)擊“去尋寶”按鈕(稱此時(shí)的場景為「家場景」),讓神龍去尋寶(稱此時(shí)的場景為「尋寶場景」),尋寶過程中神龍會遇到福袋和龍蛋。福袋自動掉落到寶箱中,而龍蛋需要用戶點(diǎn)擊。尋寶過程中,紅色的主按鈕上有倒計(jì)時(shí),倒計(jì)時(shí)結(jié)束后尋寶結(jié)束,用戶可以打開寶箱領(lǐng)取獎勵。尋寶過程中,場景中會有一些可點(diǎn)擊的發(fā)光建筑,用戶點(diǎn)擊它們,發(fā)光效果消失,可能觸發(fā)任務(wù)。

在「家場景」,用戶可以點(diǎn)擊小女孩,與之產(chǎn)生輕互動,如下圖所示。

圖片

包含四個(gè)主題的「尋寶場景」,每次尋寶會隨機(jī)一個(gè)主題,如下,從左到右分別是山川、雪鄉(xiāng)、丹霞和江南。

圖片

招財(cái)神龍互動玩法實(shí)現(xiàn)

實(shí)現(xiàn)招財(cái)神龍的互動玩法,需要多個(gè)工種配合。首先產(chǎn)品提出玩法需求,描述整個(gè)場景的構(gòu)成要素和玩法邏輯。然后設(shè)計(jì)同學(xué)根據(jù)產(chǎn)品的描述,產(chǎn)出設(shè)計(jì)草圖,逐步細(xì)化,最終通過 DCC 軟件(如:C4D)生成 3D 模型、視頻、2D 貼圖或動畫等美術(shù)資源。程序需要根據(jù)產(chǎn)品需求和設(shè)計(jì)產(chǎn)出實(shí)現(xiàn)互動玩法的代碼邏輯。整個(gè)開發(fā)過程需要三方通力合作,尤其需要程序和設(shè)計(jì)同學(xué)的有效溝通,以確保設(shè)計(jì)方案可以用程序順利實(shí)施。為了保障產(chǎn)品質(zhì)量,還需要測試人員驗(yàn)收產(chǎn)品。整個(gè)開發(fā)過程大致如下圖所示。開發(fā)過程是持續(xù)迭代的,比如:產(chǎn)品可能在開發(fā)中期提出新需求,就需要設(shè)計(jì)、程序和測試做出響應(yīng)。

圖片

這里以程序的視角描述招財(cái)神龍互動玩法的實(shí)現(xiàn)。如上文所述,招財(cái)神龍互動部分由「家場景」和「尋寶場景」構(gòu)成,兩個(gè)場景通過一個(gè)轉(zhuǎn)場動畫過渡。每個(gè)場景使用了不同的美術(shù)資源和互動技術(shù)。程序不直接消費(fèi) DCC 軟件生成的美術(shù)資源,而是消費(fèi) SAR Creator 產(chǎn)出的資源包(即 bundle)。

  • bundle: 設(shè)計(jì)在 SAR Creator 編輯器中導(dǎo)入 DCC 軟件的產(chǎn)物(如:3D 模型),通過二進(jìn)制序列化生成的運(yùn)行時(shí)消費(fèi)用的資源包。
  • prefab: 一個(gè) bundle 可以包含多個(gè) prefab(預(yù)制體),一個(gè) prefab 可以包含 3D 模型、2D 貼圖、動畫甚至腳本代碼等元素。

SAR Creator 為 bundle 及 prefab 提供了序列化、反序列化和管理等功能。

接下來讓我們先了解一下招財(cái)神龍頁面元素的構(gòu)成。

招財(cái)神龍頁面元素構(gòu)成

招財(cái)神龍活動在抖音App及多端(抖極、頭條、西瓜、番茄等)的任務(wù)頁上線,為了讓大家對整個(gè)招財(cái)神龍前端頁面有個(gè)清晰的認(rèn)識,這里我們以任務(wù)頁為例,為大家講解一下頁面構(gòu)成。

圖片

如上圖所示:

  • 任務(wù)頁(圖左):字節(jié)系 App(例如西瓜視頻),大多會有一個(gè)長期在線的激勵頁面,如上面左圖所示,用戶可以通過完成任務(wù)獲得現(xiàn)金、或者積分等虛擬貨幣獎勵。
  • 互動區(qū)域(圖右):如上面右圖所示,互動區(qū)域即為場景區(qū)域,是頁面主 KV (Key Visual) 中的核心區(qū)域,用 Canvas 承載,使用 SAR Creator 來渲染互動內(nèi)容。

任務(wù)頁在非活動期間,以日常的形態(tài)展示(各 App 獨(dú)立迭代),而在活動階段,則以統(tǒng)一的活動內(nèi)容展示。這是怎么做到的呢?

圖片

如上圖所示,我們把任務(wù)頁抽象為收益區(qū) + 主 KV + 任務(wù)專區(qū)。在有活動的時(shí)候,我們只需要替換主 KV 對應(yīng)的內(nèi)容就可以了。在實(shí)際開發(fā)中,活動的主 KV 則抽象為活動 SDK。在滿足活動條件時(shí),服務(wù)端下發(fā)活動內(nèi)容字段,任務(wù)頁動態(tài)渲染活動組件,完成活動內(nèi)容的展示;在活動結(jié)束后,服務(wù)端移除活動內(nèi)容字段,頁面切換回日常形態(tài)。

任務(wù)頁上開發(fā)互動內(nèi)容,存在較大的性能挑戰(zhàn)。任務(wù)頁前端 UI 繁多,業(yè)務(wù)邏輯復(fù)雜,而互動的資源加載往往又是 CPU 密集型任務(wù),所以往往在首次渲染頁面時(shí),造成頁面和互動區(qū)域的 JS 線程繁忙,進(jìn)而形成卡頓和渲染時(shí)間過長。同時(shí)由于任務(wù)頁已經(jīng)存在大量的前端 UI 和動畫,留給互動部分可用的內(nèi)存安全余量往往僅有 200-300 MB,稍有不慎就有可能導(dǎo)致 OOM。在任務(wù)頁上既要完成視覺表現(xiàn)精美,又要保證性能良好,是一件非常有挑戰(zhàn)的事情。

招財(cái)神龍前端與互動的交互

我們將前端的同學(xué)分為兩部分,一部分負(fù)責(zé)處理活動的主邏輯,例如和服務(wù)端交互、處理業(yè)務(wù)元素(例如進(jìn)度條、明信片等掛件、任務(wù)列表等),這一部分的工作角色,我們通常稱之為“前端同學(xué)”,另一部分同學(xué)主要用來處理游戲相關(guān)的邏輯,聚焦在互動上,我們稱之為“互動同學(xué)”。 他們相互協(xié)作,共同實(shí)現(xiàn)了招財(cái)神龍的活動玩法。二者的協(xié)作方式如下圖所示。

圖片

游戲初始化階段,游戲加載完 SAR Creator 運(yùn)行框架后,向前端同學(xué)“索要”本次初始化的服務(wù)端數(shù)據(jù),用來判斷該用戶進(jìn)入游戲后,應(yīng)該展示的是「家場景」還是「尋寶場景」。用戶完成相關(guān)任務(wù)后,主接口刷新。前端同學(xué)以事件通信的形式通知互動同學(xué)渲染當(dāng)前場景并播放相關(guān)動效?;油瑢W(xué)也會監(jiān)聽主接口數(shù)據(jù),更新互動模塊專有的邏輯或效果。

「家場景」的實(shí)現(xiàn)

「家場景」是引導(dǎo)用戶“喚醒神龍”、“去尋寶”以及“領(lǐng)取福袋”的核心場景,如下圖所示。本章節(jié)會將介紹「家場景」的搭建過程,并分享「家場景」開發(fā)過程中有趣的實(shí)現(xiàn)。

圖片

整個(gè)「家場景」是由 3D 和 2D 元素混合構(gòu)建的。3D 部分包括小女孩、龍、地面和雪堆。2D 元素主要有炮仗、房子以及神龍回家后小女孩頭上的提示氣泡,是用圖片實(shí)現(xiàn)的。還有一些 2D 動畫元素,比如房子后面一直循環(huán)播放的紅包動畫、龍沉睡時(shí)嘴角的“zzz”呼吸效果。

場景搭建

設(shè)計(jì)同學(xué)使用 SAR Creator 編輯器搭建「家場景」,包括 3D 模型/2D 精靈的擺放、燈光和相機(jī)參數(shù)的設(shè)置等。SAR Creator 編輯器提供了圖形化界面,可以方便地調(diào)整場景元素的層級關(guān)系、位置、朝向、縮放比例以及材質(zhì)參數(shù)等?!讣覉鼍啊沟?3D 模型使用透視相機(jī)渲染,而 2D 精靈等使用正交相機(jī)渲染。最終,SAR Creator 渲染出的場景畫面還原了設(shè)計(jì)稿的效果。

SAR Creator 場景中所有元素,包括相機(jī)、燈光等,都以 entity(實(shí)體)的形式存在,entity 之間存在父子關(guān)系,形成一棵節(jié)點(diǎn)樹,如下圖左上角“層級”標(biāo)簽頁下的內(nèi)容。父節(jié)點(diǎn) entity 的 Transform3D 組件的位置、旋轉(zhuǎn)和縮放屬性,會影響子節(jié)點(diǎn)的相同屬性。Enity 上可以掛載自定義腳本,影響 enity 的行為邏輯。SAR Creator 提供了大量操縱 entity 的引擎能力。

圖片

動畫播放

為了呈現(xiàn)出精彩的效果、給用戶帶來盡可能好的視覺體驗(yàn),我們設(shè)計(jì)了14個(gè)模型動畫,并通過出色的邏輯串聯(lián),保證了動畫播放流程的簡潔高效。

export enum HomeAnimName {
  HomeSleep = 'home_sleep', // 沉睡
  HomeAwake = 'home_awake', // 蘇醒
  HomeIdle = 'home_idle', // 待機(jī)
  HomeClick = 'home_click', // 點(diǎn)擊效果1
  HomeClickA = 'home_click_a', // 點(diǎn)擊效果2
  HomeClickB = 'home_click_b', // 點(diǎn)擊效果3
  HomeHappy = 'home_happy', // 完成任務(wù),開心狀態(tài)
  HomeGoHome = 'home_gohome', // 龍回家
  HomeHoldBox = 'home_hold_box', // 寶箱狀態(tài)
  HomeOpenBox = 'home_open_box', // 龍推寶箱
  HomeCloseBox = 'home_close_box', // 關(guān)閉寶箱
  HomeCloseBoxIdle = 'home_close_box_idle', // 關(guān)閉寶箱后的待機(jī)態(tài)
  HomeOpenBoxIdle = 'home_open_box_idle', // 開完寶箱后的待機(jī)態(tài)
  HomeGoOut = 'home_goout' // 龍去尋寶
}

我們使用了 SAR Creator 提供的動畫播放能力:Animator 組件。獲取到 3D 模型的 animator 組件,并調(diào)用它的crossFade函數(shù),在第二個(gè)參數(shù)duration指定的時(shí)間內(nèi),從當(dāng)前動畫狀態(tài)過渡到另一個(gè)動畫狀態(tài),即下面代碼中的第一個(gè)參數(shù)anim。調(diào)用animator.on('finished',cbFunc)可以自定義動畫結(jié)束后的回調(diào)函數(shù)。

this._dragonAnimator.crossFade(anim as string, duration);
this._charAnimator.crossFade(anim as string, duration);
this._dragonAnimator.on('finished', () => this.onAnimEnd(anim, params));

設(shè)置動畫的loopCount屬性,可以指定該動畫播放的次數(shù)。設(shè)置clampWhenFinished可以指定播放完該動畫后,是否停留在最后一幀。

const setClip = () => {
    const loopCount = loop ? -1 : 1;
    
    const _dragonClip = this._animator.clips.find((i) => i.clip?.name === anim);
    if (_dragonClip) {
      _dragonClip.loopCount = loopCount;
      const action = this._dragonAnimator.getAction(anim);
      if (action) {
        action.clampWhenFinished = !loop;
      }
    }
}

基于上述的這些底層的Api,我們實(shí)現(xiàn)了一套AnimationGraph來幫助研發(fā)和設(shè)計(jì)同學(xué)更好地開發(fā)提效。

圖片

對于設(shè)計(jì)同學(xué)使用來說,例如想實(shí)現(xiàn)一個(gè)龍睡覺狀態(tài)到龍待機(jī)狀態(tài),我們可以將HomeAwakeHomeIdle動畫拖入到graph中,并創(chuàng)建動畫鏈路。

圖片

HomeAwake動畫播完以后,會在HomeIdle動畫進(jìn)行l(wèi)oop播放。選中鏈路,可以對鏈路進(jìn)行配置和預(yù)覽。

圖片

對于研發(fā)同學(xué),可以基于graph進(jìn)行邏輯條件的配置。

圖片

如上圖所示,例如進(jìn)入游戲后,用戶可能是在“龍沉睡”或者“龍待機(jī)”的狀態(tài),我們通過在Graph的變量區(qū)建立代碼運(yùn)行的邏輯條件(支持Number和Boolean兩種類型),可以自定義一個(gè)case變量,當(dāng)case = 1,播放“龍沉睡”、當(dāng)case = 2,播放“龍待機(jī)”。

在代碼中,我們可以通過使用AnimationController.setValue(variableName,value)來觸發(fā)動畫執(zhí)行。

const animationController = this.entity.getScript(AnimationController);
if(showAwake) {
    // 需要播沉睡
    animationController.setValue("case", 1)
}else if(showIdle){
    //需要播放idle
    animationController.setValue("case", 2)
}

再比如,在某一個(gè)時(shí)間,用戶點(diǎn)擊了“去尋寶”按鈕,這時(shí)候通過設(shè)置animationController.setValue("showGoOut",true)即可觸發(fā)龍去尋寶的動畫。

我們還為動畫播放提供了鉤子函數(shù),在動畫播放的特定時(shí)間,觸發(fā)自定義的邏輯回調(diào)。

onStateEnter

在進(jìn)入狀態(tài)時(shí)觸發(fā)

onStateExit

在完全退出狀態(tài)時(shí)觸發(fā)

onStateUpdate

在狀態(tài)更新時(shí)觸發(fā)

// 獲取動畫控制器組件
const animationController = this.entity.getScript(AnimationController);

animationController.on('onStateEnter',(controller:AnimationController,state:AnimationState)=>{
    //在此處實(shí)現(xiàn)業(yè)務(wù)邏輯
});

坐標(biāo)同步

在實(shí)現(xiàn)一些特殊效果時(shí),為了保障效果的高度還原,我們使用了坐標(biāo)同步。例如小女孩頭上的提示氣泡和龍嘴角的“zzz”呼吸特效,接下來以氣泡為例介紹一下這一部分的實(shí)現(xiàn)。

圖片

若用常規(guī)的模式在 3D 場景中擺放一個(gè) 2D 的片,會導(dǎo)致小女孩動的時(shí)候,渲染出來的氣泡會穿幫或者 z-fighting。

圖片

3D-2D 坐標(biāo)同步的做法是將 Bubble 節(jié)點(diǎn)放在 UICanvas(SAR Creator 處理 2D 元素的節(jié)點(diǎn))中,每一幀將小女孩模型里的骨骼變換節(jié)點(diǎn)在 3D 空間中的位置轉(zhuǎn)化成 UICanvas 坐標(biāo)系的坐標(biāo),再實(shí)時(shí)設(shè)置 Bubble 的位置屬性。

圖片

圖片

坐標(biāo)同步代碼如下??

const TEMP_VEC3 = new Vector3();
export const threeD2UICanvas = (entity: Entity, camera: PerspectiveCamera) => {
  entity?.object?.getWorldPosition(TEMP_VEC3);
  const vec3 = camera?.project(TEMP_VEC3) || new Vector3();
  // 375 * 500 為畫布大小
  const x = vec3.x * 375;
  const y = vec3.y * 500;
  return { x, y };
};

每一幀設(shè)置 UICanvas 畫布中氣泡節(jié)點(diǎn)的位置,最終實(shí)現(xiàn)小女孩在 3D 場景中動來動去,頭上的氣泡也會跟著一起移動。

class BubbleScript extends Script {
    // ECS 腳本每一幀的回調(diào)
    onUpdate() {
        if(NEED_SYNC_POS){
            const bubbleRootIn3D = CharGlb.getChildByName('girl_Root_for_bubble')
            const bubbleEntityIn2D = UICanvas.getChildByName('Bubble')
            // 3D場景下的相機(jī)
            const cameraIn3D = MainScene.getChildByName('MainCamera')
            // sync pos
            const pos = threeD2UICanvas(bubbleRootIn3D, cameraIn3D)
            bubbleEntityIn2D.position?.set(pos.x, pos.y)
        }
    }
}

「尋寶場景」的實(shí)現(xiàn)

「尋寶場景」是一個(gè)純 2D 互動場景,是招財(cái)神龍玩法的重要環(huán)節(jié)。為了實(shí)現(xiàn)有趣、自然的互動效果,「尋寶場景」要處理許多復(fù)雜邏輯。為了讓互動和前端在動效上銜接流暢,互動和前端會在必要時(shí)通信。

簡化版的“尋寶”邏輯如下圖所示,包括地形等美術(shù)資源的加載、相機(jī)處理、探測點(diǎn)檢測福袋和龍蛋觸發(fā)以及地形回收等邏輯。每次尋寶開始前,服務(wù)端提前下發(fā)“尋寶數(shù)據(jù)”,包括本次尋寶開始和結(jié)束的時(shí)間戳以及 timeline 信息,timeline 是一個(gè)“道具”觸發(fā)列表,列表中每個(gè)元素包含一個(gè)道具 id、觸發(fā)時(shí)間戳、道具類型和道具狀態(tài)等信息。

圖片

「尋寶場景」的 timeline 數(shù)據(jù)結(jié)構(gòu)偽代碼如下面所示。其中"prop_type"是道具類型,可能是福袋或龍蛋。福袋不需要用戶點(diǎn)擊交互,尋寶結(jié)束后總是發(fā)放給用戶。在視覺效果上神龍會撞上福袋。但龍蛋需要用戶手動點(diǎn)擊,若不點(diǎn)擊就會錯(cuò)失對應(yīng)獎勵。"timestamp"是道具觸發(fā)的時(shí)間戳。

/** 一次尋寶的信息 */
export interface TreasureHuntData {
  /** 當(dāng)前狀態(tài) */
  treasure_hunt_status: TreasureHuntStatus;
  /** 時(shí)間軸開始時(shí)間 */
  start: Int64;
  /** 時(shí)間軸結(jié)束時(shí)間 */
  end: Int64;
  /** 時(shí)間軸信息 */
  timeline: Array<PropTriggerInfo>;
  current_time: Int64;
  // ...
}

export interface PropTriggerInfo {
  /** 道具的id */
  prop_id: string;
  /** 在時(shí)間軸上的時(shí)間戳 */
  timestamp: Int64;
  /** 類型 */
  prop_type: PropType;
  /** 道具領(lǐng)取狀態(tài),尋寶結(jié)束時(shí)才有 */
  propStatus?: PropStatus;
}

相機(jī)邏輯

「尋寶場景」使用一個(gè)正交相機(jī)渲染。「尋寶場景」的地形大部分時(shí)間保持不動,相機(jī)不停地往前移動。相機(jī)的邏輯比較簡單,只是 x 軸不停地增加,其偽代碼如下所示。

// deltaTime是上一幀到當(dāng)前幀的時(shí)間間隔,_moveSpeed是相機(jī)移動速度
this._camEntity.position.x += deltaTime * this._moveSpeed;

相機(jī)移動速度可在 SAR Creator 中配置,如下圖中紅框中的 Speed 所示。

圖片

SAR Creator 提供了裝飾器工具@ScriptUtil,用于把一個(gè)腳本及其字段暴露給編輯器。相機(jī)配置腳本 TravelCameraConfig.ts 掛在上圖中 TravelCamera 節(jié)點(diǎn)下,其偽代碼如下:

import { Script, ScriptUtil } from '@sar-creator/toolkit/engine/core';

// TravelCameraConfig是一個(gè)腳本,繼承SAR Creator的Script類。
// 腳本類名前使用裝飾器@ScriptUtil.Register(),可以在編輯器中掛到節(jié)點(diǎn)上
@ScriptUtil.Register('TravelCameraConfig')
export default class TravelCameraConfig extends Script {
  // 在腳本字段名前使用裝飾器@ScriptUtil.Field(),可以在編輯器中編輯該字段
  @ScriptUtil.Field('float', { default: 0, params: { precision: 4 } })
  speed = 0;
  // ...
}

構(gòu)建無限地形

每次尋寶的時(shí)間長度由服務(wù)端動態(tài)下發(fā),最長為 20 分鐘。每個(gè)主題的「尋寶場景」都有一個(gè)地形塊隊(duì)列,每個(gè)地形塊以 prefab 的形式提供。線上,每個(gè)主題的地形隊(duì)列由兩個(gè)地形塊構(gòu)成,這里我們記作 map_x_a.prefab 和 map_x_b.prefab,其中 x 是主題的索引。每個(gè)主題的地形塊 prefab 由設(shè)計(jì)同學(xué)在 SAR Creator 中制作完成,并以資源包的形式提供給研發(fā)同學(xué),極大地減少了二者的工作耦合度,提升了開發(fā)效率。

「尋寶場景」一屏的設(shè)計(jì)分辨為 750x1000。每個(gè)地形塊的寬度為 3750。這樣兩個(gè)地形塊的寬度就是 10 屏,能提供足夠多的細(xì)節(jié)差異、降低場景元素的重復(fù)感。下圖是一個(gè)地形塊 prefab 在 SAR Creator 中的樣子,可以看出它在 3750x1000 的矩形外,還會多出一些視覺元素(如左右邊界上的云),這些多出的視覺元素能夠和另一個(gè)地形塊上的視覺元素有機(jī)地融合。

圖片

為了讓大家更容易理解,我們把山川主題的兩個(gè)地形 prefab 都拖到 SAR Creator 中,如下圖所示,它們總是可以無縫拼接的。

圖片

在實(shí)際項(xiàng)目中,因?yàn)橹黝}是隨機(jī)指定的,所以這兩個(gè)地形 prefab 是用代碼動態(tài)加載的,而非直接拖到場景中。為了讓用戶更早地看到「尋寶場景」的視覺內(nèi)容,我們同步加載第一個(gè)地形塊 prefab , 異步加載第二個(gè)地形塊 prefab。其偽代碼如下所示。

async _loadTerrains(travelScene2D: Object2D): Promise<void> {
    const terrainNames = TerrainNamesByTheme[this._theme];
    // 加載當(dāng)前主題第一塊地形prefab
    const firstTerrainName = terrainNames[0];
    // 注_loadTerrain是異步的,返回promise。我們會在本函數(shù)返回前await此promise。
    this._firstTerrainPromise = this._loadTerrain(firstTerrainName!);

    // 異步加載其它地形prefab,其實(shí)對于線上的情況就只有第二塊地形了。
    const terrainPromises = 
        terrainNames.filter((_, idx) => idx !== 0).map((i) => this._loadTerrain(i));

    void Promise.all(terrainPromises).then(async () => {
      // 注意要保證第一塊地形已經(jīng)加載好了,_tryCreateFirstTerrainBlock函數(shù)內(nèi)部做判斷,
      // 保證第一塊地形塊不被創(chuàng)建兩次。
      await this._tryCreateFirstTerrainBlock(travelScene2D);
      
      let lastTerrainBlock = this._firstPrefabBlock;
      const terrainPos = this._terrainOffset.clone();
      for (const terrainPromise of terrainPromises) {
          if (lastTerrainBlock !== undefined) {
            const terrainEntity = await terrainPromise;
            terrainPos.x += lastTerrainBlock.getBlockSize();
            lastTerrainBlock = this._createTerrainBlock(travelScene2D, terrainEntity, false, terrainPos);
          }
      }
    });

    // 同步加載第一個(gè)地形block
    await this._tryCreateFirstTerrainBlock(travelScene2D);
  }

當(dāng)隊(duì)首的地形塊完全離開屏幕后,把它移到隊(duì)尾成為“新”的地形塊。為了處理地形塊邊緣多出的部分視覺元素,延遲一屏讓當(dāng)前隊(duì)首地形塊消失,提前一屏讓隊(duì)列中第二個(gè)地形塊可見,讓用戶看不到任何縫隙,偽代碼如下所示。

_recycleTerrain(cameraPos: Vector3): void {
    const headRightX = this._terrainBlocks[0].getBlockRightPositionWorld();
    const terrainScreenWidth = this._terrainBlocks[0].getTerrainScreenWidth();
    const screenLeftEdge = cameraPos.x - this._halfScreenWidth!;
    const screenRightEdge = cameraPos.x + this._halfScreenWidth!;

    // 首隊(duì)地圖延遲一屏消失
    if (headRightX + terrainScreenWidth < screenLeftEdge) {
      // 隊(duì)首地形右邊界,離開屏幕左邊緣
      this._terrainBlocks[0]?.setVisible(false);
      const headBlock = this._terrainBlocks.shift();
      if (headBlock) {
        const tailPos = this._terrainBlocks[this._terrainBlocks.length - 1].getPosition();
        headBlock.setPositionX(tailPos.x + this._terrainPrefabLength);
        this._terrainBlocks.push(headBlock);
        // 重置隊(duì)首地形上的探測點(diǎn)
        headBlock.resetDetectors();
        // 通知prefab離開屏幕等
      }
    } else if (
      headRightX - terrainScreenWidth <= screenRightEdge &&
      !this._terrainBlocks[1].getVisible()
    ) {
      // 地圖隊(duì)列中第二個(gè)地圖,提前一屏顯示。隊(duì)首地形右邊界,離開屏幕右邊緣
      this._terrainBlocks[1].setVisible(true);
      // 通知prefab進(jìn)入屏幕等
    }
  }

地形上還有一些掛載點(diǎn),程序根據(jù)當(dāng)前機(jī)型的評分等,掛載不同類型的發(fā)光建筑。例如,高端機(jī)會掛載用 Spine 制作的發(fā)光建筑,而低端機(jī)掛載普通的精靈圖。高端機(jī)能實(shí)現(xiàn)好的視覺 效 果,低端機(jī)減輕了 CPU 負(fù)擔(dān),保障程序運(yùn)行流暢。下圖紅框中的節(jié)點(diǎn)就是發(fā)光建筑的掛載點(diǎn)。

圖片

探測點(diǎn)、神龍和福袋

「尋寶場景」中,神龍是最顯眼的視覺元素,是用 Spine 制作的,但它并非一直在播放。神龍的行為和場景中一些被稱為探測點(diǎn)的特殊節(jié)點(diǎn)有關(guān)。下圖左邊紅框內(nèi)有一個(gè)名為"commonDetector_common_2"的探測點(diǎn),該探測點(diǎn)同層級還有一個(gè)福袋槽位"redpacket_2"節(jié)點(diǎn),下圖右邊的福袋就是掛在"redpacket_2"節(jié)點(diǎn)上。下圖右邊的紫色方塊是左邊的探測點(diǎn)的可視化“符號”,只在開發(fā)階段標(biāo)識探測點(diǎn)位置,方便調(diào)試。一個(gè)地形塊可能有 5 到 6 個(gè)探測點(diǎn),大約一屏的寬度一個(gè)探測點(diǎn)。

圖片

探測點(diǎn)命名規(guī)則是程序和設(shè)計(jì)約定好的,第一個(gè)下劃線“_”前面的部分是探測點(diǎn)類型,而后面部分神龍動畫名稱,如下圖左邊紅、藍(lán)框圈住的地方。探測點(diǎn)有多種類型,后面詳述。

每個(gè)探測點(diǎn)上還掛有一個(gè)可配置腳本,如下圖右下角紅框圈住的區(qū)域,可以配置當(dāng)前探測點(diǎn)觸發(fā)時(shí)的神龍?jiān)?z 軸上的層級("Z value"),以及該探測點(diǎn)觸發(fā)后對應(yīng)的福袋槽位上掛的福袋多少秒后播放 “ 出現(xiàn) ” 動畫,即下圖左下角的"Red Packet Appear Time"),多少秒后播放 “ 消失 ” 動畫,即下圖左下角的"Red Packet Hide Time"。

圖片

在水平方向上,當(dāng)一個(gè)探測點(diǎn)位于屏幕中心時(shí),該探測點(diǎn)被觸發(fā)。不能以 x 軸上探測點(diǎn)到屏幕中心的距離接近 0 來判斷一個(gè)探測點(diǎn)到達(dá)了屏幕中心,這樣有很大誤差,因?yàn)橄鄼C(jī)移動速度很快。甚至有可能錯(cuò)過探測點(diǎn)的觸發(fā)時(shí)機(jī),比如下一幀本來要觸發(fā)的,結(jié)果當(dāng)前幀由于某種原因卡頓了一下,deltaTime 突然變大很多,導(dǎo)致下一幀探測點(diǎn)直接越過屏幕中心,且距離遠(yuǎn)大于零。

在實(shí)際項(xiàng)目中,每個(gè)探測點(diǎn)都有一個(gè)標(biāo)志位,這里記作 isTriggered,當(dāng)探測點(diǎn)在屏幕中心右邊時(shí),isTriggered 記為 false,當(dāng)某一幀探測點(diǎn)突然在屏幕中心左邊時(shí),說明探測點(diǎn)剛剛越過了屏幕中心,探測點(diǎn)觸發(fā),設(shè)置其值為 true。在回收隊(duì)首地形塊時(shí),其上所有探測點(diǎn)的 isTriggered 標(biāo)志位都要重置為 false,因?yàn)榇藭r(shí)該地形塊會被移到場景的最右邊,也在屏幕中心右邊。

當(dāng)一個(gè)探測點(diǎn)被觸發(fā)時(shí),程序播放對應(yīng)的神龍動畫,神龍?jiān)趫鼍爸杏蝿?。同時(shí),程序根據(jù)探測點(diǎn)上配置的時(shí)間設(shè)置定時(shí)器,經(jīng)過"Red Packet Appear Time"秒后,播 放 福袋的“出現(xiàn)”動畫,出現(xiàn)動畫結(jié)束后自動播福袋的待機(jī)動畫,經(jīng)過"Red Packet Hide Time"秒后,播放福袋的“消失”動畫。此神龍頭部恰好撞到福袋。這些時(shí)間的值是設(shè)計(jì)同學(xué)根據(jù)神龍和福袋的動畫時(shí)長在 SAR Creator 中配置的。程序只需讀取配置,實(shí)現(xiàn)代碼邏輯。時(shí)間線上,探測點(diǎn)觸發(fā)與神龍和福袋動畫的關(guān)系如下圖所示。

圖片

設(shè)計(jì)同學(xué)在制作神龍的每個(gè)動畫時(shí),都是以對應(yīng)主題的第一個(gè)地形塊的左下角為參考點(diǎn)。程序運(yùn)行時(shí),需要在第一個(gè)地形塊中,記錄每個(gè)探測點(diǎn)和地形塊左下角的偏移向量。在后面所有地形塊中播放神龍動畫前,都要重置神龍的位置。神龍的新位置是以探測點(diǎn)為基準(zhǔn),減去對應(yīng)的偏移向量得到的。設(shè)計(jì)同學(xué)保證所有探測點(diǎn)都在第一個(gè)地形塊中出現(xiàn)。

圖片

每個(gè)主題的「尋寶場景」有多個(gè)類型的探測點(diǎn),如上圖所示。普通探測點(diǎn)達(dá)到屏幕中心時(shí),播放對應(yīng)的神龍動畫,并且若福袋槽位上掛有福袋,會在配置的時(shí)間后,播放對應(yīng)的福袋動畫。兩個(gè)特殊探測點(diǎn)是在普通探測點(diǎn)基礎(chǔ)上添加了限制或功能。

  • 好友龍?zhí)綔y點(diǎn):用戶獲得其他用戶助力后去尋寶,好友龍?zhí)綔y點(diǎn)首先被觸發(fā),程序除了播放主龍動畫外,還播放好友龍動畫,下圖左邊半透明的就是好友龍。
  • 接近好友龍?zhí)綔y點(diǎn)的探測點(diǎn):以“nearFriendDetector_”開頭的探測點(diǎn)是在位置上十分接近“好友龍?zhí)綔y點(diǎn)”的探測點(diǎn),如下圖右邊紅框中第二個(gè)探測點(diǎn)(紫色方塊)。當(dāng)有好友助力時(shí),在第一個(gè)地形塊首次出現(xiàn)時(shí),程序不觸發(fā)這類探測點(diǎn),因?yàn)楹糜妖埾嚓P(guān)的動畫只出現(xiàn)一次且不能被打斷。其它情況,其行為和普通探測點(diǎn)一致。

圖片

尋寶過程中福袋其實(shí)有兩種美術(shù)表達(dá)形式:

  • Spine 動畫:神龍“撞”到的福袋,即上面所述的,是互動側(cè)實(shí)現(xiàn)的,每個(gè)福袋是一個(gè) Spine 動畫,如下圖左邊紅框里圈住的部分。
  • Lottie 動畫 當(dāng)神龍撞到 Spine 福袋后,Spine 福袋消失,同時(shí)屏幕中心出現(xiàn)一個(gè)大福袋,它是一個(gè) Lottie 動畫 ,并掉落到底部的寶箱中,如右圖藍(lán)框中圈中的福袋,這是前端同學(xué)實(shí)現(xiàn)的。

圖片

「尋寶場景」的龍蛋視覺效果,是前端同學(xué)實(shí)現(xiàn)的?;觽?cè)代碼根據(jù) timeline 數(shù)據(jù)檢測到一個(gè)龍蛋觸發(fā)時(shí),就向前端發(fā)送消息,前端代碼彈出一個(gè)龍蛋的 Lottie 動畫,如本文開頭的視頻所示。

2D 場景實(shí)現(xiàn)“3D”效果

尋寶場景」是 2D 的,如何實(shí)現(xiàn)自然的“3D”效果呢?下圖左邊是丹霞主題,龍可以穿過石拱門,龍身一部分在拱門前,另一部分在拱門后;右邊是山川主題,龍可以繞著山體一圈,龍尾在山后,而龍頭在山前。這是設(shè)計(jì)同學(xué)通過在 SAR Creator 中設(shè)置 2D 元素的層級(z-軸)實(shí)現(xiàn)的。

圖片

以上圖右邊的“龍繞山”為例山頂其實(shí)有一小片是單獨(dú)的精靈圖,有單獨(dú)的層級 ,與山體的層級不一樣。當(dāng)龍走到這里時(shí),程序把龍的層級設(shè)置為一個(gè)恰好處于山頂和山體層級之間的值,這樣就達(dá)到視覺效果了。下圖右邊是把山頂往左偏移后的效果,方便觀察。

圖片

「尋寶場景」有四個(gè)主題的地形,但神龍只有一個(gè),獨(dú)立于地形之外。在不同主題和探測點(diǎn)處,神龍的層級值是不同的,這一點(diǎn)是通過在探測點(diǎn)上添加 Z Value 配置實(shí)現(xiàn)的,在上一小節(jié)的截圖中展示過。每當(dāng)一個(gè)探測點(diǎn)觸發(fā)時(shí),程序先讀取配置的 Z Value,并把它賦值給神龍的 Entity,然后再播放神龍動畫,就實(shí)現(xiàn)“龍繞山”的“3D”效果了。相關(guān)流程如下圖所示。

圖片

相關(guān)偽代碼:

// 播放每一個(gè)動畫時(shí),主龍的transform3D.position.z的值都有一個(gè)對應(yīng)的配置,以實(shí)現(xiàn)渲染層級。
  newWorldPos.z = curDetector.ipZValue; // ipZValue是探測器上配置的Z Value。
  const ipAnimEventInfo = {
    isFriendDragon: curDetector.isFriendDector,
    animName: curDetector.ipAnimName,
    newIPPos: newWorldPos
  };
  // 播放神龍動畫,playIPAnimation()內(nèi)部重置神龍位置
  playIPAnimation(GameEvent.TRAVEL_IP_ANIMATE, ipAnimEventInfo);
}

正是為了實(shí)現(xiàn)這種“3D 效果” ,我們才引入探測點(diǎn)的概念,互動代碼需要感知「尋寶場景」的環(huán)境信息,以播放對應(yīng)的神龍動畫。引入福袋槽位的概念是為了方便實(shí)現(xiàn)神龍頭部撞上福袋的視覺效果。福袋槽位的位置是由設(shè)計(jì)同學(xué)精心設(shè)計(jì)好的,可以保證龍頭恰好在對應(yīng)的時(shí)間經(jīng)過那里。福袋槽位的位置是固定的,所以互動代碼并不能完全遵循服務(wù)器下發(fā)的 timeline 中福袋的觸發(fā)時(shí)間戳,而是盡量和它對齊,同時(shí)有一些自己的規(guī)則,比如:不能早于 timeline 中的時(shí)間觸發(fā)福袋、優(yōu)先把福袋放置在最近的可用福袋槽位上等。

場景管理策略

上文介紹了「家場景」和「尋寶場景」的實(shí)現(xiàn)。如何將這兩個(gè)場景串起來,做好場景管理呢?

首先,我們需要確認(rèn)游戲初始化完后,應(yīng)該加載哪個(gè)場景。引擎能力準(zhǔn)備完畢后,互動向前端獲取本次用戶進(jìn)入游戲的活動數(shù)據(jù),判斷進(jìn)入游戲后是“尋寶中”還是“在家”的狀態(tài),根據(jù)狀態(tài),加載對應(yīng)場景的資源,然后展示給用戶。

如何實(shí)現(xiàn)兩個(gè)場景的絲滑切換?例如,用戶此刻在「家場景」,點(diǎn)擊“去尋寶”,如下圖所示。

圖片

龍轉(zhuǎn)場

我們會播一個(gè)龍的轉(zhuǎn)場動畫,轉(zhuǎn)場完,給用戶展示出另一個(gè)場景。

首先,設(shè)計(jì)同學(xué)導(dǎo)出一份 spine 動畫資源, 有 start、loop、end 三個(gè)動畫,分別為龍從屏幕左下角起飛、龍身占滿整個(gè)屏幕循環(huán)播放、龍身離開直到龍尾離開屏幕。

圖片

在龍身 loop 動畫的這一段時(shí)間內(nèi),進(jìn)行另一個(gè)場景的加載和邏輯處理。

圖片

相關(guān)代碼如下:

interface TransferLifeCycle {
    onStart?: () => Promise<void>; // loop動畫開始時(shí)機(jī),此時(shí)用戶完全看不到轉(zhuǎn)場后面的內(nèi)容
    onEnd?: () => void; // loop動畫結(jié)束時(shí)機(jī),此時(shí)用戶能看到轉(zhuǎn)場后面的一些內(nèi)容
    onRemove?: () => void; // end動畫結(jié)束時(shí)機(jī)。此時(shí)龍尾巴完全離開屏幕
    onError?: (e: Error) => void; // 轉(zhuǎn)場出錯(cuò)
}

// 轉(zhuǎn)場邏輯
class Transfer {
    _spine: Spine; // 轉(zhuǎn)場的動畫資源
    _transfer!: TransferLifeCycle; // 存轉(zhuǎn)場的鉤子函數(shù)
    _canEnd = false // 標(biāo)記用戶的start邏輯是否處理完畢
    
    startTransfer = async (params: TransferLifeCycle) => {
        this._transfer = { ...this._transfer, ...params }
        try {
            // 開始觸發(fā)spine的start動畫播放,交由spine的complete監(jiān)聽來處理每一個(gè)階段的邏輯
            this._canEnd = false
            // 若未加載Spine,則加載spine資源,并播放其的'start'動畫,略。
        } catch(e){
            this._transfer?.onError?.(e);
        }
    }
    // Spine資源加載完畢后,此回調(diào)函數(shù)被自動調(diào)用
    async onSpineAnimComplete(entry: any) {
        const animateName = entry.animation.name;
        // start動畫播完 => 需要開始播loop動畫,并處理onStart的邏輯
        try {
            if (animateName === 'start'){
                // 播放Spine的'loop'動畫, 略。
                await this._transferParams.onStart?.()
                this._canEnd = true // 標(biāo)記用戶處理完了onStart邏輯
            } else if(animationName === 'loop'){
                if(this._canEnd) {
                    // 處理完了onStart邏輯。播放end動畫,略。
                    this._transfer?.onEnd?.()
                }
            } else if (animationName === 'end'){
                this._transfer?.onRemove?.()
            }
        } catch (e){
            this._transfer?.onError?.(e)
        }
    }
}

“家”和“尋寶”兩個(gè)場景的管理怎么做呢?主要使用了“預(yù)加載”、“緩存”和“銷毀”三種手段。

預(yù)加載

為了做到場景加載的更快,對場景進(jìn)行預(yù)加載,提升用戶的體驗(yàn)。

游戲初始化后,若加載的是“家”場景,則充分利用加載完“家”到用戶“點(diǎn)擊尋寶”之間的這段時(shí)間,對“尋寶”場景進(jìn)行預(yù)加載。

const isHome = mainData.isHome // 是否是家場景

const preloadTravel = () => {
    const { bundle } = assetManager.loadBundle('travel')
    bundle.load('Travel.prefab')
}

const preloadHome = () => {
    xxx
}

// 預(yù)加載另一個(gè)場景
const preload = () => {
    if(isHome){
        preloadTravel()
    }else{
        preloadHome()
    }
}

利用 bundle.preload( prefab )可以將 prefab 依賴到的資源提前 fetch 到本地 。

緩存和銷毀

除了預(yù)加載資源,我們還適當(dāng)?shù)厥褂昧?strong>緩存,用空間換時(shí)間,提升切換場景的速度。 

SAR Creator 提供了將子節(jié)點(diǎn)從父節(jié)點(diǎn)移除,但是不銷毀其依賴的資源的能力。這是實(shí)現(xiàn)緩存邏輯的基礎(chǔ)。

class SceneManager {
    homeRoot?: Entity; // 家場景
    travelRoot?: Entity; // 尋寶場景
    
    // 加載
    async loadHomeRoot() {
        // 若有緩存,這步就不會走,直接addChild即可
        if(!this.homeRoot){
            this.homeRoot = await bundle.load('HomeRoot.prefab')
        }
        
        // 加載緩存的或者第一次初始化出來的家場景
        if(this.homeRoot){
            scene.addChild(this.homeRoot)
        }
    }
    
    async loadTravelRoot() {
        if(!this.travelRoot){
            this.travelRoot = await bundle.load('TravelRoot.prefab')
        }
    }
    
    // 緩存
    dispose() {
        // 緩存
        if(USE_STORAGE){
            // 將節(jié)點(diǎn)從場景中移除,但保留其依賴的資源
            this.homeRoot?.parent?._deleteEntityFromChildren(this.homeRoot)
        }else{
            // 銷毀
            entity.dispose()
        }
    }
}

所有機(jī)型無差別地緩存,風(fēng)險(xiǎn)很大。為此,我們對低端機(jī)采取資源銷毀的邏輯。

使用entity.dispose方法實(shí)現(xiàn)銷毀邏輯,它會遞歸該 entity 及所有子 entity 依賴的資源,釋放其紋理、material、geometry 等。

對于使用緩存還是銷毀,程序定義了如下數(shù)據(jù)結(jié)構(gòu):

export interface DowngradeIParams {
    // 靜態(tài)獲取
    enable: boolean,
    blackList: [],
    i32Forbidden: boolean, // 是否在32位包上禁用緩存能力
    deviceScoreHigh: 10, // 超過此評分算高端
    deviceScoreMid: 8, // 超過此評分算中端
    deviceLevel: ['high', 'mid', 'low'], // 緩存能力啟用的機(jī)型
    
    // 動態(tài)獲取
    memoryLimit: Infinity // 剩余內(nèi)存超過這個(gè)數(shù)才啟用
}

上面數(shù)據(jù)結(jié)構(gòu)提供了全局開啟/關(guān)閉(enable)、機(jī)型黑名單、32 包禁用、機(jī)型打分、動態(tài)內(nèi)存等多個(gè)度量標(biāo)準(zhǔn)來幫助我們做緩存/銷毀的判斷,配置的數(shù)據(jù)走 settings(字節(jié)內(nèi)部客戶端配置動態(tài)下發(fā)平臺)下發(fā)。

基于這些技術(shù),每次場景切換時(shí),我們根據(jù)當(dāng)前的機(jī)型信息和實(shí)時(shí)內(nèi)存數(shù)據(jù)來判斷采用哪種策略,例如,剩余內(nèi)存不夠多時(shí),加載“尋寶”場景,并銷毀“家”場景的所有資源,以此來保障游戲穩(wěn)定性。

責(zé)任編輯:龐桂玉 來源: 字節(jié)跳動技術(shù)團(tuán)隊(duì)
相關(guān)推薦

2024-04-12 14:42:21

Typescript渲染技術(shù)

2024-04-10 07:04:17

2024-04-16 14:12:04

WasmWebGL前端

2024-04-10 07:09:33

編輯器

2020-06-24 07:50:56

抖音特效移動應(yīng)用

2023-02-23 13:42:18

技術(shù)AI

2021-10-21 10:03:09

鴻蒙HarmonyOS應(yīng)用

2014-01-17 17:30:43

盈飛無限InfusionSPC

2021-06-28 05:19:32

抖音電腦

2022-06-06 12:19:08

抖音功耗優(yōu)化Android 應(yīng)用

2019-03-07 15:04:37

抖音快手同城

2022-01-22 07:44:12

抖音PC 版電腦刷抖音

2019-06-21 09:55:10

刷抖美腿App

2021-03-18 17:32:29

抖音紅包技術(shù)

2021-03-03 15:31:38

人工智能抖音算法北斗導(dǎo)航

2022-08-26 16:24:19

抖音體系化建設(shè)項(xiàng)目
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號