Rust 学习资源

博主发现咸鱼咸鱼着居然到 2022 年了,忽然良心不安,因此先从这篇比较水的资源总结开始吧! 这篇文章主要是总结下学 Rust 参考过的资料,会随着博主对 Rust 的关注随缘更新。 更新日志 2023/03/02 新增 noxasaxon/learning_rust.md。 2023/01/06 新增 mouse 姐姐新书《Rust Atomics and Locks: Low-Level Concurrency in Practice》 和STATE MACHINES。 2022/12/28 新增 Ray Tracing in One Week 2022/12/27 新增 LSM in a week 2022/12/25 新增 tfpk/macrokata 2022/12/22 新增 Comprehensive Rust 2022/12/06 新增 night-cruise/async-rust 2022/11/28 新增 《dyn async traits》系列 中文版 2022/11/28 新增 Writing Interpreters in Rust: a Guide WebAssembly Compiler 2022/11/15 新增 北京大学编译原理课程在线文档, jondot/rust-how-do-i-start 2022/11/13 新增 Rust Game Series 2022/10/17 新增 smallnest/concurrency-programming-via-rust 《Rust 并发编程实战课》 2022/09/26 新增 Learn wgpu 中文版, Vulkan Tutorial(Rust) 2022/08/24 新增 Rust 源码剖析 中文版 2022/08/24 新增 Boshen/javascript-parser-in-rust Writing a container in Rust 2022/07/05 新增 Visualizing memory layout of Rust’s data types 2022年开源操作系统训练营 基础 The Rust Programming Language 堪称 Rust 的 “The Book”,是目前最权威的 Rust 系统教程,入门必读,最近也更新到了 2021 版本。 中文版(经常更新) rustwiki 中文版 Rust by Example 实例化的讲解方法,通过一个个可实际运行的例子去介绍 Rust 的特性和用法,有的时候,代码是最好的老师。 中文版 The Rust primer for beginners 给初学者的 Rust 中文教程。 Rust入门秘籍 这是一本 Rust 的入门书籍,相比官方书籍《The Rust Programming Language》,本书要更详细、更具系统性,本书也尽量追求准确性。 Rust First Steps 微软的 Rust 教程,简短精炼,适合初学者。 官方中文 Rust Cookbook Rust 程序设计语言(Rust 官方教程简体中文版)的简要实例示例集合:展示了在 Rust 生态系统中,使用各类 crate 来完成常见编程任务的良好实践。 中文版 Rustlings 官方出品,涵盖大量小练习,打怪通关学习 Rust。 Jetbrains IDE 可以直接下载课程,编辑器内写代码做练习。 Learning Rust With Entirely Too Many Linked Lists 通过写双链表来学习 Rust Read Rust - Getting Started Read Rust 是一个集合了有价值的 Rust 文章/博客的网站,其中 Getting Started 部分有各种 Rust 知识点相关的十分优秀的文章。 Stanford CS 110L:Safety in Systems Programming This class is focused on safety and robustness in systems programming. We will use the Rust programming language as a vehicle to teach mental models and paradigms that have been shown to be helpful in preventing errors, and we will look at how these features have made their way back into C++. 2020 年课程的 B 站中文字幕版 2021 年课程主页、2022 年课程主页 Rust 语言圣经(Rust 教程 Rust Course) rust-course 国人写的 Rust 教程,对Rust语言进行全面且深入的讲解,书中辅以生动的示例和习题。 Rust 官方文档中文教程 rust-lang-cn 组织翻译的官方文档,另外这个组织也翻译了很多 Rust 相关的书籍。 Visualizing memory layout of Rust’s data types 可视化了 Rust 的类型在内存中的布局,入门必看。 Rust 实践指南 zzy/rust-guide 《Rust 实践指南》,聚焦重要的主题,展示可能的解决方案。以开发中的实际问题为导向,以优雅的解决方案为目标,以完整的实例实践解决方案。 Comprehensive Rust Google Android 团队的四天 Rust 教程。 Bilibili:软件工艺师 微软 MVP,做了不少 C#、Go、Rust 的教程,其中 Rust 相关的有 Rust编程语言入门教程 和 Rust Web 全栈开发教程 Rust Language Cheat Sheet quickref.me Rust cheatsheet quickref.me 是一个汇聚了大部分语言的语法索引页, 其中也包含了 Rust, 可以帮助大家快速找到想用的语法。 rust-lang/api-guidelines 中文版:Rust API 编写指南 这是一组关于如何设计和呈现 Rust APIs 的建议。 这些建议主要由 Rust library 团队编写, 总结了 Rust 生态下构建标准库和其他 crates 的经验。 《Programming Rust, 2nd Edition》简单的翻译 第一版图灵社区有翻译:Rust程序设计,第二版多了两章,可以考虑买第一版的电子版 pdf。 rustlang-cn/Rustt RustCn 翻译计划,翻译一些 Rust 的技术文章。 suhanyujie/article-transfer-rs 一些 Rust/Go 文章翻译 进阶 Rust Standard Library Reference The Rust Reference Rust 语言的 reference manual,你应该收藏好,以便于在对某个语言细节不清楚时在这里进行查阅。 中文版 The unsafe Book The Rustonomicon Rust 死灵书主要讲 Rust 高级特性,如何使用 unsafe Rust。 中文版 The Little Book of Rust Macros 对于 Rust 宏有详细的讲解,里面的注释很全面。 中文版 night-cruise/async-rust 介绍 Rust 中 async/await 语法和异步运行时的原理和工作机制的电子书 《dyn async traits》系列 Niko 是 Rust 语言诸多特性的设计者(比如 NLL)。这个系列主要探索在 trait 中支持 async fn,因此主要聚焦于思路梳理与原型设计。 中文版 smallnest/concurrency-programming-via-rust 《Rust 并发编程实战课》 《Go 并发编程实战课》的作者鸟窝系统整理的 Rust 的并发编程的相关资料。主要是从入门入手,让大家了解和熟悉这些并发原语,在工作中用起来。 《Rust Atomics and Locks: Low-Level Concurrency in Practice》 mouse 姐姐出版的关于 Rust 并发的书,可以在她博客免费阅读,亚马逊也可以购买:Amazon。 Asynchronous Programming in Rust 不是很新的中文版 A Guide to Porting C/C++ to Rust The Rust FFI Omnibus 使用 Rust 编写代码用到其他语言的示例集合. 中文版 Jon Gjengset YouTube Channel (Crust of Rust Playlist) Rust Design Patterns 有许多问题具有共同的形式。由于事实上 Rust 并不完全是面向对象的,设计模式也与其他面向对象的编程语言不同。 细节不同的同时,因为他们有相同的形式,他们可以用同样的基本方法解决。 中文版 The Rust Performance Book 介绍很多优化 Rust 程序性能的工具、技巧、调试方法等方面的书。 Problem-solving with algorithms and data structures using Rust 国人写的一本 Rust 书籍,包括算法分析,基本数据结构和算法,外加一些实战。 Rust 源码剖析 中文版 国人写的一本 Rust 书籍,针对 Rust 语言本身和开源库的代码进行分析。 dtolnay/case-studies dtolnay 是 anyhow, thiserror, cxx 等库的作者,这是他对一些 tricky Rust code 的分析。 Bilibili:Databend Databend 社区持续做了不少 Rust 的公开课。仓库地址 Bilibili:爆米花胡了 这个 up 主做了很多 Rust 过程宏的视频教程。 Bilibili:喜欢历史的程序君 陈天在极客时间开了门 Rust 的课,同时也在持续输出一些 Rust 视频教程。 KAIST CS431: Concurrent Programming Github repo 本课程面向对并行计算机系统的现代理论和实践感兴趣的计算机科学(或相关学科)的高年级本科生(或研究生)。 Rust for the Polyglot Programmer 面对有经验的程序员的 Rust 指南。 High Assurance Rust: Developing Secure and Robust Software 本书介绍了如何构建我们可以合理信任(justifiably trust)的高性能软件。这意味着有足够的数据来支持对我们代码的功能和安全性的信心。可信性是高安全性(high assurance)软件的一个标志。 Warrenren/inside-rust-std-library 本书主要对 Rust 的标准库代码进行分析。按照内存相关,基本数据类型,ops Trait, Option 类型,Result 类型,Iterator,切片类型,智能指针类型等逐一进行源码分析。 有潜力的教程 Rust 101 Lecture Series 与伦敦帝国理工学院计算社会系合作的 Rust 系列讲座 Rust Lecture Series with Imperial College London’s Department of Computing Society Effective Rust 练习实战的小项目 知乎-学习Rust适合写什么练手项目? Exercism.io course-rs/tokio-course 《Tokio 异步编程》翻译并扩展了 tokio 官网的教程, 深入讲述了如何编写 Rust 高并发异步程序 Github: cfsamson 这哥们喜欢用 Rust 实现一些小例子如:Futures、greenthreads、async、epoll 等。 STATE MACHINES Part1 Part2 Part3 [2022.12] 用 Rust 实现状态机 LSM in a week [2022.12] Build a simple LSM-Tree storage engine in Rust. Github tfpk/macrokata [2022.12] MacroKata, a set of exercises which you can use to learn how to write macros in Rust. Github Writing Interpreters in Rust: a Guide [2022.11] 用 Rust 写解释器,仓库 Boshen/javascript-parser-in-rust [2022.08] A book on writing a JavaScript Parser in Rust 2022年开源操作系统训练营 [2022.07] 教程共分为八章,主要展示如何从零开始,用 Rust 语言写一个基于 RISC-V 架构的类 Unix 内核。 北京大学编译原理课程在线文档 [2022.06] 作者 MaxXSoft 是本科编译原理课程的助教。为了让本科生更好地理解编译器工作的原理,在参考多个其他教程之后,他设计了一套全新的,教你从零开始写个编译器的教程。 Rust 实现 知乎-课程介绍 Writing a container in Rust [2022.05] 用 Rust 写容器。 Lisp interpreter in Rust [2022.05] lisp-rs 项目用 Rust 实现了一个解释器,用于 Scheme 的一个小子集,即 Lisp 方言。 Implementing a size-bounded LRU cache with expiring entries for my DNS server (in Rust) [2022.03] 使用 Rust 实现一个有大小限制可过期的 LRU 缓存。 Implementing and Optimizing a Wordle Solver in Rust [2022.03] Jon Gjengset 的六小时一镜到底视频流教程,这次是实现一个 Wordle 求解器。 Writing a Programming Language (in Rust) [2022.02 updating] Implementing the NTFS filesystem in Rust [2022.02] Rust Latam: procedural macros workshop [2022.01 updating] 实战学习写 Rust 过程宏。 Rust Runtime 设计与实现 [2021.12] 系列文章主要介绍如何设计和实现一个基于 io-uring 的 Thread-per-core 模型的 Runtime。 Building a GUI app in Rust Building a web app in Rust [2021.10] 作者用 egui 库去实现了 newsapi 的客户端和网页端(WebAssembly)。 Rust过程宏入门 (Risp (in (Rust) (Lisp))) [2021.07] Rust 实现 Lisp 解释器 WebAssembly Compiler [2021.05] Rust 实现 WebAssembly Parser, Compiler and Runtime. Learning to Fly: Let’s simulate evolution in Rust! (pt 1) [2021.01] 利用神经网络和遗传算法创建一个进化模拟,并编译应用程序到 WebAssembly Ray Tracing in One Week [2020.12] Ray Tracing in One Week 系列的 Rust 版本 Github Rust Game Series [2020.11] 用 Rust 和 winapi 来用 D3D11 写三消游戏 Building a Pixel Editor in Rust & WebAssembly (and Javascript) [2020.08] 作者用 Rust 和 WebAssembly 做了个网页端的简陋版像素画板。 Writing NES Emulator in Rust [2020.08] Rust 实现 NES 模拟器,不过最后一章到现在还是 todo。 Building a DNS server in Rust [2020.06] pingCAP/talent-plan [2020.05] Rust 网络编程 TP 201: Practical Networked Applications in Rust TP 202: Distributed Systems in Rust Writing an OS in Rust 部分中文版 [2020.05] PNGme: An Intermediate Rust Project [2019.06] Implementing TCP [2019.05] 强烈推荐!Jon Gjengset 通过 Linux TUN/TAP 来实现 TCP 协议。三个视频加起来共 16 小时。 这个 up 主视频风格独特,内容有深度,录像不剪辑,每集时间巨长,好处就是可以了解一个完整项目的开发过程和解决问题的思路。 Learning Parser Combinators With Rust [2019.04] Build Your Own Shell using Rust [2018.11] So You Want to Build a Language VM [2018.07] 游戏开发相关 有哪些值得推荐的Rust游戏引擎或图形渲染库? Rust GameDev WG Vulkan Tutorial(Rust) 这老哥给自己的 Vulkan Rust 绑定 vulkanalia 参考 Vulkan Tutorial 写的教程。 我们也可以用 ash 来参考着写,两个 Vulkan binding crate 语法很像。 The Ray Tracer Challenge [2022.02 updating] 这老哥用 Rust 从零写一个 Raytracer,并把 live coding 的过程也录制上传在系列视频链接 Vulkan with Rust by example 又是用 Rust 和 ash crate 来写 Vulkan 的一系列博文。 Ashen Aetna [2022.01 updating] 作者兴趣使然用 Rust 和 ash crate 来学习图形学的教程。 ash 是跨平台图形接口 Vulkan 的 Rust 绑定。 Unofficial Bevy Cheat Book Rust 游戏引擎 Bevy 的书。 中文版:Bevy 游戏引擎开发指南 Learn Wgpu 中文版 Wgpu 是 WebGPU API 规范的一个 Rust 实现。 WebGPU 是由 GPU for the Web Community Group 发布的一个规范。它的目的是允许网络代码以安全和可靠的方式访问 GPU 功能。 它通过模仿 Vulkan API,并将其转换为主机硬件使用的任何 API(DirectX、Metal、Vulkan)来实现。 很多 Rust 游戏引擎都基于这一层图形 HAL。 Tutorial: Writing a Tiny Rust Game Engine for Web [2022.01] Roguelike Tutorial in Rust + tcod [2020.04] Adventures in Rust: A Basic 2D Game [2018.02] 其他领域相关 The CLI Book The WebAssembly Book The Embedded Book An Experimental Course on Operating Systems Zero to Production in Rust (Building Backend Services) Rust 动态 This week in Rust Newsletter 每周更新一次,把最新的 Rust 资源推到你的邮箱,这是跟踪 Rust 最新技术与事件的好方法。 Discord Official Rust Community Server Official Rust Server Telegram Rust 众 飞书 Rust 中文社群 The Rust Sub Reddit Rust语言开源杂志(2021)月刊 Rust语言开源杂志(2022)季刊 各种汇总(Awesome 系列) Awesome Rust [A curated list of Rust code and resources] 针对 Rust 语言的 awesome lists,这里面汇集了各种各样的 Rust 库和资源,去参与或学习开源项目是当你入门后最好的进阶方法。 rust-learning 一个由社区维护的关于学习 Rust 的资源的汇总。 EthanYuan/Rust-Study-Resource 又是一个关于学习 Rust 的资源的汇总。 The Little Book of Rust Books Rust 相关书籍的汇总。 sger/RustBooks Rust 相关书籍的汇总。 sunface/fancy-rust Rust酷库推荐。使用我们精心挑选的开源代码,让你的Rust项目Fancy起来! EvanLi/Github-Ranking Github 中 Rust 库星星排名的 Top 100,每日刷新。 35 Rust Learning Resources Every Beginner Should Know in 2022 一篇推荐新手资源的文章 Podcast Rustacean Station Podcast RustTalk 主播:写代码的西瓜 Rust 语言中文社区 是一个相比干货分享的地方,偏文字,RustTalk 更侧重“湿货”,不仅仅会介绍到 Rust 的设计理念,更多的回去挖掘 Rust 背后的奇人轶事。 博客 https://llever.com/ 包含很多 Rust 周报及相关博文的翻译,不过现在好像不更新了。 芽之家 同样是包含很多 Rust 周报及相关博文的翻译,同样好像不更新了😓 博客 RSS 名称订阅链接 This Week in Rusthttps://this-week-in-rust.org/atom.xml Read Rusthttps://readrust.net/all/feed.rss Rust Reddit Hothttps://reddit.0qz.fun/r/rust/hot.json Rust.cchttps://rustcc.cn/rss Awesome Rust Weeklyhttps://rust.libhunt.com/newsletter/feed Rust精选https://rustmagazine.github.io/rust_magazine_2021/rss.xml Rust on Mediumhttps://medium.com/feed/tag/rust Rust GameDev WGhttps://gamedev.rs/rss.xml 知乎专栏-时光与精神小屋https://rsshub.app/zhihu/zhuanlan/time-and-spirit-hut 酷熊 Amos fasterthanlihttps://fasterthanli.me/index.xml pretzelhammer/rust-bloghttps://www.ncameron.org/blog/rss/ Nick Cameronhttps://github.com/pretzelhammer/rust-blog/releases.atom FOLYDhttps://folyd.com/blog/feed.xml Alex Chihttps://www.skyzh.dev/posts/index.xml 作为参考的学习路线 各种方法入门 noxasaxon/learning_rust.md jondot/rust-how-do-i-start 路线1 Rust Study RoadMap 作者在文中提供了两种学习路线。 路线2 通读 Rust by Example,把其中的例子都自己运行一遍,特别是对其中指出的错误用法也调试一遍。 通读 The Rust Programming Language,在进行了第一步后,已经基本对 Rust 的常用概念有所了解了,这个时候再读这本官方教程,进一步理解某些细节。 行了,到这一步后你就可以尝试做一个项目了,然后在做项目的过程中你一定会需要各种各样的库,请到 Crates上搜索,寻找适合你需求的 crate,了解它们的用法,必要时查阅它们的源码。一开始写实际代码时,你肯定会很痛苦,Rust 编译器一定会不断地折磨你,这个时候不要放弃,返回去再看 Rust by Example和The Rust Programming Language,然后终有通过编译的那一刻,恭喜你,入坑了! 常用站点 Crates Rust 类库 Docs.rs Rust 类库文档 Are we game yet 关于游戏开发 Are we web yet 关于 Web 开发 Are we (I)DE yet 关于 IDE rust-library-i18n Rust 中文文档,可以在 IDE 中使用 其他资料 The 10 books that helped me, as a hobbyist, on my journey to learn Rust to re-code a Django application Rustnote 某个网友的个人笔记 本文参考 https://rust-lang-cn.org/article/23 https://letsgetrusty.kartra.com/page/XDk8 https://rustcc.cn/

2022/1/18
articleCard.readMore

用 Rust 实现简单的光线追踪

学 Rust 十来天了,自己被这个语言惊艳到,就跟着教程 Ray Tracing in One Weekend 写了个很简陋的光线追踪示例练习,项目在 Latias94/rust_raytracing。 学这门语言的时候,感觉就是上手容易遇到很多新概念,容易学不下去,跟编译器作斗争…不过作为一个还很新的系统编程语言,工具链如文章、包管理、格式化、编译器等很完善,官方教程很棒,社区也很活跃。 学 Rust 的契机其实是在 V2EX 上看到有人在纠结学 Go 还是 Rust,底下的帖子也有不少夸 Rust 语言的,因此自己也开始关注 Rust 语言。后来发现 Rust 的用武之地非常广,Github 上还能找到不少 Rust 做的游戏引擎,其中一部分主打 ECS 功能,例如:bevyengine/bevy 、Ralith/hecs 等。 学习 Rust 语言,其实也是在了解一个现代化的语言该有的样子,了解 C++ 或其他语言部分设计上的不足,以及 Rust 是打算如何从根源解决这些问题的。这部分我作为一个初学者,不打算展开讲。大家有空可以了解一下 Rust 语言,看看官方的教程《Rust 程序设计语言》。 总而言之,我觉得光线追踪的教程可以作为学一门新语言后严肃学习的项目,做完成就感也满满! 顺便推荐一篇好文:新技术学习不完全指北:以 Rust 为例。 最后放下示例的渲染图: 五一劳动节快乐!

2021/5/5
articleCard.readMore

24 天像素画从入门到放弃

前不久十分厌学,想着是不是学废了,就想找到其他的东西学学,于是有一天周日尝试了同时入门 Blender 和 像素画。 建模其实和 Unity 用初始模型搭积木差不多,入门也还好。但是自己从小就是对美术绝缘,只有初中的时候会和其他同龄人一样照着漫画书瞎画。后来就直到现在,因此开始画像素画的时候还是十分不适应,感觉每一个点都是为无用艺术界添砖加瓦。 后来决定画像素画的契机是听播客介绍到一个码农姐姐为了做游戏坚持 100 天画像素画,于是少年那颗不知天高地厚的心怦然地燃烧起来。 教练!我想学画画! 兴起 作为一个资深松鼠症患者,Steam 里早已经躺着不知道什么时候打折入手的像素画神器 “Asprite”,我原本以为一辈子再也不会下载它,这就是命运的安排吧。 很多软件能画像素画,毕竟像素画就只是由点构成的,不过好的工具能事半功倍。用习惯 PS 的美术朋友可能会选择继续用 PS 改笔触画像素画,对于我来说,功能太多的软件反而会打压我的一时兴起的兴趣,于是我跟着 Aseprite!超方便的像素画软件初学者教程 花半小时大概清楚 Aseprite 有的功能。 Aseprite 是收费的,在 Github 开源 ,如果你想自己编译,可以参考 [GUIDE] How to build Aseprite from source. (Aseprite free & legal) 。我是在 Steam 上买的,现在看了看是 70 元,买了 Win、Mac、Linus 都能用,不需要自己分开编译,更新也会更方便些。 自己一开始跟着 Youtube 博主的视频教程入门,其中有前面 Aseprite 初学者教程的作者 MortMort、开了 Pixel 101 系列教程的 Pixel Pete 等。 我喜欢看视频教程入门的原因有几点: 我想看他们是怎么开始构思一幅画的(像素画的型) 画像素画的过程(先画什么后画什么,颜色如何选择) 对软件的使用,如何适当地使用软件的功能更快地画出你想要的画。 了解像素画独有的技巧,如:像素抖动等 一开始我看的是 Pixel 101 的前几个教程,Pixel Pete 在像素极其有限的情况下很快就能画出不同的物品,让我知道精准控制地像素就能通过不同颜色影响画在人们脑海中的想象。 因此我对像素画的理解就是:易入门、精准控制少量颜色和少量像素、费时少易出货,需要的基础也比原画板绘少非常多。 由于我当前工作室的游戏就是像素风格,因此问了几位原画同事,都说像素画是最简单的,他们一开始有原画基础,临摹下像素画的画法,很快就都能上手画像素画了。 于是我决定开始 100 天像素画挑战,每天花一个小时左右鼠绘像素画。 学习 第一天开始画之后,我发现很多问题: 一个像素能表现的东西是有限的,我要决定这个像素应该表现成什么,从而决定是什么颜色。 像素画的分辨率十分低,阴影过渡和线条可能会非常生硬,画太多又会有色块现象。 像素画中颜色的数量要有限制,阴影可以用两到三个颜色过渡,太多颜色会很乱。 于是找了本书 《Make Your Own Pixel Art》 参考,这本书面对的是没绘画基础的新手,用的软件也是 Aseprite。这本书讲的非常详细,我印象最深的就是我需要先学会画出不同基本模型的光照,例如圆柱、正方体等,这些形状和光照会画之后,再将它们组合起来,创造出自己的物品或角色。 对于调色板的颜色,可以在 Lospec 中选一些经典的颜色,这样我们暂时不用考虑颜色的对比、饱和度挑选等,先限制自己在调色板中用色,后期学颜色理论(Color Theory)了再自己配色。 下面是第三天时做的书中的练习,给出一个角色的剪影,我来上色。 第五天我尝试画自己的角色,画布是 $ 64 × 64 $ ,主题是太空。其中用到刚学的像素抖动,星星的表示,不过有些光影的地方还是错的。 于是每天画画,时不时尝试不同风格的像素画,中途还尝试了下画动画帧。每天也会用 Eagle 收集一下参考用的素材,这个软件看动画帧也非常方便。 挑战途中,我还找了一些素材参考,其中十分有用的是风农翻译的蔚蓝主美 Pedra Medeiros 的一系列像素画教程:saint11像素宝典 和 JKLLIN 的像素画学习系列。像素宝典里面还教了很多游戏中很有用的动画帧画法,比如攻击时的准备帧、不同风格如光魔法和黑暗魔法的表现等。 如果还想深入,我十分推荐 Michael Azzi 写的 Pixel Logic 这本书,B 站的物暗先生汉化过。这本书里面介绍了很多像素画的专有概念,还引用了很多像素游戏作为参考,如果想做一个像素游戏,你能从中获益很多。 我还经常逛逛 Twitter 的像素画标签,如:pixelart、ドット絵等,Artstation 的 Pixel Art 画,还有 Deviant Art 的 Pixel Art 主题。找到参考的同时,还能进一步找到自己喜欢的风格,这样可以跟着画的作者进一步了解这种风格的像素画画法。 放弃 直到第 24 天我决定放弃,其实还是时间的问题,回到家自己的时间也就两三个小时,有时候纠结一下颜色和参考,一堆时间就过去了,我希望把时间更多地重新放在技术上。 当然这 24 天我也是收获了不少,对像素画有了基本的了解,对颜色也开始有了那么点概念。工作中做游戏系统原型的时候都能不等美术资源,自己随便画图凑合用了 hhh。游戏开发者在学像素画的时候,也能请教下工作室中的美术,以后说不定还能靠着自己的像素画做独立开发,一箭双雕! 大家在一开始画的时候从小图画起,$ 16 × 16 $ 到 $ 64 × 64 $ 的画布就已经足够了,这样的画布也不需要绘画板,鼠绘就足够了,我相信画到一定程度,就会知道自己需不要一个绘画板了。 最后 本文作为学习过程的记录,希望能给读者激起学像素画的兴趣,避免走一些弯路。博主学像素画也是为了点一下独立开发的技能,虽然画的不怎么样,至少不怕画画了! 如果你对我 24 天的像素画感兴趣,可以点击下方按钮,或者博客右上角的相对应的标签查看。 像素画 像素画挑战

2021/4/18
articleCard.readMore

Compute Shader 简介

做游戏的时候,我们经常要面对各种优化问题。DOTS 技术栈的出现提供了一种 CPU 端的多线程方案,那么我们是否也能将一些计算转到 GPU 上面,从而平衡好对 CPU 和 GPU 的使用呢?对我而言,以前使用 GPU 无非是通过写 vert/frag shader、做好渲染相关的设置等操作,但实际上我们还能使用 GPU 的计算能力来帮我们解决问题。Compute Shader 就是我们跟 GPU 请求计算的一种手段。 本文将从并行架构开始,依次讲解一个最简单的 Compute Shader的编写、线程与线程组的概念、GPU 结构和其计算流水线,并讲解一个鸟群 Flocking 的实例,最后介绍 Compute Shader 的应用。全文较长,读者可以通过目录挑想看的看。 Compute Shader 也和传统着色器的写法十分不一样,写传统 Shader 写怕了的同学请放心~ 介绍 当今的 GPU 已经针对单址或连续地址的大量内存处理(亦称为流式操作,streaming operation)进行了优化,这与 CPU 面向内存随机访问的设计理念则刚好背道而驰。再者,考虑到要对顶点与像素分别进行单独的处理,因此 GPU 现已经采用了大规模并行处理架构。例如,NVIDIA 公司开发的 “Fermi” 架构最多可支持 16 个流式多处理器(streaming multiprocessor, SM),而每个流式处理器又均含有 32 个 CUDA 核心,也就是共 512 个 CUDA 核心。 CUDA 与 OpenCL 其实就是通过访问 GPU 来编写通用计算程序的两组不同的 API。 现代的 CPU 有 4-8 个 Core,每个 Core 可以同时执行 4-8 个浮点操作,因此我们假设 CPU 有 64 个浮点执行单元,然而 GPU 却可以有上千个这样的执行单元。仅仅只是比较 GPU 和 CPU 的 Core 数量是不公平的,因为它们的职能不同,组织形式也不同。 显然,图形的绘制优势完全得益于 GPU 架构,因为这架构就是专为绘图而精心设计的。但是,一些非图形应用程序同样可以从 GPU 并行架构所提供的强大计算能力中受益。我们将 GPU 用于非图形应用程序的情况称为通用 GPU 程序设计(通用 GPU 编程。General Purpose GPU programming, GPGPU programming)。当然,并不是所有的算法都适合由 GPU 来执行,只有数据并行算法(data-parallel algorithm) 才能发挥出 GPU 并行架构的优势。也就是说,仅当拥有大量待执行相同操作的数据时,才最适宜采用并行处理。[1] 粒子系统是一个例子,我们可简化粒子之间的关系模型,使它们彼此毫无关联,不会相互影响,以此使每个粒子的物理特征都可以分别独立地计算出来。 对于 GPGPU 编程而言,用户通常需要将计算结果返回 CPU 供其访问。这就需将数据由显存复制到系统内存,虽说这个过程的速度较慢(见下图),但是 GPU 在运算时所缩短的时间相比却是微不足道的。 针对图形处理任务来说,我们一般将运算结果作为渲染流水线的输入,所以无须再由 GPU 向 CPU 传输数据。例如,我们可以用计算着色器(Compute Shader)对纹理进行模糊处理(blur),再将着色器资源视图(shader resource view,DirectX 的概念),与模糊处理后的纹理相绑定,以作为着色器的输入。 计算着色器虽然是一种可编程的着色器,但 Direct3D 并没有将它直接归为渲染流水线中的一部分。虽然如此,但位于流水线之外的计算着色器却可以读写 GPU 资源。从本质上来说,计算着色器能够使我们访问 GPU 来实现数据并行算法,而不必渲染出任何图形。正如前文所说,这一点即为 GPGPU 编程中极为实用的功能。另外,计算着色器还能实现许多图形特效——因此对于图形程序员来说,它也是极具使用价值的。前面提到,由于计算着色器是 Direct3D 的组成部分,也可以读写 Direct3D 资源,由此我们就可以将其输出的数据直接绑定到渲染流水线上。 最简单的 Compute Shader 现在我们来看看一个最简单的 Compute Shader 的结构。 Unity 右键 → Create → Shader → Compute Shader 就可以创建一个最简单的 Compute Shader。 Compute Shader 文件扩展名为 .compute,它们是以 DirectX 11 样式 HLSL 语言编写的。 1 2 3 4 5 6 7 8 9 10 #pragma kernel CSMain RWTexture2D<float4> Result; [numthreads(8,8,1)] void CSMain(uint3 id : SV_DispatchThreadID) { // 为了演示,我把模板中下面这行改了 Result[id.xy] = float4(0, 1, 1, 1.0); } 第 1 行:一个计算着色器资源文件必须包含至少一个可以调用的 compute kernel,实际上这个 kernel 对应的就是一个函数,该函数由 #pragma 指示,名字要和函数名一致。一个 Shader 中可以有多个内核,只需定义多个 #pragma kernel functionName 和对应的函数即可,C# 脚本可以通过 kernel 的名字来找到对应要执行的函数( shader.FindKernel(functionName))。 第 3 行: RWTexture2D 是一种可供 Compute Shader 读写的纹理,C# 脚本可以通过 SetTexture() 设置一个可读写的 RenderTexture 供 Compute Shader 修改像素颜色。其中 RW 代表可读写。 第 5 行:numthreads 设置线程组中的线程数。组中的线程可以被设置为 1D、2D 或 3D 的网格布局。线程组和线程的概念下文会提到。 第 6 行:CSMain 为函数名,需要和 pragma 定义的 kernel 名一一对应。一个函数体代表一个线程要执行的语句,传进来的 SV_DispatchThreadID 是三维的线程 id,下文会提到。 第 9 行:根据当前线程 id 索引到可读写纹理对应的像素,并设置颜色。 C# 脚本这边 1 2 3 4 5 6 7 8 9 10 11 12 private void InitShader() { _image = GetComponent<Image>(); _kernelIndex = computeShader.FindKernel("CSMain"); int width = 1024, height = 1024; _rt = new RenderTexture(width, height, 0) {enableRandomWrite = true}; _rt.Create(); _image.material.SetTexture("_MainTex", _rt); computeShader.SetTexture(_kernelIndex, "Result", _rt); computeShader.Dispatch(_kernelIndex, width / 8, height / 8, 1); } 第 4 行:一个 Compute Shader 可能有多个 Kernel,这里根据名字找到需要的 KernelIndex,这样脚本才知道要把数据送给哪一个函数运算。 第 6、7 行:创建一个支持随机读写的 RenderTexture 。 第 10 行:为 Compute Shader 设置要读写的纹理。 第 11 行:设置好要执行的线程组的数量,并开始执行 Compute Shader。线程组数量的设置下文会提到。 将 Compute Shader 在 Inspector 赋值给脚本,然后将脚本挂在一个有 Image 组件的 GameObject 下,就能看到蓝色的图片。 到现在我们应该大概明白了: kernel 函数里面执行的是一个线程的要执行的逻辑。 我们需要设置线程组的数量(Dispatch)、和线程组内线程的数量(numthreads)。 我们可以为 Compute Shader 设置纹理等可读写资源。 那么什么是线程组和线程呢?我们又该如何设置数量? 如何划分工作:线程与线程组 在 GPU 编程的过程中,根据程序具体的执行需求,可将 线程 划分为由 线程组(thread group) 构成 的网格(grid)。 numthread 和 Dispatch 的三维 Grid 的设置方式只是方便逻辑上的划分,硬件执行的时候还会把所有线程当成一维的。因此 numthread(8, 8, 1) 和 numthread(64, 1, 1) 只是对我们来说索引线程的方式不一样而已,除外没区别。 线程组构成的 3D 网格 下图是 Dispatch(5,3,2), numthreads(10,8,3) 时的情况。 注意下图 Y 轴是 DirectX 的方向,向下递增,而 Compute Shader 中 Y 轴是相反的,向上递增,这里参考网格内的结构和线程组与线程的关系即可。 上图中还显示了 SV_DispatchThreadID 是如何计算的。 不难看出,我们能够根据需求定义出不同的线程组布局。例如,可以定义一个具有 X 个线程的单行线程组 [numthreads(X, 1, 1)] 或内含 Y 个线程的单列线程组 [numthreads(1, Y, 1)]。 还可以通过将维度 z 设为 1 来定义规模为 X×YX × YX×Y 的 2D 线程组,形如 [numthreads(X, Y, 1)]。我们应结合所遇到的具体问题来选择适当的线程组布局。 例如当我们处理 2D 图像时,需要让每一个线程单独处理一个像素,就可以定义 2D 的线程组。假设我们 numthreads 设置为 (8, 8, 1),那么一个线程组就有 8×88×88×8 个线程,能处理 8×88×88×8 的像素块(内含 64 个像素点)。 那么如果我们要处理一个 texResolution×texResolutiontexResolution × texResolutiontexResolution×texResolution 分辨率的纹理,那么需要多少个线程组呢? x 和 y 方向都需要 texResolution/8texResolution / 8texResolution/8 个线程组。 可以通过线程组来划分要处理哪些像素块(8×88×88×8) numthreads 有最大线程限制,具体查阅不同平台的文档:numthreads 。 前面介绍了如何设置线程组和线程的数量,现在介绍线程组和线程在硬件的运行形式。 线程组的 GPU 之旅 Fermi 架构 Ampere 架构 我们知道 GPU 会有上千个“核心”,用 NVIDIA 的说法就是 CUDA Core。 SP:最基本的处理单元,streaming processor,也称为 CUDA core。最后具体的指令和任务都是在 SP 上处理的。GPU 进行并行计算,也就是很多个 SP 同时做处理。我们所说的几百核心的 GPU 值指的都是 SP 的数量; SM:多个 SP 加上其他的一些资源组成一个 streaming multiprocessor。也叫 GPU 大核,其他资源如:warp scheduler,register,shared memory 等。SM 可以看做 GPU 的心脏(对比 CPU 核心),register 和 shared memory 是 SM 的稀缺资源。CUDA 将这些资源分配给所有驻留在 SM 中的 threads。因此,这些有限的资源就使每个 SM 中 active warps 有非常严格的限制,也就限制了并行能力。 这些核心被组织在流式多处理器(streaming multiprocessor, SM)中,一个线程组运行于一个多处理器(SM)之上。每一个核心同一时间可以运行一个线程。 流式多处理器(streaming multiprocessor, SM)是 Nvidia 的说法,AMD 对应的单元则是 Compute Unit。 因此,对于拥有 16 个 SM 的 GPU 来说,我们至少应将任务分解为 16 个线程组,来让每个多处理器都充分地运转起来。但是,要获得更佳的性能,我们还应当令每个多处理器至少拥有两个线程组,使它能够切换到不同的线程组进行处理,以连续不停地工作(线程组在运行的过程中可能会发生停顿,例如,着色器在继续执行下一个指令之前会等待纹理的处理结果,此时即可切换至另一个线程组)。 SM 会将它从 Gigathread 引擎(NVIDIA 技术,专门管理整个流水线)那收到的大线程块,拆分成许多更小的堆,每个堆包含 32 个线程,这样的堆也被称为:warp (AMD 则称为 wavefront)。多处理器会以 SIMD32 的方式(即 32 个线程同时执行相同的指令序列)来处理 warp,每个 CUDA 核心都可处理一个线程。 “Fermi” 架构中的每个多处理器都具有 32 个 CUDA 核心。 每一个线程组都会被划分到一个 Compute Unit 来计算,线程组中的线程由 Compute Unit 中的 SIMD 部分来执行。 如果我们定义 numthreads(8, 2, 4),那么每个线程组就有 8×2×4=648×2×4=648×2×4=64 个线程,这一整个线程组会被分成两个 warp,调度到单个 SIMD 单元计算。 单个 SM 处理逐个 warp,当一个 warp 暂时需要等待数据的时候,就可以先换其他 warp 继续执行。 如何设置好线程组的大小 我们应当总是将线程组的大小设置为 warp 尺寸的整数倍。让 SM 同时容纳多个 warp,能够以防一些情况。例如有时候为了等待某些数据就绪,你不得不停下来。比如说,我们需要通过法线纹理贴图来计算法线光照,即使该法线纹理已经在 Cache 中了,访问该资源仍然会有所耗时,而如果它不在 Cache 中,那就更加耗时了。用专业术语讲就是 Memory Stall(内存延迟)。与其什么事情也不做,不如将当前的 Warp 换成其它已经准备就绪的 Warp 继续执行。[2] 上图来自:DirectCompute Lecture Series 210: GPU Optimizations and Performance NVIDIA 在 Maxwell 更改了 SM 的组织方式,即 SMM——全新的 SM 架构。每个 SM 分为四个独立的处理块,每个处理块具备自己的指令缓冲区、调度器以及 32 个 CUDA 核心。因此 Maxwell 中可以同时运行 4 个以上的 Warp,实际上,在 GTC2013 大会上的一个 CUDA 优化视频里讲到,在常用 case 中推荐使用 30 个以上的有效 Warp,这样才能确保 Pipeline 的满载利用率。 —— Guohui Wang NVIDIA 公司生产的图形硬件所用的 warp 单位共有 32 个线程。而 ATI 公司采用的 “wavefront” 单位则具有 64 个线程,且建议为其分配的线程组大小应总为 wavefront 尺寸的整数倍。另外,值得一提的是,不管是 warp 还是 wavefront,它们的大小在未来几代中都有可能发生改变。 总之,每个 SM 的操作度是 warp,但是每个 SM 可以同时处理多个 warp。然后因为有内存等待(memory stall)的问题,同一个 thread block 有可能需要等待内存才做,因此可以使用多个线程组交叉运行。warp 对我们是不可见和不可编程的,我们可编程的只有线程组。[3] 还可以参考 GPU Open 中 Compute Shader 部分。 GPU Compute Unit 接下来我们看一下 GPU 内部的结构,这里的内容来自 Compute Shaders: Optimize your engine using compute / Lou Kramer, AMD,Lou Kramer 以 AMD 的 GCN 架构为例,介绍了 GPU 大体的结构。 这里 GCN 就是一个 Compute Unit,Vega 64 显卡有 64 个 Compute Unit。 GCN 有 4 个 SIMD-16 单元(即 16 个线程同时执行相同的指令序列)。 线程间交流 多个线程组间的交流 上面提到,线程并不能访问其他组中的共享内存。如果线程组需要互相交流,那么就需要 L2 cache 来支持。但是 L2 cache 性能肯定会有折扣,因此我们要保证组间的交流尽可能少。 单个线程组内的交流 如果单个线程组内线程需要互相交流,则需要 Local Data Share (LDS) 来完成。 LDS 会被其他着色阶段(shader stage)使用,例如像素着色器就需要 LDS 来插值。但是 Compute Shader 的用途和传统着色器不一样,不是必须要 LDS,因此我们可以随意地使用 LDS。 1 2 3 4 5 6 7 8 9 10 groupshared float data[8][8]; [numthreads(8,8,1)] void main(ivec3 index : SV_GroupThreadID) { data[index.x][index.y] = 0.0; GroupMemoryBarrierWithGroupSync(); data[index.y][index.x] += index.x; … } 需要组内共享的变量前加 groupshared ,同时为了保证其他线程也能读到数据,我们也需要通过 Barrier 来保证他们读的时候 LDS 里面有需要的数据。 LDS 比 L1 cache 还快! Vector Register 和 Scalar Register 如果有些变量是线程独立的,我们称之为 “non-uniform” 变量。(如果一个线程组内有 64 个线程,就要存 64 份数据) 如果有些变量是线程间共享的,我们称之为 “uniform” 变量,例如线程组 id 是组内每个线程都一样的。(每个线程组内只存 1 份数据) “non-uniform” 变量会被储存到 Vector Register(VGPR, vector general purpose register)中。 “uniform” 变量会被储存到 Scalar Register(SGPR, scalar general purpose register)中。 如果用了过多 “non-uniform” 变量导致 Vector Register 装不下,就会导致分配给 SIMD 的线程组数量降低。 与传统着色器执行流程的异同 Vert-Frag Shader 首先 Command Processor 会收集并处理所有命令,发送到 GPU,并告知下一步要做什么。 Draw() 命令发送后,Command Processor 告知 Graphics Processor 要做的事情。 我们可以将 Graphics Processor 看作是输入装配器(Input Assembler)的硬件对应的部分。 然后类似于顶点着色器这些就会被送到 Compute Unit 去计算,处理完会到 Rasterizer (光栅器),并返回处理好的像素到 Compute Unit 执行像素着色(Pixel shader)。 最后才会输出到 RenderTarget 。 下图中,AMD 显卡架构中的 Compute Unit 相当于 nVIDIA GPUs 中的流式多处理器(streaming multiprocessor, SM)。 Compute Shader 首先 Command Processor 仍会收集并处理所有命令,发送到 GPU。 我们不需要传数据到 Graphics Processor,因为这不是一个 Graphics Command,而是直接传到 Compute Unit。 Compute Unit 开始处理 Compute Shader,输入可以有 constants 和 resources(对应 DirectX 的 Resource 可以绑定到渲染管线的资源,例如顶点数组等),输出可以有 writable resources(UAV, Unordered Access View 能被着色器写入的资源视图)。 总结 因此,如果我们用了 Compute Shader,可以不通过渲染管线,跳过 Render Output,使用更少硬件资源,利用 GPU 来完成一些渲染不相关的工作。 此外,Compute Shader 的流水线需要的信息也更少。 Boids 示例 讲完了理论,这里来看看我们在 Unity 中使用 Compute Shader 来做一个鸟群(Boids)的 demo。 群落算法可以参考:Boids (Flocks, Herds, and Schools: a Distributed Behavioral Model) 代码示例地址:Latias94/FlockingComputeShaderCompare 群落算法简单来讲,就是模拟生物群落的自组织特性的移动。 Craig Reynolds 在 1986 年对诸如鱼群和鸟群的运动进行了建模,提出了三点特征来描述群落中个体的位置和速度: 排斥(separation):每个个体会避免离得太近。离得太近需要施加反方向的力使其分开。 对齐(Alignment):每个个体的方向会倾向于附近群落的平均方向。 凝聚(Cohesion):每个个体会倾向于移动到附近群落的平均位置。 在这个示例中,我们可以将每一只鸟的位置和方向用一个线程来计算,Compute Shader 负责遍历这只鸟的周围鸟的信息,计算出这只鸟的平均方向和位置。C# 脚本则负责每一帧传入凝聚(Cohesion)的位置、经过的时间,再从 Compute Shader 获取每一只鸟的位置和朝向,设置到每一只鸟的 Transform 上。 设置数据 文章开头的例子中,脚本给 Shader 设置了 RWTexture2D<float4> ,让 Compute Shader 能直接在 Render Tecture 设置颜色。 对于其他类型的数据,我们首先要定义一个结构(Struct),再通过 ComputeBuffer 与 Compute Shader 交流数据。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 // FlockingGPU.cs struct Boid { public Vector3 position; public Vector3 direction; }; public class FlockingGPU : MonoBehaviour { public ComputeShader shader; private Boid[] _boidsArray; private GameObject[] _boids; private ComputeBuffer _boidsBuffer; // ... void Start() { _kernelHandle = shader.FindKernel("CSMain"); uint x; // 获取 Compute Shader 中定义的 numthreads shader.GetKernelThreadGroupSizes(_kernelHandle, out x, out _, out _); _groupSizeX = Mathf.CeilToInt(boidsCount / (float) x); // 塞满每个线程组,免得 Compute Shader 中有线程读不到数据,造成读取数据越界 _numOfBoids = _groupSizeX * (int) x; InitBoids(); InitShader(); } private void InitBoids() { // 初始化 _Boids GameObject[]、_boidsArray Boid[] } void InitShader() { // 定义大小,鸟的数量和每个鸟结构的大小,一个 Vector3 就是 3 * sizeof(float) // 10000 只鸟,每只占6 * 4 bytes,总共也就占 0.234mib GPU 显存 _boidsBuffer = new ComputeBuffer(_numOfBoids, 6 * sizeof(float)); _boidsBuffer.SetData(_boidsArray); // 设置结构数组到 Compute Buffer 中 // 设置 buffer 到 Compute Shader,同时设置要调用的计算的函数 Kernel shader.SetBuffer(_kernelHandle, "boidsBuffer", _boidsBuffer); shader.SetFloat("boidSpeed", boidSpeed); // 设置其他常量 shader.SetVector("flockPosition", target.transform.position); shader.SetFloat("neighbourDistance", neighbourDistance); shader.SetInt("boidsCount", boidsCount); } // ... void OnDestroy() { if (_boidsBuffer != null) { // 用完主动释放 buffer _boidsBuffer.Dispose(); } } } 获取数据 在开头最简单的 Compute Shader 一节中,我介绍了需要 Dispatch 去执行 Compute Shader 的 Kernel。 下面的 Update,设置了每一帧会变的参数,Dispatch 之后,再通过 GetData 阻塞等待 Compute Shader kernel 的计算结果,最后对每一个 Boid 结构赋值。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // FlockingGPU.cs public class FlockingGPU : MonoBehaviour { // ... void Update() { // 设置每一帧会变的变量 shader.SetFloat("deltaTime", Time.deltaTime); shader.SetVector("flockPosition", target.transform.position); shader.Dispatch(_kernelHandle, _groupSizeX, 1, 1); // 调用 Compute Shader Kernel 来计算 // 阻塞等待 Compute Shader 计算结果从 GPU 传回来 _boidsBuffer.GetData(_boidsArray); for (int i = 0; i < _boidsArray.Length; i++) { // 设置鸟的 position 和 rotation _boids[i].transform.localPosition = _boidsArray[i].position; if (!_boidsArray[i].direction.Equals(Vector3.zero)) { _boids[i].transform.rotation = Quaternion.LookRotation(_boidsArray[i].direction); } } } } 在 Compute Shader 中,也要定义一个 Boid 结构和相对应的 RWStructuredBuffer<Boid> 来用脚本传来的 Compute Buffer。Shader 主要就是对一只鸟遍历一定范围内的鸟群的信息,计算出结果返回给脚本。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // SimpleFlocking.compute #pragma kernel CSMain #define GROUP_SIZE 256 struct Boid { // Compute Shader 也定义好相关的结构 float3 position; float3 direction; }; RWStructuredBuffer<Boid> boidsBuffer; // 允许读写的数据 buffer float deltaTime; float3 flockPosition; [numthreads(GROUP_SIZE,1,1)] void CSMain(uint3 id : SV_DispatchThreadID) { Boid boid = boidsBuffer[id.x]; // ... for (int i = 0; i < boidsCount; i++) { if (i == id.x) continue; Boid tempBoid = boidsBuffer[i]; // 通过周围的鸟的信息,计算经过三个特性后,这一只鸟的方向和位置。 // ... } // ... boid.direction = lerp(direction, normalize(boid.direction), 0.94); boid.position += boid.direction * boidSpeed * deltaTime; // 设置数据到 Buffer,等待 CPU 读取 boidsBuffer[id.x] = boid; } Dispatch 之后 GetData 是阻塞的,如果想异步地获取数据,Unity 2019 新引入一个 API:AsyncGPUReadbackRequest ,可以让我们先发送一个获取数据的请求,再每一帧去查询数据是否计算完。也有同学用了测出第一次调用耗时较多等问题,具体可以参考:Compute Shader 功能测试(二)。 下面是 100 只鸟的结果: 通过 Compute Shader,我们可以通过 Compute Shader 在 GPU 直接计算好需要计算的东西(例如位置、mesh 顶点等),并与传统着色器共享一个 ComputeBuffer ,直接在 GPU 渲染,这样就省去渲染时 CPU 再次传数据给 GPU 的耗时。我们也可以将 Compute Shader 计算后的数据返回给 CPU 再做额外的计算。总而言之,Compute Shader 十分灵活。 CPU 端计算 vs GPU 端计算 假设我们在 CPU 端不用任何 DOTS,直接在每个 Update 中 for 每个鸟计算朝向和位置,这样性能是非常差的。 下图是把计算都放到 C# Update 中的 Profile: 如果放到 Compute Shader 计算,每个 Update 更新数据,这样 CPU 消耗小了很多。 感兴趣的朋友可以对比下 FlockingCPU.cs 和 FlockingGPU.cs 的代码,会发现两者的代码其实十分相似,只不过前者把 for loop 放到脚本,后者放到了 Compute Shader 中而已,因此如果大家觉得有一些地方十分适合并行计算,就可以考虑把这部分计算放到 GPU 计算。 Profile Compute Shader 我们可以通过 Profiler 来看 GPU 利用情况,通常这个面板是隐藏的,需要手动打开。 也可以通过 RenderDoc 来看,这里不展示。 优化:DrawMeshInstanced 前面我们用 Instantiate 来初始化鸟群,其实我们也能通过 GPU instancing 来优化,用 Graphics.DrawMeshInstanced 来实例化 prefab。这个优化未包含在 Github 例子中,这里提供思路。 这么做的话,位置和旋转都要在传统 shader 中计算成变换矩阵应用在顶点上,因此为了防止 Compute Shader 数据传回 CPU 再传到 GPU 的传统 shader 的开销,需要两个 Shader 共享一个 StructuredBuffer 。 这样如果要给模型加动画的话,还得提前烘焙动画,将每一帧动画的顶点和当前帧数提前传到 vertex shader(or surface shader) 里做插值,这样做的话还能根据鸟的速度去控制动画的速率。 应用 遮挡剔除(Occlusion Culling) 环境光遮蔽(Ambient Occlusion) AMD’s Radeon HD 5850: The Other Shoe Drops 程序化生成: terrain heightmap evaluation with noise, erosion, and voxel algorithms AI 寻路 Compute Shader 做寻路有点不太好的就是往往游戏(CPU)需要知道计算结果,因此还要考虑 GPU 返回结果给 CPU 的延时。可以考虑做 CPU 端并行的方案,例如用 Job System。 GPU 光线追踪 GPU Ray Tracing in Unity – Part 1 图像处理,例如模糊化等。 其他你想放到 GPU,但是传统着色器干不了的并行的解决方案。 原神 Unity线上技术大会-游戏专场|从手机走向主机 -《原神》主机版渲染技术分享 解压预烘焙的 Shadow Texture 在离线制作的时候,对于烘焙好的 shadow texture 做一个压缩,尽量地去保持精度,运行的时候解压的速度也非常快,用 Compute Shader 去解压的情况,1K×1K 的 shadow texture,解压只需要 0.05 毫秒。 做模糊处理 在进行模糊处理的时候,每个像素需要采取周边多个像素的数值进行混合,可以看到,如果使用传统的 PS,每个像素都会需要多次贴图采样,且这些采样结果实际上是可以在相邻其他像素的计算中进行重用的,因此为了进一步提升计算性能,《原神》这里的做法是将模糊处理放到 Compute Shader 中来完成。 具体的做法是,将相邻像素的采样结果存储在 局部存储空间(Local Data Share) 中,之后再模糊的时候取用,一次性完成四个像素的模糊计算,并将结果输出。[4] 天涯明月刀 《天涯明月刀》手游引擎技术负责人:如何应用GPU Driven优化渲染效果?| TGDC 2020 做遮挡剔除(Occlusion Culling)时,CPU 只能做到 Object Level,而 GPU 可以通过切分 Mesh 做进一步的剔除。 知乎上也有人尝试了实现:Unity实现GPUDriven地形。 斗罗大陆 三七研发,这款被称作 “目前最原汁原味的”《斗罗大陆》3D 手游都用到了哪些 Unity 技术? 利用 Compute Shader 对所有美术贴图逐像素对比,筛选出大量的重复、相似、屯余、大透明的贴图。 Clay Book 基于3D SDF 体渲染的黏土游戏:Claybook Game。 演讲:DD2018: Sebastian Aaltonen - GPU based clay simulation and ray tracing tech in Claybook 动图:https://gfycat.com/gaseousterriblechupacabra Jelly in the sky Finished my compute shader based game 这帖子的哥们写了六千多行 HLSL 代码做了一个完全在 GPU 执行的基于物理模拟的游戏。 Steam:Jelly in the sky on Steam 动图:https://gfycat.com/validsolidcanine 开源项目 cinight/MinimalCompute krylov-na/Compute-shader-particles keijiro/Swarm ellioman/Indirect-Rendering-With-Compute-Shaders 缺点 虽然 Unity 帮我们做了跨平台的工作,但是我们仍然需要面对一些平台差异。 本小节内容大部分来自 Compute Shader : Optimize your game using compute。 难 Debug 数组越界,DX 上会返回 0,其它平台会出错。 变量名与关键字/内置库函数重名,DX 无影响,其他平台会出错。 如果 SBuffer 内结构的显存布局与内存布局不一致,DX 可能会转换,其他平台会出错。 未初始化的 SBuffer 或 Texture,在某些平台上会全部是 0,但是另外一些可能是任意值,甚至是NaN。 Metal 不支持对纹理的原子操作,不支持对 SBuffer 调用 GetDimensions。 ES 3.1 在一个 CS 里至少支持 4 个 SBuffer(所以,我们需要将相关联的数据定义为 struct)。 ES 从 3.1 开始支持 CS,也就是说,在手机上的支持率并不是很高。部分号称支持 es 3.1+ 的 Android 手机只支持在片元着色器内访问 StructuredBuffer。 使用 SystemInfo.supportsComputeShaders 来判断支不支持[5] 最后 我相信 Compute Shader 这个词不少读者应该都会在其他地方见过,但是大都觉得这个技术离我们还很远。我身边的朋友问了问也没怎么了解过,更不要说在项目上用了,这也是这篇文章诞生的原因之一。 当我们面临使用 DOTS 还是 Compute Shader 的抉择时,更应该从项目本身出发,考虑计算应该放在 CPU 还是 GPU,Compute Shader 中跟 GPU 沟通的开销是否能够接受。读者也可以参考下 Unity Forum 中相关的讨论:Unity DOTS vs Compute Shader。 开始碎碎念,去年的年终总结也没写,今年到现在就憋出一篇文章,十分不应该。其实也是自己没什么好分享的,自己还需要多学习。当然也很高兴通过博客认识到不同朋友,这是我写作的动力,谢谢你们。 参考 chenjd/Unity-Boids-Behavior-on-GPGPU 关于Compute Shader的一些基础知识记录 Compute Shaders: Optimize your engine using compute / Lou Kramer, AMD 《DirectX 12 3D 游戏开发实战》第13章 计算着色器 ↩︎ Render Hell —— 史上最通俗易懂的GPU入门教程(二) ↩︎ 知乎 - “问个CUDA并行上的小白问题,既然SM只能同时处理一个WARP,那是不是有的SP处于闲置?”的评论 ↩︎ 米哈游技术总监分享:《原神》主机版渲染技术要点和解决方案 ↩︎ ComputeShader 手机兼容性报告 ↩︎

2021/4/18
articleCard.readMore

博客新增公开笔记部分

我认为博客应该放一些经过思考的、实践的、适合读者阅读的文章,自己有时候也会在看其他视频教程或文章时记一些笔记。有些笔记本身不太适合分享出来,因为做笔记不可避免的会按照自己的思路和现有知识来定制,可能和别人注意重点不太一样。 因此我打算将一些比较成文的、有结构性的、有参考价值的笔记分享出来,这也能锻炼我把笔记组织成文的能力。 这些公开的笔记我放在独立的一个 Notion 页面中,这个页面可以点击博客上方的公开笔记,或者这个链接找到:公开笔记。 Notion 本身对公式、排版都比较友好,但是打开可能要科学上网,我是懒得把这些文章往博客搬了…不过用 Notion 有个好处就是,我对公开笔记的编辑都能实时更新到。 正文太空了也不好,就放个我笔记的主页图吧~

2020/10/3
articleCard.readMore

图形学常见的变换推导

注意:由于这个博客主题对 MathJax 支持不好,部分推导转用图片代替,或者可以移步我的 Notion 笔记:Transformation。 本文是 Games101-现代计算机图形学入门 第三和第四节课的笔记,文中对二维变换、三维变换、视图变换、正交投影和透视投影做了推导,相关视频在下方。 GAMES101-Lecture03 Transformation GAMES101-Lecture04 Transformation Cont. 本文同时参考了《Unity Shader 入门精要》的第四章,作者公开了第四章的 PDF,可以在下面下载到。 candycat1992/Unity_Shaders_Book 闫老师的推导十分简洁易懂,我也尽量把过程补充到文章中,读者看了我相信肯定也能跟着思路把变换公式推导出来。 在读本文的过程中,也推荐参考上面提到的视频和 pdf 互相参考,本文是视频中推导的详细笔记,冯乐乐的 pdf 中虽然没有投影变换的推导,但是在很多地方都把理论讲的十分清晰,例如必要的数学基础和各种图形学概念的讲解。 线性变换 x′=ax+by y′=cx+dy\begin{array}{l}x^{\prime}=a x+b y \\\ y^{\prime}=c x+d y\end{array} x′=ax+by y′=cx+dy​ 如果我们可以把变换写成这样一种形式,矩阵乘以输入坐标等于输出坐标,这样可以叫做线性变换。 [x′ y′]=[ab cd][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right][x′ y′​]=[a c​bd​][x y​] x′=Mx\mathbf{x}^{\prime}=\mathbf{M} \mathbf{x}x′=Mx Scale Matrix x′=sx y′=sy\begin{array}{l}x^{\prime}=s x \\\ y^{\prime}=s y\end{array}x′=sx y′=sy​ 其变换矩阵: [x′ y′]=[s0 0s][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}s & 0 \\\ 0 & s\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right][x′ y′​]=[s 0​0s​][x y​] Scale (Non-Uniform) x y 可以不均匀地缩放 [x′ y′]=[sx0 0sy][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}s_{x} & 0 \\\ 0 & s_{y}\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right][x′ y′​]=[sx​ 0​0sy​​][x y​] Reflection Matrix Horizontal reflection: x′=−x y′=y\begin{array}{l}x^{\prime}=-x \\\ y^{\prime}=y\end{array}x′=−x y′=y​ [x′ y′]=[−10 01][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{cc}-1 & 0 \\\ 0 & 1\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right][x′ y′​]=[−1 0​01​][x y​] Shear Matrix 2D Rotation Matrix 齐次坐标 Translation 平移变换非常特殊。 x′=x+tx y′=y+ty\begin{array}{l}x^{\prime}=x+t_{x} \\\ y^{\prime}=y+t_{y}\end{array}x′=x+tx​ y′=y+ty​​ 写出来简单,但是两个式子不能写成线性变换的形式。 [x′ y′]=[ab cd][x y]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right][x′ y′​]=[a c​bd​][x y​] 只能写成: [x′ y′]=[ab cd][x y]+[tx ty]\left[\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right]=\left[\begin{array}{ll}a & b \\\ c & d\end{array}\right]\left[\begin{array}{l}x \\\ y\end{array}\right]+\left[\begin{array}{l}t_{x} \\\ t_{y}\end{array}\right][x′ y′​]=[a c​bd​][x y​]+[tx​ ty​​] 因此平移变换并不是线性变换。 但是我们不希望将平移变换看作一个特殊的例子,那么有没有办法将缩放、错切、平移等变换用一种统一的方式来表示? 在计算机科学,永远要考虑 “Trade-Off”。数据结构中不同降低时间复杂度的办法都会引入空间复杂度。如果两者都能低就很好,但更多时候是非此即彼的事情。“No Free Lunch Theory”。 引入齐次坐标,可以通过增加一个维度来将平移变换也写成矩阵乘一个点的形式。 向量具有平移不变性,因此后面是 (x, y, 0),平移变换后也不变。 我们也可以通过 w 分量来推出我们操作的结果: Valid operation if w-coordinate of result is 1 or 0 vector + vector = vector point – point = vector point + vector = point point + point = ?? Affine Transformations 仿射变换 Affine map = linear map + translation (x′ y′)=(ab cd)⋅(x y)+(tx ty)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime}\end{array}\right)=\left(\begin{array}{ll}a & b \\\ c & d\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y\end{array}\right)+\left(\begin{array}{l}t_{x} \\\ t_{y}\end{array}\right)(x′ y′​)=(a c​bd​)⋅(x y​)+(tx​ ty​​) Using homogenous coordinates: (x′ y′ 1)=(abtx cdty 001)⋅(x y 1)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime} \\\ 1\end{array}\right)=\left(\begin{array}{ccc}a & b & t_{x} \\\ c & d & t_{y} \\\ 0 & 0 & 1\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y \\\ 1\end{array}\right) ​x′ y′ 1​ ​= ​a c 0​bd0​tx​ty​1​ ​⋅ ​x y 1​ ​ 2D Transformations Scale S(sx,sy)=(sx00 0sy0 00 1 )\mathbf{S}\left(s_{x}, s_{y}\right)=\left(\begin{array}{ccc}s_{x} & 0 & 0 \\\ 0 & s_{y} & 0 \\\ 0 & 0 & \text { 1 }\end{array}\right)S(sx​,sy​)= ​sx​ 0 0​0sy​0​00 1 ​ ​ Rotation R(α)=(cos⁡α−sin⁡α0 sin⁡αcos⁡α0 001)\mathbf{R}(\alpha)=\left(\begin{array}{ccc}\cos \alpha & -\sin \alpha & 0 \\\ \sin \alpha & \cos \alpha & 0 \\\ 0 & 0 & 1\end{array}\right)R(α)= ​cosα sinα 0​−sinαcosα0​001​ ​ Translation T(tx,ty)=(10tx 01ty 001)\mathbf{T}\left(t_{x}, t_{y}\right)=\left(\begin{array}{ccc}1 & 0 & t_{x} \\\ 0 & 1 & t_{y} \\\ 0 & 0 & 1\end{array}\right)T(tx​,ty​)= ​1 0 0​010​tx​ty​1​ ​ 逆变换 因此变换顺序是很重要的,不满足交换律。 R45⋅T(1,0)≠T(1,0)⋅R45R_{45} \cdot T_{(1,0)} \neq T_{(1,0)} \cdot R_{45}R45​⋅T(1,0)​=T(1,0)​⋅R45​ 矩阵是从右到左运算的: T(1,0)⋅R45[x y 1]=[101 010 001][cos⁡45∘−sin⁡45∘0 sin⁡45∘cos⁡45∘0 001][x y 1]T_{(1,0)} \cdot R_{45}\left[\begin{array}{l}x \\\ y \\\ 1\end{array}\right]=\left[\begin{array}{ccc}1 & 0 & 1 \\\ 0 & 1 & 0 \\\ 0 & 0 & 1\end{array}\right]\left[\begin{array}{ccc}\cos 45^{\circ} & -\sin 45^{\circ} & 0 \\\ \sin 45^{\circ} & \cos 45^{\circ} & 0 \\\ 0 & 0 & 1\end{array}\right]\left[\begin{array}{l}x \\\ y \\\ 1\end{array}\right]T(1,0)​⋅R45​ ​x y 1​ ​= ​1 0 0​010​101​ ​ ​cos45∘ sin45∘ 0​−sin45∘cos45∘0​001​ ​ ​x y 1​ ​ 矩阵没有交换律,但有结合律。 三维变换 齐次坐标系下的三维变换可以写成下面的形式 (x′ y′ z′ 1)=(abctx defty ghitz 0001)⋅(x y z 1)\left(\begin{array}{l}x^{\prime} \\\ y^{\prime} \\\ z^{\prime} \\\ 1\end{array}\right)=\left(\begin{array}{llll}a & b & c & t_{x} \\\ d & e & f & t_{y} \\\ g & h & i & t_{z} \\\ 0 & 0 & 0 & 1\end{array}\right) \cdot\left(\begin{array}{l}x \\\ y \\\ z \\\ 1\end{array}\right) ​x′ y′ z′ 1​ ​= ​a d g 0​beh0​cfi0​tx​ty​tz​1​ ​⋅ ​x y z 1​ ​ Scale S(sx,sy,sz)=(sx000 0sy00 00sz0 0001)\mathbf{S}\left(s_{x}, s_{y}, s_{z}\right)=\left(\begin{array}{cccc}s_{x} & 0 & 0 & 0 \\\ 0 & s_{y} & 0 & 0 \\\ 0 & 0 & s_{z} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)S(sx​,sy​,sz​)= ​sx​ 0 0 0​0sy​00​00sz​0​0001​ ​ Translation T(tx,ty,tz)=(100tx 010ty 001tz 0001)\mathbf{T}\left(t_{x}, t_{y}, t_{z}\right)=\left(\begin{array}{cccc}1 & 0 & 0 & t_{x} \\\ 0 & 1 & 0 & t_{y} \\\ 0 & 0 & 1 & t_{z} \\\ 0 & 0 & 0 & 1\end{array}\right)T(tx​,ty​,tz​)= ​1 0 0 0​0100​0010​tx​ty​tz​1​ ​ Rotation 绕轴旋转 Rotation around x-, y-, or z-axis Rx(α)=(1000 0cos⁡α−sin⁡α0 0sin⁡αcos⁡α0 0001)\mathbf{R}_{x}(\alpha)=\left(\begin{array}{cccc}1 & 0 & 0 & 0 \\\ 0 & \cos \alpha & -\sin \alpha & 0 \\\ 0 & \sin \alpha & \cos \alpha & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)Rx​(α)= ​1 0 0 0​0cosαsinα0​0−sinαcosα0​0001​ ​ Ry(α)=(cos⁡α0sin⁡α0 0100 −sin⁡α0cos⁡α0 0001)\mathbf{R}_{y}(\alpha)=\left(\begin{array}{cccc}\cos \alpha & 0 & \sin \alpha & 0 \\\ 0 & 1 & 0 & 0 \\\ -\sin \alpha & 0 & \cos \alpha & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)Ry​(α)= ​cosα 0 −sinα 0​0100​sinα0cosα0​0001​ ​ Rz(α)=(cos⁡α−sin⁡α00 sin⁡αcos⁡α00 0010 0001)\mathbf{R}_{z}(\alpha)=\left(\begin{array}{cccc}\cos \alpha & -\sin \alpha & 0 & 0 \\\ \sin \alpha & \cos \alpha & 0 & 0 \\\ 0 & 0 & 1 & 0 \\\ 0 & 0 & 0 & 1\end{array}\right)Rz​(α)= ​cosα sinα 0 0​−sinαcosα00​0010​0001​ ​ 绕着 x 轴旋转,说明 y 和 z 都是在进行旋转的,但 x 不变。因此绕 x 轴的旋转矩阵相比二维的旋转矩阵,第一行是不变的。中间部分和二维旋转矩阵一样。 绕 y 轴旋转不一样,这里涉及到我们要如何思考轴的相互顺序。 根据右手螺旋定则,x 叉乘 y 得到 z,y 叉乘 z 得到 x。但 z 叉乘 x 才能得到 y,是反的,因此 Ry 部分不一样。 Rodrigues’ Rotation Formula 我们能够解决一些简单的问题,复杂的问题可以转化成一些简单问题的组合。 给定根据三个轴的旋转,能否将某一个方向旋转到任意一个方向上去? Rotation by angle α round axis n 有人将任意一个旋转分解成通过 x y z 轴分别做旋转。 R(n,α)=cos⁡(α)I+(1−cos⁡(α))nnT+sin⁡(α)(0−nzny nz0−nx −nynx0)\mathbf{R}(\mathbf{n}, \alpha)=\cos (\alpha) \mathbf{I}+(1-\cos (\alpha)) \mathbf{n} \mathbf{n}^{T}+\sin (\alpha)\left(\begin{array}{ccc}0 & -n_{z} & n_{y} \\\ n_{z} & 0 & -n_{x} \\\ -n_{y} & n_{x} & 0\end{array}\right)R(n,α)=cos(α)I+(1−cos(α))nnT+sin(α) ​0 nz​ −ny​​−nz​0nx​​ny​−nx​0​ ​ 证明过程可以参考闫令琪老师的证明: GAMES101_Lecture_04_supp.pdf 公式给了我们一个旋转矩阵,定义中给了我们一个旋转轴 n 和旋转角度 α。旋转角度好理解,但旋转轴似乎不能这么简单地定义。因为一个旋转轴首先跟起点有关系,然后跟方向有关系,只给一个向量是不是不太合适? 假如说沿着 y 轴旋转,跟沿着 x 和 n 各等于 1 并且也是沿着 y 方向的向量。方向一样,但起点不一样,结果肯定也是不一样的。因此我们说沿着某个轴的方向旋转,就默认了是过原点的,这样起点就在原点上,方向就是 n 方向。 那么如果轴 n 可以平移怎么办?那么我们可以将其进行变换的分解。如果我们要沿着任意轴旋转且轴的起点不在原点,我们可以将所有的东西移到起点为原点的条件下,再旋转,再移回去。 四元数相关 我们上面所用到的旋转矩阵是不太适合做插值的,例如二维旋转 10 度的旋转矩阵加二维旋转 20 度的旋转矩阵求平均,不能得到二维旋转 15 度的旋转矩阵。四元数在这方面方便很多。 View/Camera Transformation 视图变换 定义相机 定义一个相机需要三个变量,位置,朝向,和一个向上的方向。 视图变换 当相机和要拍的东西一起移动的时候,那拍出来的相片是一样的。也就是说,当我们移动物体时,只要同时以相同的方式移动相机,没有相对位置,那么得出来的结果就是一样的。 如果我们将相机放在一个固定的位置上,那么所有东西在移动时,都可以认为是其他东西在移动,而相机一直在原点不动。相机永远往 -z 方向看,以 y 轴为向上方向(右手坐标系,符合 OpenGL 传统)。这是约定俗成的。相机放在原点有很多好处,能简化计算。 从坐标空间的角度来看,就是将物体和相机从世界空间转到观察空间(摄像机空间)。 我们要将相机移到原点,就需要先把相机中心 e 平移到原点,还得把观察的方向 g 移到 -z 上,再把向上方向 t 旋转到 y 方向上,把 g X t 的方向移到 x 方向上。 下面将这系列操作转为矩阵操作。 求视图变换矩阵 先把相机中心 e 平移到原点 Tview=[100−xe 010−ye 001−ze 0001]T_{v i e w}=\left[\begin{array}{cccc}1 & 0 & 0 & -x_{e} \\\ 0 & 1 & 0 & -y_{e} \\\ 0 & 0 & 1 & -z_{e} \\\ 0 & 0 & 0 & 1\end{array}\right]Tview​= ​1 0 0 0​0100​0010​−xe​−ye​−ze​1​ ​ 平移矩阵写好后,接下来写旋转矩阵。 把观察的方向 g 旋转到 -z 上,把向上方向 t 旋转到 y 方向上,g X t (g 叉乘 t)的方向旋转到 x 方向上 Rotate g to -z , t to y, g X t To x (世界空间到观察空间) Consider its inverse rotation: x to g X t , y to t, z to -g (观察空间到世界空间) 我们可以反过来写,例如把 x 轴 (1,0,0 ) 旋转到 g X t 方向上的旋转矩阵,就比 g X t 移到 x 轴的旋转矩阵要好写很多,而这两个旋转矩阵是互逆的。写出 x 轴旋转到 g X t 方向的旋转矩阵后,再求其逆变换就是我们所需要的 g X t 移到 x 轴的旋转矩阵。 x to g X t , y to t, z to -g 的旋转矩阵就是: 这里 z to -g 是因为我们定义相机的坐标空间为右手坐标系。 Rview−1=[xg^×t^xtx−g0 yg^×t^yty−g0 zg^×t^ztz−g0 0001]R_{v i e w}^{-1}=\left[\begin{array}{cccc}x_{\hat{g} \times \hat{t}} & x_{t} & x_{-g} & 0 \\\ y_{\hat{g} \times \hat{t}} & y_{t} & y_{-g} & 0 \\\ z_{\hat{g} \times \hat{t}} & z_{t} & z_{-g} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right]Rview−1​= ​xg^​×t^​ yg^​×t^​ zg^​×t^​ 0​xt​yt​zt​0​x−g​y−g​z−g​0​0001​ ​ 要验证也很简单,用该旋转矩阵变换 x 轴就能得到 g X t 的方向。 那么我们的旋转矩阵就能通过对上面的矩阵求逆得出: 因为旋转矩阵是正交矩阵,因此要求逆矩阵,对其转置即可。 Rview=[xg^×t^yg^×t^zg^×t^0 xtytzt0 x−gy−gz−g0 0001]R_{v i e w}=\left[\begin{array}{cccc}x_{\hat{g} \times \hat{t}} & y_{\hat{g} \times \hat{t}} & z_{\hat{g} \times \hat{t}} & 0 \\\ x_{t} & y_{t} & z_{t} & 0 \\\ x_{-g} & y_{-g} & z_{-g} & 0 \\\ 0 & 0 & 0 & 1\end{array}\right]Rview​= ​xg^​×t^​ xt​ x−g​ 0​yg^​×t^​yt​y−g​0​zg^​×t^​zt​z−g​0​0001​ ​ 这样我们世界空间到观察空间的变换矩阵就能得出来了:M_view=R_view·T_view 其中 V_g×t 为 g×t 的向量,V_e 为相机原点。 相机需要进行这种变换,变换到约定俗成的位置(原点)上去,那么其他所有物体也需要做这样的变换,这样相对运动不变。这个就是视图变换。 模型变换和视图变换经常一起被称为模型视图变换(ModelView Transformation)。 Projection Transformation 投影变换 Projection in Computer Graphics 3D to 2D Orthographic projection Perspective projection Perspective projection vs. orthographic projection Orthographic Projection 正交投影 方法一 A simple way of understanding Camera located at origin, looking at -Z, up at Y (looks familiar?) Drop Z coordinate Translate and scale the resulting rectangle to [−1,1]2[-1,1]^{2}[−1,1]2 将坐标中的 z 扔掉,如何区分物体的前和后? 感兴趣可以参考 Catlikecoding Render 1 中 Orthographic Camera 部分。 方法二 In general, we want to map a cuboid [l, r] x [b, t] x [f, n] to the “canonical (正则、规范、标准)” cube [-1,1]^3 我们在 x 轴上定义左和右 [l, r] (左比右小),y 轴上定义下和上 [b, t](下比上小),z 轴上定义远和近 [f, n](远比近小)。 不管 x, y 多大,都将其映射到 [-1, 1] 之间。这也是个约定俗成的事情,能方便计算。这样任何空间中的长方体,都可以映射成一个标准的立方体。 这也是**标准化设备坐标(NDC)**的定义。 上面的左比右小是相对于 x 轴来说的,下比上小是相对于 y 轴说的,但 z 轴上不太直观,因为我们推导的 NDC 是右手坐标系,(相机)看的是 -z 方向,因此一个面离我们远,说明 z 值更小。离我们近,说明 z 值更大。 在标准化设备坐标系中 OpenGL 使用的是左手坐标系,因为左手系在这一点上会比较方便。但也会造成别的问题:x × y ≠ z。 这里可以参考:LearnOpenGL 进入3D 的 右手坐标系 部分。 Slightly different orders (to the “simple way”) Center cuboid by translating 移到原点 Scale into “canonical” cube 映射到 [-1, 1],也就是缩放 Translate (center to origin) first, then scale (length/width/height to 2) 因为 -1 到 1 的长度就是 2。 因此我们可以用一个平移矩阵和缩放矩阵来求出正交投影矩阵,先平移,再缩放: 如果把长方体范围缩成立方体,物体不会被拉伸吗? 会,这就涉及到另外一个变换。在所有变换做完之后,还要做一个视口变换,还要做一次拉伸。 Perspective Projection 透视投影 Most common in Computer Graphics, art, visual system Further objects are smaller Parallel lines not parallel; converge to single point 平行线就是永不相交的两条线,但照片上铁轨是平行的,却交于一点。透视投影的情况下,一个平面相当于被投影到了另外一个平面上,这种情况下就不是平行线了。 Recall Before we move on Recall: property of homogeneous coordinates (x, y, z, 1),(k x, k y, k z, k !=0), (x z, y z, z^2, z !=0) all represent the same point (x, y, z) in 3D 只要一个点乘于一个不为零的 k,那么它们还是一个点。那么我们还可以将其乘以 z,其表示的点还是空间中同样的点。下面我们会用到。 e.g. (1, 0, 0, 1) and (2, 0, 0, 2) both represent (1, 0, 0) Simple, but useful 怎么做透视投影 How to do perspective projection First “squish” the frustum into a cuboid (n→n, f→f) (M_persp→ortho) Do orthographic projection ( M_ortho, already known!) 透视投影的视锥体中,远的平面比近的平面要大。 我们可以把远的平面往里“挤”,“挤”到同一高度且同近平面大小,“挤”成空间中的长方体,再做正交投影就解决了。 我们已经知道正交投影怎么做了,因此剩下的就是“挤”这个操作。 在这个过程中,需要规定: 近平面上任何一个点不变。 Z 值不变 远平面的中心也不会发生变化 求出任何一个点挤压后的 x’, y’ 值 要做“挤”的操作,首先要知道任何一个点的 x, y 值是怎么变化的。因为我们任何一个面都要挤成近平面大小,我们也可以将 (x,y,z) 投影到近平面上求出变换后的 x’, y’ 值。对于 x, y 值来说,这种变换是线性的。 因此,在视锥体的上面一部分中,我们可以通过相似三角形求出变换后的 x’, y’ 值。(z’ 值不是线性变化的,后面会提到) 上图中,n 为近平面的 z 值,z 为任何一个点(x,y,z)中的 z 值。 挤压后的 y’ 值,我们可以通过相似三角形原理得出: y′=nzyy^{\prime}=\frac{n}{z} yy′=zn​y 同理可得挤压后的 x’ 值: x′=nzxx^{\prime}=\frac{n}{z} xx′=zn​x 在齐次坐标系中,对于变换后的 (x’, y’, z’) 我们只剩下 z’ 未知。 这里给矩阵乘了 z,其表示的点还是空间中同样的点。 也就是说 (x,y,z,1) 经过 Mpersp→ortho 矩阵“挤压”后,会被映射到 (nx,ny,??,z): Mpersp→ortho(4×4)(x y z 1)=(nx ny  unknown  z)M_{p e r s p \rightarrow o r t h o}^{(4 \times 4)}\left(\begin{array}{c}x \\\ y \\\ z \\\ 1\end{array}\right)=\left(\begin{array}{c}n x \\\ n y \\\ \text { unknown } \\\ z\end{array}\right)Mpersp→ortho(4×4)​ ​x y z 1​ ​= ​nx ny  unknown  z​ ​ 根据上式,我们可以得出部分的 Mpersp→ortho 矩阵: Mpersp→ortho=(n000 0n00 ???? 0010)M_{p e r s p \rightarrow o r t h o}=\left(\begin{array}{llll}n & 0 & 0 & 0 \\\ 0 & n & 0 & 0 \\\ ? & ? & ? & ? \\\ 0 & 0 & 1 & 0\end{array}\right)Mpersp→ortho​= ​n 0 ? 0​0n?0​00?1​00?0​ ​ 对于 z,我们不知道 z 会怎么变,我们只规定了近的平面上和远的平面上 z 不变。 Observation: the third row is responsible for z’ Any point on the near plane will not change 近平面的点不变,对于任何 (x,y,n,1) 运算完了一定还是 (x,y,n,1) Any point’s z on the far plane will not change 远平面的点,虽然 x, y 会变化,但是 z 没有变。 求出任何一个点挤压后的 z’ 值 由“近平面的点不变,对于任何 (x,y,n,1) 运算完了一定还是 (x,y,n,1)”可得: 这里给矩阵乘了 n,其表示的点还是空间中同样的点。 因此 Mpersp→ortho 第三行一定是 (0,0,A,B) 的形式,因为: 由上式可得: An+B=n2A n+B=n^{2}An+B=n2 前面我们已经知道第三行前两个数是 0。 我们前面已经规定了远平面的中心经过 Mpersp→ortho 变换后也不会发生变化。 另外一个等式可以用远平面可以用其特殊的中心点得出,给中心点再乘个 f 可得: 平截头体(Frustum)被压缩成长方体以后,内部的点的 z 值是更偏向于近平面还是更偏向于远平面? 可以参考 ScratchAPixel 的 The Perspective and Orthographic Projection Matrix Depth Precision Visualized 定义视锥 前面提到了长方体近平面的 l, r, b, t,有没有更好的方法去定义这些呢? vertical field-of-view (fovY) and aspect ratio 我们现实中相机有视角的定义,也就是可以看到的角度的范围,也就是 field of view。广角相机就是可视角度比较大,对于视锥体来说,就是张的比较开。 垂直的可视角度就是 fovY。而相机的长宽比就是 aspect ratio。 我们也可以通过 fovY 和 aspect ratio,来推出水平的可视角度。 How to convert from fovY and aspect to l, r, b, t? 完成推导正交投影矩阵 正交投影没有 fovY,在 Unity 中,正交投影的参数由 Camera 组件中的参数 Size, Near, Far(Viewport Rect 暂时忽略)和 Game 视图的横纵比(aspect ratio)共同决定。 这里的 Near 是近裁面的距离,也就是 -n,Far 同理,等于 -f。 Size 属性用来更改视锥体竖直方向上高度的一半,也就是前面近平面的高度 t。 由此可得正交投影近远平面的高度 t-b 为:2·Size=2·t 正交投影近远平面的宽度 r-l 为: Aspect⋅近远平面的高度=2⋅Aspect⋅Size=2⋅Aspect⋅tAspect\cdot \text{近远平面的高度}=2\cdot Aspect\cdot Size=2\cdot Aspect\cdot tAspect⋅近远平面的高度=2⋅Aspect⋅Size=2⋅Aspect⋅t 注意:这里的 n 和 f 是 -z 轴上的,代表近裁面和远裁面的 z 值,值为负数。 完成推导透视投影矩阵 前面已经得出: 注意:这里的 n 和 f 是 -z 轴上的,代表近裁面和远裁面的 z 值,值为负数。 通常我们透视投影的参数除了近裁面远裁面的距离外,还会有 fov 和 Aspect,且 r+l=0,因此整理公式可得: 后记 在长文的最后,我强烈推荐大家也手推一下各种变换,n, f 取 -z 轴上的 z 值或绝对值(也就是距离)得出来的变换矩阵也不一样,都推导一遍可以理解更深刻。 此外,我们也可以开始实现一个简单的 CPU 软光栅渲染器,我近期也在准备写一个软光栅,把必要的过程都推导一遍,到时候再写博文分享一下。

2020/7/27
articleCard.readMore

2020年5月技术导读

最近一直在思考自己在技术学习上是否过于低效的问题。出现这个问题的缘由是,自己工作下班之后没多少精力和时间去留给自己学习。本来就有限的时间,如果还没有合适的学习计划,而是一昧凭兴趣去学一些离自己目标方向比较远的东西,可能会事倍功半。 就我而言,自己目前的水平更像是“磨”出来的,东学学西学学,什么都有点印象,但是专业方向的知识之间的连接却非常离散。读研的时候有大量时间给我“磨”,总归能学会点什么,但工作则不一样。 例如一本《Shader 入门精要》目前看了两次,但仍然害怕离开书来写 Shader。再者,一门技术很久不用,再拿起来也需要时间。为此,我总结了三点原因: 工作中很少用到,游戏有特效师,自己也不会主动地思考怎样的 Shader 能为游戏带来更好的效果。 学的时候只光看了,跟着书敲了,但一直没有走出书本,去自己实现自己想要的功能。 觉得 Shader 像 CSS 一样,要记各种东西,凭经验实现效果,而这种知识自己最容易不用就忘。(当然这是错的,这也是为什么我在上期导读选择跟 Games101 图形学课程打基础) 基于上面的原因,导读也是偏向自己打基础的方面。 上一期的博文在:《2020年4月技术导读》 书籍 《软技能2:软件开发者职业生涯指南》 豆瓣地址 其实正如上文所说,我学东西靠的是“磨”,但这并不是什么好的学习方式。而且除去编程,作为一个开发者,我们仍然有很多“软技能”需要注意,例如自己的职业规划、如何发现技能短板、如何推进职业生涯等等。 我相信不止是我,很多开发者也在不断地摸索如何成为一个目标中资深且专业的开发者。而本月出版的这本书就是为这方面而量身定做的,是著名的《软技能》的第二版。这本书目前看到的翻译都十分优秀,每次开卷都有益。如果你也想专注于自己的职业发展,我十分推荐这本书。 下面是书中介绍的十步学习法,自己根据它来调整了下学习计划: 公开课 Games101 图形学入门 这门公开课我已经在上期导读中提到:《2020年4月技术导读》,再次提到是因为经过学习,我认为课程中理论部分讲的深入浅出,而且也能了解到当下流行的图形技术和它们是怎么跟我们所学的联系在一起。 闫老师提到他的学习方式是:Why, What then How,课程也是按照这种思路来设计的。先问为什么要学这个东西,然后问学的是什么,最后了解这东西具体是怎么运作的、怎么用。How 部分是最不重要的,也是最容易忘的部分,我们只需把握好 Why 和 What 部分即可。 那课程是怎么把知识点联系起来的呢,就凭借一个个 Why 来连接。拿光线追踪来举例,下面是我做的笔记: 如果你对笔记感兴趣,我也将其分享了出来:《光线追踪》。 The Cherno 的游戏引擎教程 油管地址 B站搬运地址 教学版引擎仓库地址 Up 主 The Cherno 在自己的 CPP 和 OpenGL 教程之后,再基于两者推出了游戏引擎教程。引擎的仓库是跟着视频教程的进度而推进的,有兴趣的可以了解下。 The Missing Semester of Your CS Education 课程地址 B站搬运地址 这门公开课是通过知乎专栏《6.NULL:恨不相逢“未嫁时”》了解到的。 Classes teach you all about advanced topics within CS, from operating systems to machine learning, but there’s one critical subject that’s rarely covered, and is instead left to students to figure out on their own: proficiency with their tools. We’ll teach you how to master the command-line, use a powerful text editor, use fancy features of version control systems, and much more! 简而言之,这门课不是教计算机的理论,而是介绍一些常用的工具,让你在面对问题的时候有更多的手段。自己可以看看一些不熟悉的主题查漏补缺。 MIT 教授 Gilbert Strang 「线性代数」课程 课程地址 这门公开课是通过知乎专栏《86岁还在录网课:MIT教授Gilbert Strang最新「线性代数」课程上线》了解到的。 这门课程在今年录制,目前有 6 个视频讲座,对于之前录制的课程也可以在 B 站找录播,例如:麻省理工公开课:线性代数。 具体介绍可以看知乎专栏,自己对于这门课程优先级比较低,线代遇坎儿了再学。 其他计算机公开课 有网友总结了国外的计算机课程,还附上了视频链接,可以参考《电子工程和计算机科学系》。 文章 图形程序学习经历 文章作者认为从图形 API 开始从底向上了解硬件做的事情到摸透一整个流程是个好的学习过程,其中也给出了很多有价值的参考资料。作者声称能以较快速度在两个月满足工作要求,完成图形程序入门的知识体系。 Entity Component System for Unity: Getting Started Ray Wenderlich 的教程通常会提供初始项目,手把手带你写代码,加上独特风格的配图,很适合用来入门某个技术。这次带来的是 Unity ECS 的入门,用 Entities 包实现一个坦克射击游戏。 Fixing Performance Problems - 2019.3 Unity 发表的关于解决性能问题的教程,里面有很多小知识点,对初学者可能有帮助。虽然之前好像看过,但这是 2019.3 版本的。 Cache的基本原理 作者图文并茂地讲解了缓存内部是如何运作的,Visio 手绘风格的图画也很清晰明了。对缓存感兴趣的还可以进一步关注作者的专栏。 Refactoring.Guru 这网站也图文并茂地介绍了重构、 设计模式、 SOLID 原则 (单一职责、 开闭原则、 里氏替换、 接口隔离以及依赖反转) 以及其他和智能编程主题相关的内容。 每位程式設計師都該知道的記憶體知識 这系列文章是 Ulrich Drepper 於 2007 年写的论文《What Every Programmer Should Know About Memory》的中文繁体翻译,这篇论文十分有名,游戏开发者都应该看看了解内存的细节。 Zero allocation code in C# and Unity 文章作者开源过叫 Svelto 的 ECS 框架,这篇文章是他对 Zero allocation 代码(也就是自己管理内存)的理解,也介绍了相关的概念、书籍和很多查看内存分配的工具,对想了解 DOTS 的同学可以阅读一下。 最后 其实本篇导读相当一部分在讲的是我个人怎么样看待自己目前学习情况,每次导读中所有的内容我其实只能看完一部分,怎么样更高效地学和怎么样更好地平衡生活是我最近不断思考的问题。当然了,我不会放弃。 这次导读分享的多是游戏内存、缓存、图形学相关的内容,自己没分享过某种具体实现的 Shader 的文章,是因为我认为自己基础要先打好,这也是我对其他 Shader 课程的态度。希望这次分享能对读者有帮助。

2020/5/24
articleCard.readMore

2020年4月技术导读

首先,感谢所有将自己时间贡献在知识分享的人们。 本文将简单地介绍下最近我看到的想学的、有意思的教程、视频等,内容不仅限于游戏开发。大家可以做个参考,说不定其中就能找到适合自己的教程,进一步打算接下来的学习。 这个文章类型和之前写的都不一样,一方面有自己时间少、沉淀不够、不能持续输出的原因,另一方面是因为这些东西都比较碎,也不好在记录生活的微博上发布一些专业的东西。至于这个导读会不会成为一个系列来更新,还要看看我有没有足够东西来分享啦。 书籍 《程序员修炼之道(第2版)》 豆瓣地址 在我经过了入门阶段之后,发现很多以前学的很多教程,都很难在工作中进行参考。恰巧云风翻译的第二版已经出版了,里面的内容让我对编程“修炼”有了更深的理解。 投资知识,收益最佳。 ——本杰明·富兰克林 学习学习再学习! 游戏开发:世嘉新人培训教材 豆瓣地址 这本书我从 17 年等到现在,三年了,拿到手之后觉得所有的等待都是值得的。 从第一章就能看出作者踏实的编程功底和力求和对优化的不倦追求。一个命令行推箱子游戏从过程式的实现,到运用 C++风格的面向对象的实现,到章节末为了讲解底层内存存储结构而把项目中所有变量都存在一个内存数组中的实现。从中作者讲解基础的 C++ 语法、位运算、指针和引用的异同等概念。 接下来的章节,作者还讲解 2D 图形处理、状态、碰撞、数学等等游戏密不可分的章节,由于作者都为章节提供了类库,因此不必担心书是否会过时,读者只需踏踏实实跟着作者打基础即可。 虽然我很少写 C++,也清楚学 C++ 在游戏开发中是逃不开的,干脆就啃这本书的同时把 C++ 也给上手了,一箭双雕! Data-Oriented Design 一本深度讲解面向数据设计的书籍,当你熟悉了面向对象编程,可以尝试看看这本书,用一种不同的思维方式看待你的数据。Unity 开发者在 ECS 正式版公布之前,也可以先深入了解一下面向数据设计的模式。 作者在其网站公开了书籍内容,地址为:Data-Oriented Design 同时也可以参考这篇导读:Highly recommended read : dataorienteddesign.com/dodbook 公开课 图形学 零基础如何学习计算机图形学?——闫令琪的回答 游戏编程类书籍涉及的内容太杂,不适合用作专门学习图形学的参考资料。OpenGL / Shader 确实是应该学习的内容,但是以我的理解来看,更适合先入门图形学基础之后再去进一步学习,这样会简单很多。这也是我个人的理念:我们要学的是图形学,而不是图形学 API,这两者应该完全分开看待。另外 OpenGL / Shader 这块学多了会容易让人产生理解偏差,觉得图形学就等于实时渲染(当然是错的,但是很多人真的这么认为!)。 于是闫老师发布了一门公开课:“GAMES101: 现代计算机图形学入门”。 本课程将全面而系统地介绍现代计算机图形学的四大组成部分:(1)光栅化成像,(2)几何表示,(3)光的传播理论,以及(4)动画与模拟。每个方面都会从基础原理出发讲解到实际应用,并介绍前沿的理论研究。通过本课程,你可以学习到计算机图形学背后的数学和物理知识,并锻炼实际的编程能力。 官网:GAMES101: 现代计算机图形学入门 B站录播:GAMES101-现代计算机图形学入门-闫令琪 课程作业系统:GAMES2020在线课程:计算机图形学(闫令琪)课程作业系统 国外 MIT CMU 部分课程翻译 Simviso 是一个翻译国外课程的民间组织,目前已经翻译了:MIT 6.004 计算机组成原理、MIT 6.824 分布式系统 2020、CMU数据库15-445/645、斯坦福编译原理 等顶级教程。他们接下来的翻译计划也在 simviso 国外MIT CMU 斯坦福计算机科班顶级课程翻译系列 中提到。(我在想这一段字的知识浓度得有多高 Orz) 原理性的东西参考这些教程最好不过了,我们还可以跟着课程手写一个数据库、CPU 等等。 教程 Custom SRP Catlike Coding > Custom SRP Catlike Coding 的教程一向是从基础讲到进阶应用,在 SRP 的主题中,他写了不少基于 Unity 2019 版本的自定义渲染流水线的教程,目前还在持续更新。 Ray Tracing In One Week 跟着 Ray Tracing In One Week 系列教程一步步来做个光线追踪器,也可以在学完上面图形学公开课中的光线追踪部分后来看这个教程作为补充。这个教程也可以用 Unity 来做,例如我的 Latias94/RayTracingInOneWeekWithUnity,虽然只跟到第八章 = =。 跟完这个教程之后,如果对进一步的 DOTS、优化方面学习感兴趣的话,我还提供了下面四篇文章/项目作为参考。 Daily Pathtracer 跟着 aras-p 做一个 C# + burst 路径追踪器,还可以参考一下陈嘉栋老师的介绍:《Daily Pathtracer!安利下不错的Pathtracer学习资料》 Unity Toy Path Tracer 路径追踪器的另一个实现,该作者想进一步探索 Unity 的 DOTS 技术栈,尤其是 Burst + Job System,对这方面感兴趣的不要错过。 weekend-tracer 又一个 C# + burst 的路径跟踪器实现。 GPU Ray Tracing in One Weekend 用 Compute Shader 实现光追的基于 GPU 的实现。 Go 语言探索万物原理 了解了当前使用的技术,可以扩宽领域,学习一些和项目不相关的东西,说不定会带来灵感。 老司机带你飞系列 Github 地址:happyer/distributed-computing 《老司机带你用 Go 语言实现 MapReduce 框架》 《老司机带你用 Go 语言实现 Raft 分布式一致性协议》 《老司机带你用 Go 语言实现 Paxos 算法》 《老司机带你用 Go 语言实现分布式数据库》 7 天用 Go 动手写/从零实现系列 Github 地址:geektutu/7days-golang 7天用Go从零实现Web框架 - Gee 7天用Go从零实现分布式缓存 GeeCache 7天用Go从零实现ORM框架 GeeORM 视频 【双语】纪念碑谷的关卡创作挑战 | Monument Valley’s Level Design | Mix and Jam Mix and Jam 用六分钟清晰地讲解了纪念碑谷制作思路,并提供了项目参考。 文章 Ray Wenderlich - Introduction to Shaders in Unity Ray Wenderlich 的教程通常会提供初始项目,手把手带你写代码,加上独特风格的配图,很适合用来入门某个技术。这次带来的是如何写 Unity Shaders,实现一个简单的水流 Shader。 最后 看了看内容,这次分享的视频和文章都偏少了,其实主要是想分享前面的书籍和公开课,有机会的话有价值的视频和文章我也会多关注,并且分享到导读中。如果你喜欢这系列,或者想对这个系列有其他的想法,欢迎在评论区告诉我。

2020/4/14
articleCard.readMore

《图解 C# 教程 第5版》与性能优化

这本书仍然是入门 C# 最好的一本书。 这本书新版出来的时候我十分关注,于是英子姐送了一本给我,本文也是答应英子姐所写的一篇文章。她一开始还问我“你现在还需要看这本入门书吗?”,我认为是的。工作了遇到了不少问题,大都跟自己基础不牢有关系。 这本书以图形为载体,生动地介绍了 C# 语言本身。其中图形对我们了解 C# 语法在内存中的本质十分有帮助,异步、异常等章节中的处理流程图也很清晰明了,这也是我看重的一点。 本文会从本书出发,简单讲讲代码优化的一些点。 在内存中的形态 为什么要了解 C# 在内存中的形态呢? 书中第四章介绍内存区域的栈中,有一句话说的很好: 作为程序员,你不需要显式地对它做任何事情。但了解栈的基本功能可以更好地了解程序在运行时在做什么,并能更好地了解 C# 文档和著作。 游戏开发中,除了业务逻辑,我们还会更关注游戏的性能本身。我们需要保证游戏能流畅运行在大部分机型上,保证每一帧能流畅地播放,例如 CPU 需要处理渲染代码、物理模拟、动画回调等等,其中我们的代码也有可能引起性能问题。我们需要更了解执行代码的代价,例如: 这些代码产生了多少 GC GC 只会产生一次还是每帧都会产生 在极端情况下代码的性能如何 是否使用了正确的数据结构 Unity API 或者一些库 API 的背后到底做了什么 … 这本书相对上一版多了 .Net Core, C# 7.0 语法的讲解,对于我而言,重温的是第 4、7、11、13、15、17、19 和 27 章节,这些内容是我工作中经常要接触、着手优化的地方。书中对于异步编程也介绍地很好,但对于我来说,反射、异步编程、新增语法等到以后有需要再看也不迟。 脚本的性能优化,无非是用更合适的代码去实现需求,不必要的内存都给我吐出来!(注:作者在生活中并没有这么吝啬) 下面会列举一些代码写法的性能对比。 结构和类 这其实也是用栈还是用堆的考量。 垃圾回收 Unity 用的是 mono 虚拟机,其堆的内存是通过垃圾回收算法 Boehm GC 来管理的,其不分代(Non-generational)和非压缩式(Non-compacting)的特性,导致了我们平常要注意避免加载过多的小内存,从而内存碎片化(Memory fragmentation)。 分代:大块内存、小内存、超小内存分在不同内存区域来进行管理。此外还有长久内存,当有一个内存很久没动的时候会移到长久内存区域中,从而省出内存给更频繁分配的内存。 压缩式:当有内存被回收的时候,压缩内存会把下图空的地方重新排布。 内存碎片化:内存过多小内存,导致大内存不能有效地被使用。 具体可以参考 Unity 文档 Understanding the managed heap 同时也推荐高川老师的演讲:浅谈 Unity 内存管理,和我看视频时的笔记:笔记。 用结构还是类 这里推一篇微软官方文档:Choosing Between Class and Struct 引用类型被分配在堆上并被垃圾回收算法管理,值类型则分配在栈上,栈会按需 unwind 来释放他们。因此,值类型的释放比引用类型的释放开销要小。 书中 11.9 小节还提到: 对结构进行分配的开销比创建类实例小,所以使用结构代替类有时可以提高性能,但要注意装箱和拆箱的高昂代价。 值类型数组的分配和释放比引用类型数组的分配和释放开销也更小。 除了最基本的修改值类型和引用类型的区别外,要注意的是传递参数或者返回返回值的时候,值类型都会隐性地被创建,这可能也会产生没想到的内存开销。 从 .Net 内存分配成本的角度来说,类的对象储存的内存首先需要分配 4 个字节作为对象头字节(object header word),跟着再分配 4 个字节作为方法表指针(method table pointer),这些字段是服务于 JIT 和 CIL 的,是隐藏的分配成本。 保留在堆中所需的内存还会根据操作系统位数来决定: 32 位系统中,堆上的对象会对齐到最近 4 字节的倍数,因此如果一个对象只有一个 byte 成员,也需要对齐占 4 个字节,因此这个对象总共占堆上 12 个字节。 64 位系统中,堆上的对象会对齐到最近 8 字节的倍数,方法表指针和对象头字节也会分别占 8 字节的内存。 (注:平常开发我们不需要这么抠门,上面只是一个小知识点。) 大多时候我们都会用类型来实现设计模式、框架的设计,那什么时候使用结构体呢?我们可以遵循微软爸爸的建议: 逻辑上表示单个值 大小小于 16 个字节 不会改变值 不需要经常装箱拆箱 对于一些特定的场景下,我们也可以享受值类型数组在内存中线性排布的福利,例如内存连续、SIMD 等。Unity 的 DOTS 技术栈就是一个很好的例子。 推荐阅读: 在C#中使用Struct代替Class 作者用 DOTS(结构体、Job System、Burst)实现 A 星寻路实现性能飞跃:y2b 搜 “Pathfinding in Unity DOTS!” 装箱 第 17 章介绍了转换,其中提到了装箱拆箱。那么装箱的代价有多大呢?我们可以做个测试: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public const int Iterations = 100_000; // 其他地方初始化了 Iterations 大小的随机数组 private int[] numberArr = null; protected override bool MeasureTestA() { // 设定大小以避免自动扩容带来的性能消耗 Stack stack = new Stack(Iterations); for (int i = 0; i < Iterations; i++) { stack.Push(numberArr[i]); // int -> object 装箱 } return true; } protected override bool MeasureTestB() { Stack<int> genericStack = new Stack<int>(Iterations); for (int i = 0; i < Iterations; i++) { genericStack.Push(numberArr[i]); } return true; } 查看 Profiler 可以看到,调用十次 TestA 产生了 26.7MB GC,用了 267.22 毫秒;调用十次 TestB 只产生了 3.8MB GC,用了 20.92 毫秒。因此大量的装箱拆箱会导致不必要的性能消耗,而有些消耗则是完全可以避免的。 我的 Rider 插件 Heap Allocations Viewer 也会提示我 TestA 中存在装箱的情况。 最后 写到这里,发现还很多东西没讲完就已经这么多篇幅了… 大家对于平常代码的不同写法也可以测试下性能,例如: foreach 和 for 装箱和拆箱 一维数组、多维数组(矩形数组)和交错数组(Jagged Arrays) 这里还是强调下尽量使用一维数组,实在需要用多维数组的话,可以改用交错数组 通过 for 循环复制数组和 Array.CopyTo 方法 字符串拼接,string 和 Stringbuilder 反射和 DynamicMethod … 上面装箱的截图中的测试项目我也上传了 Github:Latias94/UnityCsharpPerformanceTest,不用 Unity 的同学也可以参考下实际的测试代码:UnityCsharpPerformanceTest/Assets/Scripts,自己写个命令行项目来跑下对比。 当然我们开发中还是要以需求的变化为主,不能过早优化从而破坏代码的扩展性。

2020/2/1
articleCard.readMore

2019 年的收获与成长

今年发生了很多事情,博客也因此从七月停更到了现在,实在惭愧…现在趁着年终,赶紧抓住 2019 年的尾巴了,来总结下我的这一年。 本文真的会很啰嗦,但是希望能帮到希望用 Unity 恰饭或者其他技术恰饭的同学。 毕业 今年的上半年完成了研究生的学业,结束了留学生涯。 我的专业课程比较松,总共两年要修 16 节课的学分,但是必修课只有四节,因此我可以尽可能的选择有实战的课程。这边课程的大作业大多需要团队协作,但是有些 IT 研究生同学是跨专业过来的,不是很能写代码,所以有时候挺考验自身能力的(笑。 我组完队一般都希望能把大作业的规模做大些,一方面是作业能拿比较好的分数,另一方面是求职的时候可以拿出能往简历上放的项目经验。 毕业后我发现这种选择是对的,我的队友在毕业后还问我们的线上项目怎么开不起来了,他们也到了找工作的时间,还让我帮忙看了看简历。澳洲工作是很休闲的,大部分下午五六点就能下班。身边同学也在努力地留下来,考 PTE、CCL 考试凑分拿 PR。自己因为还是想做游戏,澳洲环境不太好,就回了国。 从面试到工作 由于课程结束三个月后才能参加毕业典礼拿到毕业证,当时我对求职还不太上心,还想等着春招。但是又不想在家里混吃混喝,就开始每天刷刷面试题,学学感兴趣的,同时也开始在某直聘找工作,打算每周面试一次,接触下当前的就业形势,同时查漏补缺。 第一周 第一周面试了家一百多人的游戏公司,一上来要求三十分钟解一道 Leetcode hard 的题…好不容易解出来了,又要求递归改迭代,又问有没有能优化的点。之后还问了些逻辑题,这时候挺庆幸自己复习了《程序员面试经典(第5版)》(第六版刚出噢),基本还能 hold 住场面,但是后来分析算法的时间复杂度分析的十分糟糕,于是就没有然后了… 当天十分沮丧,恰巧面试的地方和一个主美朋友工作的地方很接近,就约了个饭。他开导我说:”拿美术来说,不同公司也会需要不同的美术:古风游戏自然需求专精画古风的,科幻游戏需求的美术风格很明显也不一样。再面试几家就好,今天面试只代表公司不适合你。“我听了很有道理!于是继续不务正业学了喜欢的东西,简单复习复习算法,刷了刷题。 第二周 第二周又接到另一个游戏公司 HR 的面试邀请,面试时直接来了三个面试官,两个程序大佬一个制作人。很明显的,面试风格都不一样。他们事先看了我的简历,看了我的博客。刚好第一周的时候更新了一篇 DOTS 的博文,于是他们一开始就让我介绍下 Unity 的 DOTS 技术栈是什么,还有一些概念细节。后来的其他问题很明显能感觉他们在考察我知识的广度,例如图形学,我简历上提到的 C# 热更新等。 刚好那段时间”不务正业“地跟着《自己动手实现Lua》写了一半的 Lua 虚拟机,于是问到对 Lua 是否熟悉的时候,我就提了一嘴最近在学的东西,接着又展开新的问答。整个过程中,我觉得面试官的风格和第一周公司的面试风格完全不一样,但是有些地方还是答得不够好,于是又在家瞎学。 一周后,我拿到第二家公司的 Offer,成为了公司工具人。 我觉得从面试就能看出公司关注的是开发人员的哪些方面,如主美朋友所说,如果不愿意改变自己学习的风格,那就找到需求这种风格的公司,接下来的工作也印证了这一点。 上面提到的内容 《程序员面试经典(第6版)》(第5版) DOTS 相关博文《洞明 Unity ECS 基础概念》 《自己动手实现Lua:虚拟机、编译器和标准库》,用Go语言实现 Lua 虚拟机、编译器和标准库。 了解代码的另一面 入职后,才发现公司写了一套自己的 C# 热更新,这种热更新是和 xLua 一样的注入式热更,跟 ET 框架分两个项目跑的还不一样(下文会解释)。有意思的是,在我入职过了几个月后,xLua 作者也开源了C# 注入式的热更新项目:InjectFix,作者还配套写了一套 IL 的运行时,听说性能还比 ILRuntime 更好些。 感兴趣的可以先看看 xLua 作者的讲解: 如何评价腾讯在Unity下的xLua(开源)热更方案? 作为游戏开发者,如何看待腾讯最新发布的Unity热修复工具InjectFix? 先前基于 ILRuntime 的热更新,例如 ET 框架,大多是分两个项目,主项目和热更项目,热更项目放一些需要经常修改的战斗逻辑、UI 界面等。这样可以直接把热更项目都放在 ILRuntime 上跑,整个项目都能热更,十分方便,但是这样十分依赖 ILRuntime 的性能。 那么注入式的热更有什么区别呢?我们给每个函数前加 if 判断,如果 ILRuntime 上跑的(能热更的)DLL里面有对应的方法,就执行热更的方法,这样 ILRuntime 的性能问题也能避免开来,因为我们可能只有需要热更的函数在 ILRuntime 上面跑,而不是整个项目。 那么,古尔丹,代价是什么呢? ——格罗玛什·地狱咆哮 代价就是能热更的东西极其局限,只能热更函数、和新增的类等。 在了解原因之前,我们先来看看例子,假设我们游戏就这么多代码: 1 2 3 4 5 6 7 8 9 10 // Unity 2019.2 之前,Scripting Runtime Version: .Net 3.5 Equivalent(Deprecated) public class TestIL : MonoBehaviour { void Start() { int[] arr = {1, 2, 3, 4}; Action action = () => Debug.Log("Hello IL"); action(); } } 上面是看上去如古天乐平平无奇的代码,当我们用 dnSpy 反编译 Library\ScriptAssemblies\Assembly-CSharp.dll 后会发现: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class TestIL : MonoBehaviour { ... private void Start() { int[] array = new int[] { 1, 2, 3, 4 }; Action action = delegate() { Debug.Log("Hello IL"); }; action(); } // 编译器生成的匿名函数 [CompilerGenerated] private static void <Start>m__0() { Debug.Log("Hello IL"); } [CompilerGenerated] private static Action <>f__am$cache0; } 编译器为我们的 Action 生成了匿名函数,那也就是说如果我需要更改 Debug.Log 中打印的字符串,我只需在热更 DLL 中提供:修改后的函数 + 编译器生成的匿名函数就 okay 了?实际上没那么简单,因为编译器又作妖了。 1 2 3 4 5 6 void Start() { int[] arr = {1, 2, 3, 4}; Action action = () => Debug.Log("Hello " + arr[0]); // 修改打印 action(); } 再次查看反编译后的 DLL: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 private void Start() { int[] arr = new int[] { 1, 2, 3, 4 }; Action action = delegate() { Debug.Log("Hello " + arr[0]); }; action(); } [CompilerGenerated] private sealed class <Start>c__AnonStorey0 { public <Start>c__AnonStorey0() { } internal void <>m__0() { Debug.Log("Hello " + this.arr[0]); } internal int[] arr; } 由于 action 中引用了局部变量,mono 编译器将本该生成的匿名方法生成了匿名类,并在调用的时候传入 arr int 数组。 现在我们调整下我们的热更新策略:如果我们检测到编译器生成的匿名函数,将其转换成匿名类,再把这个新增的类复制到热更 DLL 中。 还是有问题! 这时候就需要认识 C# 的中间语言—— MSIL(又称 IL),每句 C# 代码都可以转换成可读性较好的类似于机器代码的 IL 代码。 当我们查看 Start 函数的 IL 代码时: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 .method private hidebysig instance void Start () cil managed { .maxstack 4 .locals init ( [0] class TestIL/'<Start>c__AnonStorey0', [1] class [System.Core]System.Action ) IL_0000: newobj instance void TestIL/'<Start>c__AnonStorey0'::.ctor() IL_0005: stloc.0 IL_0006: nop IL_0007: ldloc.0 IL_0008: ldc.i4.4 IL_0009: newarr [mscorlib]System.Int32 IL_000E: dup // 下面这串是什么?怎么又引用了另外一个类? IL_000F: ldtoken field valuetype '<PrivateImplementationDetails>'/'$ArrayType=16' '<PrivateImplementationDetails>'::'$field-1456763F890A84558F99AFA687C36B9037697848' IL_0014: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle) IL_0019: stfld int32[] TestIL/'<Start>c__AnonStorey0'::arr IL_001E: ldloc.0 IL_001F: ldftn instance void TestIL/'<Start>c__AnonStorey0'::'<>m__0'() IL_0025: newobj instance void [System.Core]System.Action::.ctor(object, native int) IL_002A: stloc.1 IL_002B: ldloc.1 IL_002C: callvirt instance void [System.Core]System.Action::Invoke() IL_0031: ret } // end of method TestIL::Start 在 dnSpy 中找了找,发现了 PrivateImplementationDetails 类: 这看起来应该是这个数组被存到了某个地方,这个类只是提供了 id 告诉 IL 这个数组应该在哪找?通过查询 Roslyn 编译器的文档,发现了这个类的注释:”The main purpose of this class so far is to contain mapped fields and their types.“ 所以我们要热更的话,还需要将 PrivateImplementationDetails 类复制到热更 DLL 中。 我们怎么分析代码是否是匿名函数呢?Mono.Cecil 就是一套基于 IL 分析程序集的库,我们可以通过这个库来判断哪些方法不能热更等,这又是另外一个话题,略过不提。 以上只是注入热更的一个小插曲,但是涉及的东西就已经与一开始 Start 方法中的三行代码相去甚远了。如果我们还要支持重载函数的热更,泛型类中函数的热更,就更是让人掉头发的话题,涉及的 IL 十分复杂。 现代的高级语言为我们封装了太多东西,提供了方便编程的语法糖,但也为我们知根知底的学习方式设立了门槛。 但是当我们了解了 IL 中间语言的话,我们以后面对”在匿名函数引用 for 循环中变量的行为会诡异“等问题的时候,我们可以直接反编译 DLL 来看代码真正的面目是怎么样的。 不小心写了足以充当一篇文章的内容,但是我想表达的是: 对于游戏开发者,我们有必要对自己的代码做了什么有充分的了解,谨慎运用语法糖,这样才能充分掌握游戏的性能。 虽说我们远远没达到造 InjectFix 轮子的程度,但是了解该技术的根基——IL,再尝试根据其他文档来分析,能让我们更好的了解这个框架的背后,了解这种热更新的优缺点。 上面提到的内容 InjectFix C# 热更新 dnSpy .Net 反编译编辑器 Mono.Cecil 基于 IL 分析程序集的库,dnSpy 也是基于这个库来分析的。 自顶向下,再自底向上 游戏开发很多技术都倚重于硬件的能力,因此我们有必要对这些硬件的实现和原理有所了解。但这方面也是我的弱点,我个人喜欢按照兴趣来学,因此我的总结的方法是:自顶向下,再自底向上。 就如上面热更新的例子一般,自顶向下就是揭开抽象的面纱,从感兴趣的框架或库的应用入手,逐步通过各种方式来了解底层的原理。 拿 DOTS 技术栈做例子,ECS 的编程模式保证数据线性地排列在内存中,恰好能内存 cache 缓存命中率,从而得到更高效的数据读取。更多细枝末节可以先放一旁,例如 Shared components 这种与理念不相符的共享组件是怎么实现的。 知道了内存 cache 缓存命中率在其中发挥巨大作用后,我刷知乎还发现,Disruptor 库为了对齐 128 个字节方便 CPU cache line 用,选择浪费空间提高缓冲命中率。 到底内存的结构是怎么样的?缓存命中率又是什么?字节对齐又是什么?为什么这样的做法能提高性能?带着种种疑问,我们开始自底向上地了解内存结构。 从感兴趣的应用入手,了解硬件底层,这样不至于太枯燥。学到一定程度,当我们把知识网中重要的几个概念都了解之后,我们可以再阅读相关操作系统的书,将周边的知识点与之前比较大的概念相连接,互相印证,从而结成结实的知识图谱。 一开始我想重新捡起《编码:隐匿在计算机软硬件背后的语言》这本书,这本书从手电筒聊天开始,依次讲了摩斯电码、二进制、再到继电器电路、组合成 CPU 等等,能解答我的疑惑,但是后来发现节奏偏慢。于是又看了 Crash Course Computer Science(计算机科学速成课) 中讲 CPU 的一集,觉得节奏非常好,通过 12 分钟视频讲解除法电路、缓存、流水线设计、并行等概念,揭开计算机一层一层的抽象,我认为这是自底向上最好的教材。另外在知乎刷到一篇文章:《带你深入理解内存对齐最底层原理》也是很好的佐料。 了解了硬件原理之后,这种优化能带到实践中吗?一维数组的顺序访问比逆序访问要快,那么二维数组呢? 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class TestCache : MonoBehaviour { private const int LINE = 10240; private const int COLUMN = 10240; void Start() { int[,] array = new int[LINE, COLUMN]; Stopwatch stopwatch = new Stopwatch(); stopwatch.Start(); for (int i = 0; i < LINE; i++) // 344 ms { for (int j = 0; j < COLUMN; j++) { array[i, j] = i + j; // 一行一行访问 } } stopwatch.Stop(); Debug.Log("用时1:" + stopwatch.ElapsedMilliseconds + " ms"); stopwatch.Restart(); for (int i = 0; i < LINE; i++) // 1274 ms { for (int j = 0; j < COLUMN; j++) { array[j, i] = i + j; // 一列一列访问 } } stopwatch.Stop(); Debug.Log("用时2:" + stopwatch.ElapsedMilliseconds + " ms"); } } 一行一行访问和一列一列访问的效率很明显不一样,自此,我们自底向上地把对硬件的了解反映到了实际编码中。 由于自己就是兴趣导向的学习,所以学东西难免有所偏科,但是我认为只要感兴趣的够多,就不怕偏科(笑。每个人有自己学习的节奏,在这里只是提供一种思路。 上面提到的内容 《Unity DOTS 走马观花》 你在开源项目里看到过哪些精髓的代码片段? 《编码:隐匿在计算机软硬件背后的语言》 Crash Course Computer Science(计算机科学速成课)第九集:高级 CPU 设计 建议空闲的同学从第一集开始看起,内容十分详实有趣。 《带你深入理解内存对齐最底层原理》,其专栏也是个宝藏之地。 找到适合自己的学习方式 说起来容易,但是要运用之前,我首先需要知道学习这种知识的途径有哪些,才能从中选择最适合自己的学习方式。 拿 Unity 中的渲染来说,我能通过相关官方文档来学,能通过看博文来了解,也能通过 Direct3D、OpenGL 相关教程来学,或者重新拿起图形学的书本,因为都会涉及到渲染流水线的知识。 那么怎么样的才算是适合自己的学习方式呢?有的同学可能喜欢先把理论吃一遍再来实战,我喜欢通过动手来了解。我尝试跟着 LearnOpenGL 教程和一些 Shader 教程来学习渲染管线的知识,也初识 Batch(批次)、GPU Instancing 等名词,但是不同教程都有不同侧重:Shader 教程可能着重教你如何实现各种效果,而图形学课程可能对这些名词的背后实现语焉不详。 之后一直压着疑惑使用着这些技术,我后来又发现了一些选择:要不自己写一套软渲染器?又或者我可以通过 Unity 新出的 Scriptable Render Pipeline(可编程渲染管线)来自定义一套自己的渲染管线?这似乎已经很靠近我想学的东西了,再考虑自己的时间和兴趣,我决定跟着 catlikecoding 的 SRP 教程写一个可编程渲染管线,尝试以一种较底层的角度来了解渲染管线相关的实现。 上面提到的内容 LearnOpenGL 英文版 中文版 《Unity Shader 入门精要》 图形学:Scratchapixel Scriptable Render Pipeline 可编程渲染管线(目前在学 catlikecoding 的教程): Custom SRP (Unity 2019 and later) Scriptable Render Pipeline(Unity 2018) 啰嗦了这么多,其实只是想分享下我学习的经验:正所谓曲高和寡,越是进阶的知识点,教程风格越五花八门,当然也会越少人写。每个人有自己的学习风格,尽量多拓展自己的知识来源,不要将自己限制在国内教程和书籍中。 如何扩展知识来源呢? 国内外出版社书籍(此处推荐英子姐写的《程序员最喜欢的技术书大都出自这 20 家出版社》) O’Relly Learning 的书籍分类做的比较好,可以拿来关注新书的出版。 关注国内出版社的书讯 关注 Youtube 博主、博客博主、知乎专栏等(我通过 RSS 订阅) Github 看有没有人上传自己的 Demo,例如一个小型的光线追逐实现、或者一个神奇宝贝 Unity 复刻版等。 线下 Meetup 技术分享。 有了选择,就可以根据自己兴趣爱好来跟着学习,想办法把他们学到脑子里! 先写,再优化 工作后的这段时间我一直在思考,我距离独立造轮子还差多少能力。先写框架划分模块?还是先实现功能?如何写出高内聚低耦合的代码?抛去代码可读性和模块的划分不谈,写出一个轮子最基本的功能对于我可能都是一个难题。 我的工位在写出热更新的大佬的旁边,一开始大佬说招我的原因,是希望我能接受他的热更新框架,继续维护。可惜自己能力不够,看懂了实现,但是却无从下手,之后也就不了了之。不过有段时间,我问大佬问题问到什么程度呢?我一转头瞄下他,他就会划着电脑椅过来等我提问… 大佬在工作室中负责战斗以外的技术攻坚,有需求的话就会主动去学,去实现,C# 热更新框架就是他的作品。后来团队觉得可能要通过帧同步来防外挂,他又开始看相关的论文,文章来从头写,虽然到现在团队还没用上。耳濡目染之下,我也会跟着大佬去看帧同步相关的内容,有时候当当小黄鸭,帮他查漏补缺。 **对于造轮子,有需求,有思路,就先开始写!**这是我这段时间从大佬身上学到的一点。例如他认为我们 UI 的数据不好管理,于是参考了《Flux 架构》和其他文章,准备将这种理念与 Unity 结合,于是他又开新坑了。 他给我的评价是:知识面较广,好学,就是不肯开始写。 说的没错,太多考虑反而会束手束脚,适得其反。先把功能写出来,设计模式按照经验来划分,能用了再优化,这是功利的做法,但也是能让人顾虑少地造轮子的做法。 新的开始 工作的节奏和上学完全不一样,虽说是 9.5 6.5 5,但是两个小时的通勤仍然让我措手不及。公司下班吃完饭,回到家九点,随便看点啥就十一点了,早上还得八点多起… 最后尝试更换了出行方式,从地铁改成坐巴士上班,这样有位置坐,才能静下心看看书。尽量把阅读安排在通勤时间上,这样才能勉强保持学的进度。 工作方面压力也还行,平常有时间的话能关注下业务逻辑以外的东西。例如有一次运营拿来一个我们游戏的脚本样本,我花了点时间解包,学了学反编译,再让负责战斗的大佬针对性地做了点预防。这个过程对我而言也是新的经验,其中应对 iOS 外挂时借鉴了图灵的《九阴真经:iOS黑客攻防秘籍》的思路。 前不久转了正,算是给学生时代交了份答卷。随着见识的增长,自己对博文和代码又有了更高的要求。见识了“好”的文章后,自己怎么写才能组织好文章,才能更好地讲述一些知识点? 我们需要放下书本,去实践,去体验,去观察,去琢磨,去尝试,去创造,去设计,去stay hungry, stay foolish。 号称终极快速学习法的费曼技巧,究竟是什么样的学习方法? 学习、实践、总结、再清楚地解说一件事,并将其写成博文,这是我对我下一阶段的要求。其中学习总结的速度也得跟上,我已经看到有读者抱怨我博客断更了…… 今年读过的书: 《Unity3D网络游戏实战(第2版)》 《游戏架构:核心技术与面试精粹》 《深入理解C#(第3版)》 《利用Python进行数据分析 原书第2版》 《白话机器学习算法》 《游戏安全——手游安全技术入门》 《九阴真经:iOS黑客攻防秘籍》 《网络多人游戏架构与编程》 《自己动手实现Lua : 虚拟机、编译器和标准库》 《C#从现象到本质》 明年想更深入多线程、内存布局、和渲染相关的话题。 加油吧,感谢 2019 年帮助过我的所有人。

2019/12/4
articleCard.readMore

洞明 Unity ECS 基础概念

虽然网络上已经有不少 ECS 文档的汉化,但自己读官方文档的时候也会产生不少疑问,为此通过查询各种资料,写下本文。 本文从 ECS 官方文档出发,加之内存布局结构的讲解,力求读者能够和博主一起吃透 ECS 中的基本概念。同时建议读者可以先读读我的上一篇博文《Unity DOTS 走马观花》的 ECS 部分,本文不再复述前文已经提到过的相关概念。 ECS 与 Job System 我认为有必要重申其两者的关系。 Job System 能帮我们方便地写出线程安全的多线程代码,其中每个任务单元称为 Job。 ECS,又称实体组件系统。与传统的面向对象编程相比,ECS 是一种基于数据设计的编程模式。前文从内存结构分析了 OOP 模式的缺点,也提到了 ECS 是怎么样基于数据的设计内存结构的。 Job System 是 Unity 自带的库,而要使用 ECS 我们需要从 Package Manager 中安装 “Entities” 预览包。这两者虽说完全是两种东西,但是他们能很好地相辅相成:ECS 保证数据线性地排列在内存中,这样通过更高效的数据读取,能有效提升 Job 的执行速度,同时也给了 Burst 编译器更多优化的机会。 Entities(实体) 在 World中, EntityManager 管理所有实体和组件。 当你需要创建实体和为其添加组件的时候, EntityManager会一直跟踪所有独立的组件组合(也就是原型 Archetype)。 创建实体 最简单的方法就是在编辑器直接挂一个 ConvertToEntity 脚本,在运行时中把 GameObject 转成实体。 在编辑器中挂脚本,GameObject 会在运行时中转成实体 脚本中,你也可以创建系统(System)并在一个 Job 中创建多个实体,也可以通过 EntityManager.CreateEntity 方法来一次生成大量 Entity。 我们可以通过下面四种方法来创建一个实体: 用 ComponentType 数组创建一个带组件的实体 用 EntityArchetype 创建一个带组件的实体 用 Instantiate 复制一个已存在的实体和其当前的数据, 创建一个空的实体然后再为其添加组件 也可以通过下面的方法一次性创建多个实体: 用 CreateEntity 来创建相同原型(archetype)的实体并填满一个 NativeArray (要多少实体就提前设定好 NativeArray 的长度) 用 Instantiate 来复制一个已存在的实体并填满一个 NativeArray 用 CreateChunk 来显式创建内存块(Chunks),并且填入自定数量的给定原型的实体 增加和移除组件 实体被创建之后,我们可以增加和移除其组件。当我们这样做的时候,相关联的原型(Archetype)将会被改变, EntityManager 也需要改变内存布局,将受影响的数据移到新的内存块(new Chunk of memory),同时也会压缩原来内存块中的组件数组。 对实体的修改会带来内存结构的改变。 实体的修改包括: 增加和移除组件 改变 SharedComponentData的值 增加和删除实体 这些操作都不能放到 Job 中执行,因为这些都会改变内存中的数据结构。因此我们需要用到命令(Commands)来保存这些操作,将这些操作存到 EntityCommandBuffer 中,然后在 Job 完成后再依次执行 EntityCommandBuffer 中储存的操作。 World(世界) 每一个 World 包含一个 EntityManager 和一系列的 ComponentSystem。一个世界中的实体、原型、系统等都不能被另外一个世界访问到。你可以创建很多 World ,例如通常我们会使用或创建一个负责主要逻辑运算的 simulation World 和负责图形渲染的 rendering World 或 presentation World。 当我们点击运行按钮进入 Play Mode 时,Unity 会默认创建一个 World,并且增加项目中所有可用的 ComponentSystem。我们也可以关闭默认的 World 从而自己创建一个。 Default World creation code (see file: Packages/com.unity.entities/Unity.Entities.Hybrid/Injection/DefaultWorldInitialization.cs) Automatic bootstrap entry point (see file:Packages/com.unity.entities/Unity.Entities.Hybrid/Injection/AutomaticWorldBootstrap.cs) Components(组件) ECS 中的组件是一种结构,可以通过实现下列接口来实现: IComponentData ISharedComponentData ISystemStateComponentData ISharedSystemStateComponentData EntityManager 会组织所有实体中独立的组件组合成不同的原型(Archetypes),还会将拥有同样原型的所有实体的组件(数据)储存到一起,都放到同一个**内存块(Chunks)**中。 如果你为一个实体新增了一个组件,那么其原型就改变了,实体的数据也需要从原来的内存块移到新的内存块,因为只有相同原型的实体数据才会放到相同的内存块中。 一个原型由很多个内存块组成,这些内存块中存的都是拥有相同原型的实体。 General Purpose Component(普通用途组件) 这里指的是最普通的组件,可以通过实现 IComponentData 接口来创建。 IComponentData 不存储行为,只储存数据。IComponentData 还是一个结构体(Struct)而不是一个类(Class),这意味着被复制时默认是通过值而不是通过引用。 通常我们会用下面的模式来修改组件数据: 1 2 3 4 5 6 var transform = group.transform[index]; // Read transform.heading = playerInput.move; // Modify transform.position += deltaTime * playerInput.move * settings.playerMoveSpeed; group.transform[index] = transform; // Write IComponentData 结构不包含托管对象(managed objects)的引用,所有IComponentData 被存在无垃圾回收的块内存(chunk memory)中。 你可能还听过一种组件是不包含数据、只用来标记的“Tag”组件(Tag component),其用途也很广,例如我们可以轻易地给实体加标记来区分玩家和敌人,这样系统中能更容易通过组件的类型来筛选我们想要的实体。如果我们给一个内存块(Chunk)中的所有实体都添加"Tag“组件的话,只有内存块中对应的原型会修改,不添加数据,因此官方也推荐利用好”Tag“组件。 See file: /Packages/com.unity.entities/Unity.Entities/IComponentData.cs. Shared components(共享组件) Shared components 是一种特殊的组件,你可以把某些特殊的需要共享的值放到 shared component 中,从而在实体中与其他组件划分开。例如有时候我们的实体需要共享一套材质,我们可以为需要共享的材质创建 Rendering.RenderMesh,再放到 shared components 中。原型中也可以定义 shared components,这一点和其他组件是一样的。 1 2 3 4 5 6 7 8 9 [System.Serializable] public struct RenderMesh : ISharedComponentData { public Mesh mesh; public Material material; public ShadowCastingMode castShadows; public bool receiveShadows; } 当你为一个实体添加一个 shared components 时, EntityManager 会把所有带有同样 shared components 的实体放到一个同样的内存块中(Chunks)。shared components 允许我们的系统去一并处理相似的(有同样 shared components 的)实体。 内存结构 每个内存块(Chunk)会有一个存放 shared components 索引的数组。这句话包含了几个要点: 对于实体来说,有同样 SharedComponentData 的实体会被一起放到同样的内存块(Chunk)中。 如果我们有两个存储在同样的内存块中的两个实体,它们有同样的 SharedComponentData 类型和值。我们修改其中一个实体的 SharedComponentData 的值,这样会导致这个实体会被移动到一个新的内存块中,因为一个内存块共享同一个数组的 SharedComponentData 索引。事实上,从一个实体中增加或者移除一个组件,或者改变 shared components 的值都会导致这种操作的发生。 其索引存储在内存块而非实体中,因此 SharedComponentData 对实体来说是低开销的。 因为内存块只需要存其索引,SharedComponentData 的内存消耗几乎可以忽略不计。 因为上面的第二个要点,我们不能滥用 shared components。滥用 shared components 将让 Unity 不能利用好内存块(Chunk),因此我们要避免添加不必要的数据或修改数据到 shared components 中。我们可以通过 Entity Debugger 来监测内存块的利用。 拿上一段 RenderMesh 的例子来说,共享材质会更有效率,因为 shared components 有其自己的 manager 和哈希表。其中 manager 带有一个存储 shared components 数据的自由列表(freelist),哈希表可以快速地找到相应的值。内存块里面存的是索引数组,需要找数据的时候就会从 Shared Component Manager 中找。 其他要点 EntityQuery 可以迭代所有拥有相同 SharedComponentData 的实体 我们可以用 EntityQuery.SetFilter() 来迭代所有拥有某个特定 SharedComponentData 的实体。这种操作开销十分低,因为 SetFilter 内部筛选的只是 int 的索引。前面说了每个内存块都有一个SharedComponentData 索引数组,因此对于每个内存块来说,筛选(filtering)的消耗都是可以忽略不计的。 怎么样获取 SharedComponentData 的值呢?EntityManager.GetAllUniqueSharedComponentData<T> 可以得到在存活的实体中(alive entities)的所有的泛型 T 类型的SharedComponentData 值,结果以参数中的列表返回,你也可以通过其重载的方法获得所有值的索引。其他获取值的方法可以参考 /Packages/com.unity.entities/Unity.Entities/EntityManagerAccessComponentData.cs。 SharedComponentData 是自动引用计数的,例如在没有任何内存块拥有某个SharedComponentData 索引的时候,引用计数会置零,从而知道要删除SharedComponentData 的数据 。这一点就能看出其在 ECS 的世界中是非常独特的存在,想要深入了解可以看这篇文章《Everything about ISharedComponentData》。 SharedComponentData 应该尽量不去更改,因为更改 SharedComponentData 会导致实体的组件数据需要复制到其他的内存块中。 你也可以读读这篇更深入的文章《Everything about ISharedComponentData》。 System state components(系统状态组件) SystemStateComponentData 允许你跟踪系统(System)的资源,并允许你合适地创建和删除某些资源,这些过程中不依赖独立的回调(individual callback)。 假设有一个网络同步 System State,其监控一个 Component A 的同步,则我只需要定义一个 SystemStateComponent SA。当 Entity [有 A,无 SA] 时,表示 A 刚添加,此时添加 SA。等到 Entity [无 A,有 SA] 时,表示 A 被删除(尝试销毁Entity 时也会删除 A)。 《浅入浅出Unity ECS》 BenzzZX SystemStateComponentData 和 SystemStateSharedComponentData 这两个类型与 ComponentData 和 SharedComponentData 十分相似,不同的是前者两个类型都是系统级别的,不会在实体删除的时候被删除。 Motivation(诱因) System state components 有这样特殊的行为,是因为: 系统可能需要保持一个基于 ComponentData 的内部状态。例如已经被分配的资源。 系统需要通过值来管理这些状态,也需要管理其他系统所造成的状态改变。例如在组件中的值改变的时候,或者在相关组件被添加或者被删除的时候。 “没有回调”是 ECS 设计规则的重要元素。 Concept(概念) SystemStateComponentData 普遍用法是镜像一个用户组件,并提供内部状态。 上面引用的网络同步的例子中,A 就是用户分配的 ComponentData,SA 就是系统分配的 SystemComponentData。 下面以 FooComponent (ComponentData)和 FooStateComponent(SystemComponentData)做主要用途的示例。前两个用途已经在前面的网络同步例子中呈现过。 检测组件的添加 如果用户添加 FooComponent 时,FooStateComponent 还不存在。FooSystem 会在 update 中查询,如果实体只有 FooComponent 而没有 FooStateComponent,,则可以判断这个实体是新添加的。这时候 FooSystem 会加上 FooStateComponent 组件和其他需要的内部状态。 检测组件的删除 如果用户删除 FooComponent 后,FooStateComponent 仍然存在。FooSystem 会在 update 中查询,如果实体没有 FooComponent 而有 FooStateComponent,,则可以判断 FooComponent 已经被删除了。这时候 FooSystem 会给删除 FooStateComponent 组件和修改其他需要的内部状态。 监测实体的删除 通常 DestroyEntity 这个方法可以用来: 找到所有由某个实体 ID 标记的所有组件 删除那些组件 回收实体 ID 以作重用 然而,DestroyEntity 无法删除 SystemStateComponentData 。 在你删除实体时,EntityManager 不会移除任何 system state components,在它们没被删除的时候,EntityManager 也不会回收其实体的 ID 。这样允许系统(System)在一个实体被删除的时候,去整理内部的状态(internal state),也能清理关联着实体 ID 的相关的资源和状态。实体 ID 只会在所有 SystemStateComponentData 被删除的时候才被重用。 Dynamic Buffers(动态缓冲) DynamicBuffer 也是组件的一种类型,它能把一个变量内存空间大小的弹性的缓冲(variable-sized, “stretchy” buffer)和一个实体关联起来。它内部存储着一定数量的元素,但如果内部所占内存空间太大,会额外划分一个堆内存(heap memory)来存储。 动态缓冲的内存管理是全自动的。与 DynamicBuffer 关联的内存由 EntityManager 来管理,这样当DynamicBuffer 组件被删除的时候,所关联的堆内存空间也会自动释放掉。 上面的解释可能略显苍白,实际上 DynamicBuffer 可以看成一个有默认大小的数组,其行为和性能都和 NativeArray(在 ECS 中常用的无 GC 容器类型)差不多,但是存储数据超过默认大小也没关系,上文提到了会创建一个堆内存来存储多的数据。DynamicBuffer 可以通过 ToNativeArray 转成 NativeArray 类型,其中只是把指针重新指向缓冲,不会复制数据。 【Unity】ECSで配列を格納する Dynamic Buffers 这篇文章中,作者用DynamicBuffer 来储存临近的圆柱体实体,从而更方便地与这些实体交互。 定义缓冲 1 2 3 4 5 6 7 8 9 10 11 12 13 // 8 指的是缓冲中默认元素的数量,例如这例子中存的是 Integer 类型 // 那么 8 integers (32 bytes)就是缓冲的默认大小 // 64 位机器中则占 16 bytes [InternalBufferCapacity(8)] public struct MyBufferElement : IBufferElementData { // 下面的隐式转换是可选的,这样可以少写些代码 public static implicit operator int(MyBufferElement e) { return e.Value; } public static implicit operator MyBufferElement(int e) { return new MyBufferElement { Value = e }; } // 每个缓冲元素要存储的值 public int Value; } 可能有点奇怪,我们要定义缓冲中元素的结构而不是 Buffer 缓冲本身,其实这样在 ECS 中有两个好处: 对于 float3 或者其他常见的值类型来说,这样能支持多种 DynamicBuffer 。我们可以重用已有的缓冲元素的结构,来定义其他的 Buffers。 我们可以将 Buffer 的元素类型包含在 EntityArchetypes 中,这样它会表现得像拥有一个组件一样。例如用 AddBuffer() 方法,可以通过 entityManager.AddBuffer<MyBufferElement>(entity); 来添加缓冲。 Systems(系统) 系统负责将组件数据从一个状态(state)通过逻辑处理到下一个状态。例如系统可以根据帧间隔和实体的速度,在当前帧更新所有移动实体的位置。 世界初始化后提供了三个系统组(system groups),分别是 initialization、simulation 和 presentation,它们会按顺序在每帧中执行。 系统组的概念会在下文提到。 ComponentSystem(组件系统) ComponentSystem 通常指 ECS 实体组件系统中最基本的概念 System,它提供要执行的操作给实体。 ComponentSystem 不能包含实体的数据。从传统的开发模式来看,它与旧的 Component 类有点相似,不过 ComponentSystem 只包含方法。 一个 ComponentSystem 负责更新所有匹配组件类型的实体。例如:系统可以通过条件过滤来获得所有拥有 Player 标记(Tag)和位置(Translation)的实体,再对获得的一系列 Player 实体进行处理。其中这种条件过滤由 EntityQuery 结构定义。 要注意的是,ComponentSystem 只在主线程中执行。 我们可以通过继承 ComponentSystem 抽象类来定义我们的系统。 See file: /Packages/com.unity.entities/Unity.Entities/ComponentSystem.cs. JobComponentSystem(任务组件系统) 前文提到了 ECS 能很好的和 JobSystem 一起合作,那么这个类型就是一个很好的例子。ComponentSystem 只在主线程中执行,而 JobComponentSystem 则能在多线程中执行,更能利用多核的优势。 自动化的 Job 依赖管理 JobComponentSystem 能帮我们自动管理依赖。原理很简单,来自不同系统的 Job 可以并行地读取相同类型的 IComponentData。如果其中一个 Job 正在写(write)数据,那么所有的 Job 就不能并行地执行,而是设定它们的依赖来安排执行顺序。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class RotationSpeedSystem : JobComponentSystem { [BurstCompile] struct RotationSpeedRotation : IJobForEach<Rotation, RotationSpeed> { public float dt; public void Execute(ref Rotation rotation, [ReadOnly]ref RotationSpeed speed) { rotation.value = math.mul(math.normalize(rotation.value), quaternion.axisAngle(math.up(), speed.speed * dt)); } } // 所有对 Rotation 读/写的和对 RotationSpeed 进行写操作的 // 已经排程的 Job 会自动放到 JobHandle 类型的依赖句柄 inputDeps 中 // 在方法中,我们也需要把自己的 Job 依赖加进句柄中,并在方法末尾返回回来。 protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new RotationSpeedRotation() { dt = Time.deltaTime }; return job.Schedule(this, inputDeps); } } 怎么运行的? 所有 Jobs 和系统会声明它们会读/写哪些组件类型(ComponentTypes)。JobComponentSystem 返回的 JobHandle 依赖句柄会自动注册到 EntityManager 中,以及所有包含读或写(reading or writing)信息的类型中。 这样如果一个系统对 Component A 进行写操作而之后另一个系统会对其进行读操作, JobComponentSystem 会查询读取(reading)的类型列表,然后传给你一个依赖。依赖包含第一个系统返回的 JobHandle,也就是包含“一个系统对 Component A 进行写操作”这个依赖,并将其作为第二个系统的参数传入。 JobComponentSystem 简单地按照需求维护一个依赖链,这样不会对主线程造成影响。但是如果一个非 Job 的 ComponentSystem 要存取(access)相同的数据会怎么样呢?因为所有的存取都是声明好的,因此对于所有 ComponentSystem 需要进行存取的组件类型(component type)相关联的 Jobs,ComponentSystem 都会先自动完成这些相关的 Jobs,再在 OnUpdate 中调用依赖。 依赖管理是保守的(conservative)和确定性的(deterministic) 依赖管理是保守的。 ComponentSystem 只是简单的跟踪所有使用的 EntityQuery,然后基于 EntityQuery 存储需要读或写的类型。 当在一个系统中分发多个 Jobs 的时候,依赖必须被发送到所有 Jobs 中,即使不同的 Jobs 可能需要更少的依赖。如果这里被证明有性能问题,那最好的解决方法是将系统一分为二。 依赖管理的手段也是保守的。它通过提供一个非常简单的 API 来允许确定性和正确的行为。 Sync points(同步点) 所有结构性的变化都有确切的同步点(hard sync points)。 CreateEntity、Instantiate、 Destroy、 AddComponent、 RemoveComponent、SetSharedComponentData 都有一个确切的同步点。这代表所有通过 JobComponentSystem 排期的 Jobs 都会在创建实体之前自动完成。 例如,在一帧中间的 EntityManager.CreateEntity 可能带来较大的停滞,因为所有在世界中的提前排期好的 Jobs 都需要完成。 如果要在游戏中避免上面提到的停滞,可以使用 EntityCommandBuffer。 Multiple Worlds(多个世界) 所有世界(World)都有自己的 EntityManager ,因此 JobHandle 依赖句柄的集合都是分开的。一个世界中的确切的同步点(hard sync points)不会影响另外一个世界。因此,对于流式传输和程序化生成的场景,最后在一个世界中创建实体然后移到另一个世界作为一个事务(transaction)并在帧的开始执行。 对于上面的问题可以参考 ExclusiveEntityTransaction 和 System update order。 Entity Command Buffer(实体命令缓冲) EntityCommandBuffer 解决了两个重要问题: 在 Job 中无法访问 EntityManager,因此不能通过它来管理实体。 当你使用 EntityManager 时(例如创建一个实体),你会使所有已被注入的数组和 EntityQuery 无效。(这里注入的概念大概是指:系统中可以设定某个过滤条件,给过滤条件加上 [inject] 后,系统会在启动时为这个属性根据条件注入数据,这样就能得到我们想要的数据。会无效是因为你修改了实体数据,那么结果可能会发生改变。) EntityCommandBuffer 的抽象允许我们去把需要对数据的更改(changes)排好队,这个更改可以来自主线程或者 Jobs,这样数据可以晚一点在主线程接受更改,从而将其和获取数据分离开来。 我们有两种方法来使用 EntityCommandBuffer : 在主线程 update 的 ComponentSystem 子类有一个 PostUpdateCommands(其本身是一个EntityCommandBuffer ) 可以用,我们只要简单地把变化按顺序放进去即可。在系统的 Update 调用之后,它会立刻自动在世界(World)中进行所有数据更改。这样可以防止数组数据无效,API 也和 EntityManager 很相似。 1 2 3 4 5 6 7 8 PostUpdateCommands.CreateEntity(TwoStickBootstrap.BasicEnemyArchetype); PostUpdateCommands.SetComponent(new Position2D { Value = spawnPosition }); PostUpdateCommands.SetComponent(new Heading2D { Value = new float2(0.0f, -1.0f) }); PostUpdateCommands.SetComponent(default(Enemy)); PostUpdateCommands.SetComponent(new Health { Value = TwoStickBootstrap.Settings.enemyInitialHealth }); PostUpdateCommands.SetComponent(new EnemyShootState { Cooldown = 0.5f }); PostUpdateCommands.SetComponent(new MoveSpeed { speed = TwoStickBootstrap.Settings.enemySpeed }); PostUpdateCommands.AddSharedComponent(TwoStickBootstrap.EnemyLook); 对于 Jobs 来说,我们必须从主线程的 EntityCommandBufferSystem 中请求一个 EntityCommandBuffer,再传到 Job 里面让其调用。 每当 EntityCommandBufferSystem 进行 update,命令缓冲都会在主线程中重新把更改按创建的顺序执行一遍。这样允许我们集中进行内存管理,也保证了创建的实体和组件的确定性。 Entity Command Buffer Systems(实体命令缓冲系统) 在一个系统组中,有一个 Entity Command Buffer Systems 运行在所有系统组之前,还有一个运行在所有系统组之后。比较建议的是我们可以用已存在的命令缓存系统(command buffer system)之一,而不用创建自己的,这样可以最小化同步点(sync point)。 在 ParallelFor jobs 中使用 EntityCommandBuffers 在 ParallelFor jobs 使用 EntityCommandBuffer 存 EntityManager 的命令(command)时, EntityCommandBuffer.Concurrent 接口能保证线程安全和确定性的回放(deterministic playback)。 1 2 3 4 5 6 7 // See file: /Packages/com.unity.entities/Unity.Entities/EntityCommandBuffer.cs. public Entity CreateEntity(int jobIndex, EntityArchetype archetype = new EntityArchetype()) { ... m_Data->AddCreateCommand(chain, jobIndex, ECBCommand.CreateEntity, index, archetype, kBatchableCommand); return new Entity {Index = index}; } EntityCommandBuffer.Concurrent 的公共方法都会接受一个 jobIndex 参数,这样能回放(playback)已经按顺序保存好的命令。 jobIndex 作为 ID 必须在每个 Job 中唯一。从性能考虑,jobIndex 必须是传进 IJobParallelFor.Execute() 的不断增长的 index。除非你真的知道你传的是啥,否则最安全的做法就是把参数中的 index 作为 jobIndex 传进去。用其他 jobIndex 可能会产生正确的结果,但是可能在某些情况下会有严重的性能影响。 1 2 3 4 5 6 7 8 9 10 11 12 namespace Unity.Jobs { [JobProducerType(typeof (IJobParallelForExtensions.ParallelForJobStruct<>))] public interface IJobParallelFor { /// <summary> /// <para>Implement this method to perform work against a specific iteration index.</para> /// </summary> /// <param name="index">The index of the Parallel for loop at which to perform work.</param> void Execute(int index); } } System Update Order(系统更新顺序) 组件系统组(Component System Groups)其实是为了解决世界(World)中各种 update 的顺序问题。一个系统组中包含了很多需要按照顺序一起 update 的组件系统(component systems),可以来指定它成员系统(member system)的 update 顺序。 和其他系统一样, ComponentSystemGroup 也继承自 ComponentSystemBase ,因此系统组可以当成一个大的“系统”,里面也用 OnUpdate() 函数来更新系统。它也可以被指定更新的顺序(在某个系统的之前或之后更新等,下文会讲),并且也可以嵌入到其他系统组中。 默认情况下, ComponentSystemGroup 的 OnUpdate() 方法会按照成员系统(member system)的顺序来调用他们的 Update(),如果成员系统也是一个系统组,那么这个系统组也会递归地更新它的成员系统。总体的系统遵循树的深度优先遍历。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // See file: /Packages/com.unity.entities/Unity.Entities/ComponentSystemGroup.cs. protected override void OnUpdate() { if (m_systemSortDirty) SortSystemUpdateList(); foreach (var sys in m_systemsToUpdate) { try { sys.Update(); } catch (Exception e) { Debug.LogException(e); } if (World.QuitUpdate) break; } } System Ordering Attributes(系统顺序属性) [UpdateInGroup] 指定某个系统成为一个 ComponentSystemGroup 中的成员系统。如果没有用这个属性,这个系统会自动被添加到默认世界(default World)的 SimulationSystemGroup 中。 [UpdateBefore] 和 [UpdateAfter] 指定系统相对于其他系统的更新顺序。这两个系统必须在同一个系统组(system group)中,前文说到系统组也可以嵌套,因此只要两个系统身处同一个根系统组即可。 例子:如果 System A 在 Group A 中、System B 在 Group B 中,而且 Group A 和 Group B 都是 Group C 的成员系统,那么 Group A 和 Group B 的相对顺序也决定着 System A 和 System B 的相对顺序,这时候就不需要明确地用属性标明顺序了。 [DisableAutoCreation] 阻止系统从默认的世界初始化中创建或添加到世界中。这时候我们需要显式地创建和更新系统。然而我们也可以把这个系统和它的标记(tag)加到 ComponentSystemGroup 的更新列表中(update list),这样这个系统会正常地自动更新。 Default System Groups(默认系统组) 默认世界(default World)包含 ComponentSystemGroup 实例的层次结构(hierarchy)。在 Unity Player Loop 中会添加三个根层次(root-level)的系统组。 下图中打开 Entity Debugger,也能看到这三个系统组和其顺序。 这三个系统组各司其职, InitializationSystemGroup 做初始化工作, SimulationSystemGroup 在 Update 中做主要的逻辑运算, PresentationSystemGroup 做图形渲染工作。 如果勾选 “Show Full Player Loop” 项,还能看到完整的游戏主循环,以及系统组执行的顺序。 下面列表也展示了预定义的系统组和其成员系统: InitializationSystemGroup (在游戏循环(Player Loop)的 Initialization 层最后 update) BeginInitializationEntityCommandBufferSystem CopyInitialTransformFromGameObjectSystem SubSceneLiveLinkSystem SubSceneStreamingSystem EndInitializationEntityCommandBufferSystem SimulationSystemGroup(在游戏循环的 Update 层最后 update) BeginSimulationEntityCommandBufferSystem TransformSystemGroup EndFrameParentSystem CopyTransformFromGameObjectSystem EndFrameTRSToLocalToWorldSystem EndFrameTRSToLocalToParentSystem EndFrameLocalToParentSystem CopyTransformToGameObjectSystem LateSimulationSystemGroup EndSimulationEntityCommandBufferSystem PresentationSystemGroup(在游戏循环的 PreLateUpdate 层最后 update) BeginPresentationEntityCommandBufferSystem CreateMissingRenderBoundsFromMeshRenderer RenderingSystemBootstrap RenderBoundsUpdateSystem RenderMeshSystem LODGroupSystemV1 LodRequirementsUpdateSystem EndPresentationEntityCommandBufferSystem P.S. 内容可能在未来有更改 Multiple Worlds(多个世界) 前文多处提到默认的世界,实际上我们可以创建多个世界。同样的组件系统(component system)的类可以在不同的世界中初始化,而且每个实例都可以处于不同的同步点以不同的速度进行update。 当前没有方法手动更新一个世界中的所有系统,但是我们可以控制哪些系统被哪个世界控制,和它们要被加到哪个现存的世界中。自定义的世界可以通过实现 ICustomBootstrap 接口来创建。 Tips and Best Practices(提示与最佳实践) **用 [UpdateInGroup] 为你的系统指定一个 ComponentSystemGroup 系统组。**如果没有用这个属性,这个系统会自动被添加到默认世界(default World)的 SimulationSystemGroup 中。 **用手动更新循环(manually-ticked)的 ComponentSystemGroups 来 update 在主循环中的系统。**添加 [DisableAutoCreation] 阻止系统从默认的世界初始化中创建或添加到世界中。这时候我们可以在主线程中调用 World.GetOrCreateSystem() 来创建系统,调用 MySystem.Update() 来 update 系统。如果你有一个系统要在帧中早点或者晚点运行,这种做法能更简单地把系统插到主循环中。 **尽量使用已存在的 EntityCommandBufferSystem 而不是重新添加一个新的。**因为一个 EntityCommandBufferSystem 代表一个主线程等待子线程完成的同步点(sync point),如果重用一个在每个根系统组(root-level system group)中预定义的 Begin/End 系统,就能节省多个同步点所带来的额外时间间隔(可以回去看同步点小节的示意图,同步点的位置是由最晚执行完的子线程所决定的)。 **避免放自定义的逻辑到 ComponentSystemGroup.OnUpdate() 中。**虽然 ComponentSystemGroup 功能上和一个组件系统(component system)一样,但是我们应该避免这么做。因为它作为一个系统组,在外面不能马上知道成员系统是否已经执行了 update,因此推荐的做法是只让系统组当一个组(group)来用,而把逻辑放到与其分离的组件系统中,再定好该系统与系统组的相对顺序。 最后 自己才刚考完试,所以计划的文章一直拖到现在。ECS 对我而言充满着吸引力,可能有些程序员也会对性能特别执着吧,它就像魔法一样,完全不同的开发模式,还需要我们深入了解内存的结构。尽管 ECS 可能在工作中对我是一种屠龙技,但有些知识啊,学了就已经很开心了~ 我的毕业季也到来了,有空的话可能会写写 Demo 把剩下的实践部分补完,当然计划也可能搁浅。不管怎么样,希望本文对 ECS 同好有所帮助,有问题也欢迎在评论指出。 参考 《Entity Component System Manual》 BrianWill LearnUnity/ecs-jobs 《ECS Memory Layout》 《World, system groups, update order, and the player loop》 UniteLA 2018 - ECS deep dive

2019/7/15
articleCard.readMore

Unity DOTS 走马观花

简单介绍 Data-Oriented Technology Stack (DOTS, 数据导向型技术栈) ,其包含了 C# Job System、the Entity Component System (ECS) 和 Burst。 特点 DOTS 要实现的特点有: **性能的准确性。**我们希望的效果是:如果循环因为某些原因无法向量化,它应该会出现编译器错误,而不是使代码运行速度慢 8 倍,并得到正确结果,完全不报错。 跨平台架构特性。我们编写的输入代码无论是面向 iOS 系统还是 Xbox,都应该是相同的。 我们应该有不错的迭代循环。在修改代码时,可以轻松查看为所有架构生成的机器代码。机器代码“查看器”应该很好地说明或解释所有机器指令的行为。 安全性。大多数游戏开发者不把安全性放在很高的优先级,但我们认为,解决 Unity 出现内存损坏问题是关键特性之一。在运行代码时应该有一个特别模式,如果读取或写入到内存界限外或取消引用 Null 时,它能够提供我们明确的错误信息。 其中向量化指的是 Vectorization。 向量化的相关介绍: https://stackoverflow.com/questions/1422149/what-is-vectorization https://www.wikiwand.com/en/Array_programming Burst Unity 构建了名为 Burst 的代码生成器和编译器。 当使用 C# 时,我们对整个流程有完整的控制,包括从源代码编译到机器代码生成,如果有我们不想要的部分,我们会找到并修复它。我们会逐渐把 C++ 语言的性能敏感代码移植为 HPC# (高性能 C#,下文会提到)代码,这样会更容易得到想要的性能,更难出现 Bug,更容易进行处理。 如果 Asset Store 资源插件的开发者在资源中使用 HPC# 代码,资源插件在运行时代码会运行得更快。除此之外,高级用户也会通过使用 HPC# 编写出自定义高性能代码而受益。 ECS Track: Deep Dive into the Burst Compiler - Unite LA Burst 对于 HPC# 更详细的支持可以在下面找到: Burst User Guide 深入栈 向量化(Vectorization)无法进行的常见情况是,编译器无法确保二个指针不指向相同的内存,即混淆情况(Alias)。Alias 的问题在 Unity GDC 中也有一个演讲提到过:Unity at GDC - C# to Machine Code。 Collections 类就是为了解决这个问题而诞生的,里面包含 NativeList、NativeHashMap<TKey, TValue>、NativeMultiHashMap<TKey, TValue> 和 NativeQueue 四种额外的数据结构。 两个 NativeArray 之间从不会发生混淆这种情况,这也是为什么我们将会经常使用这些数据结构。我们可以在 Burst 中运用这个知识,使它不会由于害怕两个数组指针指向相同内存而放弃优化。 Unity 还编写了 Unity.Mathemetics 数学库,提供了很多像 Shader 代码的数据结构。Burst 也能和这数学库很好的工作,未来 Burst 将能够为 math.sin() 等计算作出牺牲精度的优化。 对于 Burst 而言,math.sin() 不仅是要编译的 C# 方法,Burst 还能理解出 sin() 的三角函数属性,同时知道 x 值较小时会出现 sin(x) 等于 x 的情况,并了解它能替换为泰勒级数展开,以便牺牲特定精度。 跨平台和架构的浮点准确性是 Burst 未来的目标。 传统模式的问题 传统模式指的是什么呢? 跟 MonoBehaviours 打交道 数据和其处理过程耦合在一起 高度依赖引用类型 问题一:数据分布在内存的各个角落 离散的数据导致搜索效率十分低下,还有 Cache Miss 的问题,这个问题可以参考下面的链接: ECS 的泛泛之谈 问题二:很多不必要的数据也被提供了 例如当我们要调用 Transform 时,可能实际上我们只需要 position 和 rotation 两个属性来移动 gameObject,但是其他不需要的数据也被提供给了 gameObject。 问题三:低效的单线程数据处理 传统模式只使用单线程来按顺序一个一个地处理数据和操作,这样十分低效。 高性能 C#(HPC#) 当我们使用 C# 语言时,仍然无法控制数据在内存中如何进行分布,但这是我们提升性能的关键点。 除此之外,标准库面向的是“堆上的对象”和“具有其它对象指针引用的对象”。 也就是意味着,当处理性能敏感代码时,我们可以放弃使用大部分标准库,例如:Linq、StringFormatter、List、Dictionary。禁止内存分配,即不使用类,只使用结构、映射、垃圾回收器和虚拟调用,并添加可使用的部分新容器,例如:NativeArray 和其他集合类型。 我们可以在越界访问时得到错误和错误信息,以及使用 C++ 代码时的调试器支持和编译速度。我们通常把该子集称为高性能 C# 或 HPC#。 它可以被总结为: 大部分的原始类型(float、int、uint、short、bool…),enums,structs 和其他类型的指针 集合:用 NavtiveArray<T> 代替 T[] 所有的控制流语句(除了 try、finally、foreach、using) 对 throw new XXXException(...) 给予基础支持 Job System Job System 是针对上述传统模式问题的一种解决方式。例如下图可以把发射子弹看成一个 Job,从而用多线程来并行地处理发射操作。 目前主流的 CPU 有 4-6 个物理核心,8-12 个逻辑核心,多线程处理将能够更好地发挥 CPU 的性能。 传统的多线程问题也有很多: 线程安全的代码十分难写 竞态条件,也就是计算结果依赖于两个或更多进程被调度的顺序 低效的上下文切换,切换线程的时候十分耗时 而 Job System 就是专注解决上面问题的一个方案,这样我们就能享受着多线程的好处来开发游戏。当然了,我们也要写出正确的 ECS 代码,熟悉新的开发模式。 解决的多线程问题 **C++ 和 C# 都无法为开发者编写线程安全代码提供太多帮助。**即使在今天,拥有多个核心游戏消费级硬件发展至今已经过去了十年,但依旧很难有效处理使用多个核心的程序。 数据冲突,不确定性和死锁是使多线程代码难以编写的挑战。Unity 想要的特性是“确保代码调用的函数和所有内容不会在全局状态下读取或写入”。Unity 希望应该让编译器抛出错误来提醒,而不是属于“程序员应遵守的准则”,Burst 则会提供编译器错误。 Unity 鼓励 Unity 用户编写 “Jobified” 代码:将「所有需要发生的数据转换」划分为 Job。 Job 会明确指定使用的只读缓冲区和读写缓冲区,尝试访问其它数据会得到编译器错误。Job 调度程序会确保在 Job 运行时,任何程序都不会写入只读缓冲区。Unity 也会确保在 Job 运行时,任何程序都不会读取读写缓冲区。 如果调度的 Job 违反了这些规则,我们会得到运行时错误(通常这种错误会在竞态条件出现时得到)。错误信息会说明,你正在尝试调度的 Job 想要读取缓冲区 A,但你之前已经调度了会写入缓冲区 A 的 Job ,所以如果想要执行该操作,需要把之前的 Job 指定为依赖。 Entity Component System Unity 一直以组件的概念为中心,例如:我们可以添加 Rigidbody 组件到游戏对象上,使对象能够向下掉落。我们也可以添加 Light 组件到游戏对象上,使它可以发射光线。我们添加 AudioEmitter 组件,可以使游戏对象发出声音。 我们实现组件系统的方法并没有很好地演变。过去我们使用面向对象的思维编写组件系统,导致组件和游戏对象都是“大量使用 C++ 代码”的对象,创建或销毁它们需要使用互斥锁修改“id 到对象指针”的全局列表。 通过使用面向数据的思维方式,我们可以更好地处理这种情况。我们可以保留用户眼中的优良特性,即只需添加组件就可以实现功能,而同时通过新组件系统取得出色的性能和并行效果。 这个全新的组件系统就是实体组件系统 ECS。简单来说,如今我们对游戏对象进行的操作可用于处理新系统的实体,组件仍称作组件。那么区别是什么?区别在于数据布局。 ECS 数据布局 ECS 使用的数据布局会把这些情况看作一种非常常见的模式,并优化内存布局,使类似操作更加快捷。 组件(Component) 首先要明确的是这里的“组件”与上文提到的 Rigidbody “组件”是不一样的概念。ECS 中的组件只会存单纯的数据,不参与任何逻辑运算,逻辑运算会交由系统(System)来处理。 原型(Archetype) ECS 会在内存中对带有相同组件(Component)集的所有实体(Entity)进行组合。ECS 把这类组件集称为原型(Archetype)。 下图的原型就是由 Position 组件、Velocity 组件、Rigidbody 组件和 Renderer 组件组成的。 如果一个实体只有三个组件(不同于前面提到的原型),那么那三个组件就组成了一个新的原型。 下面的图来自 Unite LA 的一次演讲的讲义, 很遗憾那次演讲没有录制下来。讲义可以在这里找到。 ECS 以 16k 大小的块(Chunk)来分配内存,每个块仅包含单个原型中所有实体的组件数据。 一个帖子中有人提供了更加形象的内存布局图,例如上半部分的原型由 Position 组件和 Rock 组件组成,其中整个原型占了一个块(Chunk),两个组件的数据分别存在两个数组中,里面还带着组件数据对应的实体的信息。 每个原型都有一个 Chunks 块列表,用来保存原型的实体。我们会循环所有块,并在每个块中,对紧凑的内存进行线性循环处理,以读取或写入组件数据。该线性循环会对每个实体运行相同的代码,同时为 Burst 创造向量化(Vectorization,可以参考 StackOverflow 的问题)处理的机会。 每个块会被安排好内存中的位置,以便于快速从内存得到想要的数据,详情可以参考下面的文章。 Unity2018 ECS 框架 Entities 源码解析(二)组件与 Chunk 的内存布局 - 大鹏的专栏 实体(Entity) 实体是什么?实体只是一个 32 位的整数 key (和一些额外的数据例如 index 和 version 实体版本,不过在这里不重要),所以除了实体的组件数据外,不必为实体保存或分配太多内存。实体可以实现游戏对象的所有功能,甚至更多功能,因为实体非常轻量。 实体的性能消耗很低,所以我们可以把实体用在不适合游戏对象的情况,例如:为粒子系统内的每个单独粒子使用一个实体。 实体本身不是对象,也不是一个容器,它的作用是把其组件的数据关联到一起。 系统(System) 我们不必使用用户的 Update 方法搜索组件,然后在运行时对每个实例进行操作,使用 ECS 时我们只需静态地声明:我想对同时附带 Velocity 组件和 Rigidbody 组件的所有实体进行操作。为了找到所有实体,我们只需找到所有符合特定“组件搜索查询”的原型即可,而这个过程就是由系统(System)来完成的。 很多情况下,这个过程会分成多个 Job ,使处理 ECS 组件的代码达到几乎 100% 的核心利用率。ECS 会完成所有工作,我们只需要提供对每个实体运行的代码即可。我们也可以手动处理块迭代过程(IJobChunk)。 当我们从实体添加或移除组件时,ECS 会切换原型。我们会把它从当前块移动到新原型的块,然后交换之前块的最后实体来“填补空缺”。 在 ECS 中,我们还要静态声明要对组件数据进行什么处理,是 ReadOnly 只读还是 ReadWrite 读写(Job System 一小节提到过的两种缓冲区)。通过确定仅对 Position 组件进行读取,ECS 可以更高效地调度 Job ,其它需要读取 Position 组件的 Job 不必进行等待。 大体上,实体提供纯粹的数据给系统,系统根据自己所需要的组件来获得相应的满足条件的实体,最后系统再通过多线程来基于 Job System 来处理数据。 这种数据布局也解决了 Unity 长期以来的困扰,即:加载时间和序列化的性能。现在从大型场景加载或流式处理 ECS 数据的时间,不会比从硬盘加载和使用原始字节多多少。 优点 总的来说,ECS 有以下好处: 为性能而生 更容易写出高度优化和可重用的代码 更能充分利用硬件的性能 原型的数据被紧密地排列在内存中 享受 Burst 编译器带来的魔法 缺点 对 ECS 的常见观点是:ECS 需要编写很多代码。因此,实现想要的功能需要处理很多样板代码。现在针对移除多数样板代码需求的大量改进即将推出,这些改进会使开发者更简单地表达自己的目的。 Unity 暂时没有实现太多这类改进,因为 Unity 现在正专注于处理基础性能。 太多样板代码对 ECS 游戏代码没有好处,我们不能让编写 ECS 代码比编写 MonoBehaviour 更麻烦。 ——Unity 而为网页游戏而生的基于 ECS 的 Project Tiny 已经实现了部分改进,例如:基于 lambda 函数的迭代 API。 最后 由于自己空闲时间不多,只能囫囵吞枣地拼凑出这样一篇笔记。上面大部分文字都是来自 Unity 的博文介绍,自己加了其他的内容帮助理解。本文从内存布局介绍了 ECS 的概念,也介绍了 Job System 和 Burst。我相信走过一遍文章之后,能清楚 Unity 对数据驱动的未来开发趋势的布局,也能更加容易从 Unity ECS Sample 中理解如何实践 ECS。 参考 Unity DOD (ECS) 基础概念与资料汇总 这篇文章总结得很好,但很多视频链接都错了,我提供给了一个改好的版本:DOD 相关文章 Unity ECS 编程官方文档选译–Getting Started 面向数据技术栈 DOTS 之 ECS 实体组件系统 On DOTS: Entity Component System - Unity Blog On DOTS: C++ & C# - Unity Blog ECS Deep Dive UniteLA 2018 - ECS deep dive Intro To The Entity Component System And C# Job System 视频中代码部分已经过时,建议参考 Unity ECS Sample 官方 Demo 来学习 ECS

2019/5/8
articleCard.readMore

属于 Unity 的 Flutter——UIWidgets

介绍 UIWidgets 是 Unity 的一个插件包,是一个从 Google 的移动 UI 框架 Flutter 演变过来的 UI 框架。 相对于原生开发的高开发成本(不同平台都需要不同的一套代码),Flutter、React-Native 等这种跨平台 UI 框架应运而生。 Flutter 自 2018 年 3 月发布以来,社区不断壮大。由于 Flutter 自身设计理念的出色,Unity 中国已经着手将其移植过来。当然了,也因为这两个东西都非常的年轻,因此开发的时候都像开荒一样。 框架图 Flutter 有自己的一套渲染系统,那么 Unity 作为一个游戏引擎,底层的图形 API 用自己的一套东西就行了,因此移植过来更方便了。 Flutter 框架结构 UIWidgets 框架结构 执行效率 这里提一些基础的知识: Batch 就是 DrawCall 的另一种说法,了解渲染流水线的同学会知道流水线在 CPU 与 GPU 之间通信时,一般有三个步骤: 把数据加载到显存中。 设置渲染状态。 调用 Draw Call Draw Call 就是一个调用命令,让 CPU 告诉 GPU 要怎么样用给定的渲染状态和输入的顶点信息来计算。Batch 里面装着顶点信息,也就是 DrawCall 中 GPU 需要的顶点信息。 DrawCall 可以在 Profiler 中看,Batches 可以在 Stats 窗口看,大家可以仔细看看上面动图(右键在新标签页打开图片)里面的数据变化。 在我随便写的一个例子中间,可以看到 Batches 数只有 1 。即使在有动画的时候 Batches 会多一点,但动画停止后 DrawCall 和 Batches 都马上下来了。这也有我这个应用写的太简单的原因,但是这种效率还是非常值得期待的。 组件树 学过前端的同学应该熟悉组件树,这里就不介绍了。 为了更高的渲染效率,Unity 采用了 Render Object Compositiing 的技术。 如果一个子树没有发生改变,Unity 就会将其渲染到一个离屏的 Render Texture 上缓存下来,需要的时候再将其贴到屏幕上。 相比之下,以前的做法是,Canvas 只要有 UI element 改动了,整个 Canvas 都需要重新绘制。即使也有一种优化做法是准备两个 Canvas 分别绘制动态 UI element 和 静态 UI element,但这样也存在很多手动管理的地方。 另外一方面,你可能也意识到了,我们不需要再管什么用同一个材质等等来优化图的合批,UIWidgets 会自动来管理这些事情。这方面也跟 FairyGUI 非常像,开发者能专注在生产效率上,让插件来管理麻烦的事情。 优点 能开发游戏以外的 APP 游戏中的 UI 新的用户体验 不用管渲染过程,提升效率 因为是 Unity 的插件,可以轻松加各种粒子效果和其他骚操作。 一套代码能跑在游戏中、APP 中、网页中和 Unity 的 Editor 窗口中。(开发者还用其做了一个 Unity 中文文档的网站,一套代码能用在网页上和 APP 端) 在静态页面进行降帧的优化,有动画效果再把帧率提上来。 和 Flutter 的 API 几乎一样,可以参考 Flutter 教程来用 UIWidgets 搭应用。 缺点 无论是 Flutter 还是 UIWidgets 都还很年轻,有很多组件 UIWidgets 还没移植过来(GridView、Circle Avatar 等等) 官方示例、文档还没完善 开发时是开荒模式,所以可能忍不住直接转用 Flutter 去了… 我的示例 这里借用了 ミライ小町 的模型,所以代码窗口大小会比较大。(项目里面还有ミライ小町的跳舞动画 animation!) 项目仓库:Latias94/UIWidget-Practice UIWidgets:UnityTech/UIWidgets 官方讲解录播:[官方直播] UIWidgets - 不止游戏!如何使用 Unity 开发跨平台应用

2019/4/1
articleCard.readMore

不越狱在 iOS 12.113.3 设备安装 Kodi

2020 年 2 月 11 日更新另外一种方法 今天一不小心发现 Kodi 这个播放神器居然还有 iPad 版! 但是苹果 App Store 不允许 Kodi 应用商家,于是自己在网上找了些方法: 越狱(手动再见) 下载官方提供的安装包用 Xcode 打包进去(太麻烦) 国内同步助手等提供的“VIP 服务”,购买服务后,用它们提供企业证书来下载(吃相难看) … 2020 年 2 月 11 日更新 最近又想装回 Kodi,但是 Tweakbox 自己用不了了,于是找了找发现了 AppCake。 AppCake 声称能在越狱和非越狱的手机上使用,直接在设备上给应用签名。 手机 Safari 打开 https://iphonecake.com/app/,点 Install。 安装描述文件 打开应用后应该还要再要你安装另一个描述文件用来给应用签名 Tweaked Tab 标签页中下载安装 Kodi! 介绍 Tweakbox 最终发现了国外一个不错的免费服务:Tweakbox,官方介绍也很令人振奋: TweakBox is an app store where you can download apps for iOS devices that are not available in the official Apple app store. TweakBox App is completely free and it has tons of features that make it a very popular choice among the other third party app stores. 简而言之,Tweakbox 允许你: 不需要越狱,苹果 ID 下载部分 Appstore 上没有或者付费的软件和游戏 下载部分主流的修改增强版软件和游戏 安装 iPhone 和 iPad 设备上在 Safari 浏览器中进入网页 Download 点 “Install Now” 按钮安装描述文件 然后就会下载 Tweakbox 应用 打开设置 -> 通用 -> 描述文件与设备管理 -> TweakBox 点击信任 之后就能打开 Tweakbox 应用来安装应用了! 打开 Tweakbox,点击屏幕上方的 Apps 选项,点击 Tweakbox Apps 一列,Kodi 应用就在其中。 点进去 install 就能下载应用了,下载完还需要再次去信任描述文件才能使用。 唯一有点缺陷的是 Kodi 有时会弹出插屏广告,断网使用即可。 汉化参考:KODI 播放器 V17 设置中文 更多 前面说到 Tweakbox 还能安装其他实用的应用: 碎碎念 周更博客真的难 = =,有些主题觉得没比别人写的好,于是就不想写了… 这周先水过去,再次赞下 Tweakbox!

2019/1/14
articleCard.readMore

Unity 开源双端框架 ET 中初尝热更新技术

ET 框架简介 正所谓时势造英雄,在 Web 开发领域或者传统软件开发领域中,人们把经过千锤百炼的代码总结出一套开发框架,从而提高开发效率,让开发者能更专注于业务本身。对于游戏领域而言,不同游戏需求的东西也不一样:有的游戏对性能有着苛刻要求,有的游戏需要快速地迭代出来,有的游戏需要联网热更新等等。因此不同的游戏框架应运而生。 例如: Game Framework 是一个基于 Unity 引擎的游戏框架,主要对游戏开发过程中常用模块进行了封装,很大程度地规范开发过程、加快开发速度并保证产品质量。 QFramework 一套渐进式的快速开发框架。框架内部积累了多个项目的在各个技术方向的解决方案。 Entitas 一套基于 C# 和 Unity 的实体组件系统。 Entities Unity 官方的实体组件系统实现,不过还是 Beta 版本,详细介绍可以查看官网。 StrangeIoC 一套基于 C# 和 Unity 的控制反转 (Inversion-of-Control) 框架。 今天介绍的是 ET 框架。 ET是一个开源的游戏客户端(基于unity3d)服务端双端框架,服务端是使用C# .net core开发的分布式游戏服务端,其特点是开发效率高,性能强,双端共享逻辑代码,客户端服务端热更机制完善,同时支持可靠udp tcp websocket协议,支持服务端3D recast寻路等等 ET 框架能让我们只用 C# 就能搞定前后端,热更新方面也采用了基于 C# 的 IL 运行时——ILRuntime, 贯彻了 “珍爱生命,远离 Lua” 这句话。目前自己接触的大多是客户端部分,因此服务器方面不做介绍。 框架文件结构 ET 官网 本身给了很多介绍,我们可以克隆 Git 仓库到本地。 下面来看看每个文件夹的作用: 客户端文件结构 本文主要来介绍客户端,因此进入到 Unity 文件夹,文件夹结构如下: 当前 Master 分支目前需要 Unity 2018.3 以上版本。使用之前需要参考下官方的 运行指南。 在 VS 中重新编译,或者 Rider Rebuild 一下项目。Scene 选择 Scenes\Init.unity,点 Play 按钮应该就能成功运行,看到登陆界面。 组件设计 ET 框架使用了组件的设计,一切都是实体(Entity)和组件(Component),官方文档 组件设计 介绍的很详细。 看完文档,我们来看看项目代码的启动入口。 这个 Init.cs 文件,在 Model 文件夹下。可能有同学注意到 Hotfix 文件夹下也有一个 Init.cs 文件,而且这两个文件夹的结构大同小异,两边都有一些相同的文件,而它们只是命名空间不一样。这是因为我们用到 ILRuntime,而 ILRuntime 最好不要跨域继承。 Model/Init.cs 文件中 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private async ETVoid StartAsync() { try { ... // 添加了组件,就赋予了各种功能。 // 例如加了 Timer 组件,就有了计时功能 Game.Scene.AddComponent<TimerComponent>(); ... // 下载热更用的 AssetBundle 包 await BundleHelper.DownloadBundle(); // 加载热更用的dll等文件,调用 Hotfix/Init.cs Game.Hotfix.LoadHotfixAssembly(); // 加载配置 Game.Scene.GetComponent<ResourcesComponent>().LoadBundle("config.unity3d"); Game.Scene.AddComponent<ConfigComponent>(); // 加载后卸载相应的 AB 包 Game.Scene.GetComponent<ResourcesComponent>().UnloadBundle("config.unity3d"); Game.Scene.AddComponent<OpcodeTypeComponent>(); Game.Scene.AddComponent<MessageDispatcherComponent>(); Game.Hotfix.GotoHotfix(); Game.EventSystem.Run(EventIdType.TestHotfixSubscribMonoEvent, "TestHotfixSubscribMonoEvent"); } ... } 通过组件设计,可以轻易地加载组件和卸载组件,例如我可以写一个心跳包组件来每隔30秒发送一个心跳包到服务器,当我需要这个组件的时候,可以直接 AddComponent,不需要的时候可以 RemoveComponent 移除组件。 登陆界面的热更新启动过程 接下来看到 Hotfix/Init.cs 文件中 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public static void Start() { try { // 注册热更层回调 ETModel.Game.Hotfix.Update = () => { Update(); }; ETModel.Game.Hotfix.LateUpdate = () => { LateUpdate(); }; ETModel.Game.Hotfix.OnApplicationQuit = () => { OnApplicationQuit(); }; ... // 加载热更配置 ETModel.Game.Scene.GetComponent<ResourcesComponent>().LoadBundle("config.unity3d"); Game.Scene.AddComponent<ConfigComponent>(); ETModel.Game.Scene.GetComponent<ResourcesComponent>().UnloadBundle("config.unity3d"); UnitConfig unitConfig = (UnitConfig)Game.Scene.GetComponent<ConfigComponent>().Get(typeof(UnitConfig), 1001); Log.Debug($"config {JsonHelper.ToJson(unitConfig)}"); // 发送事件来启动界面 Game.EventSystem.Run(EventIdType.InitSceneStart); } ... } 来看看发送的事件,代码在 Hotfix\Module\Demo\UI\UILogin\System\InitSceneStart_CreateLoginUI.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 namespace ETHotfix { // 用 Attribute 来注册事件 [Event(EventIdType.InitSceneStart)] public class InitSceneStart_CreateLoginUI: AEvent { public override void Run() { UI ui = UILoginFactory.Create(); // 这里就是启动登陆界面的地方,界面可以直接 add 或者 remove Game.Scene.GetComponent<UIComponent>().Add(ui); } } } 再来看看一个界面是怎么生成的,代码在 Hotfix\Module\Demo\UI\UILogin\System\UILoginFactory.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public static UI Create() { ... ResourcesComponent resourcesComponent = ETModel.Game.Scene.GetComponent<ResourcesComponent>(); // 让资源组件读取登陆界面的 AB 包 resourcesComponent.LoadBundle(UIType.UILogin.StringToAB()); // 从 AB 包拿到登陆界面的 GameObject GameObject bundleGameObject = (GameObject) resourcesComponent.GetAsset(UIType.UILogin.StringToAB(), UIType.UILogin); GameObject gameObject = UnityEngine.Object.Instantiate(bundleGameObject); UI ui = ComponentFactory.Create<UI, string, GameObject>(UIType.UILogin, gameObject, false); // 添加登陆界面组件 ui.AddComponent<UILoginComponent>(); return ui; ... } 来看看登陆界面组件,代码在 Hotfix\Module\Demo\UI\UILogin\Component\UILoginComponent.cs 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class UILoginComponent: Component { private GameObject account; private GameObject loginBtn; public void Awake() { // 通过引用来获取 UI 组件,再为其添加点击事件 ReferenceCollector rc = this.GetParent<UI>().GameObject.GetComponent<ReferenceCollector>(); loginBtn = rc.Get<GameObject>("LoginBtn"); loginBtn.GetComponent<Button>().onClick.Add(OnLogin); this.account = rc.Get<GameObject>("Account"); } public void OnLogin() { // 有兴趣可以再进去看看 OnLoginAsync,其中登陆的 Session 连接了服务器地址 127.0.0.1:10002 LoginHelper.OnLoginAsync(this.account.GetComponent<InputField>().text).Coroutine(); } } 服务器地址存在了 Tools 菜单中的全局配置,上面的资源路径则是热更新服务器的地址。 游戏运行后,在 Hierarchy 界面中也可以看到组件的结构,其中 uilogin.unity3d 就是登陆界面的 AB 包引用: 这就是通过热更新逻辑生成的界面,也就是说,上面的代码让我们可以通过热更新来给应用加载各种界面和改写页面跳转逻辑,当然还可以通过热更来增加修改游戏逻辑和功能。 如果不喜欢这种页面加载方式,可以考虑不使用 Hotfix/Init.cs 中的 Game.Scene.AddComponent<UIComponent>(); 这个 UIComponent,而使用其他 UI 组件,例如主仓库中的 FairyGUI 分支,让 FairyGUI 来单独负责 UI 界面。这里也可以看出基于组件的框架的灵活性,我以后也会出文章单独介绍 FairyGUI。 热更新切换 首先看看作者的介绍: 7.客户端热更新一键切换 因为ios的限制,之前unity热更新一般使用lua,导致unity3d开发人员要写两种代码,麻烦的要死。之后幸好出了ILRuntime库,利用ILRuntime库,unity3d可以利用C#语言加载热更新dll进行热更新。ILRuntime一个缺陷就是开发时候不支持VS debug,这有点不爽。ET框架使用了一个预编译指令ILRuntime,可以无缝切换。平常开发的时候不使用ILRuntime,而是使用Assembly.Load加载热更新动态库,这样可以方便用VS单步调试。在发布的时候,定义预编译指令ILRuntime就可以无缝切换成使用ILRuntime加载热更新动态库。这样开发起来及其方便,再也不用使用狗屎lua了 8.客户端全热更新 客户端可以实现所有逻辑热更新,包括协议,config,ui等等 预编译指令指的就是在 Player Setting 中,上图右下角箭头指着的地方。当前有两个预编译指令,通常在开发中,可以只填写 NET452,这样可以得到完整的堆栈信息来调试程序。还有一个预编译指令 ASYNC,加上后,应用就会从前面填写的热更新服务器下载热更包,该指令在后文会提到。 在国内环境下,手机游戏热更新的需求较强烈。市场上手机系统普遍分成 Android 和 iOS 阵营,其中 iOS 不支持 JIT 热更,因此 ET 框架给了两种选择:ILRuntime 热更新和 Mono 热更新。 两者概念可以参考文末的参考链接,在这里不多说。 体验热更新 体验热更新之前,先把项目切到 Android 平台。 按照下图配置 Mono 热更新: 确保 Scripting Backend 为 Mono,下面预编译宏去掉 ILRuntime,加上 ASYNC,按下回车键执行变更。ASYNC 说明我们现在的热更新资源从资源服务器中获取,这里的热更新资源包括 Res 文件夹、Bundles 文件夹、Hotfix 文件夹中的代码等。在这个例子中,登陆界面的代码就已经写在热更新文件夹中了,我们将尝试通过热更新来展示登陆界面。 点击 Play 按钮,会有两个报错: 第二个 Log 信息展示了应用想要获取资源的热更资源服务器地址,这个地址可以在 Tools 菜单的全局配置中找到。报错信息提示找不到终端主机。报错是理所当然的,因为我们还没有启动本地服务器。 首先要生成热更新文件,在 Tools 菜单中点击打包工具,如下图所示: 平台选择当前的 Android 平台,目前不需要打包应用,所以无视第一个单选按钮。 前面的思维导图提到了 ET 根目录的 Release 文件夹存的就是热更新资源文件。打包工具也会把打包后的资源放在 Release 文件夹下。而第二个按钮指的是是否把打包的热更新资源也放在应用中,目前也不需要选择。开启热更新后,应用会比较服务器和本地应用的 Version 文件,计算文件差异后才会下载相关的热更新资源文件。 如果勾选了第二个按钮,打包工具将会把资源也复制到 Assets/StreamingAssets 文件夹下,同时更新 Version 文件,这样我们将不能测试下载热更包的过程。 点击开始打包后,热更文件就生成了: 再点击 Tools 菜单中的 web 资源服务器开启映射了 Release 文件夹的本地文件服务器。 点击 Play 按钮,应用通过下载热更新资源,生成了登陆界面,也把热更资源下载到了应用中,也就是 Assets/StreamingAssets 文件夹。 重新启动 web 资源服务器清除 log 信息,再次运行应用,会发现没有再次下载热更新资源。因为对比了 Version 文件后,应用本地的文件已经不需要更新了。 至此,我们完成了一次完整的热更新。 总结 ET 框架给了我们一种统一的开发体验,提供了方便的热更新切换和调试方案,这足以支撑起一些小游戏的开发需求,有需要的同学可以了解下 ET 框架~ 2019 年立了个 Flag:周更技术博客,欢迎督促和交流,也欢迎常来我博客 萤火之森 逛! 参考 一些新潮的Unity热更方案 Mono和IL2CPP选哪个更合适? Unity实现c#热更新方案探究(一) 关于热更新,大家现在都是怎么实现的? 中网友 gx 的回答

2019/1/7
articleCard.readMore

Unity 中用有限状态机来实现一个 AI

最近在阅读《游戏人工智能编程案例精粹(修订版)》,本文是书中第二章的一篇笔记。 有限状态机(英语:Finite-state machine, 缩写:FSM),是一个被数学家用来解决问题的严格形式化的设备,在游戏业中也常见有限状态机的身影。 对于游戏程序员来说,可以用下面这个定义来了解: 一个有限状态机是一个设备(device),或是一个设备模型(a model of a device)。具有有限数量的状态,它可以在任何给定的时间根据输入进行操作,是的从一个状态变换到另一个状态,或者是促使一个输出或者一种行为的发生。一个有限状态机在任何瞬间只能处在一种状态。 ——《游戏人工智能编程案例精粹(修订版)》 Mat Buckland 有限状态机就是要把一个对象的行为分解成易于处理的“块”或者状态。拿某个开关来说,我们可以把它分成两个状态:开或关。其中开开关这个操作,就是一次状态转移,使开关的状态从“关”变换到“开”,反之亦然。 拿游戏来举例,一个 FPS 游戏中的敌人 AI 状态可以分成:巡逻、侦查(听到了玩家)、追逐(玩家出现在 AI 视野)、攻击(玩家进入 AI 攻击范围)、死亡等,这些有限的状态都互相独立,且要满足某种条件才能从一个状态转移到另外一个状态。 有限状态机由三部分组成: 存储任务信息的一些状态(states),例如一个 AI 可以有探索状态、追踪状态、攻击状态等等。 状态之间的一些变换(transitions),转移代表状态的转移,并且描述着状态转移的条件。例如听到了主角的脚步声,就转移到追踪状态。 需要跟随每个状态的一系列行为(actions)。例如在探索状态,要随机移动和找东西。 下图是只有三种状态的 AI 的有限状态机图示: 优缺点 实现有限状态机之前,要先了解它的优点: 编程快速简单:很多有限状态机的实现都较简单,本文会列出三种实现方法。 易于调试:因为行为被分成单一的状态块,因此要调试的时候,可以只跟踪某个异常状态的代码。 很少的计算开销:几乎不占用珍贵的处理器时间,因为除了 if-this-then-that 这种思考处理之外,是不存在真正的“思考”的。 直觉性:人们总是自然地把事物思考为处在一种或另一种状态。人类并不是像有限状态机一样工作,但我们发现这种方式下考虑行为是很有用的,或者说我们能更好更容易地进行 AI 状态的分解和创建操作 AI 的规则,容易理解的概念也让程序员之间能更好地交流其设计。 灵活性:游戏 AI 的有限状态机能很容易地由程序员进行调整,增添新的状态和规则也很容易扩展一个 AI 的行为。 有限状态机的缺点是: 当状态过多时,难以维护代码。 《AI Game Development》的作者 Alex J. Champandard 发表过一篇文章《10 Reasons the Age of Finite State Machines is Over》 if-then 实现 这是第一种实现有限状态机的方法,用一系列 if-then 语句或者 switch 语句来表达状态。 下面拿那个只有三个状态的僵尸 AI 举例: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public enum ZombieState { Chase, Attack, Die } public class Zombie : MonoBehaviour { private ZombieState currentState; private void Update() { switch (currentState) { case ZombieState.Chase: if (currentHealth <= 0) { ChangeState(ZombieState.Die); } // 玩家在攻击范围内则进入攻击状态 if (PlayerInAttackRange()) { ChangeState(ZombieState.Attack); } break; case ZombieState.Attack: if (currentHealth <= 0) { ChangeState(ZombieState.Die); } if (!PlayerInAttackRange()) { ChangeState(ZombieState.Chase); } break; case ZombieState.Die: Debug.Log("僵尸死亡"); break; } } } 这种写法能实现有限状态机,但当游戏对象复杂到一定程度时,case 就会变得特别多,使程序难以理解、调试。另外这种写法也不灵活,难以扩展超出它原始设定的范围。 此外,我们常需要在进入状态和退出状态时做些什么,例如僵尸在开始攻击时像猩猩一样锤几下胸口,玩家跑出攻击范围的时候,僵尸要“摇摇头”让自己清醒,好让自己打起精神继续追踪玩家。 状态变换表 一个用于组织状态和影响状态变换的更好的机制是一个状态变换表。 当前状态条件状态转移 追踪玩家进入攻击范围攻击 追踪僵尸生命值小于或等于0死亡 攻击玩家脱离攻击范围追踪 攻击僵尸生命值小于或等于0死亡 这表格可以被僵尸 AI 不间断地查询。使得它能基于从游戏环境的变化来进行状态变换。每个状态可以模型化为一个分离的对象或者存在于 AI 外的函数。提供了一个清楚且灵活的结构。 我们只用告诉僵尸它有多少个状态,僵尸则会根据自己获得的信息(例如玩家是否在它的攻击范围内)来处理规则(转移状态)。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class Zombie : MonoBehaviour { private ZombieState currentState; private void Update() { // 生命值小于等于0,进入死亡状态 if (currentHealth <= 0) { ChangeState(ZombieState.Die); return; } // 玩家在攻击范围内则进入攻击状态,反之进入追踪状态 if (PlayerInAttackRange()) { ChangeState(ZombieState.Attack); } else { ChangeState(ZombieState.Chase); } } } 内置规则 另一种方法就是将状态转移规则内置到状态内部。 在这里,每一个状态都是一个小模块,虽然每个模块都可以意识到其他模块的存在,但是每个模块都是一个独立的单位,而且不依赖任何外部的逻辑来决定自己是否要进行状态转移。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 public class Zombie : MonoBehaviour { private State currentState; public int CurrentHealth { get; private set; } private void Update() { currentState.Execute(this); } public void ChangeState(State state) { currentState = state; } public bool PlayerInAttackRange() { // ...游戏逻辑 return result; } } public abstract class State { public abstract void Execute(Zombie zombie); } public class ChaseState : State { public override void Execute(Zombie zombie) { if (zombie.CurrentHealth <= 0) { zombie.ChangeState(new DieState()); } if (zombie.PlayerInAttackRange()) { zombie.ChangeState(new AttackState()); } } } public class AttackState : State { public override void Execute(Zombie zombie) { if (zombie.CurrentHealth <= 0) { zombie.ChangeState(new DieState()); } if (!zombie.PlayerInAttackRange()) { zombie.ChangeState(new ChaseState()); } } } public class DieState : State { public override void Execute(Zombie zombie) { Debug.Log("僵尸死亡"); } } Update() 函数只需要根据 currentState 来执行代码,当 currentState 改变时,下一次 Update() 的调用也会进行状态转移。这三个状态都作为对象封装,并且都给出了影响状态转移的规则(条件)。 这个结构被称为状态设计模式(state design pattern),它提供了一种优雅的方式来实现状态驱动行为。这种实现编码简单,容易扩展,也可以容易地为状态增加进入和退出的动作。下文会给出更完整的实现。 West World 项目 这项目是关于使用有限状态机创建一个 AI 的实际例子。游戏环境是一个古老西部风格的开采金矿的小镇,称作 West World。一开始只有一个挖金矿工 Bob,后期会加入他的妻子。任何的状态改变或者输出都会出现在控制台窗口中。West World 中有四个位置:金矿,可以存金块的银行,可以解除干渴的酒吧,还有家。矿工 Bob 会挖矿、睡觉、喝酒等,但这些都由 Bob 的当前状态决定。 项目在这里:programming-game-ai-by-example-in-unity/WestWorld/ 当你看到矿工改变了位置时,就代表矿工改变了状态,其他的事情都是状态中发生的事情。 Base Game Entity 类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public abstract class BaseGameEntity { /// <summary> /// 每个实体具有一个唯一的识别数字 /// </summary> private int m_ID; /// <summary> /// 这是下一个有效的ID,每次 BaseGameEntity 被实例化这个值就被更新 /// 这项目居民较少,采用预定义 id 的方式,可以忽视 /// </summary> public static int m_iNextValidID { get; private set; } protected BaseGameEntity(int id) { m_ID = id; } public int ID { get { return m_ID; } set { m_ID = value; m_iNextValidID = m_ID + 1; } } // 在 GameManager 的 Update() 函数中调用,相当于实体自己的 Update 函数 public abstract void EntityUpdate(); } Miner 类 MIner 类是从 BaseGameEntity 类中继承的,包含很多成员变量,代码如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class Miner : BaseGameEntity { /// <summary> /// 指向一个状态实例的指针 /// </summary> private State m_pCurrentState; /// <summary> /// 旷工当前所处的位置 /// </summary> private LocationType m_Location; /// <summary> /// 旷工的包中装了多少金块 /// </summary> private int m_iGoldCarried; /// <summary> /// 旷工在银行存了多少金块 /// </summary> private int m_iMoneyInBank; /// <summary> /// 口渴程度,值越高,旷工越口渴 /// </summary> private int m_iThirst; /// <summary> /// 疲倦程度,值越高,旷工越疲倦 /// </summary> private int m_iFatigue; public Miner(int id) : base(id) { m_Location = LocationType.Shack; m_iGoldCarried = 0; m_iMoneyInBank = 0; m_iThirst = 0; m_iFatigue = 0; m_pCurrentState = GoHomeAndSleepTilRested.Instance; } /// <summary> /// 等于 Update 函数,在 GameManager 内被调用,每调用一次就变得越口渴 /// </summary> public override void EntityUpdate() { m_iThirst += 1; m_pCurrentState.Execute(this); } // ...其他的代码看 Github 项目 } Miner 状态 金矿工人有四种状态: EnterMineAndDigForNugget:如果矿工没在金矿,则改变位置。在金矿里了,就挖掘金块。 VisitBankAndDepositGold:矿工会走到银行并且存储他携带的所有天然金矿。 GoHomeAndSleepTilRested:矿工会回到他的小木屋睡觉知道他的疲劳值下降到可接受的程度。醒来继续去挖矿。 QuenchThirst:去酒吧买一杯威士忌,不口渴了继续挖矿。 当前状态条件状态转移 EnterMineAndDigForNugget挖矿挖到口袋装不下VisitBankAndDepositGold EnterMineAndDigForNugget口渴QuenchThirst VisitBankAndDepositGold觉得自己存够钱能安心了GoHomeAndSleepTilRested VisitBankAndDepositGold没存够钱EnterMineAndDigForNugget GoHomeAndSleepTilRested疲劳值下降到一定程度EnterMineAndDigForNugget QuenchThirst不口渴了EnterMineAndDigForNugget 再谈状态设计模式 之前提到要为状态实现进入和退出这两个一个状态只执行一次的逻辑,这样可以增加有限状态机的灵活性。下面是威力加强版: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public abstract class State { /// <summary> /// 当状态被进入时执行这个函数 /// </summary> public abstract void Enter(Miner miner); /// <summary> /// 旷工更新状态函数 /// </summary> public abstract void Execute(Miner miner); /// <summary> /// 当状态退出时执行这个函数 /// </summary> public abstract void Exit(Miner miner); } 这两个增加的方法只有在矿工改变状态时才会被调用。我们也需要修改 ChangeState 方法的代码如下: 1 2 3 4 5 6 7 8 9 public void ChangeState(State state) { // 执行上一个状态的退出方法 m_pCurrentState.Exit(this); // 更新状态 m_pCurrentState = state; // 执行当前状态的进入方法 m_pCurrentState.Enter(this); } 另外,每个具体的状态都添加了单例模式,这样可以节省内存资源,不必重复分配和释放内存给改变的状态。以其中一个状态为例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class EnterMineAndDigForNugget : State { public static EnterMineAndDigForNugget Instance { get; private set; } static EnterMineAndDigForNugget() { Instance = new EnterMineAndDigForNugget(); } public override void Enter(Miner miner) { if (miner.Location() != LocationType.Goldmine) { Debug.Log("矿工:走去金矿"); miner.ChangeLocation(LocationType.Goldmine); } } public override void Execute(Miner miner) { miner.AddToGoldCarried(1); miner.IncreaseFatigue(); Debug.Log("矿工:采到一个金块 | 身上有 " + miner.GoldCarried() + " 个金块"); // 口袋里金块满了就去银行存 if (miner.PocketsFull()) { miner.ChangeState(VisitBankAndDepositGold.Instance); } // 口渴了就去酒吧喝威士忌 if (miner.Thirsty()) { miner.ChangeState(QuenchThirst.Instance); } } public override void Exit(Miner miner) { Debug.Log("矿工:离开金矿"); } } 看到这里,大家应该都会很熟悉。这不就是 Unity 中动画控制器 Animator 的功能吗! 没错,Animator 也是一个状态机,有和我们之前实现十分相似的功能,例如:添加状态转移的条件,每个状态都有进入、执行、退出三个回调方法供使用。 我们可以创建 Behaviour 脚本,对 Animator 中每一个状态的进入、执行、退出等方法进行自定义,所以有些人直接拿 Animator 当状态机来使用,不过我们在下文还会为我们的状态机实现扩展更多的功能。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class NewState : StateMachineBehaviour { // OnStateEnter is called when a transition starts and the state machine starts to evaluate this state //override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // OnStateUpdate is called on each Update frame between OnStateEnter and OnStateExit callbacks //override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // OnStateExit is called when a transition ends and the state machine finishes evaluating this state //override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) { // //} // ... } 使 State 基类可重用 由于上面四个状态是矿工独有的状态,如果要新建不同功能的角色,就有必要创建一个分离的 State 基类,这里用泛型实现。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public abstract class State<T> { /// <summary> /// 当状态被进入时执行这个函数 /// </summary> public abstract void Enter(T entity); /// <summary> /// 旷工更新状态函数 /// </summary> public abstract void Execute(T entity); /// <summary> /// 当状态退出时执行这个函数 /// </summary> public abstract void Exit(T entity); } 状态翻转(State Blip) 这个项目其实有点像模拟人生这个游戏,其中有一点有意思的是,当模拟人生的主角做某件事时忽然要上厕所,去完之后会继续做之前停止的事情。这种返回前一个状态的行为就是状态翻转(State Blip)。 1 2 3 private State<T> m_pCurrentState; private State<T> m_pPreviousState; private State<T> m_pGlobalState; m_pGlobalState 是一个全局状态,也会在 Update() 函数中和 m_pCurrentState 一起调用。如果有紧急的行为中断状态,就把这行为(例如上厕所)放到全局状态中,等到全局状态为空再进入当前状态。 1 2 3 4 5 6 7 8 9 10 11 12 13 public void StateUpdate() { // 如果有一个全局状态存在,调用它的执行方法 if (m_pGlobalState != null) { m_pGlobalState.Execute(m_pOwner); } if (m_pCurrentState != null) { m_pCurrentState.Execute(m_pOwner); } } StateMachine 类 通过把所有与状态相关的数据和方法封装到一个 StateMachine 类中,可以使得设计更为简洁。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 public class StateMachine<T> { private T m_pOwner; private State<T> m_pCurrentState; private State<T> m_pPreviousState; private State<T> m_pGlobalState; public StateMachine(T owner) { m_pOwner = owner; } public void SetCurrentState(State<T> state) { m_pCurrentState = state; } public void SetPreviousState(State<T> state) { m_pPreviousState = state; } public void SetGlobalState(State<T> state) { m_pGlobalState = state; } public void StateMachineUpdate() { // 如果有一个全局状态存在,调用它的执行方法 if (m_pGlobalState != null) { m_pGlobalState.Execute(m_pOwner); } if (m_pCurrentState != null) { m_pCurrentState.Execute(m_pOwner); } } public void ChangeState(State<T> newState) { m_pPreviousState = m_pCurrentState; m_pCurrentState.Exit(m_pOwner); m_pCurrentState = newState; m_pCurrentState.Enter(m_pOwner); } /// <summary> /// 返回之前的状态 /// </summary> public void RevertToPreviousState() { ChangeState(m_pPreviousState); } public State<T> CurrentState() { return m_pCurrentState; } public State<T> PreviousState() { return m_pPreviousState; } public State<T> GlobalState() { return m_pGlobalState; } public bool IsInState(State<T> state) { return m_pCurrentState == state; } } 新人物 Elsa 第二个项目会演示之前的改进。Elsa 是矿工 Bob 的妻子,她会清理小木屋和上厕所(老喝咖啡)。其中 VisitBathroom 状态是用状态翻转实现的,即上完厕所要回到之前的状态。 项目地址:programming-game-ai-by-example-in-unity/WestWorldWithWoman/ 消息功能 好的游戏实现趋向于事件驱动。即当一件事情发生了(发射了武器,主角发出了声音等等),事件会被广播给游戏中相关的对象。 整合事件(观察者模式)的状态机可以实现更灵活的需求,例如:一个足球运动员从队友旁边通过时,传球者可以发送一个(延时)消息,通知队友应该什么时候到相应位置来接球;一个士兵正在开枪攻击敌人,忽然一个队友中了流弹,这时候队友可以发送一个(即时)消息,通知士兵立刻救援队友。 Telegram 结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public struct Telegram { public BaseGameEntity Sender { get; private set; } public BaseGameEntity Receiver { get; private set; } public MessageType Message { get; private set; } public float DispatchTime { get; private set; } public Dictionary<string, string> ExtraInfo { get; private set; } public Telegram(float time, BaseGameEntity sender, BaseGameEntity receiver, MessageType message, Dictionary<string, string> extraInfo = null) : this() { Sender = sender; Receiver = receiver; DispatchTime = time; Message = message; ExtraInfo = extraInfo; } } 这里用结构体来实现消息。要发送的消息可以作为枚举加在 MessageType 中,DispatchTime 是决定立刻发送还是延时发送的时间戳,ExtraInfo 能携带额外的信息。这里只用两种消息做例子。 1 2 3 4 5 6 7 8 9 10 11 12 public enum MessageType { /// <summary> /// 矿工让妻子知道他已经回到小屋了 /// </summary> HiHoneyImHome, /// <summary> /// 妻子通知矿工自己什么时候要将晚饭从烤箱中拿出来 /// 以及通知矿工食物已经放在桌子上了 /// </summary> StewReady, } 发送消息 下面是 MessageDispatcher 类,用来管理消息的发送。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 /// <summary> /// 管理消息发送的类 /// 处理立刻被发送的消息,和打上时间戳的消息 /// </summary> public class MessageDispatcher { public static MessageDispatcher Instance { get; private set; } static MessageDispatcher() { Instance = new MessageDispatcher(); } private MessageDispatcher() { priorityQueue = new HashSet<Telegram>(); } /// <summary> /// 根据时间排序的优先级队列 /// </summary> private HashSet<Telegram> priorityQueue; /// <summary> /// 该方法被 DispatchMessage 或者 DispatchDelayedMessages 利用。 /// 该方法用最新创建的 telegram 调用接受实体的消息处理成员函数 receiver /// </summary> public void Discharge(BaseGameEntity receiver, Telegram telegram) { if (!receiver.HandleMessage(telegram)) { Debug.LogWarning("消息未处理"); } } /// <summary> /// 创建和管理消息 /// </summary> /// <param name="delay">时间的延迟(要立刻发送就用零或负值)</param> /// <param name="senderId">发送者 ID</param> /// <param name="receiverId">接受者 ID</param> /// <param name="message">消息本身</param> /// <param name="extraInfo">附加消息</param> public void DispatchMessage( float delay, int senderId, int receiverId, MessageType message, Dictionary<string, string> extraInfo) { // 获得消息发送者 BaseGameEntity sender = EntityManager.Instance.GetEntityFromId(senderId); // 获得消息接受者 BaseGameEntity receiver = EntityManager.Instance.GetEntityFromId(receiverId); if (receiver == null) { Debug.LogWarning("[MessageDispatcher] 找不到消息接收者"); return; } float currentTime = Time.time; if (delay <= 0) { Telegram telegram = new Telegram(0, sender, receiver, message, extraInfo); Debug.Log(string.Format( "消息发送时间: {0} ,发送者是:{1},接收者是:{2}。消息是 {3}", currentTime, sender.Name, receiver.Name, message.ToString())); Discharge(receiver, telegram); } else { Telegram delayedTelegram = new Telegram(currentTime + delay, sender, receiver, message, extraInfo); priorityQueue.Add(delayedTelegram); Debug.Log(string.Format( "延时消息发送时间: {0} ,发送者是:{1},接收者是:{2}。消息是 {3}", currentTime, sender.Name, receiver.Name, message.ToString())); } } /// <summary> /// 发送延时消息 /// 这个方法会放在游戏的主循环中,以正确地和及时地发送任何定时的消息 /// </summary> public void DisplayDelayedMessages() { float currentTime = Time.time; while (priorityQueue.Count > 0 && priorityQueue.First().DispatchTime < currentTime && priorityQueue.First().DispatchTime > 0) { Telegram telegram = priorityQueue.First(); BaseGameEntity receiver = telegram.Receiver; Debug.Log(string.Format("延时消息开始准备分发,接收者是 {0},消息是 {1}", receiver.Name, telegram.Message.ToString())); // 开始分发消息 Discharge(receiver, telegram); priorityQueue.Remove(telegram); } } } DispatchMessage 函数会管理消息的发送,即时消息会直接由 Discharge 函数发送到接收者,延时消息会进入队列,通过 GameManager 游戏主循环,每一帧调用 DisplayDelayedMessages() 函数来轮询要发送的消息,当发现当前时间超过了消息的发送时间,就把消息发送给接收者。 处理消息 处理消息的话修改 BaseGameEntity 来增加处理消息的功能。 1 2 3 4 5 6 7 8 9 10 11 12 13 public abstract class BaseGameEntity { // ... 省略无关代码 public abstract bool HandleMessage(Telegram message); } public class Miner : BaseGameEntity { public override bool HandleMessage(Telegram message) { return m_stateMachine.HandleMessage(message); } } StateMachine 代码也要改: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class StateMachine<T> { public bool HandleMessage(Telegram message) { if (m_pCurrentState != null && m_pCurrentState.OnMessage(m_pOwner, message)) { return true; } // 如果当前状态没有代码适当的处理消息 // 它会发送到实体的全局状态的消息处理者 if (m_pCurrentState != null && m_pGlobalState.OnMessage(m_pOwner, message)) { return true; } return false; } } State 基类也要修改: 1 2 3 4 5 6 7 8 9 10 public abstract class State<T> { /// <summary> /// 处理消息 /// </summary> /// <param name="entity">接受者</param> /// <param name="message">要处理的消息</param> /// <returns>消息是否成功被处理</returns> public abstract bool OnMessage(T entity, Telegram message); } Discharge 函数发送消息给接收者,接收者将消息给他 StateMachine 的 HandleMessage 函数处理,消息最后通过 StateMachine 到达各种状态的 OnMessage 函数,开始根据消息的类型来做出处理(例如进行状态转移)。 具体实现请看项目代码:programming-game-ai-by-example-in-unity/WestWorldWithMessaging/ 这里实现的场景是: 矿工 Bob 回家后发送 HiHoneyImHome 即时消息给他的妻子 Elsa,提醒她做饭。 Elsa 收到消息后,停止手上的活儿,开始进入 CookStew 状态做饭。 Elsa 进入 CookStew 状态后,把肉放到烤炉里面,并且发送 StewReady 延时消息提醒自己在一段时间后拿出烤炉中的肉。 Elsa 收到 StewReady 消息后,发送一个 StewReady 即时消息给 Bob 提醒他饭已经做好了。如果 Bob 这时不在家,命令行将显示 Discharge 函数中的 Warning “消息未处理”。Bob 在家,就会开心地去吃饭。 Bob 收到 StewReady 的消息,状态转移到 EatStew,开始吃饭。 总结 有时候我们可能会用到多个状态机来并行工作,例如一个 AI 有多个状态,其中包括攻击状态,而攻击状态又有不同攻击类型(瞄准和射击),像一个状态机包含另一个状态机这种层次化的状态机。当然也有其他不同的使用场景,我们不能受限于自己的想象力。 本文根据《游戏人工智能编程案例精粹(修订版)》进行了 Unity 版本的实现,我对有限状态机也有了更清晰的认识。阅读这本书的同时也会把 Unity 实现放到下面的仓库地址中,下篇文章可能会总结行为树的知识,如果没看到请督促我~ 项目地址:programming-game-ai-by-example-in-unity 引用 《游戏人工智能编程案例精粹(修订版)》

2018/6/23
articleCard.readMore

2D 像素风平台游戏 Aretha’s Journey

继《我设计的第一个桌游》后,这次带来的是电子游戏的作业—— 2D像素平台游戏 Aretha’s Journey。 游戏背景 来自潘达尼亚的女孩 Aretha 已经离乡别井几年了,回乡之际,发现族人们都被神所诅咒而变成了石头,原因是他们的族人曾尝试反抗神。于是 Aretha 便踏上拯救家园之路… 游戏截图 美术风格采用了像素风,游戏一共有五关,第一关为教学关,第五关为 Boss 战,截图如下: 游戏介绍 游戏类型是常见的平台游戏。主角可以收集金币,去商人那购买"遗物"(Relic),遗物可以看做是一种装备,穿上能提供一些特殊增益,例如增加血上限、打怪吸血等。本来想设计成 Roguelike 中众多能提供不同能力的道具,但苦于时间不足,因此在游戏中只设计了几个能购买的遗物,有些遗物能在宝箱里面随机开出来。 游戏地图是用 Unity 2017 的新特性 TileMap 做的,镜头使用 2D Cinemachine。现在回看作品,其实这一类型的游戏并不难,但是我们团队除了我都是第一次接触 Unity,踩过不少坑,也导致到 Due 的时候很多东西都没做完。 我负责了游戏中除战斗系统外的编程任务,例如:Game Loop、Item Respawn、角色状态、背包系统等功能。可以说很多代码都是重用性很高的,但自己以前没有积累,所以做这游戏的时候也只能从零开始做,在编程中逐渐模块化系统代码。 Showcase 不过很开心的是,游戏被入选到学校的 Student Games Showcase 里面,这个 Showcase 里还有其他同学期其他同学所做的优秀的游戏作品。大家有兴趣的话可以在这个网站查看2018年的入选游戏:Student Games 。我们游戏在 Showcase 的评委投票和公开投票中都获得第二名的成绩,自己和小组都是很开心的~ 结语 游戏下载:Aretha-s-Journey 总而言之,游戏设计课认识不少新朋友,也收获了很多,自己在游戏开发的路上又往前走了一步。 Flag: 以后博客会更新得勤一些,学了啥就写啥,不能等到吃透了一个东西再放上来= =。 Thank you for playing my game.

2018/6/18
articleCard.readMore

我设计的第一个桌游

很久没写文章了,这次带来的是游戏设计课中的桌游大作业。 小组成员在第一节课就随机分好了,我们一开始选择每人准备一个桌游原型,然后共同选择一个原型来深入设计,最后一起选择了我的设计原型。 第一版设计 我的设计是:每个玩家初始有 4 HP,四个玩家从左下角通过掷骰子的方式在地图的点上往终点走,先到终点者胜,HP为零时需要从起点重新开始。地图中有四种不同功能的点,玩家到达某个点时会触发相应效果: 黑点:无特殊效果 绿点:休息点,回 1 HP 红点:陷阱点,拿一张陷阱卡,立即使用来接受惩罚,如: 扣血、禁一回合等效果 黄点:宝物点,拿一张宝物卡,可以在当前回合使用,也可以藏起来等以后的回合使用,如:回血、其他玩家倒退三步、禁某玩家一回合等效果 原型的灵感主要来自于最近常玩的 “Slay the Spire” 中的地图,玩家通过选择不同的道路抉择,目标是活着打败关底 BOSS。在第一版设计中,整个的PVE设计下,也考虑了 PVP 因素:两个玩家走到同一个点时可以进行决斗,每人 -1 HP。由于步数主要依赖于随机的骰子得来的数字,所以道路不能设计太多,尽量增加玩家相遇的机会。同时在较长的支路中,宝物点较多,陷阱点也较多,机遇与风险并存。 这样设计的话,我们主要考虑地图设计的平衡和去扩展卡牌的效果即可。 但是问题也来了: 总有运气出奇好的玩家一路趋吉避凶,什么挫折都没遇到就到终点了 玩家死了之后就要回到起点重新开始,没干劲继续玩了 游戏性不足,现有的游戏元素还是太少,主要靠骰子和抓牌时的运气,玩家要考虑的实际上不多 第二版设计 针对前面的第一点问题,新增了“地形”的设计: 地形不是一个能走的点,效果只有在玩家走过(在点上走跨越地形)时触发。相对于点的设计,地形是一个“必须触发”的设计。每个玩家在跨过山脉的时候必须 -1 HP,遇到龙窟的时候也能必得一张宝物卡,这样降低了运气的成分,提高了功能卡的利用率。 对于上面的第二个问题,我们通过设计“旅馆”的一种地形,玩家走过 +1 HP,同时可以当做玩家的重生点,玩家死后可以从路过的旅馆重生。 针对第三个问题,我们新增了英雄卡的设计。每个英雄有不同能力,例如坦克天生带 5 HP,一些特殊的英雄还能根据现有元素设计能力。我们也新增了 AP(攻击力)和装备卡的设计,注入更多的 RPG 元素。 成品 地图 英雄卡 装备卡及卡背 陷阱卡 陷阱卡太多就不放上来了,效果主要是退几步、禁一回合、丢到所有的卡牌回到最近的起点等等。 宝物卡 效果主要有: 回血 偷别人一张卡(宝物卡或者装备卡) 其他玩家退三步 多一次掷骰子行动的机会 +1 HP 并再拿一张宝物卡 每个玩家拿一张宝物卡(增加卡牌利用率) 和某玩家决斗!(增加 PVP 的存在感) … 说明书中部分内容 结语 在昨天晚上的游戏设计课里,每个小组都要玩别的小组做的桌游并打分,在五分满分的前提下,参与的小组给我们游戏大多打了四分以上,主要进行游戏设计部分的我还是很开心的!!!在文尾再次感谢小组成员所付出的努力,特别是主要进行美术设计的韩国小哥。 下一个大作业做电子游戏,准备做 Roguelike,有什么好的建议也可以在评论区提出~

2018/4/13
articleCard.readMore

寻路算法-贪婪最佳优先算法

最近开始接触寻路算法,对此不太了解的话建议读者先看这篇文章《如何快速找到最优路线?深入理解游戏中寻路算法》 。 所有寻路算法都需要一种方法以数学的方式估算某个节点是否应该被选择。大多数游戏都会使用启发式(heuristic) ,以 h(x) 表示,就是估算从某个位置到目标位置的开销。理想情况下,启发式结果越接近真实越好。 ——《游戏编程算法与技巧》 今天主要说的是贪婪最佳优先搜索(Greedy Best-First Search),贪心算法的含义是:求解问题时,总是做出在当前来说最好的选择。通俗点说就是,这是一个“短视”的算法。 为什么说是“短视”呢?首先要明白一个概念:曼哈顿距离。 曼哈顿距离 曼哈顿距离被认为不能沿着对角线移动,如下图中,红、蓝、黄线都代表等距离的曼哈顿距离。绿线代表欧氏距离,如果地图允许对角线移动的话,曼哈顿距离会经常比欧式距离高。 在 2D 地图中,曼哈顿距离的计算如下: 贪婪最佳优先搜索的简介 贪婪最佳优先搜索的每一步,都会查找相邻的节点,计算它们距离终点的曼哈顿距离,即最低开销的启发式。 贪婪最佳优先搜索在障碍物少的时候足够的快,但最佳优先搜索得到的都是次优的路径。例如下图,算法不断地寻找当前 h(启发式)最小的值,但这条路径很明显不是最优的。 贪婪最佳优先搜索“未能远谋”,大多数游戏都要比贪婪最佳优先算法所能提供的更好的寻路,但大多数寻路算法都是基于贪婪算法,所以了解该算法很有必要。 首先是节点类,每个节点需要存储上一个节点的引用和 h 值,其他信息是为了方便算法的实现。存储上一个节点的引用是为了像一个链表一样,最后能通过引用得到路径中所有的节点。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class Node { // 上一个节点 public Node parent; // 节点的 h(x) 值 public float h; // 与当前节点相邻的节点 public List<Node> adjecent = new List<Node>(); // 节点所在的行 public int row; // 节点所在的列 public int col; // 清除节点信息 public void Clear() { parent = null; h = 0.0f; } } 下面是图类,图类最主要的任务就是根据提供的二维数组初始化所有的节点,包括寻找他们的相邻节点。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 // 图类 public class Graph { public int rows = 0; public int cols = 0; public Node[] nodes; public Graph(int[, ] grid) { rows = grid.GetLength(0); cols = grid.GetLength(1); nodes = new Node[grid.Length]; for (int i = 0; i < nodes.Length; i++) { Node node = new Node(); node.row = i / cols; node.col = i - (node.row * cols); nodes[i] = node; } // 找到每一个节点的相邻节点 foreach (Node node in nodes) { int row = node.row; int col = node.col; // 墙,即节点不能通过的格子 // 1 为墙,0 为可通过的格子 if (grid[row, col] != 1) { // 上方的节点 if (row > 0 && grid[row - 1, col] != 1) { node.adjecent.Add(nodes[cols * (row - 1) + col]); } // 右边的节点 if (col < cols - 1 && grid[row, col + 1] != 1) { node.adjecent.Add(nodes[cols * row + col + 1]); } // 下方的节点 if (row < rows - 1 && grid[row + 1, col] != 1) { node.adjecent.Add(nodes[cols * (row + 1) + col]); } // 左边的节点 if (col > 0 && grid[row, col - 1] != 1) { node.adjecent.Add(nodes[cols * row + col - 1]); } } } } } 在算法类中,我们需要记录开放集合和封闭集合。开放集合指的是当前步骤我们需要考虑的节点,例如算法开始时就要考虑初始节点的相邻节点,并从其找到最低的 h(x) 值开销的节点。封闭集合存放已经计算过的节点。 1 2 3 4 // 开放集合 public List<Node> reachable; // 封闭集合,存放已经被算法估值的节点 public List<Node> explored; 下面是算法主要的逻辑,额外的函数可以查看项目源码。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public Stack<Node> Finding() { // 存放查找路径的栈 Stack<Node> path; Node currentNode = reachable[0]; // 迭代查找,直至找到终点节点 while (currentNode != destination) { explored.Add(currentNode); reachable.Remove(currentNode); // 将当前节点的相邻节点加入开放集合 AddAjacent(currentNode); // 查找了相邻节点后依然没有可以考虑的节点,查找失败。 if (reachable.Count == 0) { return null; } // 将开放集合中h值最小的节点当做当前节点 currentNode = FindLowestH(); } // 查找成功,则根据节点parent找到查找到的路径 path = new Stack<Node>(); Node node = destination; // 先将终点压入栈,再迭代地把node的前一个节点压入栈 path.Push(node); while (node.parent != null) { path.Push(node.parent); node = node.parent; } return path; } 除此以外还有些展示算法的类,代码不在这里展出。下面是算法执行的截图,其中白色格子为可走的格子,灰色格子是不可穿越的,红色格子为查找到的路径,左上角格子为查找起点,右上角格子为查找终点。 后一个实例也展现了其"短视"的缺点,红线走了共65个格子,但蓝箭头方向只走了45个格子。 最后 还有一种方案就是直接计算起点到终点的路径,这样可以节省一点计算开销。如下方右图,左图为广度优先算法。 本项目源码在 Github-PathFindingDemo 。 了解了贪婪最佳优先算法后,下一篇文章会在本文基础上讲A* 寻路。 参考资料 如何快速找到最优路线?深入理解游戏中寻路算法 关于寻路算法的一些思考(1):A*算法介绍 《游戏编程算法与技巧》

2017/12/17
articleCard.readMore

3D数学基础-矩阵变换(二)

2020年7月26日更新: 最近对变换重新学习整理了,文章在:图形学常见的变换推导。 上一篇笔记3D 数学基础-向量运算基础和矩阵变换记录了一些向量和矩阵运算的基础,和一些矩阵基本的变换。这篇笔记主要介绍了平移变换、齐次坐标、平移和旋转变换的组合、法线变换和改变坐标系。 平移(Translation) 前一篇文章总结了旋转、缩放等变换,这里介绍一下平移变换。 假如我们需要把 X 坐标从 x 变换到 x + 5,我们需要构造什么样的变换矩阵来实现平移呢? [x′y′z′]=[?][xyz]=[x+5yz]\begin{bmatrix}x' \\ y' \\ z' \end{bmatrix}=\begin{bmatrix} & & \\ & ? & \\ & & \end{bmatrix}\begin{bmatrix}x \\ y \\ z \end{bmatrix}=\begin{bmatrix}x+5 \\ y \\ z \end{bmatrix} ​x′y′z′​ ​= ​​?​​ ​ ​xyz​ ​= ​x+5yz​ ​ 要注意的是,变换矩阵不能包含 x、y、z 等坐标变量。例如要得到 x + 5,那么矩阵的第一个行向量不能是 $$(1, 0, 5/z)$$,因为变换的一个重要性质是变换矩阵保持不变。 我们可以给矩阵多加一个维度 w,令其等于 1,这样就不需要用到 x、y、z 这些坐标变量了。 [x′y′z′w′]=[1005010000100001][xyz1]=[x+5yz1]\begin{bmatrix}x' \\ y' \\ z' \\ w' \end{bmatrix}=\begin{bmatrix} 1 & 0 & 0 & 5\\ 0 & 1 & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix}x \\ y \\ z \\ 1 \end{bmatrix}=\begin{bmatrix}x+5 \\ y \\ z \\ 1 \end{bmatrix} ​x′y′z′w′​ ​= ​1000​0100​0010​5001​ ​ ​xyz1​ ​= ​x+5yz1​ ​ 这里引用到了齐次坐标的概念。 齐次坐标 齐次坐标表示是计算机图形学的重要手段之一,它既能够用来明确区分向量和点,同时也更易用于进行仿射几何变换。 —— F.S. Hill, JR 《计算机图形学(OpenGL 版)》作者 想要了解三维坐标是怎么样扩展到四维坐标,可以先了解下二维空间中的齐次坐标 $$(x, y, w)$$。 下图的三维坐标中在 $$w=1$$ 处有一个二维平面,则该平面上的二维平面坐标可以表示为 $$(x, y, 1)$$,图中的 $$(1.0, 0.8, 1.0)$$ 就是一个在该二维平面上的点。对于不在二维平面上的点,我们则将其投影到二维平面上,所以齐次坐标 $$(x, y, w)$$ 映射到实际二维平面上的点为 $$(x/w, y/w)$$,例如另外一个点 $$(2.5, 2.0, 2.5)$$ 在 $$w=1$$ 二维平面投影得到的点为 $$(1.0, 0.8)$$。 同理,三维空间的点可以认为是在四维空间中 $$w=1$$ 的“平面”上,所以齐次坐标 $$(x, y, z, w)$$ 映射到三维空间上的点为 $$(x/w, y/w, z/w)$$,这也就是点的非齐次坐标。 [xyzw]=[x/wy/wz/w1]\begin{bmatrix}x \\ y \\ z \\ w \end{bmatrix}=\begin{bmatrix}x/w \\ y/w \\ z/w \\ 1 \end{bmatrix} ​xyzw​ ​= ​x/wy/wz/w1​ ​ 四维齐次坐标的任何常量缩放会得到相同的结果,也就是说,它包含了点的非齐次坐标所有可能的缩放(还记得上面图中的两个点吗?),w 可以等于任何数值: 如果 w > 0,这表示一个真实物理世界的点,我们可以用 x,y,z 三个坐标除以 w 得到这个真实的点。 如果 w = 0,这表示一个无穷远处的点。在实际应用中,通常表示一个向量。 齐次坐标的优点: 让我们可以同时考虑所有的变换,如平移、观察、旋转、透视投影等。(如果我们要做缩放变换,可以直接变换 x、y、z 的值并且保持 w 不变。) 整个渲染管线都以 4 * 4 的齐次坐标矩阵以及对应的四维向量为基础,只有在最后要表示真实点的位置的时候,才需要把齐次坐标变为非齐次。(计算机里除法是个非常复杂的操作,需要花费更多的时间周期,但齐次坐标只需在渲染管线最后一步做除法。) 不会出现特殊情况。有时考虑判断直线是否相交,当它们平行的时候,有些公式会出错,但无穷远点已经在齐次坐标中约定了 w=0。 四阶矩阵和齐次坐标是计算机图形软件和硬件中普遍使用的标准。 下面表示的是一个点向量加上一个平移变量: P′=TP=[100Tx010Ty001Tz0001][xyz1]=[x+Txy+Tyz+Tz1]=P+TP{}'=TP=\begin{bmatrix} 1 & 0 & 0 & T_{x}\\ 0 & 1 & 0 & T_{y}\\ 0 & 0 & 1 & T_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix}x \\ y \\ z \\ 1 \end{bmatrix}=\begin{bmatrix}x+T_{x} \\ y+T_{y} \\ z+T_{z} \\ 1 \end{bmatrix}=P+TP′=TP= ​1000​0100​0010​Tx​Ty​Tz​1​ ​ ​xyz1​ ​= ​x+Tx​y+Ty​z+Tz​1​ ​=P+T 有时候平移矩阵 T 内的左上角部分会直接用单位矩阵来表示,其中 $$I_{3}$$ 表示 3 * 3 的单位矩阵。 T=[100Tx010Ty001Tz0001]=[I3T01]T=\begin{bmatrix} 1 & 0 & 0 & T_{x}\\ 0 & 1 & 0 & T_{y}\\ 0 & 0 & 1 & T_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}=\begin{bmatrix} I_{3} & T\\ 0 & 1 \end{bmatrix}T= ​1000​0100​0010​Tx​Ty​Tz​1​ ​=[I3​0​T1​] 组合平移和旋转变换 当要对一个点进行两种变换的时候,就会涉及到变换顺序的问题,是先平移再旋转,还是先旋转再平移? 先旋转再平移 P′=(TR)P=MP=RP+TP{}'=\left ( TR \right )P=MP=RP+TP′=(TR)P=MP=RP+T 其中 T 为平移矩阵,R 为旋转矩阵,矩阵 P 通过平移再旋转后得到矩阵 P’。注意,在上面的公式中做的是标准的向量计算,在计算时才用齐次坐标计算。 由于我们使用的是列矩阵,矩阵的阅读顺序应该从右到左。也就是说对于 TR 这个旋转矩阵而言,(TR)P 这个操作,就是对矩阵 P 先 R 旋转,再进行 T 平移。 M=[100Tx010Ty001Tz0001][R11R12R130R21R22R230R31R32R3300001]=[R11R12R13TxR21R22R23TyR31R32R33Tz0001]=[RT01]M=\begin{bmatrix} 1 & 0 & 0 & T_{x}\\ 0 & 1 & 0 & T_{y}\\ 0 & 0 & 1 & T_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix} R_{11} & R_{12} & R_{13} & 0\\ R_{21} & R_{22} & R_{23} & 0\\ R_{31} & R_{32} & R_{33} & 0\\ 0 & 0 & 0 & 1 \end{bmatrix}=\begin{bmatrix} R_{11} & R_{12} & R_{13} & T_{x}\\ R_{21} & R_{22} & R_{23} & T_{y}\\ R_{31} & R_{32} & R_{33} & T_{z}\\ 0 & 0 & 0 & 1 \end{bmatrix}=\begin{bmatrix} R & T \\ 0 & 1 \end{bmatrix}M= ​1000​0100​0010​Tx​Ty​Tz​1​ ​ ​R11​R21​R31​0​R12​R22​R32​0​R13​R23​R33​0​0001​ ​= ​R11​R21​R31​0​R12​R22​R32​0​R13​R23​R33​0​Tx​Ty​Tz​1​ ​=[R0​T1​] MP=[RT01][P001]=[RPT01]=RP+TMP=\begin{bmatrix} R & T \\ 0 & 1 \end{bmatrix}\begin{bmatrix} P & 0 \\ 0 & 1 \end{bmatrix}=\begin{bmatrix} RP & T \\ 0 & 1 \end{bmatrix}=RP+TMP=[R0​T1​][P0​01​]=[RP0​T1​]=RP+T 先平移再旋转 P′=(RT)P=MP=R(P+T)=RP+RTP{}'=(RT)P=MP=R(P+T)=RP+RTP′=(RT)P=MP=R(P+T)=RP+RT M=[R11R12R130R21R22R230R31R32R3300001][100Tx010Tx001Tx0001]=[R3×3R3×3T3×101×31]M=\begin{bmatrix} R_{11} & R_{12} & R_{13} & 0\\ R_{21} & R_{22} & R_{23} & 0\\ R_{31} & R_{32} & R_{33} & 0\\ 0 & 0 & 0 & 1 \end{bmatrix}\begin{bmatrix} 1 & 0 & 0 & T_{x}\\ 0 & 1 & 0 & T_{x}\\ 0 & 0 & 1 & T_{x}\\ 0 & 0 & 0 & 1 \end{bmatrix}=\begin{bmatrix} R_{3\times 3} & R_{3\times 3}T_{3\times 1}\\ 0_{1\times 3} & 1 \end{bmatrix}M= ​R11​R21​R31​0​R12​R22​R32​0​R13​R23​R33​0​0001​ ​ ​1000​0100​0010​Tx​Tx​Tx​1​ ​=[R3×3​01×3​​R3×3​T3×1​1​] MP=[R3×3R3×3T3×101×31][P001]=[RPRT01]=RP+RTMP=\begin{bmatrix} R_{3\times 3} & R_{3\times 3}T_{3\times 1}\\ 0_{1\times 3} & 1 \end{bmatrix}\begin{bmatrix} P & 0\\ 0 & 1 \end{bmatrix}=\begin{bmatrix} RP & RT\\ 0 & 1 \end{bmatrix}=RP+RTMP=[R3×3​01×3​​R3×3​T3×1​1​][P0​01​]=[RP0​RT1​]=RP+RT 对比先平移再旋转的结果,可以看到不同的地方是 T 变成了 RT,也就是说旋转也施加在了平移的方向上。在下图中可看到小人在 Y 轴方向发生了平移,所以先旋转再平移可能会更容易得到理想的结果,但两种变换组合都是可行的。(在 gluLookAt 的推导中需要先做平移,gluLookAt 是 OpenGL 中观察变换的一个关键函数。) 法线变换 在图形学中曲面法线的方向也很重要。 法线(Normal) 法线是始终垂直于某平面的虚线。在数学几何中法线指平面上垂直于曲线在某点的切线的一条线。 ——百度百科 切线的位置实际上就是曲面上的几何位置,因此它们的变换矩阵和曲面的变换矩阵保持一致。 t→Mtt\rightarrow Mtt→Mt 而法线变换是另外一个矩阵,我们称之为 Q。 n→Qn  Q=?n\rightarrow Qn~~Q=?n→Qn  Q=? 法线必须垂直于这些切线,因此法线和切线的点积等于 0。 nTt=0n^{T}t=0nTt=0 变换后法线和切点依然互相垂直,可得: (Qn)T=(nTQT)(Mt)=0(Qn)^{T}=(n^{T}Q^{T})(Mt)=0(Qn)T=(nTQT)(Mt)=0 在该等式中,只有当 $$Q^{T}M=I$$ 时,矩阵才容易求解。 nTQTMt=0⇒QTM=In^{T} Q^{T} M t=0 \Rightarrow Q^{T} M=InTQTMt=0⇒QTM=I 最后可得法线变换公式,其中 $$M^{-1}$$ 只对左上角的 3 * 3 矩阵求逆和转置: Q=(M−1)TQ=(M^{-1})^{T}Q=(M−1)T 要进行法线变换,这个公式要施加在曲面上所有的法线上。 另外要注意的是法线是一个向量,不会随着平移而改变,因此平移变换对法线没影响。 改变坐标系 在图中,点 (2, 1) 要平移到 (1, 1) 处,可以直接把点向左平移,也可以看成是坐标系的改变,将坐标系向右平移。 引出坐标系的概念是因为,在很多情形下,我们需要一个特定的物理位置在不同的坐标系间变换,例如上一篇文章正交坐标系中所举的例子。下图中,有世界坐标系、相机坐标系和点 P。 点 P 在两个坐标系中的坐标是不同的,而在图形学中,我们经常要做这样的变换。 如果我们在世界坐标系中有一个点,要计算出其在相机坐标系中的位置,则要同时考虑相机旋转的坐标系、相机的位置和观察的位置(视点)。 在二维空间下的旋转中能看到二维旋转矩阵的推导过程,在此基础上,可以看作坐标系向右旋转了θ度,这样点 P 就在新坐标系中到达目标位置 P’ 了。 这样我们就能通过旋转矩阵来得到新的 uv 坐标系了! [uv]=[cosθ−sinθsinθcosθ][xy]\begin{bmatrix} u\\v \end{bmatrix}=\begin{bmatrix} cos\theta & -sin\theta \\ sin\theta & cos\theta \end{bmatrix}\begin{bmatrix} x\\y \end{bmatrix}[uv​]=[cosθsinθ​−sinθcosθ​][xy​] 之前讨论的都是三维的变换,下篇笔记会介绍图形学中的观察(Viewing)。3D 数学基础知识有点枯燥,但这是入门图形魔法的必经之路。 参考资料 3D 数学基础:图形与游戏开发 edx-Computer Graphics

2017/9/25
articleCard.readMore