苦等4年,《黑神话:悟空》终于在周二正式上线了。朋友们开始玩了吗?不会还有人像我一样是被抛弃的 Xbox 玩家吧?不会吧?言归正传,今天我们抛开游戏不谈,来聊一聊游戏开发领域的技术话题。 近年来,随着游戏行业的发展和国产游戏的崛起,越来越多的作品选择使用 Unreal 进行开发,《黑神话:悟空》这一旷世大作便是基于 Unreal 引擎开发,更多我们熟知的游戏如《原神》、《王者荣耀》则是基于 Unity 开发。 鲁迅曾经说过:一个成功游戏引擎的背后,离不开一个默默支持他的热更脚本,不论你使用 Unreal 还是 Unity,不论你写 C++ 还是 C#,开发一款大型游戏,总是离不开热更新技术的支持,也离不开背后默默支持你热更的 Lua。 本文作者及其团队以 Lua 与游戏行业热更新技术史为基础,创作了这样一篇女朋友也能看懂的科普向技术好文,看完以后记得分享给你的女朋友哦!(你是怎么知道我女朋友给我买了典藏版的?)
本文为标题党,作者不支持读者的任何抽象行为,阅读本文产生的任何后果,作者概不负责。
说到游戏和 VR 技术,不得不提到如今游戏行业的当红炸子鸡双 U(Unity 3D 以及 Unreal Engine),目前的游戏开发领域,基本处于 Unity 和 Unreal (UE4、UE5)双雄争霸的格局。随着业务的发展,我们终端团队也需要在游戏开发的方向进行一定的技术储备,针对 Android VR 设备进行热更新能力预研,于是,这篇技术预研与分享便应运而生了。
然而鲁迅曾经说过:爱一个人是藏不住的,但是爱两个一定要藏住。游戏引擎也是如此。相比于 Unreal,Unity 容易上手、开发者众多、强大的生态和 Asset Store 则是让其成为国内众多 VR 厂商的首选,Unity 优秀的 3D 渲染能力能为我们提供更有科技感的动画与交互,IL2CPP 脚本后端出色的性能也可以为业务生态赋能,于是我们选择先爱 Unity。
在方案实施的过程中也涌现出了若干问题,其中一个比较棘手的是在我们的业务需要将 Unity SDK 提供给开发者,但 SDK 接入的模式就导致了我们的 SDK 升级不能依赖开发者手动升级版本出包发版,热更新将作为我们后续的主要升级手段。如何能在 Android 平台进行多、快、好、省的 Unity SDK 热更新呢?这就是我们今天要讨论的问题。
本文首先向您简单介绍游戏行业热更新技术的发展历史,然后是我们的热更新的架构设计,以及方案的穿刺验证。我们希望能给您讲一个故事,而不是一个严肃的技术分享,希望能让您在忙碌一天后得到些许放松的同时也能了解到不同技术领域的发展。
当然,笔者只是游戏客户端开发新手,如果您是游戏开发大佬,这篇文章可能在您看来只是班门弄斧,如有谬误还望您不吝赐教;如果您不是技术同学,只是一位游戏发烧友,也请不要急着滑走,相信也能从这里读到游戏行业的发展历程,了解游戏热更新技术的演进历史和演进方向。
3.1 缘起魔兽
时间回到二十年前,千禧年左右的端游时代,推开一间网吧大门,香烟和泡面气息扑面而来,密密麻麻摆放的电脑座无虚席,有人喊打喊杀、有人嬉笑叫骂,劣质座椅上每一个人,都如同打了鸡血一般,那时有很多 90 后,就在这样一间小小的网吧,在《魔兽世界》中挥洒着青春。
这也是游戏工业的蛮荒时代,彼时游戏引擎的概念还只是一个模糊的雏形,如今的双星巨人 Unity 和 UE 在那时只是嗷嗷待哺的婴儿,而《魔兽世界》,则是第一个将 Lua 成熟应用到大型游戏的产品,当时的《魔兽》对 Lua 的使用还算是保守的,基本只是应用在客户端 UI 脚本、客户端插件方面,Lua 代码的比例不算高。
3.2 巴西取经
而到了网吧的另一端,另一部分 90 后玩家则爱上了网易公司开发的回合制游戏:《梦幻西游》和《大话西游》。他们的取经团队得益于交通技术的突飞猛进和一带一路的布局建设,在取经路上开上了“公路之王”白龙马X5,但是开车的时候没用腾讯地图打导航,导致迷了路越过天竺一路向西,一直到了巴西才刹住车,给国内开发者取回了“Lua 圣经”(注:Lua 诞生于巴西里约热内卢天主教大学)。他们不仅在客户端充斥着数不胜数的 Lua 脚本,甚至服务端逻辑都大量采用 Lua 编写,虽然现在看起来是有些不太靠谱,但在当年还用 Dephi 这种古早语言写网游(如《是兄弟就来砍我》)的时代,这其实是非常先进的,甚至是超前的。
3.3 初识 Lua
《梦幻西游》和《大话西游》对于 Lua 炉火纯青的应用,也将 Lua 在业界的应用推向了高潮。“Lua 圣经”一时间被游戏开发者们奉为圭皋,那以后的游戏开发 er 从此走上了 Lua 热更新的路一去不复返,Lua 的风光一时无两。开发者不断建设 Lua 生态,新的游戏又路径依赖或是看中生态选择 Lua,形成一个良性循环,也使得 Lua 的地位不断巩固达到高峰,不论引擎从 cocos 演进到双 U,平台也从端游切换到手游,时至今日,Lua 也仍是一棵常青树,仍是游戏行业动态化与热更新领域绕不过去的话题。
当然,Lua 的火热并不仅仅是几款游戏的功劳,我们今天只是用这几个爆款游戏的故事串起时间线引出 Lua 的崛起史。简单来说,在当时的历史局限性下,Lua 学习的便捷性、Lua 与 C 语言无障碍通信(古早时代机器性能受限,C 系语言大行其道)、抑或是团队技术栈、历史包袱和路径依赖等等,个中原因也让其成为游戏开发者们不二的的选择。一个人有其命运,一个语言亦如是,Lua 便是这些年游戏热更新领域的那个被幸运女神眷顾,被时代洪流推到台前的那颗明星。
既然如此成熟且可靠,那么 Lua 热更新方案 自然便成为我们第一个考虑的热更新方案,同时 Lua 拥有司内的 xLua 和司外的 sLua 等成熟库的支持,成熟的开源社区和丰富的踩坑经验自然带给我们更大的底气,Lua 热更新方案也是我们着重考虑的一大方向。
3.4 质疑 Lua
然而,随着游戏行业的发展,游戏工业进步迅速,新技术层出不穷,编程语言也在数年间进行了现代化的进化、迭代与改革,大家逐渐发现 Lua 一些让人难以接受的缺点,Lua 也从“小甜甜“变成了”牛夫人“。比如游戏行业大佬、网易登山工作室前技术总监、KCP 作者韦易笑曾细数过 lua 十宗罪:
明明是 90 年代才发明的语言,浑身透着一股 60-70 年代的味道。
—— (愤怒的💢)韦神 韦易笑
为了让没写过 Lua 程序员们能更直观地感受 Lua 的“美”,韦神特意在这里贴出几段 Lua 代码供大家奇码共赏析。
如果 Lua 想实现不依赖任何库,检测一个文件是否存在,请欣赏:
-----------------------------------------------------------------------
-- file or directory exists
-----------------------------------------------------------------------
function os.path.exists(name)
if name == '/' then
return true
end
if os.native and os.native.exists then
return os.native.exists(name)
end
local ok, err, code = os.rename(name, name)
if not ok then
if code == 13 or code == 17 then
return true
elseif code == 30 then
local f = io.open(name,"r")
if f ~= nil then
io.close(f)
return true
end
elseif name:sub(-1) == '/' and code == 20 and (not windows) then
local test = name .. '.'
ok, err, code = os.rename(test, test)
if code == 16 or code == 13 or code == 22 then
return true
end
end
return false
end
return true
end
为了保证代码的 portable,不依赖 C 模块,只使用 Lua 标准 api 写出来就是这样,全是 work-around,再来欣赏下如何求一个文件的绝对路径:
-----------------------------------------------------------------------
-- absolute path (system call, can fall back to os.path.absolute)
-----------------------------------------------------------------------
function os.path.abspath(path)
if path == '' then path = '.' end
if os.native and os.native.GetFullPathName then
local test = os.native.GetFullPathName(path)
if test then return test end
end
if windows then
local script = 'FOR /f "delims=" %%i IN ("%s") DO @echo %%~fi'
local script = string.format(script, path)
local script = 'cmd.exe /C ' .. script .. ' 2> nul'
local output = os.call(script)
local test = output:gsub('%s$', '')
if test ~= nil and test ~= '' then
return test
end
else
local test = os.path.which('realpath')
if test ~= nil and test ~= '' then
test = os.call('realpath -s \'' .. path .. '\' 2> /dev/null')
if test ~= nil and test ~= '' then
return test
end
test = os.call('realpath \'' .. path .. '\' 2> /dev/null')
if test ~= nil and test ~= '' then
return test
end
end
local test = os.path.which('perl')
if test ~= nil and test ~= '' then
local s = 'perl -MCwd -e "print Cwd::realpath(\\$ARGV[0])" \'%s\''
local s = string.format(s, path)
test = os.call(s)
if test ~= nil and test ~= '' then
return test
end
end
for _, python in pairs({'python3', 'python2', 'python'}) do
local s = 'sys.stdout.write(os.path.abspath(sys.argv[1]))'
local s = '-c "import os, sys;' .. s .. '" \'' .. path .. '\''
local s = python .. ' ' .. s
local test = os.path.which(python)
if test ~= nil and test ~= '' then
test = os.call(s)
if test ~= nil and test ~= '' then
return test
end
end
end
end
return os.path.absolute(path)
end
3.5 放弃 Lua
抛开韦神写下这些文字时溢出荧幕的愤怒情绪不谈,单是看完这飞满 magic number 和 nil、end 的 Lua 代码,想必各位程序员内心已经将其丑拒了。前些年,Lua 的广泛应用多是因为历史包袱、iOS 平台限制反射等客观原因,是一种无奈。而我们的项目一来没有历史包袱、二来暂不考虑 iOS 平台的移植,同时我们的工程架构(见下)更是打算将热更新的资源包与代码包作为程序的主体,仅留下一个入口,这样能更加灵活地进行升级与发布,而这也意味着 Lua 将成为工程的主力语言,占比甚至超过整个项目 80%(一般项目中 Lua 只占 10%~20% 比例),这也将给研发和维护工作带来双重灾难。总的来讲,Lua 确实不适合工程化与多人合作开发,所以,在一番痛苦的抉择中,我们决定放弃 Lua。
3.6 初识 PuerTS
相比被我们丑拒的 Lua,随着近年来技术的更新换代,游戏热更新领域有了更多新贵,我们也有了更多的选择。一个更好的选择是 JS/TS,选择他们的理由很简单,相比刚刚看过的 Lua 代码,眉清目秀的 TypeScript 简直让我们赏心悦目、心旷神怡,JS/TS 强大的开发者生态让我们能充分利用前端社区和轮子等丰富资源,同时 TypeScript 语法与工程化能力都非常卓越,让码农们出现问题时第一时间想的不是喷语言的设计,而是从自己写的代码身上找原因,大大提升开发人员的幸福感以及团队内部的和谐程度。就连 xLua 作者、司内大佬 johnche 也从 Lua 转向 JavaScript/TypeScript,开发了新一代热更新框架 PuerTS。
基于这样的考虑,我们调研了 内置 Webview + JS/TS 引擎进行热更新方案,该类方案我们首推司内优秀开源力作 PuerTS,PuerTS 是 Unity/Unreal/Dotnet 下的 TypeScript 编程解决方案,为 Unity/Unreal 引擎提供了一个 JavaScript 运行时,同时提供 TypeScript 声明文件生成能力,易于通过 TypeScript 访问宿主引擎,我们想选择 PuerTS 的理由也很简单,官方的理由已经非常让我们心动:
3.7 初识 HybridCLR
不得不说,PuerTS 似乎像是解决问题的终极方案了(甚至连 Unreal 引擎的热更新也一并解决),JS/TS 也是大势所趋。但是经过这么多考量,做 Android 的我们不禁出现了疑惑,为什么游戏的热更新,还要用一个新的语言去搞,而不是像 Android 直接使用平台本身的 Java/Kotlin 等原生语言直接做热更呢?
带着这样的疑问,我们发现,在以前那个没技术可选、机器性能受限的年代,受制于开发成本、社区资源、霸道的 iOS 平台,我们的选择颇为有限。但是现在随着游戏行业日益发展,我们拥有更多的选择,如果希望只用 C# 就实现热更新,那么可以考虑 纯 C# 热更新 HybridCLR 方案 ,HybridCLR 是一个特性完整、零成本、高性能、低内存的的 Unity 全平台原生 C# 热更方案。
介绍 HybridCLR 的原理之前,我们需要先介绍 Mono 和 IL2CPP,这二者都是 C# 脚本后端运行时,其中 Mono 是一个开源的工程,旨在使开发者能够在不同的操作系统和硬件平台上编写和运行相同的脚本代码,从而支持跨平台,Mono 采用 JIT 方式执行代码;IL2CPP 则是将 C# 脚本编译的 IL 转化为 C++,再编译成本地机器码进行跨平台和利用各平台对 C++ 的优化从而提高性能,IL2CPP 采用 AOT 编译的方式。
Mono 的脚本编译流
IL2CPP 的脚本编译流
在一定程度上,IL2CPP 可以理解为 Mono 的 AOT 模块,HybridCLR 则第三方实现了 Mono 的 interpreter 模块,进行解释执行代码,补充了 IL2CPP AOT 动态性不足的问题。HybridCLR 使得 IL2CPP 变成一个全功能的 Runtime,原生(即通过System.Reflection.Assembly.Load)支持动态加载dll,从而支持 iOS 平台的热更新,其优势也是显而易见的。
3.8 放弃 PuerTS 与 HybridCLR
PuerTS 方案让人心动,HybridCLR 也让我们流连,但是回到我们需求本身,我们发现两个方案都有些过重,PuerTS 需要引入一个 JS 运行时,HybridCLR 也深入到运行时做了相当多的工作,相当于为 IL2CPP 添加了 interpreter 模块,越重的依赖带来越大的未知。一方面,本着奥卡姆剃刀:如无必要,勿增实体的原则,作为 SDK 寄生于宿主环境,依赖环境的增加意味着稳定性和可靠性的降低,最好是不要引入更多语言、依赖、运行时;另一方面,这两个方案滋生了供应链风险,当依赖出现风险或者安全问题,我们的升级成本是高昂的,甚至是无法后台无感知升级的。于是,在一番考量后,这两个方案暂时搁置,我们希望寻求一种更简单更可控的解决方案。
3.9 选择 Asset Bundle + 替换 dll 反射方案
在经过一众方案的比较后,我们不禁思考,有没有一种方案,既不需要繁重的依赖,越简单越好,又能有较高的可维护性,不用引入一堆 Lua 脚本堆成屎山;既不要随意引入依赖造成各种兼容性问题和疑难杂症,又能尽量在简洁的同时做到优秀的工程化呢?于是我们进一步删繁就简,从 Unity 引擎和语言层面本身的能力挖掘,最终调研了一个不依赖任何库、仅仅使用 Unity 本身和 C# 语言能力就到热更新的返璞归真方案:Asset Bundle + 替换 dll 反射 C# 热更,前面说过,很多框架是考虑到历史原因以及 iOS 平台的限制,而我们作为一个没有历史包袱的项目,也不需要考虑霸道的 iOS 平台,简单、稳定且通用就是我们的诉求,那么 Asset Bundle + 替换 dll 反射方案就能满足我们的需求
游戏热更新分为资源(模型、美术资源等)热更新与逻辑代码热更新,其中 Asset Bundle 主要负责资源的热更新,这也是官方的成熟方案,在前面讲到其他几种热更新方案中,我们也是采用这种方式进行资源热更。我们的方案中为了达到简洁少依赖的目的,采用 Asset Bundle 打包热更新资源与代码,包括场景资源以及代码 dll,并动态加载并动态绑定给物体(这里后续需要做一些安全校验保证文件在传输中不被篡改)
这个方案似乎完美解决了我们的问题,没有引入额外的依赖和运行时,仅使用 Unity 官方原生能力便达到了热更新的需求,同时也不需要切换开发语言,但是由于我们的方案在网上没有很多现成的资料和踩坑,只是理论上可行,实际是否可行仍需验证。基于此,我们需要先梳理一下方案选型,做一个架构设计,这就是接下来要讲的 热更新方案选型与架构设计,然后我们需要对这个方案进行可行性的穿刺验证,也就是再接下来的 Asset Bundle + dll 替换反射 C# 热更新方案穿刺验证报告。
感谢您愿意看到这里,到这里以后,就是比较枯燥的技术方案了,前面的技术史(chui niu bi)暂告一段落,如果您没有兴趣或者不是技术同学,那么您也可以划走去看看更多感兴趣的内容,感谢您的支持与鼓励,后续我也将用大话技术的方式给大家分享更多有趣的技术故事~
综上所述,我们的方案选型总结起来大概就有如下四种,而他们的优劣势,已经在上面文章中逐一为大家介绍了
4.1 架构设计
4.1.1 方案简述
4.1.2 架构图
基于以上方案简述,我们画出这样的架构图。
4.1.3 热更新设计序列图
4.1.4 C# 代码动态加载方案
4.2 容灾方案
作为一个嵌入到宿主应用的 Unity SDK 方案,我们通过热更新做到了业务无痛升级,但是如果外层的热更新框架出现严重 bug 想要发布升级,则难度巨大,需要推动外部开发者升级发布,而外部开发者升级意愿并不强烈。我们的方案不是完美的,总有出现各种意外的可能,为了应对这些意外,应当适当验证最最极端的 case,做最坏的打算,在外部开发者不升级的前提下也能保证业务流程的顺利进行,保障稳定性与可靠性,为此,我们采用容灾方案:
由于我们的方案虽然比较明朗,但由于其仅支持 Android 平台,市面上实践较少,目前亟需对其功能进行穿刺验证,理论成立,现在开始实战。
5.1 核心验证逻辑
热更新代码,核心逻辑是在 HotCodeSample::Start 被调用时在控制台输出 Debug::Log,以及在 HotCodeSample::Update 被调用时每分钟在控制台输出 Debug::Log。
HotCodeSample.cs
using System;
using UnityEngine;
namespace HotUpdateCodeSample
{
public class HotCodeSample : MonoBehaviour
{
private long lastLogUpdateMinute = 0;
// Start is called before the first frame update
void Start()
{
Debug.Log("[HotCodeSample::Start] this is a HotUpdateCodeSample::HotCodeSample::Start from hot update dll");
}
// Update is called once per frame
void Update()
{
long currentMinute = DateTime.Now.Minute;
// 控制打印频率
if (currentMinute <= lastLogUpdateMinute)
{
return;
}
this.lastLogUpdateMinute = currentMinute;
Debug.Log("[HotCodeSample::Update] this is a HotUpdateCodeSample::HotCodeSample::Update invoke from hot update dll");
}
}
}
5.2 实施步骤
5.2.1 壳工程实现
AssetBundleLoader.cs
using System;
using System.Collections;
using System.IO;
using System.Reflection;
using UnityEngine;
using UnityEngine.Networking;
public class AssetBundleLoader : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
Debug.Log("[AssetBundleLoader::Start]");
// StartCoroutine(ReadAssetFromMemery());
StartCoroutine(ReadAssetFromRequest());
}
// Update is called once per frame
void Update()
{
}
IEnumerator ReadAssetFromMemery()
{
Debug.Log("[AssetBundleLoader::ReadAssetFromMemery]");
// string path = @"pathToYourProject/AssetBundleDemo/AB/cube0.u3d";
string path = @"AB/cube0.u3d";
byte[] bytes = File.ReadAllBytes(path);
Debug.Log($"[ReadAssetFromMemery] bytes.length: {bytes.Length}");
AssetBundle cubeAssetBundle = AssetBundle.LoadFromMemory(bytes);
Debug.Log($"[ReadAssetFromMemery] cubeAssetBundle: {cubeAssetBundle}");
GameObject prefabGameObject = cubeAssetBundle.LoadAsset<GameObject>("Cube");
Debug.Log($"[ReadAssetFromMemery] prefabGameObject: {prefabGameObject}");
GameObject cubeGameObject = GameObject.Instantiate(prefabGameObject);
Debug.Log("[ReadAssetFromMemery] finish");
yield return null;
}
IEnumerator ReadAssetFromRequest()
{
Debug.Log("[AssetBundleLoader::ReadAssetFromRequest]");
string cubeUrl =@"file:///pathToYourProject/AssetBundleDemo/AB/cube0.u3d";
UnityWebRequest cubeRequest = UnityWebRequestAssetBundle.GetAssetBundle(cubeUrl);
yield return cubeRequest.SendWebRequest();
AssetBundle cubeAssetBundle = DownloadHandlerAssetBundle.GetContent(cubeRequest);
GameObject prefabGameObject = cubeAssetBundle.LoadAsset<GameObject>("Cube");
GameObject cubeGameObject = GameObject.Instantiate(prefabGameObject);
yield return null;
string srcUrl =@"file:///pathToYourProject/AssetBundleDemo/AB/hotupatesample.dll";
UnityWebRequest srcRequest = UnityWebRequestAssetBundle.GetAssetBundle(srcUrl);
yield return srcRequest.SendWebRequest();
AssetBundle srcAssetBundle = DownloadHandlerAssetBundle.GetContent(srcRequest);
Debug.Log($"[ReadAssetFromRequest] srcAssetBundle: {srcAssetBundle}");
// 取 Asset 用 打包前的文件 Name,不区分大小写
TextAsset textAsset = srcAssetBundle.LoadAsset<TextAsset>("hotupdatecodesample.dll.bytes");
Debug.Log($"textAsset: {textAsset}");
Assembly assembly = Assembly.Load(textAsset.bytes);
// 取类用类全名 FullName,用 `.` 连接
Type type = assembly.GetType("HotUpdateCodeSample.HotCodeSample");
cubeGameObject.AddComponent(type);
}
}
将 业务逻辑工程 中的产物 HotUpdateCodeSample.dll 复制到资源路径下,必须重命名将后缀改为 bytes 或其他支持的后缀(详见 Text Assets - Unity 手册),其中 bytes 最合适。
设置 Asset Bundle 打包,每次更新 dll,对于 unity 来说都属于识别了一个新文件,需要重新编制其 Asset Bundle 打包索引。
使用自定义的 unity Editor 打包工具 MakeAssetBundle 打包 Asset Bundle 并提供给服务器。
AssetBundleUtil.cs 该类必须放在 Unity 工程的 Asset/Editor 目录下并继承自 Editor。
using System.IO;
using UnityEngine;
using UnityEditor;
public class AssetBundleUtil : Editor
{
[MenuItem("Utils/MakeAssetBundle")]
static void CreateAssetBundle()
{
Debug.Log("[CreateAssetBundle]");
string path = "AB";
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
BuildPipeline.BuildAssetBundles(path,BuildAssetBundleOptions.ChunkBasedCompression,BuildTarget.Android);
Debug.Log("[CreateAssetBundle] finish");
}
}
5.2.2 业务逻辑工程实现
业务逻辑工程需要新建立一个 .Net Framework 工程,版本不能太高,否则unity 不支持,推荐使用 .Net。
ramework 3.5
图为在 Rider 中新建工程,在 VS 中新建也是可以的。
引入 UnityEngine.dll ,MacOS 中需要到 /Applications/Unity/Hub/Editor/2021.3.20f1c1/Unity.app/Contents/ 中寻找一下,使用 find ./ -name UnityEngine.dll ,发现在 Managed 目录下。
在 VS 中为工程添加引用:
点击 .Net 程序集,点击浏览,browser 到刚才找到的 UnityEngine.dll 位置。
依赖添加完后,就可以在依赖项中看到,并可以正确 using UnityEngine; 以及正确继承 MonoBehaviour 说明成功了。
编写业务逻辑代码,并使用 VS 编译 dll。
在 projectPath/bin/Debug 或 projectPath/bin/Release 下找到产物 HotUpdateCodeSample.dll 并提供给壳工程,即可。
5.3 验证结果
穿刺验证 Demo 实现了 资源热更新 与 代码热更新,验证了 Asset Bundle + dll 替换反射 C# 热更新方案的可行性。
5.3.1 待验证风险
经过以上的选型与论证,我们对于 Android VR 设备的 Unity 热更新的方案也有了一个可靠的选型并论证了其可行性,我们的热更新能力建设也在如火如荼地进行中。笔者作为一个游戏客户端新手,毕竟才疏学浅,水平有限,这篇文章难免有许多纰漏和不足之处,也欢迎各位大佬们对这篇文章提出指正和批评。今后我们也将继续努力研究 VR 技术,希望能为大家提供更多高质量的分享,给大家带来更多视觉和技术上的盛宴
谢谢你愿意读到这里,期待有一天能在 VR 世界中与你邂逅,再会!
-End-
原创作者|郑杰夫