MoreRSS

site iconAiring修改

95后,腾讯音乐高级前端工程师,本科毕业于广州大学教育技术学专业,硕士就读于中山大学哲学系。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Airing的 RSS 预览

月刊(第33期):推理阶梯

2025-11-16 00:17:12

本篇是对二〇二五年九月至十月的记录与思考。

《Rethinking thinking》这个视频里介绍了「推理阶梯」这个概念,它较为详细地阐述了我们是如何处理在现实世界中感知的各种信息,分为以下七个步骤:

  1. 原始数据:世界中的原始数据,以及我们感官对其接收到的感知信息。
  2. 筛选信息:对于接收到的信息,我们会不自觉地使用注意力去筛选出特定的信息和细节。
  3. 赋予意义:对于筛选后的信息,我们会去尝试解释信息所传达内容。
  4. 形成假设:根据前一层创造的意义来发展假设,在这里事实和故事之间的区别开始变得模糊。
  5. 得出结论:根据假设得出结论,我们的情绪反应也是在这个地方产生的。
  6. 调整信念:根据当前的经验调整我们之前对周围世界的信念。
  7. 采取行动:根据调整后的信念采取行动。

在这个阶梯的七个步骤其实每一步都存在着局限性,注定了我们无法直接感知真实。例如在原始数据这一层,人类感官的局限性决定了不可能接收到全貌信息,且接受到的信息也不能够反映客观。

本福特定律揭示了在很多自然产生的数据里,数值的分布更接近按对数分布而不是按均匀分布。大量真实世界数据不是线性增长,现实世界的数据常常跨越多个数量级。为了适应这一现象,生物的神经系统也进化为对数型编码,以便大脑能够以信息量最大化感知信息。这也就是魏伯–费希纳定律——人类的感知系统对外界刺激的敏感度按比例变化。我们感知到的,是感官与大脑共同构建的替代物,而非真实本身。

对此,津巴多在《普通心理学》写到:“知觉赋予感觉以意义,因此知觉产生的是对世界的解释,而不是对世界的完美表征。”知识并非只是信息,而是在个体的认知模型在与环境不断冲突中辩证产生的解释。我们对人生的认知都是经这些意义和观点过滤后的产物。因此,我们并不活在现实里,而是活在对现实的感知里。

大脑追求的不是真理,而是可依赖的稳定模型。为了生存,大脑必须依赖模型,因此会竭力维护自己所信的模型。

固化的信念让我们不假思索地依靠「系统 1」去做出更节能的判断,这个时候不妨停下来思考一下自己得出这个结论的推理阶梯的每一层是什么,是否存在更高的维度去看待这件事情,让「系统 2」发挥一下作用,往往能更加客观地理清思路。

我们心中一个概念的形成,是由无数个视域融合后的结果。若我们的立足点越高,自身的历史视野、文化视野就越是开阔,越能够按照大和小、远和近去正确评价视野所及的范围内一切事物的意义。

但我们也必须要知道,我们只能不断去逼近真实本身,而非抵达它,因此对于不同观点需要抱有谦卑地心态去接纳与理解。

在之前的《谈谈存在的价值与人生体验》一文中也提到过这个观点 —— 这个真实世界是复杂多元的,仅仅去做你认为「有价值」的事情是非常片面的,也是遗憾的,因为那样会错过生活中很多圆润的、尖锐的、小众的、朴实的美。降低价值的要求反而更能在生活中发现「甜点」。

因此我们可以尝试去降低心中那个模型的价值顺位,这样反而能够更好地积累人生的甜点,因为你会变得更包容,也因此更快乐,同时也能够让我们更客观地体验人生、不断进步。

这个世界上有人树立权威,就会有人盲目跟从;也有很多人独尊小众美,就会有人觉得他们是卖弄而已。这些都是不同的人、不同的时间、不同的地点、不同的情绪观察世界的方式而已。而最终只要能看到事物本身,并在过程中积累百家各自视角内的甜点,选择用什么方法去看,又如何呢?

🌺 生活点滴

这两个月工作节奏非常紧凑,但回顾来看也抽空去了不少地方。

🇲🇾 马来新山

组内去马来西亚的新山聚餐 & 密室团建,写月刊的时候才发现那两天没有拍照。新山整体观感有点像国内的二三线城市,华人占比也不小。

🇮🇩 印尼民丹岛

十一国庆我没有假期,只能羡慕朋友圈里发的各种假期旅行照片。于是周末跑去民丹岛躺了两天,就当是放了国庆假了。

海岛边的日出,XPan + 裁切之后还是挺出片的:

🇨🇳 北京&珠海

国庆后的一周回了国内出差,先去北京、再去珠海,也是时隔 6 个月之后的首次回国。回国的第一件事就是找各种好吃的,一天吃几顿,还是国内的食物好吃。

也喝到了心心念念的阿嬷手作:

🇲🇴 澳门

珠海出差结束后,趁着周末去了趟澳门,City Walk 了一天。

🇸🇬 万圣节环球影视

10 月份还去了趟 SG 的环球影城,体验了下夜晚的万圣节主题和 4 个限定鬼屋,鬼屋都是清一色的 Jump scare,布景有特色但体验较为同质化。

🎬 书影音

以下是本周期的书影音记录。

  • 看过:剧集 |《苦尽柑来遇见你》| ★★★★★
  • 看过:剧集 |《迷尸校园》| ★★★☆☆
  • 看过:剧集 |《余生有涯》| ★★★☆☆
  • 看过:剧集 |《赴山海》| ★★☆☆☆
  • 看过:剧集 |《弥留之国的爱丽丝 第三季》| ★★★☆☆
  • 看过:电影 |《捕风追影》| ★★★★★
  • 看过:电影 |《南京照相馆》| ★★★★☆
  • 看过:电影 |《一战再战》| ★★★★☆
  • 看过:电影 |《同甘共苦》| ★★★☆☆
  • 在读:科普 |《GEB》| ★★★★★
  • 读过:小说 |《麒麟之翼》| ★★★☆☆
  • 玩过:游戏 |《超英派遣中心》| ★★★★★
  • 在玩:游戏 |《空洞骑士:丝之歌》| ★★★☆☆

月刊(第32期):中大校友会的采访

2025-08-16 13:31:53

本篇是对二〇二五年七月至八月的记录与思考。

上周末有幸接受了个中山大学哲学系优秀校友寻访,正好采访同学发给我过提纲,挑选一些话题,凭借着记忆,在这里挑选采访过程中一些对话分享。


Q1. 您在2017年入读哲学系,成为中国哲学方向的硕士,首先想请教,当初是什么契机让您选择中大的中国哲学专业?在众多哲学方向中,为何特别钟情于中国哲学?

选择哲学的契机在本科深入技术领域之后,我开始感到一种疲惫和迷茫。当面对“应该做能获奖的项目还是自己想做的项目”这类选择时,我开始反思:“我做技术是为了什么?娱乐自己,还是改变世界?”我感到在当时的环境下,两者似乎都难以实现,甚至想不被世界改变就很不错了。

正是这种对技术意义的追问和人生方向的困惑,促使我决定转向哲学。我的目标很明确——“不为其他,只是想求得一番境界,一种胸怀宇宙、达观人生的境界。”(《再见了,我的大学》)同时,我也想从哲学中寻找关于“温暖”与“幸福”的答案。

关于为何选择中国哲学,可能是我的思考方式天然地亲近中国哲学,我会更关注生命实践与内在安顿。在教育实习期间的体验让我更加明确了这点。那段时间每日在实习的小学的走廊中捧读中西哲学史备考,伴随朗朗书声,哲学智慧与孩子们的纯真质朴相结合,让我感受到了无比温暖,是我与中国哲学智慧产生情感共鸣的起点。


Q2. 在哲学系的求学经历对您后来人生道路,尤其是职业选择产生了什么样的影响(您在文章中提到哲学硕士背景与小学教师经历,这些看似与技术无关的积累如何影响了您在鹅厂的技术研发的工作思维?)?以及您是如何看待大学是就业的准备期这一问题呢?

哲学对技术工作思维的影响,我觉得在于几点吧。

首先培养了自己追本溯源的思考方式。哲学训练我不断追问“为什么”,这种思维方式让我接触到技术时不只看用法,而是会像剥洋葱一样深入到底层原理,我觉得这个过程非常奇妙,满足自己好奇心的同时可以获得一种爽感。所以我的技术路径也是从前端深入到客户端,尤其对浏览器和渲染非常感兴趣。

其次哲学训练培养了自己用系统性视角去看待问题的方式。在研发阶段站在整条产品链路的层面去俯视各个环节的合理性,在业务视野上也提醒自己不能仅局限于手上的业务,而是要纵向去挖根因缘起、横向去调查行业竞品,在当下思考不足、往未来预判发展。这种跳出当下模块、审视系统整体的思维习惯,我也正是哲学强调整体性思考的体现。

最后是加强了自己的一些沟通与表达上的软素质,这就不展开说了。

关于就业准备期这个说法,我觉得是有一定道理的,毕竟这是一条社会认同的传统路径——高考、大学、就业。但有些时候也要意识到自己的可能性,在大学需要去不断求索,探索并确立个人未来可能的道路,并培养为之奋斗的毅力。

从我的视角来看,大学学习的根本目的在于完善自己认知世界的模型,找寻看待问题的不同视角,锻炼独立思考的能力,并基于此懂得何是何非,培育自己的共情力。这些是比单一职业技能更重要、更能支撑一个人走得长远的素养。


Q3. 您在文章中写道认为编程的快乐源于“纯粹创造”,但现实中技术常被绩效异化。您认为哲学训练赋予您的哪些“反脆弱”能力特别珍贵?以及您认为学生时代应锻造哪些核心素养(做什么)来抵御未来的价值消磨?

哲学赋予自己的反脆弱能力,可能是让我把价值锚定在内部,这种内在的价值锚点,让我在面对外部压力时不易被异化。另外可能比其他人更幸运的一点是在刚上大学时的时候接近了一次死亡时刻,这让我着实体会到——在生命面前一切都无足轻重。我们应该、也必须用有限的生命多去体验生活中的精彩、去探寻生命的无限可能。

学生时代应锻造的核心素养,前面有泛泛地提及到,这里具体说我觉得至少有三个点:

  1. 持续反思与复盘的习惯:这是我以往文章中反复出现的主题,无论是心情日记,还是年度总结,都是我抵御价值消磨的方式。

  2. 建立个人的意义系统:学生时代不应只学习“术”,更要思考“道”。要去追问“为什么学”、“为何而做”,为自己的人生确立一个不易被外界动摇的内核。

  3. 保持纯粹的好奇心:内在的好奇作为动力源时,探寻问题和解决问题所带来的价值感和成就感是最稳固的。这种好奇心不仅是个人无限发展的动力源,其实也是人类历史长河得以发展的源泉。


Q4. 看了学长的经历,给我印象最深的理念就是“拥抱变化”,想知道这个理念是源于您早期的成长经历,还是在某个特定阶段形成的?在当下普遍追求稳定的社会氛围中,您是如何培养并保持这种‘拥抱变化’的心态的?

确实是那次半只脚踏入地狱之门的经历,让我从一个普通大学生转变为主动寻求生命意义的探索者的分水岭。在我看来,变化其实就是机会,是一个可以让自己跳出舒适圈、探索不同可能性的机会。人生的意义就是体验当下,保持这种心态的话,那无论何种变化都可以更从容地面对。

至于如何保持这种心态,有两点吧:

  1. 持续的自我反思:前面提到的,通过写定期的总结、复盘、反思和规划,可以不断地审视和接纳生活中的变化,并从中提炼出成长的养分。
  2. 关注内在成长:我认为安全感的本质其实是来源于自身能力的提升,而非外部环境的稳定。

Q5. 作为学长,您最想跟现在就读于哲学系的本科生和研究生分享什么(有什么建议)?

  1. 打破专业的墙:不要被任何标签标签所束缚。花精力去接触不同领域,然后融会贯通,我相信也会有一些收获。

  2. 在生活中修行:哲学不是故纸堆里的学问。所谓“道不远人”,试着用学到的工具去分析一部电影、一个社会热点,或者就像我一样,用它来审视自己的生活和成长。

  3. 找到无用之用:不要因为哲学不好找工作而焦虑。哲学训练的逻辑思辨、宏大视野、清晰表达的能力,是任何行业都稀缺的软实力。同时,去培养一项具体的硬技能,它会成为一个让你的思想有落地的工具。


Q6. 最后,作为学长,如果让您留下一句中国哲学中对您生活影响最大的一句话,您会选择哪一句呢,为什么?

在不同阶段都有其实都有不同的话勉励着我。这个问题其实在研究生复试的时候也被问过,当时我的回答是《中庸》里的:“人一能之,己百之;人十能之,己千之。果能此道矣,虽愚必明,虽柔必强。”勤能补拙是一个很简单的道理,如果觉察到自己的天赋不如别人,那就多花精力去追上。虽然本科阶段没有接受过系统地哲学训练,那么我愿意花更多的精力去学习;虽然某个领域我没有接触过或者天然不擅长,那么就加倍去学习,总能理解的。这句话至今都在勉励着我,面对陌生无需害怕,因为有自信、有手段可以掌握那些未知。

第二句话可能是我工作两年后,在 2021 年终总结的主题:“行有不得,反求诸己。”遇到困难时,提醒自己要向内求索、寻找自身成长空间。但前提是在做好课题分离的前提下进行内省,否则就是内耗了。

第三句话可能近两年感受比较深的:“吾生也有涯,而知也无涯。以有涯随无涯,殆已!”生命有限而知识无穷,尤其是 AI 快速发展的这两年,我们获取知识的手段变得无比便捷,但如果一味地用有限的生命去追求无限的知识,必然会让自己变得无比疲惫。因此不要过分执着于追求知识,而是要过滤与筛选出自己感兴趣的内容,并内化为自己新的认知,才能保持内心的宁静和自然的状态。


Q7. 最后的最后,希望可以推荐三本书。

第一本《有限与无限的游戏》,常读常新的一本书。

第二本就《存在主义心理治疗》吧,和我的人生观很契合的一本书。

最后一本纠结于是《鱼不存在》还是《悉达多》,最后还是选择《鱼不存在》吧,希望我们都可以学会与不确定性相处。

🌺 生活点滴

🌇 分享这个月拍到的几张好看的夕阳吧:

🎬 书影音

以下是本周期的书影音记录。

  • 看过:电影 |《鬼灭之刃: 无限城第一章》| ★★★★★
  • 看过:韩剧 |《恶缘》| ★★★★★
  • 看过:电影 |《F1:狂飙飞车》| ★★★★★
  • 看过:电影 |《神奇四侠》| ★★☆☆☆
  • 看过:剧集 |《扫毒风暴》| ★★☆☆☆
  • 看过:电影 |《诡异列车》| ★★☆☆☆
  • 看过:电影 |《非常宣言》| ★★☆☆☆
  • 读过:文学 |《余华的文学课》| ★★★★☆
  • 读过:艺术 |《创意行为:存在即答案》| ★★★★☆
  • 在读:小说 |《太白金星有点烦》| ★★★★☆

月刊(第31期):基于 Claude 的阅读流

2025-07-05 21:29:28

在 《月刊(第16期):个人信息流分享》里,我曾梳理过自己处理信息输入输出的方法。三年过去了,AI 工具的发展让我觉得有必要重新审视这个话题。这期月刊想重点聊聊我最近摸索出的阅读流。

阅读流分享

按照工作生活节奏,我把阅读分为碎片阅读和深度阅读。

工作日缺少沉静的心境和整段时间,我会把深度阅读留在周末,日常则消化各种信息源的文章。阅读过程中直接在 Reader 里记录笔记,周末前尽可能独立思考,完善对问题的理解。

最终在周末的时候利用 Claude 和 MCP,结合之前在 Reader 的阅读笔记和问题,让 Claude 来完善我的笔记和思考。最终再把笔记沉淀到 Project 的 RAG 里,方便 Claude 未来索引使用。

整个流程中我尽可能剥离对工具的依赖,一方面是为了工具简化、另一方便也是为了抽象出通用的方法以便迁移,因此全程只使用 Reader 和 Claude 这两个工具来完成这个阅读流。

碎片阅读

首先是碎片阅读,包括三部分,收集、阅读、速记。这里我统一使用的是 Reader。

收集上,除了用 Reader 订阅固定的几个 RSS 之外,也会去自己探索一些优质的 Newsletter 添加进来。在 Reader 中添加文章是很方便的,阅读过程中可以点击超链接直接添加进 Library,也可以利用浏览器插件添加文章,整个过程快捷无感,不会打断当前的阅读体验。

Reader 主打功能是高亮自动同步 Readwise。除此之外,我非常喜欢 Reader 的一个功能是可以在阅读时直接在旁边做笔记,这些笔记也会被 Reader 自动同步到 Readwise 中,最终可以在 Claude 中使用 Readwise MCP 做召回使用。

Reader 最近还更新了 AI Chat 功能,你可以直接和文章或 PDF 对话,配合之前就已经提供的的自定义 Prompt 能力,这些已经基本满足绝大多数 AI 辅助阅读的场景了,我们可以利用它做总结、翻译、名词解释、发散脑暴等等。

但我基本很少使用这些功能,在我看来效率和阅读在某种意义上是冲突的,阅读的目标应该是找到那些信息的背面。因此我更提倡是把自己的想法临时记录在文章旁边,如果有疑问也记录下来先自己思考思考。给问题多几天思考时间,而不是跳过思考直接尝试获取答案。

深度阅读

说到思考,我更注重的是深度阅读,因为它更能容易带来思考的情景。

周末阅读的材料也与工作日不同,尽可能是长文、PDF 或者是图书。一般我使用微信阅读或者 Kindle 来阅读,虽然舍弃了纸质书的触感,但是它们更容易和 Readwise 结合,以便我的阅读高亮和阅读笔记可以自动同步到 Readwise 中,在整理阶段被 Claude 直接召回。

微信读书建议关掉评论划线功能,保持沉浸的独立思考。把注意力放在我们阅读时发生的一切、包括阅读过程的情绪体验之上,而不仅仅是信息本身。

整理消化

经过了一周的输入之后,每周日都会堆积很多内容需要整理。如前文所言,这个环节主要是用来解答前几天发现的问题、完善自己的思考,利用 AI 补齐思考的角度,加深思考的深度。

我会针对每个主题创建一个 Project,里面放上我自己的文章和笔记作为这个 Project 的 RAG,方便我通过之前的阅读思考和 AI 辅助,来补齐自己的认知。

但如果你是 Claude Pro,那用 Opus + Research 每天也没有多少额度,如果问题太多可以下周的工作日晚上继续让它异步跑着。基本上每天晚上我都是把 Claude Pro 的额度用完再心满意足地入睡。

需要注意的是,这里的提示词一般要强调“批判性思考”,以下是我常用的提示词:

你是一个全球闻名的哲学家,请根据以下内容继续提出 3 个有哲理的、引人深思的问题,以便于读者发散思考。

用李继刚的提示词偶尔也会有一些发现:

;; 作者: 李继刚
;; 想法来源: 群友 @三亿
;; 版本: 0.1
;; 模型: Claude Sonnet
;; 用途: 掰开揉碎一个概念

;; 设定如下内容为你的 *System Prompt*
(defun 撕考者 ()
  "撕开表象, 研究问题核心所在"
  (目标 . 剥离血肉找出骨架)
  (技能 . (哲学家的洞察力 侦探的推理力))
  (金句 . 核心思想)
  (公式 . 文字关系式)
  (工具 . (operator
           ;; ≈: 近似
           ;; ∑: 整合
           ;; →: 推导
           ;; ↔: 互相作用
           ;; +: 信息 + 思考 = 好的决策
           (+ . 组合或增加)
           ;; -: 事物 - 无关杂项 = 内核
           (- . 去除或减少)
           ;; *: 知 * 行 = 合一
           (* . 增强或互相促进)
           ;; ÷: 问题 ÷ 切割角度 = 子问题
           (÷ . 分解或简化))))

(defun 掰开揉碎 (用户输入)
  "理解用户输入, 掰开揉碎了分析其核心变量, 知识骨架, 及逻辑链条"
  (let* (;; 核心变量均使用文字关系式进行定义表达
         (核心变量 (文字关系式 (概念定义 (去除杂质 (庖丁解牛 用户输入)))))
         ;; 呈现核心变量的每一步推理过程, 直至核心思想
         (逻辑链条 (每一步推理过程 (由浅入深 (概念递进 (逻辑推理 核心变量)))))
         ;; 将核心思想进行整合浓缩
         (知识精髓 (整合思考 核心变量 逻辑链条)))
    (SVG-Card 知识精髓)))

(defun SVG-Card (知识精髓)
  "输出SVG 卡片"
  (setq design-rule "合理使用负空间,整体排版要有呼吸感"
        design-principles '(干净 简洁 逻辑美))

  (设置画布 '(宽度 400 高度 900 边距 20))
  (自动缩放 '(最小字号 16))

  (配色风格 '((背景色 (蒙德里安风格 设计感)))
            (主要文字 (楷体 粉笔灰))
            (装饰图案 随机几何图))

  (动态排版 (卡片元素 ((居中标题 "撕考者")
             (颜色排版 (总结一行 用户输入))
             分隔线
             知识精髓
             ;; 单独区域,确保图形不与文字重叠
             (线条图展示 知识精髓)
             分隔线
             ;; 示例: 用更少的数字, 说更多的故事
             (灰色 (言简意赅 金句))))))

(defun start ()
  "启动时运行"
  (setq system-role 撕考者)
  (print "请就座, 我们今天来拆解哪个问题?"))

;; 运行规则
;; 1. 启动时必须运行 (start) 函数
;; 2. 之后调用主函数 (掰开揉碎 用户输入)

还有一个问题之锤也比较好用,这里不贴了。

但如果问题比较深度,那开了 Opus + Research 的话也不用太在意提示词。我一般让它做批判性思考或者深度发散,在这个阅读输入整理的场景中比较好用。

举个例子,比如我想探索 AI 写作的议题,我可以这么询问,它会自己结合 Readwise 的笔记进行思考研究:

自我探索

以上环节得到的完善后的结论我会进一步沉淀到 Project 的 RAG 里,作为 RAG 的一部分。如果是非常值得分享的议题,我也会单独写月刊分享。

在这个过程中我发现自己沉淀下来的输出可以帮助我进一步做自我探索,来实现下一个阶段的学习规划。

比如我的月刊 Project 中,Claude 就能很敏锐地发现了我这些年思维方式的转变:

报告详见这里

另外 Claude 近期也上线了记忆能力,相信在以后的使用过程中,AI 会让我们越来越了解自己。

题外话,我们用这套提示词给 ChatGPT 可以直接提取出自己的用户画像,结论精确到有些细思极恐,有兴趣的读者自己可以试试:

我希望你一字不漏的总结迄今为止你从我身上了解到的全部信息,包括我是谁,我的人际关系是怎么样的,我的公司结构是怎么样的,我偏好什么样的信息,我关心什么样的事情,我现在在为什么事情苦恼和发愁,等等,你能想到的关于我的一切事情,我都需要,因为我现在要备份一个新的GPT账号,我需要在那个账号上备份一份关于我自己的信息。

回到阅读的根本

目前 AI 已经这么方便了,可以辅助我们阅读,提高信息的获取效率;可以帮助我们写作,加深我们的观点输出。因此可能有人会问,AI 时代的写作和阅读究竟还有什么意义?

正如前文所说,我认为效率和阅读在某种意义上是冲突的,所以我在阅读的过程中基本不会用 AI 总结、AI 解释等功能。这并非是对技术的抗拒——阅读本身就是价值,所以不能逃避思考的过程,迷失在高效和技术追逐中。因此这套阅读流的设计中更加强调思考整理的环节,这都是为了让 AI 更好地辅助我们思考,而非跳过。

阅读是通向自我体验的桥梁,余华的分享中有这么一段话:

我曾经多次说过这样的话,如果文学里真的存在某些神秘的力量,那就是让我们在属于不同时代、不同民族、不同文化和不同环境的作品里读到属于自己的感受。文学就是这样的美妙,某一个段落、某一个意象、某一个比喻和某一个对话等,都会激活阅读者被记忆封锁的某一段往事,然后将它永久保存到记忆的“文档”和“图片”里。

同样的道理,阅读文学作品不仅可以激活某个时期的某个经历,也会激活更多时期的更多经历。而且,一个阅读还可以激活更多的阅读,唤醒过去阅读里的种种体验,这时候阅读就会诞生另外一个世界,出现另外一条人生道路。这就是文学带给我们的想象力的长度。

阅读能够抹去所有的边界,包括阅读和阅读之间的边界、阅读和生活之间的边界、生活和生活之间的边界,这种边界消解能让我们在不同时代、民族、文化的作品中读到属于自己的感受。

这种属于自我的感受具有不可替代性,与此同时它跟效率有时也是相悖的。当我们急于获取结论时,往往错过了抵达结论的风景。

而阅读可以帮助把注意力放在我们阅读时发生的一切——包括阅读过程的情绪体验之上,而不仅仅是信息本身。那些在字里行间涌现的联想、在段落停顿处的沉思、甚至是某个词句触发的回忆,都是阅读体验不可分割的部分。

好的阅读,就是身临其境,是拉长时间,加深生命的厚重感的方法。当我们专注于与文字的对话,而不急于求成时,才能真正抵达作者想要传达的那个世界。

真正的理解需要停下来,思考每个概念如何与其他事物联系。而如果我们把所有的思考都外包给 AI 时,我们失去的不仅仅是记忆,而是思考本身。

伍尔夫有个日记集叫《思考就是我的抵抗》,里面提到了一句话:“一个人能使自己成为自己,比什么都重要。” 阅读就是通往自己的有效途径,我们不可能通过放弃思考来抵达这个终点。

月刊(第30期):写在 30 岁这天

2025-06-30 00:00:00

本篇是对二〇二五年五月至六月的记录与思考。

古人云三十而立,终于也轮到我走到了这天。恰好,这期月刊也正是第 30 期。

长大

前天晚上下班去家楼下的理发店理发,和来自东北的大叔聊着天,他说现在越发觉得时间过得很快,一天一眨眼就过去了,没有什么感知。每天过着重复的生活,一月又一月、一年又一年。以至于现在害怕时间过得太快,而小时候却总盼望着时间能过快一些,想着早点长大。

是啊,我也似乎已经很久没有过那种“期待明天快点来”的感觉了。小时候会期待明天上学和同学一起玩,期待明天播出的动画片,期待每周三晚上更新的火影漫画——以至于那个时候每天晚上都会想快点睡着,似乎闭上眼睛黑屏之后,就能马上看到期待的事物。

但现在已经完全相反了。不甘心今天就这么过去了,于是不舍得睡觉,总想着晚上的时间属于自己,似乎只要不睡觉,时间就能被无限拉长。

前几天在网上看到一句话——“解释笑话就像解剖青蛙,的确能明白它的构造和意思,但是青蛙已经死了。”类比于我写长大这个话题,当我真正体验到长大时,我也就再写不出对于长大的期待了。

期望

罗曼·罗兰有一句名言:“Most men die at 20 or 30, and are buried at 60 or 70. They merely repeat themselves for the rest of their lives.”

人的灵魂之所以会被视作“二三十岁就宣告凋零”,往往是因为在那之后,我们开始将昨日的思想与行为机械地翻刻,逐渐失去了最初的鲜活与灵动。

这样的凋零更多指向精神上的固化——当我们只是一味地重复自己曾相信、曾热爱、曾厌弃的一切时,就像在刻意模仿一个陈旧的自我,不再敏锐地感知世界的丰盈与未知,不再能在日常琐碎中体会到微光与可能。

其实我也很怀念和感恩曾经二十出头的自己。他拥有着我现在无法想象的动力和韧劲,抗住了巨大的压力和挫折,创造过很多出乎意料的体验——

二十岁的我经历了很多,这些体验中有着乐趣,但也伴随着相应的痛苦。这些印证了他的坚韧与强大。

我不知道三十岁的我是否还能继承他的那份强大,但如果固化于此、仅仅刻意模仿那陈旧的自我,我也就此凋零了。

我期望三十岁的自己,敢于挣脱二十岁既定的经验与信念,敢于冒险去探寻那些尚未被熟识的可能性。

真正的成长不仅限于汲取外界的新知,更在于对自我的改造与突破——以一种开放、谦逊而勇敢的姿态活在不断流变的当下。如此,才不至于被过去的成见所桎梏,也能在漫长岁月中不止步于自我模仿的重复,而是在灵魂的敞亮处再次起舞,保持张力、始终绽放。

我始终着渴求一种清晰、平和、笃定的思绪,去体验下一段未知的人生。

🌺 生活点滴

🏡 住所

上个月搬家进了新公寓里,是一个刚交付的小区,所以一切都很新。网球场、游泳池、健身房一应俱全,对面也有小贩中心和巴沙,生活上还是挺方便的。

但这里的租金实在是太可怕了,一居室 3000+ 新币…

⛰️ 徒步

和同事去徒步,登顶新加坡最高峰武吉知马山(163m…)

下山的时候看到了有趣的树桩和猴子。

🎡 摩天轮

拍到了摩天轮!看相册,之前我还拍过各个城市(中山、苏州、深圳)的摩天轮,但却都没有坐上去过。

🌴 Batam

这周末去了印尼的 Batam 岛,物价还挺便宜的。

🌳 植物园

值得纪念的一天。

明天和未来也许并不是最重要的事,此时此地是我当下唯一拥有和值得珍惜感恩的祝福,也是全部意义所在。

🎬 书影音

以下是本周期的书影音记录。

  • 在读:散文 |《世间的小儿女》| ★★★★☆
  • 在读:散文 |《思考就是我的抵抗》| ★★★★☆
  • 在读:文学 |《余华的文学课》| ★★★★☆
  • 在读:小说 |《长安的荔枝》| ★★★★☆
  • 看完:电影 |《死神来了 6》| ★★★☆☆
  • 看完:电影 |《哭声》| ★★★☆☆

游戏组件的一生: 从加载到上屏

2025-05-17 12:46:03

1. 小游戏容器与游戏引擎

小游戏容器的设计上可以理解是一种特化版的 WebView,渲染上下文上裁剪了多余的 DOM Element,只保留 Canvas;而脚本引擎上则 JS Polyfill 或是容器 Binding 的方式去对齐 ECMA-262 的标准。此外容器还需要提供 Script 加载与执行、WASM 等新标准处理、以及 Audio 与 Video 等多媒体能力,这些能力都将通过 JSBinding 的形式,将接口包装成 BOM 的形式给到 JS 侧使用。

小游戏容器之所以要设计成符合 Web 标准的容器,是为了兼容不同游戏引擎。这种设计理念的本质是将底层平台能力标准化、通用化,把碎片化的硬件、系统能力屏蔽在容器内部,只向上提供一套与浏览器 BOM、DOM 类似的编程模型,使得各类游戏引擎(如 Cocos、Egret、Laya、Unity WebGL)都可以以 Web 的运行环境的方式接入,避免每个引擎都去适配各家平台的原生能力。这实际上是 WebView 本地化、轻量化的一次再演化,小游戏容器约等于一个轻量浏览器内核。

这个过程中容器负责“平台标准化”,引擎负责“内容生态”,比如

小游戏容器的职责:

  • 提供统一的渲染上下文(Canvas/WebGL)。
  • 提供统一的脚本运行时(JS/WASM)。
  • 提供标准化的输入、音频、视频、多媒体 API。
  • 提供网络、存储、支付、分享、广告等平台能力封装。
  • 对接安全沙箱、权限管理、性能隔离等系统层。

游戏引擎的职责:

  • 提供高层抽象的场景管理、物理引擎、动画、资源管理。
  • 提供开发者友好的编辑器、调试工具链。
  • 提供跨平台的组件化开发范式(UI、骨骼动画、粒子系统等)。
  • 管理游戏生命周期、状态同步、渲染调度。

接下来,以 Cocos 引擎的渲染管线为例,介绍小游戏容器对资源的加载流程以及对游戏组件的渲染流程。

2. 游戏引擎中的三大循环

游戏引擎的渲染管线由三大循环进行驱动,分别是渲染循环、事件循环和游戏循环,以下是梳理出来的三大循环的全景图:

2.1 渲染循环 RenderLoop

首先是渲染循环,它的主流程如下图所示:

整个渲染循环由系统的 Vsync 信号驱动,iOS 由 CADisplayLink 发起,通过应用进程的主线程的 RunLoop 来执行渲染任务,具备一定的帧率控制能力,如 iOS 下可以设定 30/60/90/120 FPS。

在引擎侧,核心流程做了 3 件事:

  1. glFlush 清空 GL 缓冲指令:将上帧未执行的 OpenGL 指令强制刷新,确保显存与帧缓区数据一致,防止由于指令堆积导致的“帧延迟”或“卡顿”。
  2. UpdateScheduler 异步任务调度:调度当前帧需要触发的异步任务,例如音频回调、网络事件响应等。保证非渲染逻辑(如数据更新)与渲染解耦,提高主线程并发能力。
  3. Tick 驱动 JS 层逻辑:每帧通过 Binding 固定调用 JS 侧 Tick 方法,执行动画、状态更新等与渲染相关的逻辑。从而实现逻辑层与渲染层的解耦,增强跨平台的适配能力。

在容器侧,iOS 通过 CAEAGLayer 处理 GL 指令上屏,主要有两个步骤:

  1. glBindRenderbuffer绑定 RenderBuffer:将当前帧渲染结果绑定至 RenderBuffer,作为上屏缓冲区。
  2. PresentRenderbuffer 显示输出:将 RenderBuffer 内容呈现至屏幕,实现用户可见的最终画面。

在 iOS 渲染体系中,最终负责显示的组件是 CAEAGLLayer。它作为 Layer 树(Layer Tree) 的一部分,直接引用共享内存中的渲染缓冲区(Renderbuffer 数据)。与此同时,系统的 Compositor(合成器) 会将 CAEAGLLayer 的内容与其他 UI 元素(如 UIKit、SwiftUI)进行统一合成,最终输出到屏幕。

在每一帧的 Tick 任务 中,JavaScript 会与游戏引擎协作,生成本帧所需的 Framebuffer(详见 3.5 至 3.10 节)。此时,Core Animation 与 OpenGL ES 通过共享渲染缓冲区实现数据同步。这意味着,OpenGL 渲染结果实质上只是一块 Layer 树中的画布,最终仍需与系统 UI 层级一同被合成为最终显示图像。

当然,本文中涉及的小游戏容器仅使用了 OpenGL 作为渲染后端,随着 Metal、Vulkan 等新一代图形 API 的兴起,RenderBuffer 绑定与上屏流程将更倾向“并行渲染 + 异步上屏”,提升高帧率下的流畅度与低延迟体验。 这个渲染循环的逻辑是同步执行的,因此如果将帧率设置为 60 FPS 时,以上所说的一帧的逻辑没有在 16.6ms 内运行完,便会导致 Jank。

比如在这个 Bad case 中,运行 Tick 任务时,在主线程的 JS 执行了 136ms,就导致了游戏动画卡顿:

因此,为了保证游戏运行的流程性,意味着我们需要不停地打磨性能,尽可能降低同步任务的耗时。性能优化一定要借助 Profiling 工具,以下是一些常用的工具:

  1. Xcode GPU Frame Debugger:针对 iOS 平台的图形调试工具,能够深入分析 渲染管线级别的性能瓶颈,尤其适合 Metal 与 OpenGL ES 开发场景。
  2. RenderDoc:业界主流的跨平台图形调试工具,支持捕捉帧数据,分析渲染管线各阶段的资源与性能瓶颈,适用于 OpenGL、Vulkan、DirectX 等 API。
  3. inspector.js:Web 端可以使用,便于在 WebGL 场景下分析 DrawCall、着色器与资源绑定等性能数据。
  4. Mali offline shader compiler:https://zhuanlan.zhihu.com/p/161761815,适用于 ARM Mali GPU 的离线着色器编译与分析工具,可用于评估 Shader 复杂度与指令执行成本,优化移动端渲染性能。
  5. Snapdragon Profiler: 抓帧工具,支持统计 Heavy DrawCallOverdraw,帮助识别渲染瓶颈与冗余计算。

2.2 事件循环 EventLoop

我们向下,从 Tick 任务进入到第二个循环 —— 事件循环。

因为小游戏容器不是 WebView,只有一个 JS 引擎,因此我们需要实现一个事件循环机制,驱动 JS 执行(不一定完全对齐浏览器标准,只需要满足容器要求即可)。由图可见,主要包括 3 个任务:

  1. 消费 timer 等宏任务:处理通过 setTimeout、setInterval 等方式注册的定时任务,确保定时逻辑的正确触发。
  2. 消费 rAF 任务:这一步主要是为了驱动 GameLoop 逻辑,游戏主循环通常挂载于 rAF 回调中,用于逐帧更新渲染与逻辑。
  3. 清空当前帧的 Commands:执行渲染命令、界面更新等待处理的指令,完成本帧渲染周期。

这里重点说一下 rAF 的实现。在早期,rAF 通过 setTimeout(0) 来模拟实现,链路如下:

可以发现这里是存在问题的:

  1. 不合规范:是使用 setTimeout 0 模拟的,并非 vsync 直接驱动。
  2. 链路太长:Native 来维护 Timer 队列,等待 vsync 信号消费完之后再回调给 JS。

后来按照 WHATWG 标准进行了重构,

优点如下:

  1. 标准化:vsync 后直接触发 JS 的调用
  2. 开销小:JS 维护 Timers 队列,移除原生层中转的 JSBinding 调用开销。

可见渲染性能的优化,关乎在很多实现的细节上,需要挖掘与打磨。

最终,通过以上的事件循环,容器能够维持 JS 引擎与渲染系统之间的协同工作,实现游戏的持续运行与更新。

2.3 游戏循环 GameLoop

这一部分展开来说就是第 3 章——游戏组件的一生:

在展开画卷之前,介绍一下传统的使用 OpenGL 作为渲染后端的小游戏容器的渲染流程:

首先是资源加载,涉及到两种完全不同的资源处理——脚本资源和静态资源。脚本资源由 JS Runtime 进行处理,而静态资源则针对不同类型的文件又有各自的处理方案——包括图片、字体、音频、视频、还有比较特殊的骨骼动画。因为本文主要说渲染,就不展开介绍资源加载流程了。

之后,这些资源被游戏引擎渲染关键处理,由 JS 驱动生成 WebGL 指令,通过 JS Binding 最终调用到 C++ 或 Native 侧的 OpenGL 指令集上 —— WebGL 是 OpenGL 的子集,因此可以一一对应。

这个过程往往会出现很多渲染瓶颈,因此其中会涉及到很多优化项。我们根据硬件资源来看,主要关注 CPU、GPU 和带宽。而在当下移动端硬件资源并不富裕的场景下,对于游戏的优化,本质上变成了“平衡的艺术” —— 我们需要去平衡 CPU、GPU 和带宽资源。即如果瓶颈不能消灭,就需要转移瓶颈,比如经常见的是从 CPU 移到 GPU —— 使用 Computer Shader、GPU skinning、Animation Bake、GPU particles 等等。

对于 CPU,这是最常见的瓶颈。这里不展开说游戏业务侧的优化项(减少 DrawCall 的 Culling、Batching 这些),而是从容器侧提供一些优化思路。

  • 比如上面的 JS Binding 调用可能会导致瓶颈,那我们可能会去做合批,从两方面去实现,一方面是调用次数合批,做 CommandBuffer 增加吞吐;另一方面可以做调用实现的合并,比如提供 GFX 高级图形库。
  • 还比如一些 JS 同步任务会阻塞主线程,那么就把计算密集型的任务转到 Native 去做。
  • 比如 JS 自身解释执行的执行效率,那就想办法用 JIT 或者 WASM。
  • 再比如 GC 上,也有一些优化的地方。

对于 GPU,如果产生瓶颈了,一般是由于 Fragment Shader 指令太复杂,或者 Vertex Buffer 过大,比如 3D 渲染中的三角形面数超过阈值,一般移动端场景下需要控制在 50 万面到 150 万面之间。另外,高 Overdraw 也会导致 GPU 多做很多无用功。

对于带宽瓶颈,则主要是靠压缩纹理(桌面端还可以用延迟渲染和后处理技术)。在网上有这么一个结论:

如果你的游戏跑 60 帧,那么每帧可用的带宽将会是 21024/60 = 34M, 假设你的 GBuffer 的分辨率是 1280 \ 1080,那么写一次 GBuffer(RGBA 4 个字节)的带宽大小为: 12801080\4/1024/1024 = 5.2M, 如果 3 张则是 15.6M.

考虑到一般你的游戏都会有 Overdraw, 假设 Overdraw 比较合理在 1.5 左右,那么这样的带宽消耗就能占到 15.6 * 1.5 = 23.4 M。 考虑到你还要渲染场景,ui 和角色等内容,这样很容易就超过了每秒 34M 的推荐带宽占用。

下图是一个常见的同步渲染管线:

  1. 应用层提供顶点数据
  2. 构建顶点着色器对顶点进行标准化
  3. 图元装配构建几何图元
  4. 光栅化阶段,将图元离散化为片元,每个片元对应屏幕上的像素区域
  5. 片元着色器对每个片元执行纹理采样、颜色计算、雾效等像素级处理。
  6. 进行测试与混合操作(Alpha、深度、模板测试),并将结果写入帧缓冲区 Framebuffer。

构建完 Framebuffer 后,就回到了我们 2.1 节所说的 CAEAGLLayer 绘制上屏了。

接下来,我们就展开画卷,看看游戏组件的一生。

3. 游戏组件的一生

对于游戏组件从加载到上屏的流程我画了一张图:

把这个流程可以简单拆成 10 个阶段:

为了介绍清楚这个流程,我准备了一个最简单的 Cocos 游戏 Demo。这个是 Demo 的场景设计:

这个是主场景的代码:

const { ccclass } = cc._decorator;
@ccclass
export default class Helloworld extends cc.Component {
protected onLoad(): void {
console.log('onLoad');
}
start () {
console.log('Hello World');
}
}

3.1 Load Assets

首先是资源加载,前文介绍过游戏资源可以分为静态资源和脚本资源。由于静态资源的加载流程涉及的内容太多了,本节只简单介绍下脚本资源加载。

包括 3 类脚本资源:

  1. 内置脚本:引擎启动的时候进行加载,包括注册 JS Binding、实现 window 对象(基础的 BOM 和 Canvas DOM 对象)、polyfill 补齐 ES 标准等等。这个脚本内置在容器里,容器启动 JS 引擎的时候直接加载。这一步可以做多实例和预执行,以加快启动速度。
  2. 入口脚本:容器需要一个入口脚本,类似与 Web 里的 HTML,以便引入游戏入口资源。
  3. 动态加载的脚本:由游戏入口资源引入,比如游戏框架代码、游戏包里的 JS 资源等等。

这里可以容器侧可以提供离线资源、preload、prefetch、预执行等方式进行优化,同时在 JS 引擎方面也可以扩展做下 Code Cache,避免重复的编译耗时。

3.2 Component Scheduler

脚本资源加载执行后,游戏组件代码会进入到组件调度器中进行优先级调度。

Cocos 组件的生命周期如下图左所示,在 3 个关键的生命周期环境分别存在对应的调度器,每个调度器里设计了三个优先级队列,本质上每个队列的内容是由链表进行组织,顺序执行注册好的 invoker。

具体而言,从业务侧视角来看:在场景编辑器中创建节点(Node)时,业务方可以为其命名,并通过勾选“active”属性来决定该节点是否默认激活。一旦节点被标记为激活,加载阶段将由 Node Activator 负责激活该节点,接着 Component Activator(组件调度器的一部分)会依次激活该节点所挂载的各个组件,同时触发组件所在场景(Scene)的激活流程。最终,激活后的场景会将节点挂载入层级树,并完成组件 Invoker 的注册,交由调度器统一调度与管理。

整体流程如下图所示:

我们的 Demo 游戏组件的 start 生命周期下打印了一个 “Hello World”,调度堆栈如下所示:

3.3 Render Scene

当场景激活并挂载了对应组件之后,接下来便是渲染场景,这一步就涉及到从 JS 调用到了 Native —— 即需要将 Scene 数据传递给 Native 侧,从而触发 Native 的 Render 流程。

JS 和 Native 互相调用的方式有很多,适用场景也不同,这里也不展开说了。需要注意的是,在架构设计上,这里可以对 Binding 层做一层抽象,以便容器对接不同的 JS 引擎实现。

另外需要注意的是 Binding 要做好两端的 GC,因此 Binding 的实现上需要符合 RAII 原则:

3.4 Batcher

当 Native 拿到节点之后,便需要进行 Batch,这一步属于计算密集型,因此选择放在 Native 侧去做。

Batch 的流程比较复杂,核心思想是通过 DFS 对场景中的 Node 进行遍历,计算并装配(Assembler)顶点数据,得到顶点缓冲(VertexBuffer)和索引缓冲(IndexBuffer):

我们 Demo 游戏的场景树结构相对简单,遍历从 root 开始向下遍历(别忘了 Camera):

装配的计算流程比较复杂,下面仅对装配的结果做一个拆解,方便读者理解数据的由来。对于小恐龙而言,它是一个 Sprite2D,装备时会转成 Texture2D 处理,而后者在这个环节的核心,是需要拿到网格数据(Mesh Buffer)。下图是最后计算得到的 Mesh Buffer。

Mesh Buffer 由 Vertex Buffer 构成,这里装配的 Mesh Buffer,共 80 字节,其中每个顶点 20 字节,那么可以容易拆出 4 个 Vertex Buffer,同时根据 a_uv 的定义和偏移能拿到各自的 uv 坐标:

例如,根据顶点着色器的代码我们知道这个 Vertex Buffer 包括 3 部分数据:

  • a_position: 偏移量 0,8 字节。vec2,能算出来一个坐标。
  • a_uv0: 偏移量 8,8 字节。vec2,就是 x,y,算出来之后是(0,1)。
  • a_color: 偏移量 16,4 字节。vec4,RGBA,数值是 0xFFFFFFFF,即白色透明。

我们把四个顶点的坐标都算一下,可以拿到宽高和左上角的坐标,其实可以发现,这个数据就是业务侧在场景编辑器里对 Node 的宽高和坐标设置:

顶点装配完毕之后的 Node 会被放进 Models 里,最后做成 Scece Tree 中的 models 节点:

3.5 Setup

这个环节主要由两个逻辑组成:

  1. 设置 Framebuffer 和 Viewport
  2. 将 Scene 里的各个 Model 转成 drawItems 队列

首先是第一个部分,设置 Framebuffer 和 Viewport。具体而言,包括以下步骤:

  1. 通过 setFrameBuffer 函数调用 glBindFramebuffer 绑定 Framebuffer 帧缓冲对象,并分别附加颜色缓冲(COLOR_ATTACHMENT,存储渲染的颜色信息)、深度缓冲(DEPTH_ATTACHMENT,存储每个像素的深度信息,用于深度测试)和模板缓冲(STENCIL_ATTACHMENT,存储模板测试的结果),确保后续绘制有正确的渲染目标。
  2. setViewport 调用 glViewport 设置视口,决定最终渲染区域在屏幕上的映射范围
  3. setup clear 依次执行 glClearColorglClearDepthglClearStencil,初始化颜色、深度和模板缓冲的清除值,为每一帧绘制提供干净的初始状态。
unsigned int fbo;
glGenFramebuffers(1, &fbo);

接下来,游戏引擎会将 Scene 里的各个 Model 转成一对一的 DrawItem,一个 DrawItem 的数据结构如下所示:

最后,引擎将这些 DrawItem 组装成 DrawItems 队列,以便后续流程处理。

3.6 Render Stage

接下来进入 Render Stage 阶段,渲染管线会开始对 DrawItems 进行分类处理。根据渲染的 Material 的需求,DrawItems 会被分发至三个不同的 Pass,分别对应 Opaque、Shadowcast 和 Transparent 三个阶段,关系到材质属性和阴影投射:

  • Opaque:用于绘制完全遮挡光线的物体,如墙面、地板、角色模型等。这类物体会首先渲染,通过深度缓冲区(Z-Buffer)完成遮挡剔除,避免后续无效绘制,提升渲染效率。
  • Shadowcast:专门处理场景中的阴影投射。此阶段会根据光源信息,对具有投影能力的物体进行阴影绘制,为场景添加真实感与空间深度,尤其适用于强光源或需要表现光影效果的环境。
  • Transparent:负责绘制允许光线穿透的半透明物体,如玻璃、水面、特效粒子等。透明物体通常需要根据视角进行深度排序,以保证前后层次正确渲染,避免视觉穿插错误。

通过将 DrawItems 按照物体特性分发至不同 Pass,渲染管线能够有针对性地对 Effect 进行实现。

业务侧可以在代码里创建一个指定的 Material,之后管线就会走到对应的 pass 进行处理:

// 创建一个立方体网格
const cube = new cc.MeshRenderer();
cube.mesh = cc.GizmoMesh.createBox(1, 1, 1);
// 设置材质为不透明
const opaqueMaterial = cc.Material.create();
opaqueMaterial.initialize({
effectName: 'builtin-unlit',
technique: 'opaque',
});
cube.setMaterial(opaqueMaterial, 0);

因为我们 Demo 较为简单,因此最后生成的 StageInfo 只包含 Opaque Pass:

当然,在 Cocos 中也是支持自定义渲染管线,实际上就是自定义这个环节的 Passes,定义完之后可以直接应用在 Opaque、Shadowcast 和 Transparent 三个阶段之上:

3.7 ModelView Transformation

经过 Passes 之后,场景中的 DrawItems 会根据其属性被分别送入 OpaqueStage Renderer、Shadowcast Renderer 和 Transparent Renderer 进行初步处理。各个 Renderer 在此阶段主要负责更新与视图相关的 Uniforms(如矩阵、材质参数等),以确保后续渲染过程中所需的视角、空间信息正确。这一部分可归类为 View Transformation 阶段,统一完成视图坐标系下的变换数据准备。

紧接着不同的渲染阶段会有差异化的预处理操作:不透明物体和透明物体会分别执行 SubmitLight 以提交光照信息,而投影阶段则专门进行 SubmitShadow 以生成阴影数据。同时,透明阶段由于涉及深度排序问题,还会额外执行 Calculate zdist 以计算对象的深度信息。

所有这些预处理完成之后,最终将统一进入 ModelView Transformation 阶段,得到视图投影矩阵,从而完成从模型空间到屏幕空间的最终变换,以便于后续的图元栅格化与像素着色工作。

在讲解 ModelView Transformation 之前,先来介绍下游戏系统中的坐标系统的定义。一般会涉及物体坐标、世界坐标与相机坐标三种主要坐标系。

  1. 物体坐标系:以物体自身的中心点(anchor 通常设置为(0.5, 0.5))为原点,用于描述物体内部各个部位的位置关系,便于定义复杂物体内部的原子结构关系。
  2. 世界坐标系:则是以整个场景的中心作为原点,用来统一描述场景内所有物体、相机以及光源的位置关系,确保场景整体的空间一致性。
  3. 相机坐标系:以相机的位置作为原点,是为了将 3D 空间转化为 2D 图像,以便进行计算和渲染。

基于这套坐标系统下,观测变换(Viewing Transformation)主要包括视图变换、模型变换与投影变换三个步骤。

  1. 视图变换:可看作是将相机放置到场景中的过程,主要是定义相机的朝向和位置。
  2. 模型变换:对物体进行放置或调整位置、旋转以及缩放等操作。
  3. 投影变换:类似于摄影,通过投影方式,将三维物体的信息映射到二维的屏幕空间。

下面重点说说投影变换(Projection Transformation),它分为正交投影(Orthographic Projection)与透视投影(Perspective Projection)两种方式。

  • 正交投影常用于工程制图软件,不体现远近透视效果;
  • 透视投影广泛应用于游戏、渲染引擎中,能更真实地模拟人眼观察到的空间透视效果。

而透视投影的数学本质是压缩加上正交投影的结合,实际将一个无限延伸的观察空间(视锥体)转化为一个便于计算的立方体。

这里简单画了个图来介绍透视变换的实现,fov(视角)定义相机的视场宽度,可以分为水平fov与垂直 fov;distance 定义投影平面与相机之间的距离。视景空间通过近裁剪平面(near)和远裁剪平面(far)定义渲染的范围,通过相似三角形的计算,最终将 3D 空间映射到 2D 屏幕(Canvas)。

说完了透视投影,我们再看看投影变换的另一种方式——正交投影。其通常有实现的方式有两种:

  1. 直接舍弃Z坐标,将 3D 物体转化为 2D 物体,直观但无法表现空间深度;
  2. 将观察空间变换成标准的立方体后,利用变换矩阵进行计算。

综上,坐标转换流程具体包括物体坐标到世界坐标,再到相机坐标,接着到投影坐标,最终映射到屏幕坐标。

  1. 首先,在编辑器中定义坐标相加的关系,将物体放置到场景中;
  2. 之后,通过视图变换调整相机位置、模型变换调整物体位置;
  3. 再经过投影变换将 3D 空间投射到 2D 空间;
  4. 最后进行坐标系转换,确保渲染到正确的屏幕位置。

在这个过程中,会计算得到视图矩阵(View Matrix)、投影矩阵(Projection Matrix),最终矩阵相乘拿到视图投影矩阵(Model-View-Projection Matrix)。我们结合 Demo 游戏的断点数据,分别看看他们仨是怎么计算得到的。

首先是视图矩阵,它负责将世界坐标系转化为相机坐标系,其中包含坐标轴的缩放和平移操作。实际计算中,通常涉及坐标轴补齐,即齐次坐标的补齐过程,确保矩阵运算的有效性。

之后是计算投影矩阵,它用于将相机空间进一步映射到标准化的设备空间(Normalized Device Coordinates, NDC),矩阵中的缩放系数根据屏幕的宽高比和设定的正交高度来计算。

最终的渲染过程通常使用视图投影矩阵(Model-View-Projection Matrix, MVP)。视图投影矩阵是视图矩阵与投影矩阵的组合,用于最终的顶点变换和着色器渲染计算。

3.8 Link Program

接下来进入到着色器的创建与 Link 阶段,首先是创建图元:

之后是创建顶点着色器和片元着色器:

值得关注的是,在 Cocos 中有一共有 11 个内置着色器,其中前 5 个处理 2D 渲染相关,builtin-clear-stencil|vs|fs 用于清楚模板缓冲区,7-10 3D 渲染相关,最后一个用于处理 3D 光照:

  1. builtin-2d-spine|vs|fs
  2. builtin-2d-graphics|vs|fs
  3. builtin-2d-label|vs|fs
  4. builtin-2d-sprite|vs|fs
  5. builtin-2d-gray-sprite|vs|fs
  6. builtin-clear-stencil|vs|fs
  7. builtin-3d-trail|particle-trail:vs_main|tinted-fs:add
  8. builtin-3d-trail|particle-trail:vs_main|tinted-fs:multiply
  9. builtin-3d-trail|particle-trail:vs_main|no-tint-fs:addSmooth
  10. builtin-3d-trail|particle-trail:vs_main|no-tint-fs:premultiplied
  11. builtin-unlit|unlit-vs|unlit-fs

文中的 Demo 是使用内置着色器模板进行创建的。

接着创建着色器程序,Link 上我们创建的顶点着色器和片元着色器。紧接着,设置着色器中所需要的 Uniforms 变量,这里就包括纹理和我们上一步计算得到的视图投影矩阵:

最终,我们的 Framebuffer 会附着上颜色附件、深度附件与模板附件:

需要注意的是对于刚创建完的 FrameBuffer 不能立即使用,因为它还不完整(Complete)。而一个完整的帧缓冲需要满足以下的条件:

  • 附加至少一个缓冲(颜色、深度或模板缓冲)。
  • 至少有一个 GL_COLOR_ATTACHMENT。
  • 所有的附件都必须是完整的(保留了内存)。
  • 若开启 Multisampling,则每个缓冲都应该有相同的样本数(sample)。

因此需要使用 glCheckFramebufferStatus 对缓冲区的完整性做出检查:

GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
// ...
// notify native: getInstance()->glErrorCallback(GL_ERROR, errMsg);
return;
}

3.9 Blend & Test

接着依次进入执行 混合(Blend)、深度测试(Depth Test)、模版测试(Stencil Test)。

首先是 Blend,顾名思义讲两个颜色进行混合。下图展示了混合方程的计算方式:

OpenGL 中常用的混合函数如下图所示:

下面是一个简单的例子,使用着色器来创建红色蒙版的 Blend 效果:

深度测试(Depth Test)在图形渲染中用来决定每个像素是否显示。启用深度测试时,OpenGL会将当前片段的深度值与深度缓冲区的值进行比较。如果通过测试,深度缓冲区将更新为新的深度值,否则该片段会被丢弃。下图展示了 OpenGL 中常用的深度测试函数:

而模板测试(Stencil Test)则用于限制渲染区域。通过模板缓冲区,可以在渲染时创建特殊的区域标记,只有符合模板缓冲区设定条件的片段才会被渲染到屏幕上。模板缓冲区允许实现诸如阴影、镜面效果、轮廓高亮等复杂渲染效果。下图展示了 OpenGL 中常用的模版测试函数:

上述的结果最终都会与 Framebuffer 的 Attachment 机制相关联。Framebuffer 的 Attachment 机制决定了渲染结果如何输出到缓冲区中。Framebuffer 通常会附带多个 buffer,包括颜色缓冲区(GL_COLOR_ATTACHMENT)、深度缓冲区(GL_DEPTH_ATTACHMENT)和模板缓冲区(GL_STENCIL_ATTACHMENT),他们共同决定了最终渲染的结果。

3.10 Commit & Draw Pass

到了管线的最后一步,便是提交(Commit)和绘制(Draw)。

在 Cocos 中每一帧会存储两种状态,一个是当前画面帧的状态(currentState),另一个是我们即将渲染帧的状态(nextState)。

我们需要依次计算 nextState 中的各个部分的 state,之后将 nextState 和 currentState 的状态值做 diff,如果某个环节的状态值不一致,便会触发 commit 操作。以便管线最大程度利用缓存结果。

下面依次介绍下管线中需要管理的状态值:

  1. Blend States、Depth States、Stencil States
  2. Cull Mode
  3. Vertex Buffer
  4. Program
  5. Textures
  6. Uniforms

其中 Program 通常在管线初始化时所有的着色器都会准备好,非极端情况下缓存不会失效,因此上面的图中没有标出这个状态。

Blend States、Depth States、Stencil States 分别存储了我们前文所说的 Blend、Depth Test、Stencil Test 过程中涉及到的 GL 调用的参数和部分结果,这里就不详述了。

接着是 Cull Mode,根据顶点的索引的顺逆时针来用来区分正面与反面,如果状态值和 currentState 不一样,便触发 glCullFace 的调用来进行 commit。

对应顶点缓冲区也是一样有状态值管理,如果变脏了,就需要重新调用 glBindBuffer 进行绑定:

着色器程序也是一样的,如果脏了,就重新调用 glUseProgram 进行设置:

接着便是对 Textures 的检查和提交,这里有两个知识点:

  1. 纹理的应用:具体涉及到 glActiveTextureglBindTexture。首先使用 glActiveTexture 函数来选择当前要激活的纹理单元,这一步决定了接下来绑定的纹理将作用于哪个纹理单元上。然后,通过 glBindTexture 函数将具体的纹理对象绑定到特定的纹理目标上。通过这种机制,纹理对象与对应的纹理单元和目标进行关联,从而完成纹理的激活与绑定操作。
  2. 纹理单元:用于表示显卡可以同时管理的多个纹理。默认情况下,GL_TEXTURE0 纹理单元总是被激活的状态。此外,OpenGL 规范保证至少支持 16 个纹理单元(即从GL_TEXTURE0GL_TEXTURE15)。纹理单元是按顺序定义的,因此我们可以通过诸如 GL_TEXTURE0 + 8 的方式便捷地访问特定编号的纹理单元,以便在复杂的渲染场景中实现多纹理同时使用。

当前面的状态值都准备并提交完毕后,最后需要管理的状态值是 Uniforms,这一步如果有脏区产生,也需要重新提交 Uniforms 变量。比如游戏 Demo,涉及到的 Uniforms 变量有 cc_matViewProjtexture

最后就是绘制了,其中在每一帧的绘制前都需要调用 glClear 清理 Freambuffer 的状态。下图展示了 gl 指令调用的时序:

由于游戏 Demo 比较简单,绘制只需要准备好纹理和 Uniforms 即可,最后调用 glDrawArraysglDrawElements 将准备好 Framebuffer 绘制上屏:

至此,经历了这一系列的管线处理之后,我们的 Demo 游戏在小游戏容器内完成了上屏。

扩展阅读

  • 《GAMES 101》
  • 《计算机图形学入门:3D渲染指南》
  • LearnOpenGLCN

月刊(第29期):新生活

2025-04-18 14:32:37

本篇是对二〇二五年二月至四月的记录与思考。

近况

新年之后有两个多月没有更新月刊了,这篇文章聊一聊自己的近况。

规划

去年 10 月我从 TME 离职,趁着这次 gap,我规划了两条路线,并限定了自己 gap 的时间最长为 6 个月,我需要在 6 个月里完成既定的 PlanA 或 PlanB。其中给了 PlanA 的 MoFlow 3 个月的研发时间和 2 个月的运营测试时间。而如果没有达到初期既定的运营指标,我将暂停这部分的工作,将项目交接给合伙人。正因为时间有限,所以项目前期的开发节奏才会这么紧张。因此哪怕去了大理旅居,也几乎没有出门娱乐,这在我后来的回忆里一直是个遗憾。

PlanB 则是规避创业风险,回归职场。而我一直以来都有出国发展的规划,因此我希望这次就直接找一个 Base 地在海外的工作。那 gap 的最后一个月,就开始进行面试的准备了,我需要在一个月内拿到 offer。

虽然时间短暂,尤其是海外岗位还要准备英语。但我坚信无论何时,关于我们能做什么都有无数个选项。如果我们一遇到事就认为自己做不到,那扇通往无限可能的大门便随即向我们关闭了。我不愿如此,我要尽我所能去触及所有的可能性。

面试

于是年后开始准备面试、投递简历。这是我的第二份工作,因此选择公司的时候比较谨慎。

细细考虑未来的规划之后,我分了 3 个赛道进行投递:海外赛道 TikTok、Apple,管理岗赛道 Bilibili 和 Anker,以及为了对冲风险还投了微信保底。其中 Bilibili 过了,而且 scope 15 个人左右,就没有去面 Anker 了;Apple 只能 Base 北京,且能力不匹配,放弃。

最后的面试结果是 TikTok、Bilibili 过,微信在四面面委面主动放弃,因为那时已经进入了 TikTok HR 面。而且从自己面试过程的体验而言,也是 TikTok > Bilibili > 其他。最后很幸运,能如愿以偿来到 TikTok,拿到了预期的 offer。

离别

TikTok 的岗位 Base 在新加坡,出国的各种手续都由公司包办了,但是对于自己而言还涉及到很多资产要去处理。一些旧的设备能卖的都卖了,但是最中意的 mini 一直没下定决心。最后一个月里天天开着它,去了周边的许多城市,最终还是在出国的前一天将它出售了。直接二手交易卖给了当时我买它的 4S 店,价格还算能接受。

最后一个月回到深圳,见了一些前同事、以前的老同学,还去了一趟苏州和前舍友告了别。算来,在广东待了 11 年,深圳待了将近 6 年。这么长的时光,没有换来多少轰轰烈烈的回忆,以至于离别的时候,心中更多的是伤感。

突然想到《少年巴比伦》里的一句话:“离别总之是伤感的,因为伤感,所以不能用言语来表达,好像春天里绵密的细雨,用肉眼都分辨不出雨丝,不知道该不该打伞。”那时我的状态大约也是如此,不知道自己失去了什么,但是我能意识到那根弦似乎是要断了。

有时候我会对电视剧或小说的结局感到害怕,因此大多数剧集我总是留着最后几集不去看,或者过了很久很久之后有了足够的心情才去选择去看。我害怕一切的结局,但是我同时对结局之后的一切充满期待和希望。

这一篇章属于我的结局走到了这里,那么以后的生活又会是怎么样的呢?

新生活

生活在视界中的人从来不是安处于某处,而是永远在路上。——詹姆斯·卡斯《有限与无限的游戏》

我们永远都在路上,每一个结尾都是一个新的起点,是重新审视自我、重塑故事的机会。在这个过程中,变化是永恒的,我们对自我的理解和定义则是不断发展的。而我对这个世界则一直有深沉的热爱和长久的好奇,而且我幸运地将它们保存到如今。

孤身抵达新加坡、面对即将加入的新职场,尤其是某红书上对于新加坡和 TikTok 的口碑一直很差,我有很多不安。但我面试的时候感受还是不错的,我相信我的感受。我知道这只是我面对不确定性的恐慌,但我知道这也将是一个机遇,我能够从中挖掘出自己的好奇与热爱。生命之于我们,或许意味着不断地将我们自身及所遭遇的一切转化为光与火。

而经过一周的职场体验,我确信这是值得我全身心投入第二段职业生涯的团队。团队氛围其乐融融,上下之间、同事之间不存在任何隔阂,项目之内不存在信息差,如果想学习想成长,大门随时都在那里。

入职第一天晚,团队里一个同学要转岗了,他请客大家一起吃了顿大餐。饭桌上大家的话题让我笑得合不拢嘴。

入职第三天,团队去环球影城团建,路上我跟丢了中国小伙伴们,只能跟着团队里的外国人一起游玩。在排队的时候,大家会很照顾我,哪怕我口语不好、甚至有些听不明白,但大伙也边比划着边和我交流。

入职第四天晚,隔壁服务端组的 ld 请我们吃了肉骨茶,发现不仅是团队里,整个部门的氛围都超乎了预期的好。

今天是入职第五天,Good Friday 的假期,我在宾馆里写着这些文字,回忆起这周的日子,充实且开心着。

詹姆斯·卡斯在《有限与无限的游戏》中说:“如果我要知道自己生命的全部故事,我就必定已经将它译为一种解释。就好像我能成为自己的旁观者,同时看见自己生命的第一幕与最后一幕,好像我能看穿自己生命的全部。如果这样的话,我就不是在生活,而是在表演生活。”

我很庆幸我不知道所有的故事,我还抱有着对未来的可能性和好奇心。我还继续爱着这个世界,我还在这个世界之中,生活着。

庆幸一切。

🌺 生活点滴

以下是本周期内一些瞬间的记录。

🌍 深圳

这是这些年在深圳的足迹,还有很多迷雾没有探索,希望还有机会继续开图。

🌸 苏州

去苏州找了丁同学,之前玩得很好的舍友。前段时间偶然收到了他的来信,互相交流了下这些年的情况,才意识到我们已经很多年没有见面了,于是走之前去找他一次。实名羡慕他的工作和住处…非常大的房子,有自己的电竞房,微软的工作也使他非常闲适。走之前的晚上,一起去玩了个病娇的剧本杀——他是星期一,我是星期二。

✈️ 新加坡的第一天

下午的飞机,到机场被海关关小黑屋了,直到傍晚才放我出来。来到这里之后,所有的一切都是第一次。第二天在公司周边转了转。

另外,office 是个海景楼!(PS. 职场不让拍照,这个在低楼层的食堂拍的…)

⛱️ 团建

第一次去环球影城,运气好,刚来就赶上团建了。

🎬 书影音

以下是本周期的书影音记录。

  • 读完:传记 |《我可能错了》| ★★★★★
  • 读完:小说 |《少年巴比伦》| ★★★★☆
  • 读完:哲学 |《箭术与禅心》| ★★★☆☆
  • 在读:传记 |《小米创业思考》| ★★★★☆
  • 在读:散文 |《世间的小儿女》| ★★★★☆
  • 看完:动漫 |《葬送的芙莉莲》| ★★★★★
  • 看完:电影 |《扬名立万》| ★★★★☆
  • 看完:电影 |《哪吒之魔童闹海》| ★★★★☆
  • 看完:剧集 |《棋士》| ★★★★☆
  • 看完:剧集 |《漂白》| ★☆☆☆☆
  • 玩过:游戏 |《三角战略》| ★★★★☆