重装机兵原版第一部 结局只有退隐 有没有其它的 丫丫的别说了我neng 帖子看

以前有个人说的很好。 中国的游戏市场,就像个巧克力喷泉,你说你怎么吃?当然是把头摁在里面,像猪一样吃。&br&&br&&br&做脑残扒皮游戏,投入50%,回报300%,风险50%。&br&换个皮,数值照抄,八成预算扔渠道里,请个明星代言,广告一炸,泔水一样的坑钱系统都有猪一样的玩家争先恐后的跳进去。&br&&br&&br&做高大上游戏,投入1000%,回报50%,风险200%。&br&你花个好几年,投入无数,质量还未必能竞争的过国外老牌大厂。国内高大上玩家又抠得很,一点质量不好,不满意就刷差评。地图小了,喷,剧情不行,喷,数值有问题,喷,bug,喷。喷多了一言不合就滚,忠诚度不到口嫌体正直的泔水游戏玩家的10%。&br&要迎合这个群体,得和那堆资深欧美大厂刚正面不说,还得被玩家骑着跟孙子一样。&br&万一你的创意和比你强的欧美大厂撞了车,或者泄了,人家现成游戏一个dlc就拿质量压死你,你这笔生意就哭死。&br&&br&&br&你是风投,你投谁?&br&没钱,发不起高工资,没有牛逼团队,怎么做高大上游戏?&br&&br&你们只不过是小众而已。
以前有个人说的很好。 中国的游戏市场,就像个巧克力喷泉,你说你怎么吃?当然是把头摁在里面,像猪一样吃。 做脑残扒皮游戏,投入50%,回报300%,风险50%。 换个皮,数值照抄,八成预算扔渠道里,请个明星代言,广告一炸,泔水一样的坑钱系统都有猪一样的…
&img src=&/50/fe73fed07bab398ba01ba7_b.jpg& data-rawwidth=&1440& data-rawheight=&900& class=&origin_image zh-lightbox-thumb& width=&1440& data-original=&/50/fe73fed07bab398ba01ba7_r.jpg&&中国游戏幕后史三 &br&暴雪其实不想跟九城分手 魔兽世界幕后故事&br&文/BBKinG&p&  说起中国游戏幕后史,《魔兽世界》在中国的运营案例堪称经典中的经典。&/p&&p&  2004年,由于之前《魔剑》和《EQ》等大型游戏在中国的运营失败,导致美国暴雪娱乐当时正在封测的同类型网游《魔兽世界》并不被中国游戏业内看好,而这款游戏在之后的几年里,却在世界范围建立起了一个超级王国,不但成为业内标杆,还带动了一系列前所未有的游戏周边产业和文化的兴起,同时也让签下国内代理的第九城市集团名利双收,跻身中国一线游戏公司。&/p&&p&  而2009年,暴雪和九城的“离婚”案,更是沸沸扬扬,还一度牵扯到文化部和新闻出版署的高层斗法。期间双方的各种明争暗斗,你死我活,不亚于谍战血火,甚至衍生出一个地下暗战的代言人体系——舅舅党。&br&&/p&&p&  如今斗转星移,到了2014年,暴雪与网易已步入感情稳定期,新闻出版署也与广电在2013年合并,变成国家新闻出版广播电影电视总局,《魔兽世界》也即将发布6.0版本,一切仿佛都归于平静。&/p&&p&  可是,历史是绕不开,也不容忽视的。&/p&&p&  你知道美国暴雪当初并不想跟九城“离婚”吗?&/p&&p&  甚至在暴雪2008年最后一次美国总部的专项讨论会上,大家依然得出不要分家的结论。&/p&&p&  那为什么最后还是分了?谁压下最后一根稻草?是谁把对方逼上了绝路?是九城的策略失误?还是暴雪的冲冠一怒?&br&&/p&&p&  让我们从头开始&/p&&br&&p&  2004年4月,第九城市集团与维旺迪(Vivendi Universal Game)旗下的暴雪娱乐(Blizzard Entertainment)签署中国战略合作协议,第九城市取得《魔兽世界》(World of Warcraft(TM))在中国大陆地区的独家代理运营权。&/p&&p&  当时这个项目组的负责人叫孙涛,九城的CTO,据说他是帮九城抢下WOW的大功臣,也是第一任项目负责人,因为人长的非常帅,被游戏圈戏称为游戏行业最帅的男人(后来他离开九城创立了冰动游戏公司,几年后卖掉,现在平安中国工作)。&/p&&p&  另外两个重要人物是产品总监黄凌冬和技术总监江焕新。&/p&&p&  黄凌冬是九城虚拟社区的第一个成员,是公司元老了(后升任九城的产品副总裁,现腾讯上海互娱合作产品部副总经理)。他亲自筹建了《魔兽世界》项目组,早期团队里的很多人都是由他亲自面试招入的。&/p&&p&  比如,负责对外沟通的项目经理 马刚(现育碧中国高级市场总监),媒介经理刘峰(昵称Aya,现杭州乐港副总裁),以及创建&艾泽拉斯国家地理& 网站的Ediart田健,他负责汉化(2011年田健创建上海游点信息科技有限公司,做手游研发),还有贯穿九城和暴雪中国历史的WOW项目组元老吴健,当时他负责策划和运营(之后加入暴雪中国负责魔兽世界和其它暴雪产品的运营,后受邀出任空中网COO)等等。&/p&&br&&p&  技术总监江焕新,出身惠普,在魔兽世界项目启动初始加入九城,后来出任九城的副总裁,离开过九城一段时间,最近似乎又回到九城帮助公司开拓北美疆域。&/p&&br&&p&  九城当时是没有市场部的,只有一个大产品部。网站早期是外包出去的,接外包的团队叫01media,现在也是赫赫有名的设计公司了,后来专门招了一个人负责网站,这个人叫周豪ZAX(现希玛公司CEO,做电子竞技,WE俱乐部和StarsWar就是该公司旗下的品牌),还有一个网站美工叫胡依林,现在也成为知名的独立设计师了。&/p&&p&  最初的WOW团队只有十来个人(请原谅我收集到的信息有限,有些人可能会有错漏,我会不断完善的),一直到2004年年底,依然还是这些人。&/p&&br&&p&  《魔兽世界》项目组成立后,对游戏的汉化和中文配音便成了首要工作。&/p&&p&  最初的WOW版本有100多万字翻译量,当时是找了六七个兼职的翻译人员准备大干一场。九城为了方便大家工作,专门把翻译组从中信泰富移到了旁边比较清静的凯迪克大厦,还腾出一个会议室给他们加班加点的翻译,翻译好的内容,刘吉磊(后加入腾讯做手机接入)做第一轮校对,吴健做第二轮校对,田健做第三轮校对和润色,其中最为关键的是田健的润色,他凭借自己对欧美游戏文化的深刻理解,将生涩的翻译变成了优美连贯的文字,他的工作后来也得到了广大玩家的认可。&/p&&p&  中文配音找了配音出身的著名上海译制片导演 倪康 做配音导演,据悉,他同时也是后来英雄联盟的中文配音导演。&/p&&p&  倪康老师是个非常认真的人,他挑选了大江南北的顶级人员来上海配音,其中,为了给游戏里的大BOSS 奈法利安 和 拉格纳罗斯 配出霸气十足的语音,特意找了一个蒙古配音演员 龚格尔,让他连夜坐火车来上海。&/p&&p&  第二天一早,龚格尔直接进棚就开始录,但是折腾了几个小时后,大家发现:这小伙完全没感觉。后来到中午了,项目组商量了下,打算请他吃个饭就算谢绝了。&/p&&p&  可是,谁知道吃完饭后,蒙古人的精气神突然全有了,霸气的感觉也全来了,一口气2个小时就配完了所有大BOSS的音。&/p&&p&  于是,大家后来在打副本被灭的死去活来时,听到的那些欠打的嘲讽声,十有八九是龚格尔吃饱了后说的。&br&&/p&&br&&p&  筹备内测、准备上线、政府审批,以及和可口可乐的合作,便成了2005年公测开始前的几个重点工作。&/p&&p&  此时的外界依然不看好《魔兽世界》,而九城内部却越来越有信心。&/p&&p&  内测刚开始发号的时候,几天里就有30多万的暴雪粉丝来申请,而且当时的申请条件很苛刻,需要填写大量的用户信息和资料,但是WOW项目的人发现,玩家都填的相当仔细。&/p&&p&  《魔兽世界》的网络社区里也开始有了很多相当不错的内容,比如,可能现在很多老玩家还记得的一个视频叫《康熙也想玩魔兽》,做的非常不错,在2005年刚放到网上不久,就有几十万的点播量,九城的工作人员还专门去找到了视频制作者,送了几个CDKEY作为感谢。&/p&&p&  日,第一个魔兽世界日,九城在上海卢湾体育馆办了一个玩家见面会,在这个见面会上将宣布《魔兽世界》的公测时间。&/p&&p&  那天下着非常大的雨,但是却来了几千人,不但里边坐满了人,外面还挤了非常多的玩家在雨里等待,这给了WOW项目组极大的信心和鼓励,据说那天九城老板朱骏也来了,他进场看了一眼,笑了笑就回到了后台,已然成竹在胸。&/p&&p&  当天宣布公测时间为4月29日,但是实际公测时间是4月26日。&/p&&p&  内测结束的那天晚上,九城的工作人员办了一个叫 “恶魔入侵 世界末日”的活动,由运营团队控制大怪物,到各个主城里与玩家互动,跳舞,一起庆祝,暴雪对GM控制NPC的行为有严格的限定,但是那天晚上除外,所以那场活动恐怕是十分罕见的,如果你有幸参与了那天晚上的互动,那会是非常美好和值得纪念的回忆。&/p&&p&  这里要说个小插曲,公测前运营商一般会制作发放WOW的安装盘,而用来制作这个安装盘的母盘,当时是暴雪的绝密文件,因为这是最完整的游戏文件,里边整合了最新翻译,还有所有的游戏内容。&/p&&p&  暴雪对快递公司不放心,于是专门派了一个员工Stan Wang,亲自从美国送过来,九城这边的项目组兴奋的一夜没睡,就为了等这张盘,而当他们拿到这张盘,迫不及待的插入电脑后,却发现这张母盘竟然坏了,真是让人啼笑皆非。&/p&&p&  公测了,终于公测了。&/p&&p&  封顶45级,而玩家的热情超出想象,所有的服务器都在排队,那个时候美国暴雪在欧美地区曾出了一个市场文案叫:A world awaits(一个世界在等待)。结果被中国玩家戏称为:一个世界在排队。&/p&&p&  《魔兽世界》的公测排队对之后的游戏也产生了深远的影响,这变成了一种文化,有业内人士说,后来有些游戏甚至不用排队也要制造出排队的效果。&/p&&p&  强势的暴雪&/p&&p&  暴雪对九城在《魔兽世界》运营上做了很多要求,有些要求是蛮高的,比如关于服务器的选择,在暴雪的要求下,九城向惠普购买了当时最先进的服务器,而整个服务器集群的计算能力据说是远超银河计算机的。&/p&&p&  暴雪的出发点非常简单:为了玩家有最好的游戏体验。而有时候这种强势的做法,会让运营商十分难受的,这也为日后两家公司的裂痕埋下了隐患。&/p&&p&  很快,公测的人数就突破了60万。&/p&&p&  整个项目组的人都非常高兴,因为除了成就感以外,大家都很期待老板朱骏兑现他承诺的奖金。&/p&&p&  那是在2005年初,朱老板曾对大家说,如果这个游戏人数超过50万,就发50万美元的奖金。&/p&&br&&p&  而很多人直到最后走了,都没看到这笔钱,这也为后来很多项目成员的离开埋下了伏笔。&/p&&p&  2005年,野心勃勃的九城,开始布局他新的战略。陆续从国外拿回来了大量新的游戏,比如,卓越之剑、奇迹世界、地狱之门、激战等等。&/p&&p&  这个战略一方面是为了压制国内潜在的竞争对手,先下手为强,把好游戏都拿下来。另一方面也是因为九城不想一辈子做暴雪的傀儡。&/p&&p&  从战略的层面说,这个方向是对,毕竟盛大的案例摆在面前,代理公司和游戏研发公司的矛盾只会增长,必须要有所准备。但是不同于盛大的是,他们的对手可不是什么韩国公司,而是老牌巨无霸级游戏航母 美国暴雪娱乐。&/p&&p&  2005年年底,新矛盾说来就来了&/p&&p&  此时,国外已经要上新资料片《燃烧的远征》了,而这个资料片里加了很多新的技术,美国方面认为,这需要更好的服务器支持,才能确保提供给玩家更好的游戏体验,于是,他们提出,要求九城方面更新服务器。&/p&&p&  注意,此时,九城刚花大价钱买的那些远超银河的服务器,可还没用多久呢!&/p&&p&  所以,九城犹豫了。&/p&&p&  裂缝就这样又大了一些&/p&&p&  于是,暴雪开始推进亲自进入中国市场的战略,于2005年着手执行建立暴雪中国分公司的计划。&/p&&p&  可以看到,双方都在博弈,其实这没有什么对错之分,都是为了活下去,这就是丛林法则。&/p&&p&  2006年,暴雪加速在中国的扩张。&/p&&p&  而九城此时走了一步很诡异的棋,他们开始把WOW项目组的核心人员纷纷调离这个项目,让他们去负责之前拿回来的各种新游戏。而把九城自己做研发的人员调过来做WOW的运营。&/p&&p&  我个人猜测,这步棋可能有多个目的:一个是让这些已经有成功运营经验的成员去带带新游戏。一个是通过实战数据,培养自己的研发人员。还有一个,恐怕是防止WOW项目组亲暴雪派的势力过大。&/p&&p&  这步诡异的棋是谁的主意?我不知道,我也不知道我的猜测是否准确,但是我打赌做决定的人一定没想到这步棋会像七伤拳一样,伤人也伤己。&/p&&p&  首先是项目组负责人亲暴雪派的孙涛直接辞职,去建立了自己的公司,还带走了一批人。之后九城换上的新COO是Nancy Zhou周宁,相对孙涛,她对待暴雪要强势许多。(Nancy现任育碧中国的总经理)&/p&&p&  九城运营方面的负责人吴健则因为不愿意去做别的项目,被暴雪顺势挖去做了暴雪中国的第四号员工。&/p&&p&  朱骏为此大发雷霆,准备给吴健和暴雪同时发律师函,还在公司内部大会上布下了严旨,不许任何人与吴健有任何形式的接触,也不许暴雪让吴健插手任何与WOW相关的所有工作。&/p&&p&  这个事情闹的很大,逼得后来九城和暴雪还签订了一个协议,用来禁止相互间挖角,才算是慢慢平息。&/p&&p&  表面上看只是一个员工的跳槽,实际上,背后是两个公司战略级的碰撞。&/p&&p&  暴雪此时的总经理叫Michael Fong,他是一个加拿大籍香港人,他有一个重要的工作是筹建暴雪中国分公司。而负责调研的就是刚去的吴健。&/p&&p&  之后的几年,与《魔兽世界》跟中国玩家的关系越来越火热正好相反,九城和暴雪的关系越来越冷漠,一方面是暴雪希望更多的介入运营,了解细节,另一方面是九城不更新服务器,而且把数据也藏着掖着不告诉暴雪。&/p&&p&  博弈越来越激烈,矛盾也越来越大&/p&&p&  虽然更换代理商这个念头早就在暴雪内部出现过无数次,但是暴雪此时依然不想换,原因很简单,暴雪觉得任何决策都不能伤到玩家。熟悉暴雪的人可能知道,这是暴雪公司的核心文化,也是很多决策制定时的根本出发点。&/p&&p&  甚至在日,EA以1.67亿美金入股九城15%股权时,作为EA主要竞争对手的暴雪都还是怒而不发。&/p&&p&  直到2008年,忍无可忍的暴雪才开始与其它厂商进行接触,研究接盘的可行性。&/p&&p&  而这期间,九城的高层依然在用强硬的态度处理与暴雪的关系,这不但没有缓和越发紧张的局势,还最终激怒了暴雪。&/p&&p&  这里我要声明一点,九城的战略是控制并平衡自己与暴雪谈判的位置,谁都想把话语权控制在自己手里。所以,他们走多游戏代理,尝试摆脱暴雪的控制,推自主研发,甚至拉EA入伙,都是为了让自己对暴雪的谈判筹码更多一些,从战略层面上说,这个大方向是没错的。&/p&&p&  但是,可悲的是,卡住了九城脖子的是时代的瓶颈,别说那个时候的中国游戏自主研发了,即使到这么多年后的现在,世界上有几个游戏能超越《魔兽世界》的?&/p&&br&&p&  就像小国家想独立,你没核武器,没反制能力,资源和经济都没有绝对优势,最大的收入又要依靠人家,那所谓平起平坐,也就只是个口号罢了。&/p&&p&  邓小平说,落后就要挨打。&/p&&p&  如今只是被打的更文明了一点而已。&/p&&p&  而当时的九城还是一副没弄清楚状况的样子,依然强硬。&/p&&p&  就在暴雪最后一次会议依然决定不“离婚”的那天夜里,动视暴雪的老大Bobby Kotick突然下达了与九城的分家命令。&/p&&p&  或许是一个邮件?一个报表?一个句话?这已经都不重要了,强势的外企遭遇强硬的本地代理,这个性格不合的组合必然导致“离婚”。&/p&&p&  最后一根稻草缓缓的压下,却让整个局势瞬间崩塌。&/p&&p&  之后的事情,我简单说下,暴雪在与众多厂商接触后,网易和腾讯走到了最后,但暴雪美国方面觉得网易和丁磊对玩家的态度更加契合暴雪的核心文化,于是,选择了网易。  &br&&/p&&p&  日,网易《魔兽世界》重开内测。&/p&&p&  之后的故事,我们过些年再写出来吧。&/p&&p&  中国游戏幕后史&/p&&br&&br&&p&『中国电竞幕后史』实体书29.9元包邮:&br&&a href=&/?target=https%3A///item.htm%3Fspm%3Da1z10.1-c.w9584.1.pWkTys%26id%3D%26scene%3Dtaobao_shop& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&中国电竞幕后史 电竞圈幕后传奇故事 淘宝店&i class=&icon-external&&&/i&&/a&&/p&
中国游戏幕后史三 暴雪其实不想跟九城分手 魔兽世界幕后故事 文/BBKinG 说起中国游戏幕后史,《魔兽世界》在中国的运营案例堪称经典中的经典。 2004年,由于之前《魔剑》和《EQ》等大型游戏在中国的运营失败,导致美国暴雪娱乐当时正在封测的同类型网游《魔…
&p&简书地址:&a href=&/?target=http%3A///p/abfab0e6f2fc& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&Unity手游开发札记——2.5D大世界动态加载实战&i class=&icon-external&&&/i&&/a&&/p&&h2&0. 前言&/h2&&p&项目第一次对外技术测试落下帷幕,终于有时间来填&b&大世界动态加载&/b&这样一个大坑。
从去年11月份开始,在需求改变、制作方案更改等各种影响下,断断续续地制作维护这个功能,估算下来花费在它上面的有效时间也得有1个月左右。目前我们游戏大世界的制作进入铺量阶段,已经制作好的功能也经过了第一次技术测试的验证,静下心来写这篇《Unity手游开发札记——2.5D大世界动态加载实战》。&/p&&blockquote&需要说明的是:一方面任何技术方案都有其&b&适用范围&/b&,相对应的也就是它们有着自身的&b&局限性&/b&,因此这篇文章肯定不是一颗万能的“银弹”;另外一方面,在实际工程中,实现一段代码、一个技术功能点往往是最为简单的那步,设计适合团队工作的工作流程,让功能可以快速高效的产出结果,并且便于维护,才是工作量更大的部分。因此,正在阅读这篇文章的你,不必抱着多大的希翼可以通过学习它实现你们自己项目的大世界动态加载架构,它是一篇“实战记录”——意味着这里的经验经历过一个真实项目的洗礼,也意味着它们可能更适用于我们项目而已。&/blockquote&&h2&1. 需求分析和技术调研&/h2&&blockquote&一切都在变化,没有东西会消失。——奥维德:&a href=&/?target=https%3A///subject/2364765/& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&《变形记》&i class=&icon-external&&&/i&&/a&&/blockquote&&h2&1.1 需求分析&/h2&&p&“需求一直都在变化,没有需求会消失。。。”回头来看我们游戏整个大世界的制作方案的确定过程,我要把改编自奥维德名言的这句话送给我们团队的策划和美术同学,这里饱含了一个程序的吐(fen)槽(hen)。我们项目的需求变更主要体现在大世界制作方案的改变上,从2016年11月份开始,经历过传统3D制作方案、基于六边形的风格化方案、比例缩小版写实风格,最后到基于Terrain的沙盘风格。每次变更都意味着美术制作流程的变化,随之而来的就是程序需要开发的工具集调整。&/p&&p&回到项目立项初期讨论的时候,当时我们就确定了大世界的方向。其实从程序的角度能够预估这其中的技术难度,毕竟团队中从策划到程序再到美术谁都没做过完整的大世界项目。带着初创团队初生牛犊不怕虎的劲,再加上策划同学拍着胸脯说“实在不行我们就用2D地图也能接受”的允诺,就往这个方向来努力。
第一个版本美术预研的大世界效果出来之后,纠结在视角使用3D还是2.5D——2.5D的大世界制作成本和技术难度会比较低,但是从当时的设计来看,3D的体验会更好,而且看得越远越好……因此最初的技术预研方向也是在往自由3D视角的目标来做。&/p&&h2&1.2 Unity插件调研&/h2&&p&从程序角度,无论什么视角,针对Unity引擎做初步的技术调研是最基础的工作。这时候有那么一点怀念之前自己掌握引擎代码的日子,即使没有引擎组的支持,自己在引擎C++底层来做是方法明确而且效率更高的方式。好在Unity也有自身的优势——Asset Store。搜索加询问,最后找到看着还比较靠谱的两个插件—— &a href=&/?target=https%3A//www./en/%23%21/content/15356& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&SECTR COMPLETE&i class=&icon-external&&&/i&&/a& 和 &a href=&/?target=https%3A//www./cn/%23%21/content/36486& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&World Streamer&i class=&icon-external&&&/i&&/a&。
&b&World Streamer&/b&这个我没有非常仔细去看实现细节,整体的思路是按照位置拆分成按照Scene组织的格子(Grid),然后根据距离做逐步加载,因为要区分地表、特物体和细节物体等不同粒度,提供了分层拆分的功能。提供一篇找到的博客供需要的同学参考:&a href=&/?target=http%3A//blog.csdn.net/wanghaodiablo/article/details/& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&《Unity 场景分页插件 World Streamer 支持无限大地图的解决方案》&i class=&icon-external&&&/i&&/a&。
&/p&&img src=&/v2-8dccfa8ae278c9d39a1a78d121e99130_b.png& data-rawwidth=&1068& data-rawheight=&1067& class=&origin_image zh-lightbox-thumb& width=&1068& data-original=&/v2-8dccfa8ae278c9d39a1a78d121e99130_r.png&&&p&&br&&/p&&p&WorldStreamer拆分后的场景列表&/p&&p&&br&&/p&&p&&b&SECTR COMPLETE&/b&这个插件是我购买并学习了一段时间的一个插件,原因之一是这个插件是被Unity官方推荐过的,而且FireWatch游戏就是用的这个插件,可以参考GDC的演讲&a href=&/?target=http%3A///play/1023191/Making-the-World-of& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&Making the World of Firewatch&i class=&icon-external&&&/i&&/a&。这个COMPLETE是一个售价100美元的插件集合,它包括CORE、STREAM、VIS和AUDIO等几个部分。VIS做动态的遮挡剔除,动态的大世界主要是STREAM部分。
SECTR STREAM通过自动或者手动创建Sector的方式,用包围盒来决定场景中的哪些物体被放置到哪一个Sector中,然后将这些Sector导出为名称对于的分块场景,加载的时候在角色身上添加一个Loader,通过Loader与留在场景中的Sector碰撞盒进行交互来判断哪些Sector对应的场景组件需要被加载。Loader的类型不同加载方式也不同,比如包括Neighbor Loader、Region Loader、Trigger Loader甚至DIY Loader等。
&/p&&img src=&/v2-3f9c317c3a54cdf5e5c78fe1c3da2770_b.png& data-rawwidth=&1447& data-rawheight=&817& class=&origin_image zh-lightbox-thumb& width=&1447& data-original=&/v2-3f9c317c3a54cdf5e5c78fe1c3da2770_r.png&&&p&&br&&/p&&p&SECTR STREAM的拆分界面&/p&&p&由于最终我们并没有使用这两个插件,因此在此不进行更详细的描述,有兴趣的朋友可以自己买来玩一下。&/p&&h2&1.3 UWA技术分享&/h2&&p&在2016年11月份的时候,UWA组织了一场在上海的分享,其中有一个就是张强的《大规模场景的资源拆分和动态加载》,很兴奋地去听了一下,主要是2.5D视角下基于Terrian的实现方案,因为当时我们的需求方案还是倾向于3D自由视角,所以听的时候感觉帮助没有那么大。当时在回来之后的博客笔记里说——&/p&&blockquote&“我个人觉得这部分的一个问题是整个工程是基于一个Demo性质的实现,而非正式的项目,因为时间关系没有在后面进行深入的交流,因此也不清楚目前的实现是否在正式的项目中应用了。”&/blockquote&&p&在现在来看,其实张强的分享内容中有很多是我在后面设计和实现的过程中没有去考虑的部分,比如资源打包策略的制定等,这些问题都是在实际项目中需要去注意的内容。而当时我想了解但这个分享不包含的内容是大世界的制作和维护流程的部分,鉴于主题是《大规模场景的资源拆分和动态加载》,其实针对这一主题已经很有实用性了。&b&这里也借这篇文章的机会,给UWA的张强同学做一个小小的道歉,当时的评价过于草率,非常抱歉。&/b&
如果想要了解这次分享的同学可以去UWA的官网搜索,这里给一个我自己备份的PPT&a href=&/?target=http%3A//o9hm1ti4o./%25E5%25A4%25A7%25E8%25A7%%25A8%25A1%25E5%259C%25BA%25E6%2599%25AF%25E7%259A%%25B5%%25BA%%258B%%E5%E5%258A%25A8%25E6%E5%258A%25A0%25E8%25BD%25BD-%25E4%25B8%258A%25E6%25B5%25B.pdf& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&下载地址&i class=&icon-external&&&/i&&/a&。&/p&&h2&1.4 调研结果&/h2&&p&通过对这两个插件和UWA分享沙龙的学习,基本确定了在Unity中制作动态大世界的基本思路:美术制作完整场景 -& 自动/手动拆分场景 -& 运行时根据规则自动加载角色周围的部分。
&/p&&p&&br&&/p&&img src=&/v2-dd3ae6f4c355ea0ea5f081d411bc8a26_b.png& data-rawwidth=&1343& data-rawheight=&255& class=&origin_image zh-lightbox-thumb& width=&1343& data-original=&/v2-dd3ae6f4c355ea0ea5f081d411bc8a26_r.png&&&p&&br&&/p&&p&制作动态大世界的基本思路&/p&&p&与此同时,也了解到几个需要去注意的技术点:&/p&&ol&&li&光照贴图,整个大世界使用一张Lightmap显然不合适,SECTR STREM是支持自动拆分的,也有一些插件支持光照贴图的拆分,这个貌似不用太担心;&/li&&li&寻路信息,Unity 5.6之前的版本是不支持动态的Nav Mesh的,只能跟随场景加载/卸载。既然没有办法更改,暂时看起来也没有担心的必要;&/li&&li&光照探针,这个也是不支持动态加载的,但是初步看起来手游项目用这个的可能性不太大,暂时不去担心。&/li&&/ol&&p&&br&&/p&&h2&2. Demo实现&/h2&&p&在进行一系列的技术调研之后,也迎来了一大波的需求调整。通过美术工作量、项目时间限制和技术难度评估的综合考量之后,我们终于妥协为了2.5D视角,但是镜头高度会相对普通的2.5D要高不少。2.5D视角的确定让整个功能实现的技术难度降低了很大一部分,也确定了自己来开发动态加载核心功能的技术方向。经历一些纠结和试验之后,最终选择最为通用的&b&基于九宫格的动态加载方案&/b&,主要原因包括:&/p&&ol&&li&现成的插件虽然功能强大但是有各种问题,比如SECTR STREM需要对每一个Sector创建一个GameObject和对应的碰撞盒,在手游上担心有比较大的消耗;拆分过程虽然很灵活但是需要美术进行较多的操作,当拆分完毕之后,如果想再进行编辑,需要再做一遍完全的拆分过程才行;&/li&&li&九宫格的方案技术难度比较低,需要定制的内容也相对较少,可以按照我们自己的美术制作流程来进行定制;&/li&&li&最后,自己造轮子不也是程序员的乐趣之一,不是么?(手动微笑)&/li&&/ol&&p&九宫格的方案其实很简单也很好理解,将完整的大世界按照固定大小拆分成小的Chunk,然后运行时根据角色位置和约定好的Chunk尺寸判断角色所在的Chunk和周围八块的索引,加载对应的Chunk文件即可。当角色移动的时候,判断是否需要加载新的Chunk和卸载老的Chunk文件。在这个阶段美术还在做效果预研,所以自己先制作一个Demo来模拟整个功能。首先还是先设想了一下整个大世界的制作流程,大致如下:&/p&&ol&&li&美术完成整个大世界场景的制作;&/li&&li&使用自动拆分工具,根据设置好的分块大小,将场景中的每一个物体根据位置坐标拆分到对应的Chunk中;&/li&&li&将Chunk自动保存成规定路径下对应名称的场景文件(.scene),删除拆分过的Chunk文件剩下的作为BaseWorld.scene文件;&/li&&li&运行时首先加载BaseWorld,然后在角色身上绑定一个DynamicLoader,根据角色位置自动按照Additive的方式加载周围九块Chunk对应的场景。&/li&&/ol&&p&在这个工作流程下,美术制作的永远都是完整的大世界场景,约定好分块大小,只需要使用自动拆分工具就可以更新拆分后的场景文件。这里关于寻路信息和光照贴图信息的处理如下:&/p&&ol&&li&美术烘焙场景的单位为一个单独Chunk的场景文件,即在确定本块场景不修改之后再进行烘焙工作,如果还需要修改,就需要重新烘焙,烘焙过的场景加入到自动导出工具的覆盖黑名单中,完整重新导出时不再进行覆盖;&/li&&li&Navmesh和一些跨Chunk的全局物体(比如大面积的水域)暂时放置在BaseWorld中,运行时BaseWorld.scene为active的场景;&/li&&li&所有的光照、雾效果等信息一律放置在BaseWorld.scene中。&/li&&/ol&&p&&br&&/p&&p&按照这个工作流程,需要制作的工具包括场景自动拆分功能和自动加载组件两个部分。&/p&&h2&2.1 场景自动拆分实现&/h2&&p&场景自动拆分的功能比较简单,最终也仅仅实现了如下截图中的几个功能,最为核心的也就是“自动拆分场景”和“导出拆分后的物体”两个了。
&/p&&p&&br&&/p&&img src=&/v2-0fd8d18ace807e5884be_b.png& data-rawwidth=&395& data-rawheight=&318& class=&content_image& width=&395&&&p&自动拆分工具界面截图&/p&&p&代码也很简单,首先遍历所有需要处理的GameObject,我们只需要处理&b&包含MeshRender组件和Terrain组件的物体&/b&即可。这里给美术添加了一个限制,有MeshRender的GameObject的孩子节点不再进行拆分,因为为了保持原有的层次结构,如果一个GameObject的孩子被分配到了不同Chunk,那个这个作为父节点的GameObject会被完全拷贝到多个Chunk中。那么,如果父节点包含了比如MeshRender的组件,就会导致较多的渲染消耗,也并不合理,因此只要包含MeshRender这样的组件就会连着其孩子节点完整地放置到一个Chunk中。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&// 首先使用遍历出所有需要处理的GameObject
GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
List&GameObject& objsToProcess = new List&GameObject&();
foreach (GameObject root in roots)
TraverseHierarchy(root.transform, new ActionTransform((Transform obj) =&
//如果有MeshRender或者Terrain组件,并且是静态物体,则认为是一个要处理的叶子节点,不再处理其孩子节点了
GameObject tempObj = obj.gameO
if (tempObj.activeSelf == false)
if ((tempObj.GetComponent&MeshRenderer&() || tempObj.GetComponent&Terrain&()) && (!onlyStatic || (onlyStatic && tempObj.isStatic)))
objsToProcess.Add(tempObj);
}), false);
&/code&&/pre&&/div&&p&找到所有需要拆分的物体之后,直接按照位置进行拆分即可。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&// 逐个处理可能需要移动的GameObject
for (int i = 0; i & objsToProcess.C ++i)
EditorUtility.DisplayProgressBar(progressTitle, &Processing & + objsToProcess[i].name, (float)i / (float)objsToProcess.Count);
ClassifyGameObject(objsToProcess[i], width, height);
/// &summary&
/// 对一个GameObject按照位置进行分类,放置到对应的根节点下面。
/// &/summary&
/// &param name=&obj&&&/param&
static void ClassifyGameObject(GameObject obj, float width, float height)
Vector3 pos = obj.transform.
// chunk的索引
int targetChunkX = (int)(pos.x / width) + 1;
int targetChunkZ = (int)(pos.z / height) + 1;
string chunkName = ChunkRootNamePrefix + string.Format(&{0}_{1}&, targetChunkX, targetChunkZ);
GameObject chunkRoot = GameObject.Find(chunkName) ;
if (chunkRoot == null)
chunkRoot = new GameObject(chunkName);
//复制层次关系到Chunk的节点下面
GameObject tempObj =
List&GameObject& objs2Copy = new List&GameObject&();
while(tempObj.transform.parent)
objs2Copy.Add(tempObj.transform.parent.gameObject);
tempObj = tempObj.transform.parent.gameO
tempObj = chunkR
for (int i = objs2Copy.Count - 1; i & -1; --i)
GameObject targetObj = objs2Copy[i];
// 对于符合Chunk命名规则的父节点不进行拷贝过程。
if (targetObj.name.StartsWith(ChunkRootNamePrefix))
Transform parent = tempObj.transform.FindChild(targetObj.name);
if (parent == null)
parent = new GameObject(targetObj.name).
CopyComponents(targetObj, parent.gameObject);
parent.parent = tempObj.
targetObj = parent.gameO
tempObj = parent.gameO
Transform tempParent = obj.transform.
obj.transform.parent = tempObj.
// 移动完毕之后发现父节点没有孩子节点的情况下,向上遍历将无用节点删除。
while (tempParent != null && tempParent.childCount == 0)
Transform temp = tempParent.
EngineUtils.Destroy(tempParent.gameObject);
tempParent =
&/code&&/pre&&/div&&p&拆分完毕之后的场景如下图所示。这一步需要美术进行一个大致的检查,保证拆分结果的正确性。
&/p&&img src=&/v2-dd50d073bc5c05e94a0f6_b.png& data-rawwidth=&505& data-rawheight=&746& class=&origin_image zh-lightbox-thumb& width=&505& data-original=&/v2-dd50d073bc5c05e94a0f6_r.png&&&p&经过拆分的场景结构&/p&&p&然后将拆分后的组件保存到对应的Scene文件中,这里为了避免遗漏拷贝场景参数,采用了比较trick的方式——生成每一个Chunk文件时,将完整场景文件进行一次拷贝,然后删除掉不需要的GameObject,即比如要生成_worldchunk6_8.scene,将整个场景文件完整拷贝,然后删除掉除了_worldchunk6_8这个GameObject之外的所有物件。这样就做到了所有场景参数的一致性,但是代价是花费的时间稍微久一点。
这样做的意义在于,比如Ambient Source相关的参数会影响烘焙结果,如果稍微有些不同,会导致最终烘焙出来的Chunk之间存在明显的接缝问题。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&static void ExportChunksToScenes()
EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo();
GameObject[] roots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
List&string& rootsNamesToExport = new List&string&();
foreach (GameObject root in roots)
if (root.name.StartsWith(ChunkRootNamePrefix))
rootsNamesToExport.Add(root.name);
if (rootsNamesToExport.Count == 0)
EditorUtility.DisplayDialog(&Export Error&, &不存在符合导出要求的分组,请先使用自动拆分功能!&, &确定&);
if (!EditorUtility.DisplayDialog(&Info&, &导出场景将会删除之前已经导出过的场景Chunk目录,是否继续?&, &继续&, &取消&))
string sceneD
string sceneN
string exportDir = MakeExportFolder(&Chunks&, true, out sceneDir, out sceneName);
if (string.IsNullOrEmpty(exportDir))
EditorUtility.DisplayDialog(&Export Error&, &Could not create Chunks folder. Aborting Export.&, &Ok&);
string progressTitle = &导出拆分后的场景&;
EditorUtility.DisplayProgressBar(progressTitle, &Preparing&, 0);
string originalScenePath = CurrentScene();
int counter = -1;
foreach (string rootName in rootsNamesToExport)
counter += 1;
EditorUtility.DisplayProgressBar(progressTitle, &Processing & + rootName, (float)counter / (float)rootsNamesToExport.Count);
string chunkScenePath = exportDir + &/& + rootName + &.unity&;
AssetDatabase.CopyAsset(originalScenePath, chunkScenePath);
EditorSceneManager.OpenScene(chunkScenePath, OpenSceneMode.Single);
GameObject[] tempRoots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
foreach (GameObject r in tempRoots)
if (r.name != rootName)
EngineUtils.Destroy(r);
EditorSceneManager.SaveScene(EditorSceneManager.GetActiveScene());
AssetDatabase.Refresh();
// 拷贝出一个删除了Chunk物体的Base场景
string baseScenePath = sceneDir + &/& + &baseworld.unity&;
AssetDatabase.DeleteAsset(baseScenePath);
AssetDatabase.CopyAsset(originalScenePath, baseScenePath);
EditorSceneManager.OpenScene(baseScenePath, OpenSceneMode.Single);
GameObject[] chunkRoots = EditorSceneManager.GetActiveScene().GetRootGameObjects();
foreach (GameObject r in chunkRoots)
if (rootsNamesToExport.Contains(r.name))
EngineUtils.Destroy(r);
EditorSceneManager.SaveScene(EditorSceneManager.GetActiveScene());
AssetDatabase.Refresh();
// Cleanup
EditorUtility.ClearProgressBar();
&/code&&/pre&&/div&&p&拆分后的Chunk场景列表如下:
&/p&&img src=&/v2-ee134bbd9e040b54b3b0115b_b.png& data-rawwidth=&501& data-rawheight=&883& class=&origin_image zh-lightbox-thumb& width=&501& data-original=&/v2-ee134bbd9e040b54b3b0115b_r.png&&&p&&br&&/p&&p&拆分后的Chunk场景列表&/p&&h2&2.2 动态加载组件&/h2&&p&动态加载的过程也并不复杂,因为涉及到游戏内的代码,这里就不放源码了,整个算下也也就不到500行,逻辑也很简单。绑定一个Transform,每帧update检查Transform的位置所对应的Chunk的索引是否有变化,如果有则计算出需要卸载的Chunk和需要加载的Chunk卸载和加载操作。
在Demo阶段,选择使用Scene来作为Chunk的存储单元的原因主要有:&/p&&ol&&li&看到的两款插件都是基于Scene来做的,而且Unity从5.0开始就原生支持Multi-Scenes的场景加载方式,因此预想问题应该不大;&/li&&li&考虑到美术进行烘焙的最小单元是Scene,使用Scene作为最小单元可以“偷懒”不用去手动管理每一个Chunk的Lightmap数据,对于多个Scene同时进行烘焙的方案也是进行过实验,证明具有可行性的。&/li&&/ol&&p&这样,我就基于设想中的美术制作流程实现了第一版本的动态加载Demo。&/p&&h2&2.3 问题总结&/h2&&p&除了一些代码实现上的bug之外,这里值得记录的几个问题有:
&b&1) Static Batching导致的顿卡&/b&
在电脑上运行的时候已经可以感受到明显的卡顿,打开Profiler看了下发现是由于Static Batching导致的:
&/p&&img src=&/v2-32c3d8c2b4fed4e55ed2828865bec780_b.png& data-rawwidth=&1444& data-rawheight=&989& class=&origin_image zh-lightbox-thumb& width=&1444& data-original=&/v2-32c3d8c2b4fed4e55ed2828865bec780_r.png&&&p&&br&&/p&&p&Static Batching导致的加载顿卡&/p&&p&解决方法很简单,在测试项目中关闭了工程的Static Batching,而在正式工程中,场景组件不再勾选Static Batching选项,就可以避免Chunk的场景加载时这段CPU消耗的峰值。当然代价也是无法进行batching,draw call的消耗比较高。&/p&&p&&b&2) NavMesh分块测试&/b&
因为不死心,所以特意做了一下NavMesh分场景bake之后加载的效果,果然是不行的——在其中一块NavMesh上无法移动到另外一个Chunk的NavMesh上:
&/p&&p&&br&&/p&&img src=&/v2-bcda214cffbd7_b.png& data-rawwidth=&1447& data-rawheight=&728& class=&origin_image zh-lightbox-thumb& width=&1447& data-original=&/v2-bcda214cffbd7_r.png&&&p&多块NevMesh的移动试验&/p&&p&&b&3) 场景物件导入到Unity的时候中心点需要在原点&/b&
这个比较好理解,按照位置把物体划分到Chunk的时候是按照世界坐标来划分的,如果物件的中心点位置并不在中心点的话,可能会造成偏差,这也是自动拆分工具执行完毕之后需要美术进行检查的一部分工作之一。解决方法一方面是要告知美术场景物件导入到Unity的时候中心点需要在原点这个规则,另外一方面是在代码中使用包围盒的中心点而非世界坐标的位置来作为划分区域,这样可能错误的概率更小一点。当然,如果物件的形状太过奇怪,包围盒的方式也可能会有问题。&/p&&p&&b&4) 所有场景的Lightmap模式必须一致&/b&
在测试应用烘焙效果的问题的时候,出现过Lightmap失效的情况,检查后发现是因为部分场景使用了默认的Directional模式,部分场景使用了Non-Directional的模式导致的。&/p&&p&在Demo完成之后,进行打包和手机上的简单测试,基本满足了设想的要求。这段时间场景美术也进入了美术效果和制作方案的频繁更改阶段,这块工作也就搁置了很长一段时间。&/p&&h2&3. 正式版本实现&/h2&&p&最初的Demo版本没有去考虑的一个内容是像地表这样的大块Mesh是如何拆分的,原因也主要是当时美术的制作方案是按照六边形作为一个单元,每一个单元都不会很大,自然可以正确地被分割到不同的Chunk中。而后面改为T4M刷地表贴图来表现更多细节的制作方案之后,就有了可能需要让美术手动拆分或者程序来做Mesh分割的需求。想来也不是很难,按照顶点的位置来做判断,确定要分割的边界之后把这些边界上的顶点复制多份分别放到对应的Chunk下似乎也就可以了。但当这块预研工作刚刚开始推进的时候,美术又改了主意,为了表现地面的高低起伏,想用Terrain的方式来进行地表的制作。&/p&&p&技术上仍然不算什么难题,Unity有丰富的插件来做这种事情,而且相比于Unity5之后就不再维护的T4M,似乎官方的Terrain更好用也更稳定一点点。Terrain转Mesh的插件有不少,我们使用的是&a href=&/?target=https%3A//www./en/%23%21/content/47276& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&Terrain To Mesh&i class=&icon-external&&&/i&&/a&,后文统一简称T2M。经过思考和讨论,权衡一些问题之后,最后制定了如下图所示的工作流程。
&/p&&img src=&/v2-f197cdcaceea_b.png& data-rawwidth=&1471& data-rawheight=&867& class=&origin_image zh-lightbox-thumb& width=&1471& data-original=&/v2-f197cdcaceea_r.png&&&p&&br&&/p&&p&基于Terrain和T2M的工作流程图&/p&&p&我们分几步来说明一下这个流程图的几个关键步骤的设置原因和具体的制作方式。&/p&&h2&3.1 Chunk大小的确定&/h2&&p&其实在这个流程开始之前,第一件要做的事情是确定Chunk的大小尺寸。在之前Demo中构想的流程里,因为视野、美术风格都未确定,为了能够方便地兼容Chunk尺寸更改的情况,所有的组件都是在美术进行了Chunk大小的设置之后自动拆分的。这样如果中途要更改Chunk大小,其实是一件工作量不太大的事情,只是烘焙过程要重新进行。而基于Terrain的方案,虽然T2M也有自动拆分的功能,但是手游上处于性能和省电的要求,我们规定——&/p&&blockquote&&b&每一个地表所能使用的贴图层数不能超过4张,尽量保证3张的时候也是可看的,低配下程序保留了强制切换为3张的权利&/b&。&/blockquote&&p&于是美术就要求可以更加灵活地使用和分配这几层贴图。由于我们大世界会有不同的地貌和气候风格,风格之间还要有过度的效果,因此经过商讨,美术可以自由分配贴图的最小单位为一个Chunk。这样就不太好把很大一块区域作为一整个Terrain来制作,因此我们使用了一个Chunk就是一个Terrain的方案,让美术可以自由分配这个Chunk下的四张Layer贴图的内容。(这里和美术讨论的纠结过程就不详细描述了,这些琐碎的细节可能只有真正使用这种制作方案的人才能有更深的体会。)
那么,首要的问题就是确定Chunk的大小,而这个一旦确定,制作工作开展之后,再修改的代价就非常大了。好在这时候镜头的参数早已确定,于是作为&b&灵魂画手&/b&的我就经过“现场踩点”等精妙操作,画了这样一张图。。。
&/p&&img src=&/v2-ecaad711ba4f6df0615c25_b.png& data-rawwidth=&283& data-rawheight=&193& class=&content_image& width=&283&&&p&&br&&/p&&p&考虑到我们的地表还有高低起伏,再加上为了兼容策划后面一些变动的可能,我们把一个Chunk大小定义为&b&70m * 70m&/b&的大小。由于我们的美术风格还比较特殊,偏抽象沙盘的风格,因此面数和Draw Call方面相比于3D的视角或者更加写实的2.5D具有更高的可压榨性,因此这种比较远的视野范围在性能方面目前还可以接受。&/p&&h2&3.2 为美术自动生成Chunk&/h2&&p&这个时候的工作推进其实已经比较顺利了,因为整个大世界的功能需求已经确定,尺寸也不会很大,估计在&b&1000m * 1000m&/b&左右的大小。Terrain在Unity中的拷贝也有点烦,因为涉及到TerrainData的拷贝,而且这货会默认创建在Assets的根目录下,让美术去手动创建100多个Terrain对象,人力消耗暂且不说,光是想想位置摆放精准度、参数设定、资源命名和存放等问题,就觉得可能有很多屁股要擦。。。&/p&&p&于是半个小时,写一段简单代码,来自动创建:&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&private static void onInitTerrain(int xNum, int yNum, float xWidth, float yWidth)
string folderPath = &Assets/Res/Environments/Worlds/Terrains/&;
if (!System.IO.Directory.Exists(folderPath))
// Create new folder. Use substring b/c Unity dislikes the trailing /
AssetDatabase.CreateFolder(&Assets/Res/Environments/Worlds&, &Terrains&);
GameObject parent = new GameObject(&WorldTerrains&);
parent.transform.position = new Vector3(0, 0, 0);
for (int x = 1; x &= xN x++)
for (int y = 1; y &= yN y++)
TerrainData terrainData = new TerrainData();
string name = &WorldTerrain& + x + &_& +
terrainData.size = new Vector3(xWidth/16f, 600, yWidth / 16f);
terrainData.baseMapResolution = 1024;
terrainData.heightmapResolution = 513;
terrainData.SetDetailResolution(1024, 16);
// 可以在此设置默认贴图
//SplatPrototype[] terrainTexture = new SplatPrototype[3];
//terrainTexture[0] = new SplatPrototype();
//terrainTexture[0].texture = (Texture2D)Resources.Load(&Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4&);
//terrainTexture[1] = new SplatPrototype();
//terrainTexture[1].texture = (Texture2D)Resources.Load(&Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4&);
//terrainTexture[2] = new SplatPrototype();
//terrainTexture[2].texture = (Texture2D)Resources.Load(&Res/Environments/Worlds/World/terrain/caodi/world_taohuayuan_land_01.fbm/4&);
//terrainData.splatPrototypes = terrainT
terrainData.name =
GameObject terrain = (GameObject)Terrain.CreateTerrainGameObject(terrainData);
terrain.name =
terrain.transform.parent = parent.
terrain.transform.position = new Vector3(xWidth * (x - 1), 0, yWidth * (y - 1));
AssetDatabase.CreateAsset(terrainData, folderPath + name + &.asset&);
&/code&&/pre&&/div&&p&我只能说,虽然那天白天就制作方案各种讨论纠结,但是写完这段代码之后,美术更加爱我了呢~~(可惜我们美术中没有妹子=_=)&/p&&h2&3.3 场景细化和修改&/h2&&p&在这个工作流程中,我专门用浅绿色部分画出了一次性的部分,即地形生成之后,会进行整个大世界的地形和白模制作。一旦用自动拆分工具拆分出Chunk文件,这一过程在之后将不再重复进行。一方面因为这一过程代价很大,另外一方面后面基于Chunk和Multi-Scenes的方式也可以对地形等进行比较方便的修改。&/p&&p&美术最早想在T2M转换之后的mesh上应用T4M来进行地表的修改,这个方案被我否决了。因为首先两种插件的Shader是不同的,需要时间整合(虽然到写这篇文章时,我们的同事已经进行了部分整合),其次如果再引入T4M的结点,使得这个工作流变得太过复杂——虽然&b&看上去似乎灵活&/b&了,转为Mesh之后仍然可以修改地表贴图,但这个修改对于Terrain层是&b&不可逆&/b&的,如果需要再在Terrain上进行修改的时候,那些在T4M节点做的修改就会被冲掉。&/p&&blockquote&因此,在这套工作流程中,美术进行频繁修改、细化、迭代的对象,是&b&基于Terrain的地表和场景组件&/b&,转换后的Mesh地表不会进行大的改动以保证其修改源的唯一性。&/blockquote&&p&为了处理同时编辑多个Terrain的问题,比如要保证地表的连续性、贴图细节的连续性,我们引入了&a href=&/?target=https%3A//www./en/%23%21/content/44037& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&Multiple Terrain Brush&i class=&icon-external&&&/i&&/a&这个插件到工作流程中,结合Unity原生的Multi-Scenes同时编辑的功能,可以很好地处理多个Chunk需要同时编辑的需求。&b&同时提醒一下,注意控制相邻Chunk相同贴图的Tilling参数的一致性,来避免一些边界接缝问题。&/b&&/p&&p&基于Demo制作的工具,在正式的制作流程中虽然引入了T2M插件,但是之前的功能在进行较小的修改之后也都可以正常使用。而正式的版本花费精力最多的部分还是在流程的梳理和讨论,确定每一步骤的编辑对象和产出结果,并验证整个工作流的证确性。当然,正确性得到保证之后,性能上的优化也就被推到最前面了。&/p&&h2&4. 修改Chunk的存储方式&/h2&&p&在实现完成正式版本的工作流之后,使用正式的美术资源在设备上运行之后发现了一个比较严重的问题——在移动设备上,加载Chunk的过程中,会有比较明显的顿卡感。&/p&&p&通过Profiler工具进行排查,首先看到的问题之一是Shader.Parse()函数的消耗,在每一个Chunk的加载时占用到了200ms以上的时间,检查了一下是由于美术在部分组件上错误使用了Diffuse等系统材质,并且每一个Chunk场景中都保留了默认的天空盒,以及在FBX上的Default-Material中引用了Standard Shader,这些都导致在设备上有Shader编译的过程花费较多的时间。在解决完这一问题之后,发现依然有顿卡的问题,尤其当角色在Chunk边界来回行走的时候,由于初期没有做缓存,帧率的降低非常明显。下图是在设备上截取的顿卡点的时间消耗数据。
&/p&&img src=&/v2-aeb0ac9bd410ddc1640b_b.png& data-rawwidth=&1432& data-rawheight=&467& class=&origin_image zh-lightbox-thumb& width=&1432& data-original=&/v2-aeb0ac9bd410ddc1640b_r.png&&&p&&br&&/p&&p&Chunk场景加载时顿卡Profiler截图&/p&&p&经过一些思考和方案对比,我作出了将Chunk的存储方式由Scene修改为Prefab的决定,原因主要有两个:&/p&&ol&&li&之前相信插件使用Scene的方式来做加载,应该是有比较好效果的,然而调研的两个插件虽然都支持mobile,但貌似并没有找到实际在移动设备上发布的项目,再加上询问了一些在手游做了场景动态加载的项目,都是使用了Prefab的方案,因此觉得Prefab的方案在手游上的坑应该更少一些;&/li&&li&Scene的加载、卸载过程不如Prefab具有可控性,针对Scene对象做缓存也没有Prefab方便。&/li&&/ol&&p&这其实是工作量还比较最大的一次改动了,主要原因是需要针对Lightmap进行存储。这里使用的也是Unity中动态更改光照贴图设置的做法,即在每一个进行了烘焙的GameObject上添加一个Component用于存储它的lightmapIndex和lightmapScaleOffset,核心的代码参考:&a href=&/?target=http%3A////unity/199/& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&《Unity5.x场景优化之动态设置光照贴图lightmap》&i class=&icon-external&&&/i&&/a&。具体实现细节就不说了,直接可以参考文章中的源码,这里只说明下将这一方案用于动态加载大世界的时候需要进行的修改和遇到的问题。&/p&&h2&4.1 全局光照贴图索引的建立&/h2&&p&在通常的动态切换场景光照贴图的实现方案中,只需要在更换的瞬间遍历所有的需要更改贴图的组件进行更改即可,光照贴图的索引在一套光照贴图内也是不变的。但是动态加载Prefab的时候就有一个很严重的问题。&/p&&p&美术是按照单独的场景进行烘焙的,在每个场景内都有索引从0开始的Lightmap贴图,而如果想要每一个Prefab的烘焙信息都是正确的,在运行时需要所有Lightmap贴图的索引具有唯一性,即需要提前为它们分配一个整个大世界场景的全局索引。&/p&&p&我选择使用一个ScriptableObject对象来做这件事情,把它纳入到自动保存光照信息功能中。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&[CreateAssetMenu(fileName = &WorldLightmapProfile.asset&, menuName = &Custom/DynamicLightMapProfile&)]
public class DynamicWorldLightmapProfile : ScriptableObject
public List&string& GlobalL
/// &summary&
/// 寻找第一个为空的位置索引,作为全局光照贴图的索引值
/// &/summary&
public int AddGloblaLightmap(string lightmapPath)
if (GlobalLightmaps.Contains(lightmapPath))
return -1;
for (int i = 0; i & GlobalLightmaps.C ++i)
if (GlobalLightmaps[i] == &&)
GlobalLightmaps[i] = lightmapP
GlobalLightmaps.Add(lightmapPath);
return GlobalLightmaps.Count - 1;
public int GetGlobalIndex(string linghtmapPath, bool autoAdd=false)
int idx = GlobalLightmaps.IndexOf(linghtmapPath);
if (idx & -1)
else if (autoAdd)
return AddGloblaLightmap(linghtmapPath);
return -1;
/// &summary&
/// 方便管理大世界对应的光照贴图全局索引文件的辅助类
/// &/summary&
public class DynamicWorldLMProfileHelper
// 存储全局的光照索引文件路径
// Todo 这样设置会导致全局只能使用这一份,目前还不打算兼容多个动态场景,暂时先这样。。。
private static string _worldLightmapProfile = &Assets/Res/Environments/Worlds/WorldLightmapProfile.asset&;
private static DynamicWorldLightmapProfile _profile =
public static DynamicWorldLightmapProfile getProfile()
if (_profile == null)
DynamicWorldLightmapProfile profile = AssetDatabase.LoadAssetAtPath(_worldLightmapProfile, typeof(DynamicWorldLightmapProfile)) as DynamicWorldLightmapP
if (profile == null)
Debug.LogWarning(&没有默认的大世界lightmap信息的配置文件,自动创建!&);
profile = ScriptableObject.CreateInstance&DynamicWorldLightmapProfile&();
AssetDatabase.CreateAsset(profile, _worldLightmapProfile);
AssetDatabase.SaveAssets();
_profile =
public static void ClearProfile()
_profile =
public static void SaveProfile()
if (_profile)
EditorUtility.SetDirty(_profile);
AssetDatabase.SaveAssets();
&/code&&/pre&&/div&&p&这个ScriptableObject对象中只有一个数组,下标即全局的光照贴图索引,值为光照贴图的路径。选择exr文件的完整路径是为了兼容Lightmap共用或者一个场景中存在多张lightmap的情况。(目前推荐美术一个Chunk场景只使用一张Lightmap,因此这种情况并不多见,但程序结构上是完整支持的。)一个简单的示例截图如下:
&/p&&img src=&/v2-3ab7a27abdea2ce351dde_b.png& data-rawwidth=&1450& data-rawheight=&472& class=&origin_image zh-lightbox-thumb& width=&1450& data-original=&/v2-3ab7a27abdea2ce351dde_r.png&&&p&&br&&/p&&p&全局光照贴图数组&/p&&p&在每一个Chunk对应的Prefab文件中,只有一个用于控制光照贴图加载和删除的ChunkLightMapSetting对象,它里面除了存储直接的光照贴图文件之外,还存储了局部光照贴图索引和全局光照贴图索引的对应关系。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&public Texture2D[] lightmapLight, lightmapD
public LightmapsM
public int[] globalI
// 存储局部光照贴图索引和全局光照贴图索引的对应关系
&/code&&/pre&&/div&&p&在每一个带有烘焙信息的GameObject身上的RendererLightMapSetting组件中存储的lightmapIndex,是全局的光照信息。这样只需要在ChunkLightMapSetting加载和销毁的时候重新设置当前LightmapSettings的属性即可。注意由于其lightmaps属性为一个数组,因此需要将其扩展到&b&当前存在的全局索引的最大值&/b&,运行时这个数组中间会有很多贴图是空着的。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&// 扩充lightmap的数量到最大索引值
int maxLength = Mathf.Max(globalIndex) + 1;
if (LightmapSettings.lightmaps.Length & maxLength)
lightmaps = new LightmapData[maxLength];
for (int i = 0; i & maxL ++i)
lightmaps[i] = (i & LightmapSettings.lightmaps.Length && LightmapSettings.lightmaps[i] != null) ? LightmapSettings.lightmaps[i] : new LightmapData();
lightmaps = LightmapSettings.
&/code&&/pre&&/div&&h2&4.2 LightmapSettings设置的几个小坑&/h2&&p&在使用LightmapSettings的时候感觉有几个跟预期不太一样的小坑。&/p&&ol&&li&&b&LightmapSettings的lightmaps属性直接赋值是无效的&/b&,必须new一个新的对象数组或者将其赋值给一个临时数组对象,修改完毕之后再赋值回去才可以。不知道是我使用的姿势不对还是什么愿意你,另外个人觉得这里会有内存分配的问题,但是目前也没有找到更好的解决方法。&/li&&li&&b&当第一张lightmap为空的时候,整个场景会变暗很多。&/b&这个问题一开始遇到的时候以为是Lightmap加载的一个bug,反复观察了一会才发现当index为0的那个Prefab被卸载了之后,整个场景都变暗了。这个目前依然不知道原因,我们的做法是如果第0张为空的话,则选择一张已经存在的Lightmap贴图赋值给它,注意这个处理要在任何一个Prefab&b&加载或者卸载&/b&时进行。&/li&&/ol&&h2&4.3 改进后的工作流程&/h2&&p&使用Prefab代替Scene来存储Chunk,不但需要把之前已经制作好的Scene转换为Prefab,而且对于整个工作流也增加了一点工作量。改进之后的工作流程如下:
&/p&&img src=&/v2-e3bb270f58c8d1d03ab59a9bc0d29912_b.png& data-rawwidth=&1422& data-rawheight=&985& class=&origin_image zh-lightbox-thumb& width=&1422& data-original=&/v2-e3bb270f58c8d1d03ab59a9bc0d29912_r.png&&&p&&br&&/p&&p&改进后的工作流程&/p&&p&对于美术来说影响不大,只是多了一个要创建Prefab和修改之后应用到Prefab上的过程。这个修改同时带来的一个好处是在场景中可以同时存在最后使用的prefab和之前的Terrain、光照等数据了,避免了需要删除再次修改不方便,或者隐藏掉导致打包的时候带入包体等问题。一个Chunk场景的结构大致如下图所示:
&/p&&img src=&/v2-169ba12bb_b.png& data-rawwidth=&1448& data-rawheight=&622& class=&origin_image zh-lightbox-thumb& width=&1448& data-original=&/v2-169ba12bb_r.png&&&p&&br&&/p&&p&Chunk场景结构&/p&&p&图中红框内的是最后要保存的prefab数据,其他部分可以用于烘焙和修改用,保存在Scene中。需要说明的是,我们的资源打包采用了拆分美术工作目录和游戏运行目录的方式,美术的工作目录为Assets/Res,游戏运行目录为Assets/BundleResource的方式,Res中存放所有的美术资源,但是Prefab、Scene等需要被游戏直接使用的文件存储在BundleResource目录下,打包时是根据BundleResource目录下的所有文件,检索出其引用到的文件进行AssetBundle打包。在这种结构下,Chunk拆分后的Scene文件存放在Res目录下,Terrain数据也存放在Res目录下,只有最后使用的Prefab文件存储在BundleResource目录下。&/p&&blockquote&经过修改为Prefab的迭代,其实使得整个工作流程更加合理。付出的一个小代价是美术在保存光照信息之后,在编辑模式下无法正常预览烘焙的效果,需要运行游戏来预览。但这也可以通过添加ExecuteInEditor相关的逻辑来实现。(感谢钱康来同学提供这个思路~)&/blockquote&&h2&5. Chunk缓存&/h2&&p&使用Prefab代替Scene之后,加载Chunk顿卡的问题得到了一定程度上的缓解,但是仍然存在一点顿卡的感觉。临近测试,这里只做了一个简单的优化就是使用最近使用的Cache来缓存加载过的场景文件。思路非常简单,这里直接给出我们实现的LRUCache的代码。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&public class LRUCache&TKey,TValue&
public delegate void CacheOperation(TValue obj);
const int DEFAULT_CAPACITY = 255;
IDictionary&TKey, TValue& _
LinkedList&TKey& _linkedL
private CacheOperation _putInOper =
//当放入cache中的时候要做的处理
private CacheOperation _takeOutOper = //当从cache中取出来的时候要做的处理
private CacheOperation _discardOper = //当由于容量有限要从cache中丢弃的时候要做的处理
public LRUCache() : this(DEFAULT_CAPACITY) { }
public LRUCache(int capacity)
_capacity = capacity & 0 ? capacity : DEFAULT_CAPACITY;
_dictionary = new Dictionary&TKey, TValue&(_capacity);
_linkedList = new LinkedList&TKey&();
public void Set(TKey key, TValue value)
_dictionary[key] =
_linkedList.Remove(key);
_linkedList.AddFirst(key);
if (_putInOper != null)
_putInOper(value);
if (_linkedList.Count & _capacity)
TKey lastKey = _linkedList.Last.V
if (_discardOper != null)
_discardOper(_dictionary[lastKey]);
_dictionary.Remove(lastKey);
_linkedList.RemoveLast();
public bool TryGet(TKey key, out TValue value)
bool b = _dictionary.TryGetValue(key, out value);
LinkedListNode&TKey& tempNode = _linkedList.Find(key);
_linkedList.Remove(tempNode);
_dictionary.Remove(key);
if (_takeOutOper != null)
_takeOutOper(value);
/// &summary&
/// 设置针对缓存对象存取或者丢弃时的处理函数
/// &/summary&
/// &param name=&putin&&放入时的处理函数&/param&
/// &param name=&takeout&&取出时的处理函数&/param&
/// &param name=&destroy&&丢弃时的处理函数&/param&
public void SetOperation(CacheOperation putin, CacheOperation takeout, CacheOperation discard)
_putInOper =
_takeOutOper =
_discardOper =
public bool ContainsKey(TKey key)
return _dictionary.ContainsKey(key);
public int Count
return _dictionary.C
public int Capacity
if (value & 0 && _capacity != value)
_capacity =
while (_linkedList.Count & _capacity)
TKey keyToRemove = _linkedList.Last.V
if (_dictionary.ContainsKey(keyToRemove))
if (_discardOper != null)
_discardOper(_dictionary[keyToRemove]);
_dictionary.Remove(keyToRemove);
_linkedList.RemoveLast();
public void ClearCache()
if (_discardOper != null)
foreach (TKey key in _dictionary.Keys)
_discardOper(_dictionary[key]);
_linkedList.Clear();
_dictionary.Clear();
public ICollection&TKey& Keys
return _dictionary.K
public ICollection&TValue& Values
return _dictionary.V
&/code&&/pre&&/div&&p&运行的时候开辟了一个大小为5的缓存,因为考虑到会多占用额外内存,并且对于九宫格的方案来说,最坏情况下一次加载和卸载的chunk数量也就是5个。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&private LRUCache&string, LoaderObjectPair& ChunkLRUCache = new LRUCache&string, LoaderObjectPair&(5);
&/code&&/pre&&/div&&h2&6. 总结&/h2&&p&我们不是第一个在手机上实现九宫格的项目,也肯定不是做得最好的那个。我花了大约两天时间完成这篇总结,除了给一些正在做这个功能或者想做这个功能的朋友一些经验上分享之外,也是对自己之前很长一段时间断断续续在做的工作的一个总结。虽然它包含了很多细节,但是因为时间跨度实在有点久,一些讨论和思考过的细节已经遗失在了记忆中。&/p&&p&前面其实已经说了,九宫格的方案原理上非常简单,可能在需求明确的情况下,算上周边工具,开发的代码量也不过几千行,加上调试时间也可能最多2周也能够搞定。但是在整个工作流程的构建上,需要和策划需求对接,和美术制作方法匹配,要考虑的问题就多了很多,再加上可能不断变化的需求,才有了这跨度有半年之久的工作内容。&/p&&p&我想借用两个工业界的概念来表达我在整理这篇文章时的感受——“实验室技术”和“工厂技术”。制作Demo实现的过程和之前学习的两个Unity插件的内容比较像是“实验室技术”,它只需要关注核心的技术实现,提供尽量通用的解决方案,可以做得很快很漂亮;而最终落实到项目中,要整个团队可以一起应用起整个制作流程,这里有很多妥协,有很多一点也不优美的“临时解决方案”,要兼顾更多细节,甚至要考虑工具使用者的感受。后者的过程既无法写论文又不易做分享,甚至有些至关重要的细节只存在于已经熟练应用这一流程的每一个团队成员脑海中。就像富士康公司的流水线,看上去每一个步骤都没有什么技术门槛,但是外人模仿的时候却又发现有各种各样的困难,达不到同样的效果,又或者效率低下。在游戏开发中,这两项技术相辅相成,缺一不可,“实验室技术”负责提供诗和远方的大方向,“工厂技术”负责脚踏实地地把技术应用到团队生产中。而我,作为一个一线开发人员,可能接触和思考更多的是后者,因此这篇文章涉及到的高大上的“实验室技术”很少,更多的是期望把那些开发中琐碎的“工厂技术”的经验尽可能地记录下来,分享出去。&/p&&p&至于未来的工作,大世界动态加载这块还有很多问题要解决,比如第一次加载Chunk时的顿卡,为了降低DrawCall是否需要在加载时进行一次合批过程(目前我们大世界场景的DrawCall在100~150左右)等等。这些问题等到解决后会再补充一篇后续的文章进行记录和分享。&/p&&p&最后,感谢花时间阅读到这里的朋友,希望你可以从这篇文章中有所收获,也希望有经验的朋友给一些改进的建议和分享~感谢!&/p&&p&2017年7月 于杭州滨江海外高层次人才创业基地&/p&
简书地址:0. 前言项目第一次对外技术测试落下帷幕,终于有时间来填大世界动态加载这样一个大坑。
从去年11月份开始,在需求改变、制作方案更改等各种影响下,断断续续地制作维护这个功能,估算下来花费在它上…
&img src=&/50/v2-c9d532dd8feb2b807a9d4f_b.jpg& data-rawwidth=&1751& data-rawheight=&982& class=&origin_image zh-lightbox-thumb& width=&1751& data-original=&/50/v2-c9d532dd8feb2b807a9d4f_r.jpg&&&p&ShadowGun虽然是2011年的移动平台的游戏demo,但是里面的很多优化技巧到现在来看都是很值得学习的,毕竟是上过西瓜大会的。&/p&&p&网上现存的两份代码一个是shadow gun sample level,一个游戏场景,没法玩,只有一个摄像机动画,asset store上已经找不到了,另外一个是Shadowgun: Deadzone GM's Kit,带服务器,可以玩,asset store上还可以下载到。&/p&&p&下面就通过阅读demo中的代码来一起学习下。&/p&&p&&br&&/p&&p&&b&飘动的旗帜&/b&&/p&&p&&br&&/p&&img src=&/50/v2-a629a74fedf8ef1_b.jpg& data-rawwidth=&1052& data-rawheight=&561& class=&origin_image zh-lightbox-thumb& width=&1052& data-original=&/50/v2-a629a74fedf8ef1_r.jpg&&&p&&br&&/p&&p&用的就是GPUGems里面的技术Vegetation Procedural Animation and Shading in Crysis,基本原理就是在mesh的顶点色中刷入权重,利用GPU顶点动画来模拟布料被风吹的效果。&/p&&p&在maya里看下mash的顶点色&/p&&p&&br&&/p&&img src=&/50/v2-e5b5a6cbdf8d9dd576c4_b.jpg& data-rawwidth=&1117& data-rawheight=&577& class=&origin_image zh-lightbox-thumb& width=&1117& data-original=&/50/v2-e5b5a6cbdf8d9dd576c4_r.jpg&&&p&&br&&/p&&p&Shader里面&/p&&p&输入的参数&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&Properties {
_MainTex (&Base (RGB) Gloss (A)&, 2D) = &white& {}
//刮风的方向(世界坐标系下)
_Wind(&Wind params&,Vector) = (1,1,1,1)
//风的频率
_WindEdgeFlutter(&Wind edge fultter factor&, float) = 0.5
//风的频率的缩放
_WindEdgeFlutterFreqScale(&Wind edge fultter freq scale&,float) = 0.5
&/code&&/pre&&/div&&p&&br&&/p&&p&_Time是Unity的一个内置 float4变量(t/20, t,t*2, t*3),专门用来做shader动画的, &/p&&p&&br&&/p&&p&看下vert里面的关键代码&/p&&p&//计算风的一些参数&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&//计算风的一些参数
float4 windParams = float4(0,_WindEdgeFlutter,bendingFact.xx);
float2 windTime = _Time.y * float2(_WindEdgeFlutterFreqScale,1);
float4 mdlPos = AnimateVertex2(v.vertex,v.normal,windParams,wind,windTime);
//mvp矩阵变换
= mul(UNITY_MATRIX_MVP,mdlPos);
&/code&&/pre&&/div&&p&&br&&/p&&p&所以最核心的函数就是AnimateVertex2,看下它是怎么将模型里面的位置v.vertex转换到被风吹动的mdlPos。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&inline float4 AnimateVertex2(float4 pos, float3 normal, float4 animParams,float4 wind,float2 time)
// animParams stored in color
// animParams.x = branch phase
// animParams.y = edge flutter factor
// animParams.z = primary factor
// animParams.w = secondary factor
float fDetailAmp = 0.1f;
float fBranchAmp = 0.3f;
// Phases (object, vertex, branch)
float fObjPhase = dot(_Object2World[3].xyz, 1);
float fBranchPhase = fObjPhase + animParams.x;
float fVtxPhase = dot(pos.xyz, animParams.y + fBranchPhase);
// y is used for branches
float2 vWavesIn = time.yy
+ float2(fVtxPhase, fBranchPhase );
// 1.975, 0.793, 0.375, 0.193 are good frequencies
float4 vWaves = (frac( vWavesIn.xxyy * float4(1.975, 0.793, 0.375, 0.193) ) * 2.0 - 1.0);
vWaves = SmoothTriangleWave( vWaves );
float2 vWavesSum = vWaves.xz + vWaves.
// Edge (xz) and branch bending (y)
float3 bend = animParams.y * fDetailAmp * normal.
bend.y = animParams.w * fBranchA
pos.xyz += ((vWavesSum.xyx * bend) + (wind.xyz * vWavesSum.y * animParams.w)) * wind.w;
// Primary bending
// Displace position
pos.xyz += animParams.z * wind.
&/code&&/pre&&/div&&p&关键思想是分层blend,首先计算了由主体到枝干再到顶点的震动系数,edge指旗子的边缘和自身xz方向的震动,branch指的是旗子整体的y方向的上下移动,接下来用了一些很trick的方法算出了一个float2的位移值,这个值就是顶点的位置,然后是将顶点的位移blend到主干上去,接着是主干上的位移blend到代码有点不讲道理,最后再把结果在风的方向上位移一定系数的距离。&/p&&p&&br&&/p&&p&&b&UVAnimation&/b&&/p&&p&UVAnimation可以分为三个讲,滚滚浓烟,分层滚动天空盒,水面波纹&/p&&p&&br&&/p&&p&先说最简单的天空盒,就是两套UV速度,以不同的速率变化&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&o.uv = TRANSFORM_TEX(v.texcoord.xy,_MainTex) + frac(float2(_ScrollX, _ScrollY) * _Time);
o.uv2 = TRANSFORM_TEX(v.texcoord.xy,_DetailTex) + frac(float2(_Scroll2X, _Scroll2Y) * _Time);
&/code&&/pre&&/div&&p&&br&&/p&&p&&br&&/p&&img src=&/50/v2-7adbca0f112cb736ff2f62c93b9a94c7_b.jpg& data-rawwidth=&1016& data-rawheight=&516& class=&origin_image zh-lightbox-thumb& width=&1016& data-original=&/50/v2-7adbca0f112cb736ff2f62c93b9a94c7_r.jpg&&&p&&br&&/p&&p&最后又叠了一个颜色用来调节明暗关系。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&fixed4 frag (v2f i) : COLOR
fixed4 tex = tex2D (_MainTex, i.uv);
fixed4 tex2 = tex2D (_DetailTex, i.uv2);
o = (tex * tex2) * i.
&/code&&/pre&&/div&&p&晚上竟然又月亮。&/p&&p&&br&&/p&&img src=&/50/v2-a031acda5a50ab08f23efc_b.jpg& data-rawwidth=&1011& data-rawheight=&571& class=&origin_image zh-lightbox-thumb& width=&1011& data-original=&/50/v2-a031acda5a50ab08f23efc_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&&b&滚滚浓烟&/b&&/p&&p&还是用了顶点色&/p&&p&看下Mesh&/p&&p&&br&&/p&&img src=&/50/v2-ca5f1a44a4de7c672e5f_b.jpg& data-rawwidth=&898& data-rawheight=&542& class=&origin_image zh-lightbox-thumb& width=&898& data-original=&/50/v2-ca5f1a44a4de7c672e5f_r.jpg&&&p&&br&&/p&&p&地下的红色,和烟的颜色叠起来,表现火焰的感觉。&/p&&p&&br&&/p&&p&贴图是两张不同的烟,用来表现层次感。&/p&&p&&br&&/p&&img src=&/50/v2-d182bfd0bcdcc596f747b9_b.jpg& data-rawwidth=&364& data-rawheight=&191& class=&content_image& width=&364&&&p&&br&&/p&&p&&br&&/p&&p&Shader和天空盒的基本一致。&/p&&p&&br&&/p&&p&不要觉得上面两个shader比较简单就没人用了,可以自习对比下cfm的运输船&/p&&img src=&/50/v2-badf37d1bd3_b.jpg& data-rawwidth=&1920& data-rawheight=&1080& class=&origin_image zh-lightbox-thumb& width=&1920& data-original=&/50/v2-badf37d1bd3_r.jpg&&&p&&br&&/p&&p&&b&“Volumetric” effects&/b&&/p&&p&所谓的体效果包括了Glow,Light Shafts,Fog Plane,Emissive BillBoards&/p&&p&&br&&/p&&p&为了模拟光从窗户投射进来,用了一个透明的片来表现&/p&&p&&br&&/p&&img src=&/50/v2-479c28b0d137dd7348d24bcaf550b867_b.jpg& data-rawwidth=&1013& data-rawheight=&477& class=&origin_image zh-lightbox-thumb& width=&1013& data-original=&/50/v2-479c28b0d137dd7348d24bcaf550b867_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&但不是单纯地半透明片,它是View distance based fade out,有下面两个特点&/p&&p&1) 随着视角的接近,透明的程度变大,离得特别远得时候,透明度也会变大&/p&&p&2) Mesh的位置会随着摄像机的位置变化,接近的时候有一种推开的感觉(减少overdraw)&/p&&p&减少overdraw的同时,规避了透明片插在摄像机里的问题。&/p&&p&&br&&/p&&p&都是vertex shader 干的&/p&&p&核心的代码&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&float3 viewPos = mul(UNITY_MATRIX_MV,v.vertex);
= length(viewPos);
nfadeout = saturate(dist / _FadeOutDistNear);
ffadeout = 1 - saturate(max(dist - _FadeOutDistFar,0) * 0.2);
&/code&&/pre&&/div&&p&&br&&/p&&p&关于saturate函数:camps the specified value within the range of 0 to 1.&/p&&p&简单的说就是跟据面片到摄像机的距离计算出淡入淡出的系数。具体计算可以参考这里&/p&&p&&br&&/p&&p&面片涂了顶点色&/p&&img src=&/50/v2-345d9cc14910bc99dbc3fbc876c03345_b.jpg& data-rawwidth=&1346& data-rawheight=&519& class=&origin_image zh-lightbox-thumb& width=&1346& data-original=&/50/v2-345d9cc14910bc99dbc3fbc876c03345_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&在计算位置的时候会根据alpha值来计算推开的距离&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&float4 vpos = v.
vpos.xyz -=
v.normal * saturate(1 - nfadeout) * v.color.a * _ContractionA
&/code&&/pre&&/div&&p&&br&&/p&&p&官方的说法是这样&/p&&p&Vertex color alpha determines which vertices are moveable and which are not (in our case, vertices with black alpha stays, those with white alpha moves).&/p&&p&Vertex normal determines the direction of movement.&/p&&p&The shader then evaluates distance to the viewer and handles surface fade in/out appropriately.&/p&&p&&br&&/p&&p&为了实现这些效果,渲染了一大推的半透明物体,在移动平台上,会引起严重的overdraw。为了解决overdraw的问题,做了下面几点&/p&&p&1. 使用最简单的fragmentshader,基本上就只采样一张贴图。如果插值的结果不太好就用密一些的网格。&/p&&p&2. 减少半透明的面积,这个在shader里面已经体现了&/p&&p&&br&&/p&&p&还有几个用来模拟灯的地方&/p&&p&&br&&/p&&img src=&/50/v2-326bf55dcb2f9a790b3da5e204c891c6_b.jpg& data-rawwidth=&1082& data-rawheight=&528& class=&origin_image zh-lightbox-thumb& width=&1082& data-original=&/50/v2-326bf55dcb2f9a790b3da5e204c891c6_r.jpg&&&p&&br&&/p&&img src=&/50/v2-dbb557fba9_b.jpg& data-rawwidth=&1071& data-rawheight=&456& class=&origin_image zh-lightbox-thumb& width=&1071& data-original=&/50/v2-dbb557fba9_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&特点是会随机闪动。&/p&&p&&br&&/p&&p&Mesh方面还是刷了顶点色&/p&&p&&br&&/p&&img src=&/50/v2-221c12b3649abcaa1b7c6_b.jpg& data-rawwidth=&930& data-rawheight=&537& class=&origin_image zh-lightbox-thumb& width=&930& data-original=&/50/v2-221c12b3649abcaa1b7c6_r.jpg&&&p&&br&&/p&&p&插在面片上的两个长条三角形目测是为了防止在摄像机靠近的时候被culling掉。&/p&&p&&br&&/p&&p&闪动的原理是在vertexshader中利用sin函数计算出一个随机系数乘以o.color.&/p&&p&具体的计算代码如下&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&float fracTime = fmod(time,_TimeOnDuration + _TimeOffDuration);
float wave
= smoothstep(0,_TimeOnDuration * 0.25,fracTime)
* (1 - smoothstep(_TimeOnDuration * 0.75,_TimeOnDuration,fracTime));
float noiseTime = time *
(6.2831853f / _TimeOnDuration);
float noise
= sin(noiseTime) * (0.5f * cos(noiseTime * 0.6366f + 56.7272f) + 0.5f);
float noiseWave = _NoiseAmount * noise + (1 - _NoiseAmount);
wave = _NoiseAmount & 0.01f ? wave : noiseW
o.color = nfadeout * _Color * _Multiplier *
&/code&&/pre&&/div&&p&&br&&/p&&p&具体的原理可以参考&a href=&/?target=http%3A//blog.csdn.net/candycat1992/article/details/& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&这一篇&i class=&icon-external&&&/i&&/a&的分析&/p&&p&&br&&/p&&p&&b&Billboarding&/b&&/p&&p&&br&&/p&&img src=&/50/v2-a5f6ea47f02c24ddab7a3e_b.jpg& data-rawwidth=&1011& data-rawheight=&497& class=&origin_image zh-lightbox-thumb& width=&1011& data-original=&/50/v2-a5f6ea47f02c24ddab7a3e_r.jpg&&&p&&br&&/p&&p&用来表现Glow的感觉,用了两个片来模拟&/p&&p&&br&&/p&&p&&br&&/p&&img src=&/50/v2-e9f41c7fecec0b_b.jpg& data-rawwidth=&988& data-rawheight=&526& class=&origin_image zh-lightbox-thumb& width=&988& data-original=&/50/v2-e9f41c7fecec0b_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&Shader方面,除了前面的View distance
based fade out和闪动特性之外,有加了billboarding。&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&float3 centerOffs
= float3(float(0.5).xx - v.color.rg,0) * v.texcoord1.
float3 centerLocal = v.vertex.xyz + centerOffs.
float3 viewerLocal = mul(_World2Object,float4(_WorldSpaceCameraPos,1));
float3 localDir
= viewerLocal - centerL
localDir[1] = lerp(0,localDir[1],_VerticalBillboarding);
localDirLength=length(localDir);
float3 rightL
float3 upL
CalcOrthonormalBasis(localDir / localDirLength,rightLocal,upLocal);
= CalcDistScale(localDirLength) * v.color.a;
float3 BBNormal
= rightLocal * v.normal.x + upLocal * v.normal.y;
float3 BBLocalPos = centerLocal - (rightLocal * centerOffs.x + upLocal * centerOffs.y) + BBNormal * distS
BBLocalPos += _ViewerOffset * localD
&/code&&/pre&&/div&&p&&br&&/p&&p&在Mesh里面的顶点色是这样的&/p&&p&&br&&/p&&img src=&/50/v2-2b6ee4ffa44d14f9b1b7_b.jpg& data-rawwidth=&911& data-rawheight=&478& class=&origin_image zh-lightbox-thumb& width=&911& data-original=&/50/v2-2b6ee4ffa44d14f9b1b7_r.jpg&&&p&&br&&/p&&p&大概的思路是通过顶点色构建一个坐标系,然后算顶点的偏移。具体的实现可以参考&a href=&/?target=http%3A//blog.csdn.net/candycat1992/article/details/& class=& wrap external& target=&_blank& rel=&nofollow noreferrer&&这里&i class=&icon-external&&&/i&&/a&&/p&&p&&br&&/p&&p&&b&角色阴影&/b&&/p&&p&&br&&/p&&img src=&/50/v2-ef0d88e9f73ffccf9cf9bbd_b.jpg& data-rawwidth=&995& data-rawheight=&540& class=&origin_image zh-lightbox-thumb& width=&995& data-original=&/50/v2-ef0d88e9f73ffccf9cf9bbd_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&实现方法是在脚下放一个面片,render queue是 transparent – 15,基本是再所有透明物体的之前渲染。然后在面片的vertex shader中算人的AO。&/p&&p&&br&&/p&&p&在计算AO的时候,将人近似成球体&/p&&p&&br&&/p&&img src=&/50/v2-238efd93ab63_b.jpg& data-rawwidth=&1037& data-rawheight=&547& class=&origin_image zh-lightbox-thumb& width=&1037& data-original=&/50/v2-238efd93ab63_r.jpg&&&p&&br&&/p&&p&&br&&/p&&p&Shader里面的代码也很简单&/p&&p&&br&&/p&&div class=&highlight&&&pre&&code class=&language-text&&&span&&/span&#if 1
// quite suprisinly this looks better (probably there is some error in AO calculation)
ao = 1 - saturate(SphereAO(_Sphere0,wrldPos,wrldNormal) + SphereAO(_Sphere1,wrldPos,wrldNormal) + SphereAO(_Sphere2,wrldPos,wrldNormal));
ao = 1 - max(max(SphereAO(_Sphere0,wrldPos,wrldNormal),SphereAO(_Sphere1,wrldPos,wrldNormal)),SphereAO(_Sphere2,wrldPos,wrldNormal));
ao = max(ao,1 - _Intensity) + (1 - v.color.r);
o.color = fixed4(ao,ao,ao,ao);
&/code&&/pre&&/div&&p&_Sphere0;_Sphere1;_Sphere2;是由外面传进来的三个近似球体的位置,关键看下SphereAO函数&/p&&div class=&highlight&&&pre&&code cl

我要回帖

更多关于 别说了我neng 的文章

 

随机推荐