裁剪和空间管理
这篇讲的是游戏引擎里渲染优化的一个关键模块——Culling与空间管理。作者从一个基本问题出发:当场景里可渲染对象很多,但相机实际能看到的只有一小部分时,如何避免让GPU处理所有对象,从而节省CPU到GPU的带宽?最朴素的做法是对每个对象做视锥体检测,复杂度是O(n)。但当对象数量n极大时,我们需要更好的方法。 核心思路是利用空间结构来加速。把对象按空间位置组织成树状结构,这样在检测时可以快速剔除一整个分组,将复杂度降到O(log n)。文章梳理了这条技术路径的演进:从简单的等距网格,到处理不均匀分布的四叉树/八叉树,再到为解决对象跨边界问题而提出的BSP。作者特别推崇K-D Tree和BVH方案,它们通过在每次二分时智能选择分割线位置(比如让两侧对象数量均衡),能形成完全二叉树,既紧凑又高效。 作者强调,Culling本质上是一个可选的“加速缓存”,而非引擎核心容器,因此其数据结构应保持独立和简洁。例如,他建议用一块连续内存存储K-D Tree的切割信息,实现简单且支持持久化。最后,文章还探讨了实现细节,比如如何高效地对对象进行二分,以及是否需要动态调整树结构,给出了倾向于静态构建、按需重建的设计观点。
选择开源项目的几点原则
这篇讲的是资深工程师在面对琳琅满目的开源项目时,如何做出不后悔的选择。作者从自己曾受邀为校招生做技术分享的背景出发,分享了沉淀下来的三点实用原则。 核心观点非常明确:选项目,本质上是选“人”。具体来说,一要看项目是否活跃,有持续演进的历史,拒绝“已死”的项目;二要看项目主导者是否善于沟通,这是项目能否健康演进的关键;三要看项目是否专注,解决单一问题的“小而美”项目更便于集成与取舍。 作者特别强调,我们不必苛求代码完美,因为选择使用一个开源项目,就意味着选择了与维护者同行。真正重要的是找到那些勤奋、开明且专注的“合作伙伴”。文中还顺带吐槽了国内某些只发代码快照、缺乏持续维护的“伪开源”现象,让这个选择原则显得更加切中时弊。
内存的惰性初始化
这篇文章从一个 MMO 服务器压力测试的优化场景切入,探讨了当使用 A* 算法在一个巨大的三维网格(10MB 内存)中寻路时,如何解决初始化开销过大的矛盾。 实现者为避免每次调用都 memset 清零,采用在格点中记录版本号的技巧,实现了“用到时再判断”的惰性逻辑,但这依然需要全局保留这块内存。作者从更高层面指出,这本质上是一个用平坦内存空间模拟稀疏矩阵的权衡问题。 为此,他设计了一套惰性初始化的内存结构:以 64 字节(cacheline 大小)为单位划分内存,仅用一个二级标记树(总开销约 20KB)来记录哪些段落已被初始化。访问时检查标记,按需清零。这样,绝大多数未被访问的内存区域永远不会被初始化,将时间开销降至接近于零,同时空间代价极小。 文章结尾更提出了一个巧妙的延伸思路:对于这种障碍物静态且局部的寻路,与其在运行时寻路,不如用巨大的预计算空间将路径全部存储下来,实现 O(1) 查询。这为解决此类特定问题提供了不同的架构视角。
让 lua 运行时动态切换操作系统线程
这篇讲的是开发者在构建跨平台游戏引擎时,如何巧妙解决一个操作系统级的线程调度矛盾。作者从 iOS 的一个严苛限制出发:系统要求窗口消息循环必须运行在主线程,否则程序可能被杀;而引擎为了隔离耗时的业务逻辑,又必须把窗口管理模块与用户主逻辑分到不同线程。 矛盾在于,用户的业务代码期望运行在 Lua 解释器启动时的主虚拟机(VM)中,窗口模块期望在独立线程,同时窗口模块还必须占据操作系统意义上的“主线程”。作者最初认为这无解,除非像 Skynet 那样深度定制 Lua 运行时,让 VM 能自由迁移。 真正的转机来自一个巧妙的 API 设计:`thread.fork`。它通常让 func1 在当前 VM,func2 在新建 VM 和线程上并行。但作者反其道而行,让 func1(用户主逻辑)在**新线程**上运行,而让 func2(窗口模块的新 VM)继续留在**当前线程**(即操作系统主线程)上执行。由于两者都通过 `pcall` 被限制在各自作用域内,用户代码完全感知不到自身线程已切换,而窗口模块则恰好满足了系统对主线程的要求。 这个方案的巧妙之处在于,没有去硬撼操作系统的规则,而是通过“偷梁换柱”——交换两个执行流所在线程的位置,让看似不可调和的约束在架构层得到了圆满解决。
ECS 中的概念缺失
这篇讲的是作者团队在实际项目中对 ECS(实体-组件-系统)架构的一次重要反思与演进。他们指出,传统 ECS 过分强调 Component 和 System 这两个底层概念,导致在搭建复杂业务(如现代渲染管线)时,开发者需要手动组合数十个组件和系统,流程繁琐且极易出错。 为此,他们引入了两个更高层的抽象概念来解决问题。第一是 **Policy**,它将实现某个特定功能(如“可渲染物件”)所需的所有 Component 及其初始化流程封装起来。开发者创建 Entity 时只需描述其具备哪些 Policy,而无需关心底层数据构成。第二是 **Pipeline**,它用一棵树状结构来定义 System 的执行顺序,开发者可以将 System 注册到流水线的特定节点上。 通过这套设计,他们实现了将框架的复杂性封装起来,让业务层开发者只需关注 Policy 列表、Pipeline 配置和初始数据,无需直接处理 ECS 的底层细节。这既保持了 ECS 解耦与数据驱动的核心优势,又大幅降低了实际使用的门槛,确保框架能真正服务于业务开发效率。
游戏引擎中的资源生命期管理问题
这篇讲的是游戏引擎开发中资源生命期管理的架构演进与权衡。作者从团队实际遇到的问题出发:最初依赖Lua的垃圾回收,但其触发时机不可控,易造成图形渲染卡顿;而随着场景复杂化,单帧内过多的资源API调用甚至导致渲染后端崩溃。 为应对这些问题,团队先后尝试了经典的引用计数方案,但作者认为在Lua框架与内存受限的移动设备上,单纯基于“是否还有引用”来决定资源释放并非最优解。他将资源分为两类:一类是可从磁盘重新加载的IO资源(如贴图),另一类是依赖上下文、难以重建的运行时生成资源。 针对第一类资源,作者提出不应被引用关系束缚,而应遵循LRU原则,允许引擎随时释放长期未用的内存。对于第二类资源的处理灵感则来自imgui:采用“每帧主动创建”的方式来规避复杂的重建回调,再将结果缓存。这样一来,所有资源都可以统一由LRU策略管理,在内存压力下安全淘汰。文章梳理了从全量保留、Lua GC、引用计数到统一LRU缓存的完整思考路径,展现了从具体约束中提炼通用架构的工程智慧。
资源文件的转换问题
这篇讲的是作者在自研游戏引擎中遇到的一个资源转换缓存失效问题,以及由此展开的架构优化。 他们引擎的资源转换采用惰性策略:在虚拟文件系统中,根据`.lk`描述文件和平台信息按需生成最终资源。但最近发现,对于 shader 这类依赖其他 include 文件的“代码型”资源,仅靠源文件和`.lk`文件的 hash 作为缓存 key 是不够的——修改依赖文件后,系统并未感知变化,错误地返回了旧缓存。 根因在于初始设计过于简化,未考虑编译的完整依赖链。放弃惰性构建的方案很快被否决,团队最终提出一个更巧妙的方案:当请求构建时,系统会在后台无条件重新编译,并将此次编译的**完整参数和依赖关系**(包括所有依赖文件的路径及当前 hash)写入一个新的构建脚本文件(如 `a.sc.lk.ios`)。这个文件本身唯一确定了一个编译结果,其 hash 就成了新的、精确的缓存 key。 这个机制既保留了惰性转换的优点,又实现了准确缓存。相比 Unity 的 cache server,它的优势很明显:缓存键是包含依赖的完整过程,因此可以跨项目复用(同一张贴图不会因路径改变而需重新编译)。此外,文件服务器还能利用空闲时间预编译其他平台版本,这是纯键值存储的 cache server 做不到的。这个设计有点类似 Git 大文件存储,用一个轻量引用指向背后的编译服务。
程序员应该怎样提高自己
这篇文章是一位拥有近30年经验的资深程序员,对“程序员如何提高自己”这一高频问题的系统性回应。作者认为,成长始于对代码优化的乐趣——正是“写出更高效代码”的追求,驱动着程序员自发去理解操作系统、内存模型等底层原理。 精通一门语言是基石,但这意味着要了解其所有特性的代价与惯用法。作者指出,设计模式本质上是语言特定惯用法的总结。而要突破瓶颈,则必须掌握第二门语言,通过对比不同范式来拓宽解决问题的思路。 在掌握具体技能之外,更高阶的能力在于分解问题、保持简洁的架构设计思维。作者强调,真正的简洁源于对优化与代价的深刻理解,而非一味追求技巧。同时,他呼吁重视工具链掌握、脚本编写等“软能力”,并积极参与开源协作,将沟通与理解能力视为与编码同等重要的核心素养。 这些源于长期实践的经验,为年轻程序员勾勒出一条从兴趣驱动到工程思维的成长路径。
ECS 中的消息发布订阅机制
这篇讲的是作者在用Lua实现ECS框架时,如何解决“周期性状态迭代”与“响应式事件处理”之间的矛盾,并最终引入一套完备的消息发布订阅机制。 作者从实践中发现,纯粹的游戏循环难以高效处理复杂外部输入。因此,他最初刻意在ECS中回避事件系统,将内部事件都转化为状态变化。但这并不完全符合游戏混合型业务的本质。 于是,他决定为框架增加消息发布订阅模块。核心实现非常灵活:每条消息都是一个Lua table构成的键值对,例如 `{ type = "mouse", action = "move", x = 100 }`。系统通过 `world:pub` 发布,任何System则通过一个模式(pattern)来订阅感兴趣的消息,比如订阅所有 `type="new"` 的消息,或者只订阅特定实体的状态变化。 巧妙之处在于其性能优化思路。作者没有选择简单的遍历匹配,而是在订阅时建立索引缓存。发布时,先根据消息中的各个条件快速排除不相关的订阅者,大幅减少了比较次数。这种用空间换时间的策略,让消息分发效率更高。文章也探讨了面对复杂条件可能导致的缓存膨胀问题,为后续优化留下了空间。
Windows 下重定向当前进程的 stdout 到网络连接
这篇讲的是在 Windows 系统下,如何将一个正在运行的进程的标准输出(stdout)重定向到一个 TCP 网络连接中。这并非一个简单的 API 调用,作者为了解决这个需求,深入探索了 Windows 与 POSIX 在底层 I/O 机制上的根本差异。 作者指出,尽管 Windows 提供了 `_dup2` 来模拟 POSIX 的 `dup2`,但其进程标准输出句柄(HANDLE)与 C 运行时的文件描述符(fd)之间的绑定关系是静态的。在进程运行时调用 `SetStdHandle` 并无法影响 `cout` 或 `printf` 的输出流,这是解决问题的第一个关键障碍。 更麻烦的是,Windows 下的 socket 不能直接作为普通的文件句柄使用,因此无法通过 `dup2` 直接传递。作者最终采用的核心方案是:先用 `dup2` 将 stdout 重定向到一个匿名管道,然后启动一个独立的转发线程,持续从该管道读取数据并发送到目标网络连接中。这个方案还巧妙地解决了进程结束时可能丢失末尾输出的问题——通过主动关闭管道来通知转发线程结束,确保数据完整性。 整个探索过程涉及了 Windows 内核对象、句柄复制、管道 I/O 与多线程同步等多个层面的考量,最终作者将实现封装成了一个 Lua 模块,并在 GitHub 上提供了可运行的示例代码。
断点单步跟踪是一种低效的调试方法
作者从自己二十年的开发经历出发,对“断点单步跟踪”这一经典调试方法提出了一个颇具挑战性的观点:它本质上牺牲了效率来换取低门槛,是一种低效的方法。 文章详细阐述了这一观点的由来。作者从早期深度依赖图形化调试器,到转向跨平台开发后因工具不便而开始反思,逐渐转向以代码审查(Code Review)和日志输出为核心的调试范式。他认为,调试器容易让人陷入“眼前状态”的机械追踪,忽视了对程序所有可能执行路径的并行思考。相比之下,经过训练的大脑在阅读代码时,能更高效地分析所有分支并做剪枝,对程序的理解是全局且可回溯的。 文章进一步论证了这一方法的优势:它能倒逼开发者写出复杂度更低的代码,并能与日志输出形成完美配合。日志不仅提供了调试器所需的路径与状态信息,还具备更好的回溯能力和对并发系统的适应性。作者并未完全否定调试器,认为在分析崩溃现场等场景下它依然有用,但日常的 Bug 定位,理应建立在更深刻的代码理解之上。 这篇文章在开发者群体中引发了广泛共鸣,它不仅仅是在对比工具,更是在倡导一种通过提升自身心智模型来驾驭复杂度的工程哲学。
浅谈《守望先锋》中的 ECS 构架
这篇技术博客的作者从《守望先锋》GDC演讲出发,深入浅出地解析了游戏开发中的ECS架构。文章直面传统面向对象游戏引擎的痛点——每个游戏对象都捆绑了所有功能模块的Update方法,导致模块间耦合严重、内聚性差。对于像《守望先锋》这类需要复杂网络预测与同步的游戏,传统架构显得力不从心。 作者详细拆解了ECS(Entity-Component-System)的核心设计:Entity仅作为带ID的生命体容器;Component是纯数据(如位置、输入状态);System则是纯逻辑处理单元。框架负责根据System声明的Component组合,自动筛选出它关心的Entity子集进行遍历。这使得每个System能高度专注且松耦合。文章还提到了Singleton Component的演进、Utility函数的使用以及如何集中处理有副作用的行为。 最终,作者指出ECS最大的优势在于清晰分离状态与逻辑,这极大简化了网络同步中的状态快照与回滚操作。《守望先锋》利用这套架构,在60fps的固定更新频率下,优雅地处理了客户端预测、服务器仲裁及网络波动时的“时间压缩”同步难题,展现了架构在管理复杂度上的强大能力。
Paradox 的数据文件格式
这篇文章探讨的是 Paradox 游戏引擎背后一套独特的数据文件格式。作者从游戏开发实践出发,比较了游戏行业常见的 CSV/Excel 表格模式与软件领域的 JSON/XML 模式,指出它们在处理复杂树状结构数据时各有局限。 有趣的是,Paradox 的格式初看像 JSON,但作者在使用 lpeg 编写解析器时有了顿悟:其核心是嵌套的列表结构,这本质上是 Lisp 的思想。这种格式语法简洁(仅用大括号和等号键值对),却拥有比 JSON 和 CSV 更强的表达能力,能优雅地定义游戏事件、触发条件等复杂逻辑,同时保持了策划人员编辑友好的可能性。 文章通过《群星》中一段具体的游戏事件代码作为实例,展示了这种格式如何清晰地组织条件判断、效果执行等游戏逻辑。作者最终得出结论:Lisp 模式在简洁性与表达力之间找到了一个更好的平衡点,为游戏数据的组织提供了一种优于传统方案的思路。
为什么 Windows 的文件系统会有盘符,使用反斜杠分割路径
这篇技术博客从一个轻松的讨论切入——Windows系统在挂载大量盘符后会出现双字母命名的“诡异”现象,进而探讨其背后的设计逻辑。作者指出,虽然从现代视角看,盘符和反斜杠似乎是冗余的历史包袱,但其根源深植于MS-DOS的早期发展。 文章追溯到MS-DOS 1.0时代,当时主流软盘没有层级目录。盘符(A:、B:)的设计直接借鉴了更早的CP/M系统,方便用户在两个软盘驱动器间操作。随着IBM PC/XT引入10MB硬盘,盘符扩展到了C:。而路径分隔符选用反斜杠“\”而非Unix的“/”,是因为MS-DOS的开发者继承了DEC系统使用“/”作为命令行参数分隔符的惯例,为避免混淆,只能选择其他符号作为目录分隔符。 作者通过这段历史对比了Windows与Unix系系统的设计哲学:Unix将物理存储通过挂载点透明地整合进统一文件树,而Windows保留了显式的盘符概念。这些早期设计决策,最终形成了我们今天看到的、让不少程序员感到“深恶痛绝”的Windows路径风格。
在 Unity3D 的 Mono 虚拟机中嵌入 Lua 的一个方案
这篇文章探讨了在 Unity3D 中嵌入 Lua 时,如何设计一个既简洁又完备的跨虚拟机交互方案。作者指出,市面上已有的许多方案存在过度繁琐或细节不完备的问题,他从 C/S 架构的通讯模型出发,提出了核心思路:将 Mono 与 Lua 间的交互抽象为一次“异地函数调用”。 这个方案的核心精巧之处在于,它不直接暴露 Lua 的 C API,而是通过一个中间层的 struct 来传递所有数据。调用函数和参数被编码进这个 struct,统一由一个 C 函数传递给 Lua 虚拟机。这种设计极大地提高了模块的内聚性,并严格控制了 Mono 和 Lua 两套异常机制的边界,防止异常泄漏。 文章还深入剖析了方案中最具挑战性的部分:两个虚拟机间的对象循环引用管理。作者详细讨论了如何利用 Lua 的弱表(weak table)和 ephemeron table 来检测仅被外部虚拟机引用的对象,并最终解除循环引用。同时,他也务实地建议,在多数项目中,保持清晰的单边引用关系(Lua 长期持有 C# 对象,C# 短期持有 Lua 对象)是更简单有效的做法。 基于这套理念,作者在周末实现了一个名为 sharplua 的轻量级方案。它提供了极简的 API:一个创建 Lua 虚拟机,两个核心的 CallFunction 和 GetFunction 用于双向调用,以及一个 CollectGarbage 用于管理跨语言对象的内存。整个实现代码开源,结构清晰,为希望自定义嵌入方案的开发者提供了一个干净的基础模板。
Lua 中 Cache 冷数据的落地
这篇讲的是如何在 Lua 虚拟机中,为缓存模块设计一个安全的冷数据落地机制。作者从一个实际 bug 讨论出发,详细分析了不同方案的演进。 文章最初提出一个基于时间戳的朴素方案,但发现其无法保证业务正在使用数据时,数据不会被错误地异步写回。随后,作者引入 Lua 弱表和 `__gc` 元方法进行改进,利用垃圾回收机制来判断数据是否“冷”。然而,这个方案存在一个微妙的“第三状态”漏洞:当对象被 GC 回收、但其 `__gc` 方法尚未将其“复活”到待处理表时,系统会短暂地失去对该数据的追踪,导致可能从数据库加载出旧版本的数据。 为解决这个并发与状态管理的核心难题,文章最终提出了基于元表代理的方案。通过让 cache 表存储代理对象,将真正的数据隔离在另一个全局表中,从而稳定了数据从缓存中移除的时机,并使冷数据落地流程可以清晰地通过集合差集来识别目标,避免了复杂的状态竞争问题。这实质上是用间接层换取了状态管理的清晰与安全。
代理服务和过载保护
这篇讲的是如何在skynet框架中,通过前置代理服务来解决热点服务的过载问题。作者指出,服务过载是并发环境下最常见也最棘手的问题之一,而代理服务能在不增加功能服务复杂度的前提下,提供有效的保护。 核心方案是为热点服务增加一个代理层。这个代理可以智能地调度请求:当检测到某服务请求过于频繁时,会优先处理其他请求以保证公平;同时能自动丢弃那些来自已退出服务的无效请求。更重要的是,它能感知后端功能服务的负载情况,当服务过忙时缓存新请求。这带来一个实际好处:线上排障时,通过调试控制台直接发送的控制指令能绕过拥堵的请求队列,得到更快的响应。 文章不仅给出了概念,还深入了实现细节。作者展示了如何利用 `skynet.forward_type` 编写高效的代理服务,通过直接传递消息指针来避免不必要的内存拷贝。此外,还介绍了两种关键的运维能力:如何通过 `debug ping` 协议快速检测目标服务的响应延迟以判断是否过载,以及如何利用 `debug link` 指令来感知服务退出,从而清理无效请求。整套方案从架构设计到代码实现,为处理并发环境下的服务保护问题提供了清晰的思路。
排行榜奖金的发放方法
这篇讲的是游戏《流星庄园》排行榜钻石奖励规则被玩家利用、进而推导出一套更优设计方案的过程。 作者从实际漏洞出发,指出原有设计——在固定名次奖励基础上额外奖励“名次提升”——存在根本矛盾。由于玩家社群能够沟通协作,他们发现与其激烈竞争高名次,不如协调一致地交替升降名次,从而反复“提升”来获取更多系统奖励,甚至在低排名区也能无中生有地获利。 针对此问题,作者提出了一个核心思路是“固定奖金池”的改进方案。新规则将所有预期发放的钻石锁定为一个总量,用于奖励当前排名。同时,引入了一套“超越者”激励机制:每个公会的奖金,会根据本周有多少上周排名低于它的公会反超自己,而被按比例扣除;扣除的这部分奖金,将再奖励给那些成功超越别人的公会。 这个设计的巧妙之处在于,它把总支出控制在预算内,同时让“超越”行为本身能直接获得对家扣除的奖励。如此一来,玩家博弈的焦点从操纵排名变化,转向了在固定规则下进行真实的实力竞争,从而实现了设计初衷。
Lua C API 的正确用法
这篇讲的是在C/C++宿主程序中嵌入Lua时,最容易被忽视的陷阱:如何正确处理Lua的异常(error)机制。文章指出,很多开发者会忽略C API调用可能抛出异常这一点,导致程序出现未定义行为甚至直接崩溃。 作者从基础讲起,强调所有可能出错的API调用(如lua_tostring)都应被lua_pcall或lua_resume保护起来。文章用一个创建虚拟机的简单代码示例,清晰地展示了即使luaL_openlibs也可能因未捕获异常而带来风险,并推荐将核心逻辑封装为lua_CFunction再通过lua_pcall调用。 进阶部分深入讨论了在C++或多语言环境(如Unity的mono)中,Lua的异常机制(基于longjmp/throw)与宿主语言异常系统(如C++ RAII、.NET CLR)的协同难题。文章警告,直接用宿主语言的异常处理(如C++的try-catch)去捕获Lua API异常会破坏虚拟机状态,因此必须精心设计接口,将Lua与宿主视为两个通过消息通信的独立运行时,而非共享异常上下文。 最后,文章提到了在C扩展中初始化对象字段、管理栈空间等实用技巧,并附上了作者为解决跨语言交互问题而编写的示范代码。全文聚焦于“正确性”而非性能,对于任何需要深度集成Lua的开发者来说,这是一份避开关键坑点的实用指南。
浅谈 WHR 全历史排名
AlphaGo 击败李世石后,围棋积分网站给出的世界排名让作者开始探究这套评分系统的底层逻辑。文章从Bradley-Terry模型讲起,解释了为何需要Elo等级分的指数变换来直观呈现选手间的实力差距,但其本质仍是静态模型,难以适应人类水平的波动。 为解决这一问题,文中对比了多种动态评分方案:简单的增量更新系统计算便捷但信息利用不足;引入历史衰退的系统能综合考量,却可能导致不活跃选手分数跳跃。最终,文章聚焦于WHR(全历史排名),它基于动态Bradley-Terry模型,核心突破是提出了一种近似算法,能通过牛顿插值法在每次比赛后增量更新分数,并在后台进行迭代优化,从而高效地利用全部历史数据推算每个时间点的准确评分。 作者指出,WHR的开源实现还针对围棋让子棋做了胜率修正,这种思路或许可推广到其他竞技场景。整篇文章从一个现象出发,抽丝剥茧地梳理了等级分系统的演进,清晰展示了WHR在精度与效率上的巧妙权衡。