[{"content":"","date":"15 September 2026","externalUrl":null,"permalink":"/","section":"VisualGMQ的博客","summary":"","title":"VisualGMQ的博客","type":"page"},{"content":" 游戏开发混子一个。 游戏偏好 # 重度Roguelike和Roguelite玩家。有几年沉迷于TRoguelike，玩过Nethack, DCSS, CDDA, Pathos和TOME'4。本来打算玩一玩矮人要塞，结果学习成本太高加上当时已经工作了，也没怎么玩。\n后来沉迷于Roguelite，玩过《以撒的结合》，《杀戮尖塔》等知名Roguelite。\n格斗游戏铁粉。打了三年的拳皇97/98。自从15出了之后又打了450+小时。之前是SNK忠实粉丝。但这几年街霸6做的太好了，加上饿狼传说狼城做的有点捞已经粉转黑了（什么时候出拳皇16什么时候黑转粉）。街霸6有400+小时。有时也玩玩《真人快打1》。你们打拳都是嘴上说说只有我是真上网打男拳（\n比较喜欢横版动作/闯关/格斗游戏和各种独立游戏。不喜欢恐怖游戏和解密游戏。比较喜欢能操作的和自由度较高游戏。\nUP主相关 # 同时还是一个B站UP主，经常分享游戏引擎技术：单身剑法传人。\n周末有时候也直播打游戏，一般是周日下午4点到晚上8、9点。很下饭，推荐饭点观看。\n技术相关 # Github: VisualGMQ\n游戏开发和游戏引擎开发方向的程序员。前几年在研究游戏引擎开发并成功进入引擎岗呆了几年，现在打算慢慢转向UE开发了。自研引擎吃枣药丸有感觉吗。\n展开查看主包经历 大学生活 加入了机器人社团担任视觉组组长，帮助开发机器人视觉相关（做各种识别）（OpenCV调库侠）。顺便跟着硬件组学了一些单片机（C51,STM32） 在社团任期结束后加入Robocup3D小组进行3D机器人仿真的学习。 这个小众项目说白了就是给你一个3D机器人仿真环境让双方机器人队员打足球比赛。我们的工作就是通过代码控制机器人的肢体来想办法让机器人进球。 这个比赛是有实体机器人项目的，但我说不是实体机器人买不起，而是模拟更有性价比有懂的吗。 比赛本身只规定了网络协议。我们底层是国外一个UT大学做的开源底层，提供了整套的模拟环境和客户端可视化，以及一些机器人控制代码（比如机器人倒了可以自己站起来，可以向前跑步）。 现在回过头看看，这个项目的重心应该是控制理论。但当时啥也不懂也没人指导就硬造代码。我只记得有个同学研究出个长台进攻，一脚能给球踢特别远，就是机器人自己也会表演一个花式转圈最后躺在地上睡觉。 业余时间沉迷于游戏引擎底层技术。学习了OpenGL并跟着LearningOpenGL手搓了渲染器。 本来打算学学Unity/UE。但之后Games101横空出世，给我彻底拉入实时渲染的坑里滚了2年左右。在此期间学习了软渲染和Vulkan，并在B站发布了Vulkan相关视频。 毕业设计是使用Vulkan编写的游戏引擎。 当时用Vulkan和libODE（一款又老又小众的物理引擎，你问我为什么用这个，我只能说大学的Robocup3D项目中学过这个，而我又没学过其他3D物理引擎）。现在来看确实十分简陋。光照只有PBR，编辑器也没有反射也没有啥都没有。充其量只是个劣质渲染器。 初入社会 在大学纯学Vulkan和OpenGL等底层了，实习也没找，结果一毕业发现根本找不到图形相关工作（我也不是很会写Shader）。 当时国内外几乎没有Vulkan的学习资料，我本身又不是图形学专业出生（我毕业了才知道图形学居然是研究生课程）。大学时花了大量的时间从0开始啃Vulkan Specific和Vulkan的官方手册，并在外网搜罗各种Vulkan资料。结果一毕业发现根本没市场。而我本身也没有Unity/UE经验，所以根本没有游戏公司要我。 最后病急乱投医，进了广州手游黑厂做Unity客户端，一个月8k天天9-12-6，纯黑奴有感觉吗。 干了几个月后实在扛不住，跳到杭州一家做BIM的公司，担任算法工程师，做面体相关的布尔算法。在期间学了不少几何相关知识。 但依旧贼心不死，业余继续研究渲染技术。同时在B站发布了Rust软渲染相关视频。 进入引擎岗 在BIM干了一年半之后，依旧怀揣游戏引擎梦。遂跳槽进入游戏公司的游戏引擎中台，做物理引擎相关。 在跳槽期间阅读了一些开源的C++反射库源码。随后制作了我自己的C++静态/动态反射库，并在B站发布教程。 你问我为什么不做渲染，因为依旧没找到渲染岗。这辈子就是被渲染害了，从此封心锁爱再也不碰渲染。 但我又没有物理经验，也只能边干边学。经过两年的洗礼终成PhysX调库侠。 引擎梦碎 我一直有一个引擎梦，就是做一个自己的引擎。也确实在慢慢做(NickelEngine)。而引擎又分为很多部分：渲染，物理，动画，寻路和AI，工具等。我也一直在慢慢学习各个方面的底层知识。但在引擎岗工作的两年，我逐渐认识到： 最高原则： 引擎是为游戏开发服务的，纯粹做引擎没有任何意义。没有游戏的引擎只不过是空壳。必须要有游戏验证你引擎的可行性。 UE就是引擎的龙头。除了渲染（这玩意小方向太多了）外其他方向几乎已成定局，很难再做大突破。虽然大家都说UE屎，但自研的引擎肯定比UE更屎。 基于上面两点，制作通用游戏引擎是一件性价比十分低的事情。不如针对特定游戏类型做特化的游戏引擎。 从技术上来说，游戏引擎很容易被忽略的一个重要方向就是引擎工具。这玩意占据了60%朝上的时间。一款好引擎必须细细打磨工具，让工具变得易用，这样引擎才不会卡游戏开发的进度（但现在大AI时代来临这又说不准了，AI生成资产也挺快）。 但我觉得工具方向是纯堆人力的方向。技术含量较低，反正我是不会去做的。 这就陷入一个两难局面：做通用游戏引擎你比不过UE。而如果做特化游戏引擎，对于我个人而言，还不如直接用UE等现有引擎做游戏更快。 倒不如说，之所以要做特化引擎，是因为你有一个很偏门的游戏点子，市面上的引擎都满足不了你。而显然现在的我并没有这种点子。 而且引擎中脏活累活太多。我最开始做引擎是为了学习引擎中的底层技术。而实际开发过程中，技术含量其实较低，更多地是修改引擎以适配GP的需求。所以从技术学习角度来说，与其做引擎，不如对引擎技术的各个方向逐个击破。 所以我发现，做一个完整的引擎对我来说根本没有任何用处。 而且现在引擎岗位越来越少，再弄引擎是真要流落街头了。 所以我觉得，我的引擎梦要到此结束了。 最后一舞与转向UE 现在我打算逐渐转向UE客户端方向，慢慢学一些UE知识（感谢Unity的作死以及Godot的不成熟，让我只有UE这一条路可选）。引擎技术也要学，但是要分方向逐个学习。 我在B站仍旧会更新引擎底层技术相关视频（最近就更新了ECS源码分析）。但更多地不是从头造轮子，而是分析源码。因为在我这几年的职业生涯中，发现大家其实都是调库侠，要自己从头造轮子的场景非常少。所以我也不再愿意花时间造轮子。况且现在AI发展这么快，再造轮子也显得没什么意义。除非那种特别大的项目内容特别多，我们可以造一个小轮子来学习。 但我依旧要最后一舞。我正在从0开始做一款基于SDL3的2D俯视角联机游戏。里面大部分东西都是从0造轮子。我希望用我这几年学习的引擎技术真正地做一个游戏，而不是引擎。这个项目的最高纲领就是：我是在做游戏而不是通用引擎，游戏用不到的东西我就不添加。所以我写的很克制，也尽量少用高深的技术和花里胡哨的C++语法，轮子也是尽量造的小小的。希望在26年可以将项目完工。 随着大AI时代的到来，我将花更多的精力在理论学习上，而不是真正的编码。我必须确保我理解了原理，但真正的实现可以人机协作。 这几年我即没有造出自己的游戏引擎，也更是没有做出自己的游戏项目。我是两头都不沾。这就是我称自己为游戏开发混子的原因。 重度C++用户。技术栈是CMake + C++。有时使用Lua作为脚本编写游戏逻辑。\n喜欢研究引擎各个方向的技术。希望有朝一日能做出自己的游戏。\n","date":"15 September 2026","externalUrl":null,"permalink":"/about/","section":"关于我","summary":"","title":"关于我","type":"about"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/categories/game-development/","section":"Categories","summary":"","title":"Game Development","type":"categories"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/series/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E/","section":"Series","summary":"","title":"从0开始制作游戏物理引擎","type":"series"},{"content":"本文述说了在SceneQuery场景下的加速结构BVH。\n本文是SceneQuery的最后一篇文章。至此，所有的SceneQuery技术都已叙述完毕。\n当然还有其他空间划分方法（SAP，OctTree，K-DTree等）。但这些一般用于模拟时的空间划分，等到模拟章节再说。\n绝大部分理论来自于Erin Catto的GDC分享1（建议阅读）和PBRT2，而实现则参考PhysX5.4.0。\nBVH构建更多的理论来源于渲染中光线追踪方向。其中有很多乱七八糟的BVH构建（在CPU上，或使用并行计算，或在GPU上构建）。本文不说那么多乱七八糟的，只说常用的方法。\nBVH # Bounding Volume Hierarchy。即带有层次的BV。\n因为和BV的SceneQuery非常快，所以我们可以：\nRaycast：先尝试使用射线和BV判交，如果成功，再对里面的物体做真正的Raycast Sweep：先尝试用Sweep扫略过的路径构成的AABB和BV判交，成功再对里面物体做Sweep Overlap：尝试先和BV判交，成功再对里面物体做Overlap 但按这个算法，每次做Query还是得对所有物体的BV做一次判交。而BVH则是通过将BV以树状方式组织，以减少大部分物体的BV判交。\n这里每个节点都有自己的BV（这里是矩形），父节点的BV包含子节点的BV，以Raycast为例，每次进行计算时，先用光线和根节点的BV判交，如果成功，就进入到子节点，和两个子节点BV判交（节点2和3），以此类推。这样，如果和某个节点的BV不相交，那么就不需要测试其下的所有BV了。这样可以大大过滤很多判交。\n这种通过预先使用快速手段过滤大部分物体的方式叫做BroadPhase（宽检测/宽阶段）。而过滤后真正和物体本身的几何（非BV）做真的Query算法则叫做NarrowPhase（窄检测）。BroadPhase的目标就是尽可能减少进入NarrowPhase的物体。\nBVH构建 # BVH的理论很简单。难的是如何构建。\n首先是BV的merge算法，指如何将两个BV合二为一。对于AABB来说就是：\nAABB Merge(AABB a, AABB b) { AABB c; c.min = Min(a.min, b.min); c.max = Min(a.max, b.max); return c; } 而对于BVH构建，一般而言是构建成一颗二叉树（其实可以任意叉树，但从工业实践经验来说二叉最好），目前业界至少有三种构建方法：\n构建算法：\nTopDown（自顶向下）： 根节点包含所有物体。然后对于当前节点，将物体列表一分为二，所有在左边构成子节点1，所有在右边的构成子节点2。然后左右节点算各自包含物体的BV的merge（合成大BV）。然后对两个子节点递归地做同样的事情，直到子节点内物体数量降低到阈值（此阈值是用户给的，PhysX是15）。 BottomUp（自底向上）：和自顶向下反过来 Incremental（增量式）：不停地向树中插入新物体来构建。新物体通过遍历树的节点来判断自己的BV被哪个节点完全容纳。 Catto的GDC分享1中有展示一步步构建的过程，这里不再赘述。\n其中TopDown和BottomUp方式在增加/删除新节点时只能重建树，所以一般用来容纳静态物。而 Inremental方式则用于容纳动态物。\n一般的做法是，找到当前节点所包含的BV最长的那个轴，然后在这个轴上使用垂直于此轴的平面划分空间，得到此节点的两个子节点。而如何选定此轴，有两种策略。\n中位数法 # 以此轴上物体BV的中心点$c_i$排序，找到中心点中位数$c_{median}$所在的物体A。所有中心点小于$c_{median}$的物体全部分到子节点1中，大于等于的放到子节点2中。 缺点是： 1. 可能会导致某子树上物体过多，而另一个树上物体太少。这可能导致射线总是打中物体多的节点，从而导致BVH作用降低。 2. 节点BV之间存在互相重叠的情况。重叠越多，射线同时穿过两个区域的概率就越大，BVH的作用越低。\n我测试了包含一万个静态物的BVH重建（单线程），平均耗时到2.4ms。测试链接会放在本文最后，可以试一下（不过网页版肯定性能更差）。而下文要介绍的SAH平均耗时在2.6ms（好像相差并不是很大，也不知道是不是我代码写的问题）。但中位数法创造了2047个节点，SAH法只创造了1933个节点： 我的CPU配置： Binned SAH # SAH（Surface Area Heuristic，面积启发式算法）2：基于一个事实：射线击中物体的概率取决于物体的面积比例。而SAH就是算所有BV的面积，对于AABB来说就是：\nfloat ComputeCost(Tree tree) { float cost = 0.0f; for (int i = 0; i \u0026lt; tree.nodeCount; i++) { cost += Area(tree.nodes[i].nbox) } } float Area(AABB aabb) { Vector3 d = aabb.max - aabb.min; return (d.x * d.y + d.x * d.z + d.y * d.z) * 2; } 就只是计算AABB的表面积（当然这个面积可以预计算存在AABB中）。\n对于与任意节点的BV相交的射线来说，要对节点中每个物体做Query。将这个代价记为： $$ \\sum_{i = 1}^n t_{isect}(i) $$ 其中$n$是节点中元素个数。$t_{isect}$则是此光线和第$i$个元素Query的代价。\n那么假设此节点还能够细分成$A$, $B$两个节点，那么现在的代价就是： $$ c(A, B) = t_{trav} + p_A \\sum_{i = 1}^{n_A} t_{isect}(a_i) + p_B \\sum_{i = 1}^{n_B} t_{isect}(b_i) $$ 其中$t_{trave}$是射线经过上层节点的耗时（或者说确定和此节点Query的耗时），$p_A$则是此射线和节点$A$要做Query的概率。后面的求和则是和对应子节点的Query耗时。\n我们假设$t_{isect}(i)$对任意物体来说都一样（你可以认为是射线和物体BV做Query，这样他们的代价就一样了。至于NarrowPhase的代价不算入内）。\n那么对于作为$C$节点的子节点$A$，我们可以算经过$C$节点时经过$A$节点的概率，用条件概率：\n$$ p(A|C) = \\frac{S_A}{S_C} $$就变成两者面积的比值（你问我为什么是面积比我也不知道，这好像是一个数学定理）。\n那么我就可以直接用$p(A|C)$的值作为$p(A)$的值带入到$c(A, B)$了。\n那么由于我们假设了$t_{isect}$代价一样，那么就有：\n$$ c(A, B) = t_{trav} + \\frac{S_A}{S_C} \\cdot Q\\cdot n_A + \\frac{S_B}{S_C} \\cdot Q\\cdot n_B $$这里$Q$是一个常数。\n那么我们要找的就是满足$c(A, B)$最小的$C$空间的切分法：\n首先在算法最开始的最长的那个轴上，将轴均匀等分（每个等分区间称为桶(bucket)），至于分多少那是手动配置的。PBRT是12。\n然后对于桶和桶之间的每条缝，找到此缝左右两边所有桶的代价。\n这里通过$c(A, B)$的公式可以知道，对于任意缝，$Q$和$S_C$都是固定的，而且$t_{trav}$和缝的选择也无关，所以我们只需要计算\n$$ S_A \\cdot n_A $$就行。那么将缝左边视为$A$区域，右边视为$B$区域，我们可以得到简化版的$c(A, B)$:\n$$ c(A, B) = S_A \\cdot n_A + S_B \\cdot n_B $$即为：将缝左/右边所有图元的BV并起来，然后算此BV的表面积（SAH值）乘上缝左/右边的图元个数。最后这条缝的代价$c$就是左代价+右代价。\n最后取代价最小的那条缝作为分割缝。\n所以这里也就看出，缝越多（桶越多），这个代价计算越精细。但Wald在他的论文3中说在16~32个桶之间最好，超出会导致收益饱和。\n而如何判断物体BV到底在缝左边还是右边，只需要看BV的中心点在左边还是右边就行：\n这种利用桶分割的算法叫做Binned SAH.\n最后对分割出的两个子节点递归地做这个算法，直到节点中物体个数小于规定的数目（此数目也是人为配置）。\n实际实现中有些小优化：\n一般会有一个Bucket结构用来存储此桶内所有物体的BV的merge。这样，对于任意分割线，我就只需要将线一边所有桶的BV合并即可，而不需要再判断哪些物体在边一侧，也不需要真正将物体存在桶内。 我们假设从左往右检查分割线，那么可以用一个right数组用来存储每条线右边的所有BV的并。对于某条线，其左边的BV可以用上条线左边的BV与两线之间桶的BV合并得到，而右边的BV则可以直接读right数组得到，节省了BV合并计算。 中位数和SAH算法的结果对比 # 一般来说使用SAH可以构建出更紧凑的BVH，查询效率更高。\nPhysX则有三种做法：\nBVH_SPLATTER_POINTS：默认做法，直接拿到节点AABB的中点（$(AABB_{maxi} + AABB_{min_i}) * 0.5$） BVH_SPLATTER_POINTS_SPLIT_GEOM_CENTER：节点内所有物体AABB中心的平均值 SAH 从上往下速度依次减慢。而策略是用户可配置的。\n而他也不是拿到最长轴上的值，而是拿方差最大的值: $$ s^2 = \\frac{\\sum(x_i - \\bar{x})^2}{n - 1} $$ 其中$n$是节点内物体数量，$x_i$是某物体的AABB中心坐标（是的他是个Vector3），而$\\bar{x}$就是$x_i$的均值。\n最后取$s^2(x,y,z)$中值最大的轴。这看上去算得很慢，但PhysX用SIMD加速优化了，实际很快。\nLBVH # Linear BVH。比起Binned BVH的优点是：\n可以多线程并行构建 可以很好地在GPU上构建（这部分不作讨论） 此方法基于BottomUp构建方式，采用Morton Code4进行编码以加速构建。\nMorton Code # 又叫做Z-Curve4。是一种将空间划分成网格，并将网格坐标用二进制表示的编码。\n以$R^2$为例。假设将空间划分成均匀的网格。那么可以用二进制作为这些网格的坐标：\n这里x轴上的网格序号是$1, 2, 3, \u0026hellip;$对应二进制$0001, 0010, 0011, \u0026hellip;$。y轴上也是同理。这样就可以用x和y表示网格上的点：\n$$ \\begin{aligned} \u0026 y[] = x[] = \\{0000, 0001, 0010, 0011, 0100 , \\cdots \\} \\\\ \\end{aligned} $$其中某个点用x和y的二进制交替表示。假设将二进制的每一位记为$x_i$即$x = x_4x_3x_2x_1$。那么对于$x = x_4x_3x_2x_1, y = y_4y_3y_2y_1$的点的二进制表示就是\n$$ p(x, y) = y_4x_4y_3x_3y_2x_2y_1x_1 $$$R^3$下就是先x后y再z的交替表示。\n使用这种表示方式的好处是：连续的$p(x, y)$值在空间上表示一小团（一簇）空间中的点。\n比如看上图中第一个大格子（第一个$Z$），这个格子里面存储四个小点，他们的值是连续的$0000 \\sim 0011$。\n你也可以按照4个格子（4个$Z$，即16个点）为一簇进行划分。那么以左上角8个为例，他们就是$0000 \\sim 1111$。其实只要定下最高的非0位，那么其下的所有格子就都是连续的。比如定下最高位是第4位，那么$1000 \\sim 1111$的所有格子就能组成一个簇。也就是说Morton码以$2^n$为边界分割簇。\n以往格子的存储都是以矩阵方式，按行或列存储。而Morton码则将格子按簇存储。\n那么在确定了$x, y$之后，如何计算两者交错的Morton Code呢？有两种方法：\n朴素法：硬算，将$x,y$位交替拼接 Spread-And-Merge：先把单个轴的位摊开（每隔1位插一个0，$R^3$下是每隔2位插一个0），再把各轴的结果或起来： // 2D: uint64_t Part1By1(uint32_t x) { x = (x | (x \u0026lt;\u0026lt; 16)) \u0026amp; 0x0000FFFF0000FFFF; x = (x | (x \u0026lt;\u0026lt; 8)) \u0026amp; 0x00FF00FF00FF00FF; x = (x | (x \u0026lt;\u0026lt; 4)) \u0026amp; 0x0F0F0F0F0F0F0F0F; x = (x | (x \u0026lt;\u0026lt; 2)) \u0026amp; 0x3333333333333333; x = (x | (x \u0026lt;\u0026lt; 1)) \u0026amp; 0x5555555555555555; // 位已被均匀摊到偶位 return x; } uint64_t EncodeMorton2(uint32_t x, uint32_t y) { return (Part1By1(y) \u0026lt;\u0026lt; 1) | Part1By1(x); } // 3D: uint32_t Part1By2(uint32_t x) { x \u0026amp;= 0x3FF; // 由于uint32_t限制，x,y,z分别最多只能取10位 x = (x | (x \u0026lt;\u0026lt; 16)) \u0026amp; 0xFF0000FF; x = (x | (x \u0026lt;\u0026lt; 8)) \u0026amp; 0x0300F00F; x = (x | (x \u0026lt;\u0026lt; 4)) \u0026amp; 0x030C30C3; x = (x | (x \u0026lt;\u0026lt; 2)) \u0026amp; 0x09249249; // 摊到位置 0,3,6,...,27 return x; } uint32_t EncodeMorton3(uint32_t x, uint32_t y, uint32_t z) { return (Part1By2(z) \u0026lt;\u0026lt; 2) | (Part1By2(y) \u0026lt;\u0026lt; 1) | Part1By2(x); } 用了一些老八秘制位运算小技巧。\nLBVH构建 # 而LBVH构建的第一步就是：\n将所有物体的BV合并变成大BV，然后将这个BV在各轴上划分成10x10x10的网格（如果你是uint64_t那可以20x20x20，取决于你的数据宽度）。 然后看各个物体的BV中心落在哪个网格内，并对其进行Morton编码。 然后对所有MortonCode进行排序。这里十分推荐使用基数排序(RadixSort)，有如下理由：\n性能高：复杂度$O(n)$。由于我们这里的二进制位数固定（假设为uint32_t），PBRT里面是64个桶($2^6$)，每次放6位进去排，排6次全部排完。 稳定排序：这样跨平台/跨线程的排序结果是确定的。 接下来按Morton Code进行二分拆分以构建两颗子树。从最高位往最低位进行切分。由于每个二进制位只有0和1，那么为0的所有元素就可以放到左节点，而1的就放到有节点咯。\n比如说，现在有元素：\n$$ \\begin{aligned} \u0026 A = (0, 0) = 0000 \\\\ \u0026 B = (1, 0) = 0001 \\\\ \u0026 C = (0, 1) = 0010 \\\\ \u0026 D = (3, 0) = 0101 \\\\ \u0026 E = (2, 2) = 1010 \\\\ \u0026 F = (3, 2) = 1111 \\\\ \\end{aligned} $$ 那么从最高位开始，首先将第四位按0和1分开，那么$A, B, C, D$就在左子树，$E,F$就在右子树。 接下来对剩下的左右子树，看第三位。那么$B,C$就是一边，$D$ 是另外一边。$E$和$F$也被分开，以此类推：\n这看似是一个二分的递归方式。但LBVH之所以叫Linear BVH，就是因为本质上所有Morton Code都在一个数组里面，这里的切分也只是在这个数组里面找不同的区间而已，不需要搬运任何的元素。在LBVH的论文中5使用了一种并行方式快速构建：\n首先可以并行地算$\\delta_i$数组：\n$$ \\begin{aligned} \u0026 \\delta(i, j) = \\operatorname{clz}(MortonCode_{i} \\text{ XOR } MortonCode_j) \\end{aligned} $$ 这里$\\operatorname{clz}$是“找到二进制数字中最高位1前面还有多少个0”。所以这里是对任意两个Morton Code做异或，然后找最高位1前还有多少个0.\n然后定义$d$：\n$$ d(i) = \\text{sign}(\\delta(i, i+1) - \\delta(i, i-1)) $$即比较第$i$个Morton code和他相邻的码的$\\delta$谁更长。\n算法的步骤如下：\n首先对于任意的$i$，他都可以构成一颗节点：\n首先找到对应的$d(i)$，如果$d(i) \\gt 0$，那么此时找到节点的左端点，我得往右找。否则往左找。 假设往右找。那么不停地找满足$\\delta(i, k) \\gt \\delta_{min}, \\delta_{min} = \\delta(i, i-d)$的$k$。这样就找到此节点的右端。（左端同理）。这样左右两端的物体就都在同一个节点中。 接下来要找到此节点的二分位置。$\\delta(i, k)$是整个节点的容纳长度，这里在当前节点范围内找到最大的$s$满足$\\delta(i, i+sd) \\gt \\delta(i, k)$（这里可用二分查找加快效率）。这样分裂点$\\gamma = i + s\\cdot d + \\min(d, 0)$就找到了 如果整个Morton code的个数是$n$，那你完全可以开$n-1$个线程并行地去执行这个过程，那么很简单就能找到所有节点了。\n但可以看到这里等于是不停地二分节点，所以最后叶子节点中只会包含一个物体。这比起SAH方式会产生更多的节点。但这种大规模并行很有利于GPU去做，所以LBVH一般是在GPU上做。\n伪代码在论文5中都写的很清楚了。我就不再写实现了。游戏物理引擎中除非你要将BVH搬到GPU上，一般也不会实现这个。能实现基于SAH的就行了。\nHLBVH # 融合了Binned SAH和LBVH两个做法。\n在对Morton Code排序完之后，不是直接进行二分拆解，而是选定簇，对每一簇并行构建当前的BVH节点。PBRT中是用高12位相同的morton code作为一簇：\nmask = 0b00111111111111000000000000000000; // 高 12 位 for (start=0, end=1; end \u0026lt;= n; ++end) { if (end == n || (morton_codes[start] \u0026amp; mask) != (morton_codes[end] \u0026amp; mask)) { treelets.push_back({start, end - start}); // 一个簇 = [start, end) start = end; } } 某一簇我们称为$treelet_i$。在确定好簇之后，再在簇内再用LBVH构建法。\n在分完$treelet_i$之后，各个个$treelet_i$内的二分就可以并行执行了。\n那么每个$treelet_i$最后都会构建一颗属于自己的独立的树。这时可以将所有$treelet_i$视为独立的元素（独立的BV），这样问题就变成如何对这些BV进行BVH构建。这里可以使用Binned SAH构建法。\n这个算法的好处是：\n可以利用LBVH的并行 构建更好的BVH：因为越靠近树顶的BV范围越大，越决定BVH的质量。相比于LBVH简单粗暴的二分，使用SAH可以在这方面更好地提高树质量 Incremental BVH # 增量式BVH。上面说的BVH一般都用于纯静态场景，构建一次即可。在游戏物理引擎中一般用于存储静态物（但当静态物增加/删除时仍旧得重建BVH）。但物理引擎中还有很多的动态物，他们的位置时时刻刻都在改变。所以我们必须有一种增量式构建BVH的方法。\n在我的演示中（文末有），在有一万个物体的情况下，增加一个物体的耗时大概是0.07~0.1ms左右（但是我没做树旋转操作）。\nErin Catto在他的GDC分享中1中有说到三种处理移动物体的BVH构建：\nRefit ancestores（重适配）6：当物体移动，将其重新挪到新节点下，或扩大/缩小父节点及祖先节点。但这样很慢，而且可能导致整颗BVH树的质量越来越低（可能导致节点之间重叠区域越来越多） Rebuild subtrees（重构建子树）：很慢，不推荐 Remove/Re-insert（删了然后再插入）：也很慢，但是可以将时间摊平到多帧（每帧重新插入一点物体） 所以他推荐第三种方法。\n在已有的BVH上插入新物体的一种方式如下（PhysX版）：\n从树顶往下，不停地找节点BV中心离新物体BV中心最近的那个节点。一直走到叶子节点 如果叶子节点中物体没到上限，就将此物体放进去，并且更改此叶子节点和其所有父节点的BV 如果到达上限，就将物体加入，然后对此叶子节点做一次BVH构建，以将其分裂成两个新节点 Goldsmith-Salmon算法7则提出了一种启发式的选择子节点的方法：\n其原理其实就是SAH的原理：一条随机射线与一个包围盒相交的概率 ≈ 两者表面积之比（后来被 MacDonald \u0026amp; Booth 1990 形式化为 SAH）。所以插入时应该优先最小化树中各处包围盒表面积的膨胀——盒子越紧凑，射线误入该子树的概率越低。\n具体做法如下，对树中每个候选插入物体$N$计算：\n节点的表面积增量$\\Delta S_A$：即将$N$放入此节点后此节点表面积的增量 射线访问此节点的概率：$P = \\frac{S_A(N)}{S_A(Root)}$，为当前节点的表面积（未插入N）比上根节点的表面积 那么$cost = \\Delta S_A \\cdot P$。每次都选取$cost$最小的那个节点。\n可以看到这比PhysX的做法要慢上一些。\n树的旋转 # 对于树插入，显然可能会导致树退化成链表的情况。这个时候就可以用树旋转8来将树转成更平衡的树。\n其实就是AVL或红黑树里面的旋转操作，原封不动地套用到BVH来了。\n只是旋转之后的子树要重新算其父节点/祖先节点的BV。\n而决定到底转不转是用SAH：\n每个候选旋转（有四种，见Catto的GDC分享9）都算旋转前/后的 SAH 成本（该局部子树的表面积和） 只有当成本变小才执行，否则放弃 扫描方式是自底向上遍历整棵树，对每个内部节点尝试四种旋转配置，可以反复多轮直到不再改进。 论文8中说最好做旋转，旋转之后无论树来自SAH还是LBVH构建，旋转之后都能显著降低SAH成本，提升Raycast性能。\n而PhysX则是在插入节点时，看两个子节点的体积比是否大于3。如果大了就在插入之后做一次旋转。\n低移动物体的优化 # 如果物体移动速度较低，那么通常不会移动到很远的地方。所以Erin Catto的建议是给每个物体一个更大的BV。在构建BVH时用这个大BV，而当物体本身BV移出这个大BV时，才重新走BVH构建。\n何时构建BVH # 由于BVH一般用于SceneQuery，所以当增加/删除/移动物体时，将物体Pending下来，只有当有任意SceneQuery发起的时候，才重新构建树或增量构建树。\nPhysX甚至有一套算法，可以将构建树的操作摊平到多帧，以防止一帧内突然出现卡顿。\n演示 # 进去后左边Example列表中往最下面翻，有三个例子：\nBVH：拥有一万个物体的Binned BVH构建 Interactive BVH：可以从头开始一步一步增加物体以进行Binned BVH构建 Increasement BVH：从头一步一步开始增加物体或生成一堆物体以进行Increasement BVH构建（未实现树旋转） 每次重做还会在右边面板显示重做时间。下方的BVH树结构，点击节点会在场景中绘制出当前BVH节点的BV，以及高亮其容纳的物体。 BVH Building\nErinCatto: DynamicBVH_Full\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nPBRT: Bounding Volume Hierarchies\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n《On fast Construction of SAH-based Bounding Volume Hierarchies》(RT 2007)\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nZ-order curve - Wikipedia\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nMaximizing Parallelism in the Construction of BVHs, Octrees, and k-d Trees\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nWald, Boulos, Shirley 2007《Ray Tracing Deformable Scenes Using Dynamic BVHs》\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n《Automatic Creation of Object Hierarchies for Ray Tracing》\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nAndrew Kensler, \u0026ldquo;Tree Rotations for Improving Bounding Volume Hierarchies\u0026rdquo;, IEEE Symposium on Interactive Ray Tracing (RT) 2008\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nTen Minute Physics\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"31 August 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E5%85%AB/","section":"Posts","summary":"本文述说了在SceneQuery场景下的加速结构BVH。\n本文是SceneQuery的最后一篇文章。至此，所有的SceneQuery技术都已叙述完毕。\n当然还有其他空间划分方法（SAP，OctTree，K-DTree等）。但这些一般用于模拟时的空间划分，等到模拟章节再说。\n绝大部分理论来自于Erin Catto的GDC分享1（建议阅读）和PBRT2，而实现则参考PhysX5.4.0。\nBVH构建更多的理论来源于渲染中光线追踪方向。其中有很多乱七八糟的BVH构建（在CPU上，或使用并行计算，或在GPU上构建）。本文不说那么多乱七八糟的，只说常用的方法。\n","title":"从0开始制作游戏物理引擎（八）","type":"posts"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/tags/%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E/","section":"Tags","summary":"","title":"物理引擎","type":"tags"},{"content":"","date":"31 August 2026","externalUrl":null,"permalink":"/tags/%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91/","section":"Tags","summary":"","title":"游戏开发","type":"tags"},{"content":"本章说明了射线检测Raycast算法。\n大部分Raycast都是使用向量方程的数值解直接得出，这种的公式大全1都有记载不再赘述。\n本文主要叙述：\n射线与凸多面体求交的Slab方法(Cyrus-Beck, Liang-Barsky和Kay-Kajiya) Gino编写的对任意凸体的Raycat算法的论文2解释。此算法也用来做任意凸体之间的Sweep算法。 射线与三角形求交 # 使用Möller–Trumbore算法3，源于《Fast Minimum Storage Ray-Triangle Intersection》：\n一般的做法是：\n求射线与三角形所在平面的交点$p$ 看$p$在不在三角形内（可使用重心坐标法） 而Möller–Trumbore算法等于是将两步并做一步，并且论文中给了实现，此实现将所有的变量延后到需要计算的时候才算，并且给出了剔除三角形背面相交和双面三角形相交的。论文说对于TriangleMesh，可以减少25%~50%的内存（取决于顶点共享形式）。此算法可以同时得到交点和交点在三角面上的UV。\n对于射线$O + tD$以及三角形$V_1,V_2,V_3$来说，其公共点为（三角形用重心坐标表示）： $$ O + tD = (1 - u - v)V_0+ uV_1 + vV_2 $$ 用矩阵表示为： $$ \\begin{bmatrix} -D \u0026 V_1 - V_0 \u0026 V_2 - V_0 \\end{bmatrix} \\begin{bmatrix} t \\\\ u \\\\ v \\end{bmatrix} = O - V_0 $$然后令$E_1 = V_1 - V_0, E_2 = V_2 - V_0, T = O - V_0$。根据Cramer法则4可得： $$ \\begin{bmatrix} t \\\\ u \\\\ v \\\\ \\end{bmatrix} = \\frac{1}{\\begin{vmatrix}-D \u0026 E_1 \u0026 E_2\\end{vmatrix}} \\begin{bmatrix} \\begin{vmatrix} T \u0026 E_1 \u0026 E_2 \\end{vmatrix}\\\\ \\begin{vmatrix} -D \u0026 T \u0026 E_2 \\end{vmatrix}\\\\ \\begin{vmatrix} -D \u0026 E_1 \u0026 T \\end{vmatrix} \\end{bmatrix} $$ 又由线性代数中: $$ \\begin{vmatrix} A \u0026 B \u0026 C \\end{vmatrix} = -(A\\times C)\\cdot B = -(C \\times B)\\cdot A $$ 可以将上式变化为： $$ \\begin{bmatrix} t \\\\ u \\\\ v \\\\ \\end{bmatrix} = \\frac{1}{(D\\times E_2)\\cdot E_1} \\begin{bmatrix} (T\\times E_1)\\cdot E_2 \\\\ (D\\times E_2)\\cdot T \\\\ (T\\times E_1)\\cdot D \\\\ \\end{bmatrix} = \\frac{1}{P \\cdot E_1} \\begin{bmatrix} Q\\cdot E_2 \\\\ P\\cdot T \\\\ Q\\cdot D \\\\ \\end{bmatrix} $$ 这里$P = (D\\times E_2), Q = T \\times E_1$。\n之后就是使用这些变量进行计算。论文中给出了详细代码，十分通俗易懂，这里就不再细说了。\n注：剔除背面的算法中，默认规定在右手系下，顶点顺时针（CW）绕序的一侧是背面。\n射线与AABB求交 # 其实是图形学里面的方法，用于做线段裁切的。但在物理引擎求线段/射线/直线和凸体交点也很好用。\n本文一共有如下算法：\n由Cyrus-Beck在Generalized Two- and Three-Dimensional Clipping中编写的通用凸体裁剪方法 Kay-Kajiya于1986年在SIGGRAPH上发布的Ray Tracing Complex Scenes中提出的基于Cyrus-Beck算法的优化版本5 而Kay-Kayjiya算法由Amy Williams在An Efficient and Robust Ray–Box Intersection Algorithm6中提出一种更高效更鲁棒的实现，Github上也有代码7 下文中使用的定义：\n定义射线为$\\vec{s} + \\vec{d}t$。其中$\\vec{s}$为起点，$\\vec{d}$为方向 半空间$H_i(\\vec{n}, \\vec{p})$：$\\vec{p}$为平面上一点，$\\vec{n}$为法向量 Cyrus-Beck算法 # 将凸体的各个面视为半空间（朝向内侧视为正半空间），于是凸体变为这些半空间$H_i(\\vec{n}, \\vec{p})$的交集。\n首先看射线射入半空间是“射入”还是“射出”。只需要看$\\vec{d}$与$\\vec{n}$是否同向。如果同向就是射入，反向就是射出。\n论文的思想是：通过裁切射线，只保留射入和射出这一段，对所有半空间做完后，如果射线没有全被裁切完，那显然就和凸体相交。\n首先计算射线和半空间平面的所有交点，以射入和射出进行分类。但由于交点都在射线上，那么只需求得$t$即可，于是有 $$ t_{in_i},t_{out_i} $$ 那么显然，我最后裁切的线段一定满足$t \\in [\\max {t_{in_i}}, \\min {t_{out_i}}]$。\n那么只要 $$ \\max \\{t_{in_i}\\} \\lt \\min \\{t_{out_i}\\} $$ 就说明射线和凸体相交了。\n而交点则是$\\max {t_{in_i}}$和$\\min {t_{out_i}}$这两个$t$所在点。\n真正实现中有两个提前退出算法的小技巧：\n只要发现任意$t_{in_i} \\ge t_{out_i}$就直接退出，不需要真的算出全部$t$ 当射线起点在平面外侧，且方向远离平面，说明射线永远不可能射入平面，那么肯定不相交，直接返回 先判断点是否在平面半空间$H(\\vec{n}, \\vec{p})$外：$(\\vec{d} - \\vec{p}) \\cdot \\vec{n} \\le 0$ 再看方向是否远离：$\\vec{d} \\cdot \\vec{n} \\le 0$ Kay-Kajiya算法和Williams的无分支版本 # 是Cyrus-Beck算法在3D空间中对盒体的特化。现在广泛用于图形学光线追踪中计算射线和BVH的交点（物理这边也会用到BVH，关于BVH中使用Kay-Kajiya算法可以看这个教程8）。\n由于提出Slab概念，又叫Slab算法。\n算法中只考虑射线与AABB盒子相交。与OBB相交可以通过仿射变换变成与AABB相交问题。\nSlab就是：盒体的一对对面所夹的空间。所以盒体由三个slab的交集构成，每个轴各有两个垂直于此轴的Slab。我们称沿着负轴更的Slab为$Slab^-$，而沿着正轴更远的那个为$Slab^+$。\n其算法理论就是Cyrus-Beck的理论，而其在Cyrus-Beck算法上的优化是工程上的。假设AABB盒子的中心是$\\vec{c}$，半长为$\\vec{hl}$。我们不需要再通过计算判断射线是射入面还是射出面：对于一对Slab，只需看$\\vec{c} - \\vec{s}$的$x,y,z$分量的符号，如果是正的，表示是从$Slab^-$射入$Slab^+$射出。负的就是反过来。\nKay-Kajiya算法的性能上的突破在于一条射线和许多AABB判交的场景下。\n接下来看一下Williams的实现7。注意：Williams论文中并没有给出SIMD写法，他只是提了一嘴说可以利用SIMD。但PhysX，JoltPhysics中都有SIMD版本。SIMD版本主要用于一条射线和多个AABB（一般是4个因为SSE指令只有4-lane）求交，一般用于BVH空间划分算法而非Raycast中（这个后面要用到再看吧，裸SIMD指令看着也费劲）。如果只和一个AABB求交一般还是用标量版本。\n首先是Ray的实现，里面缓存了一些信息：\nclass Ray { public: Ray() { } Ray(Vector3 o, Vector3 d) { origin = o; direction = d; // 注意：这里没判断d的各个分量是不是0，这是故意的！ // 这是Williams实现的核心设计：如果分量为0，结果是+/-inf（IEEE754标准规定） inv_direction = Vector3(1/d.x(), 1/d.y(), 1/d.z()); sign[0] = (inv_direction.x() \u0026lt; 0); sign[1] = (inv_direction.y() \u0026lt; 0); sign[2] = (inv_direction.z() \u0026lt; 0); } Ray(const Ray \u0026amp;r) { origin = r.origin; direction = r.direction; inv_direction = r.inv_direction; sign[0] = r.sign[0]; sign[1] = r.sign[1]; sign[2] = r.sign[2]; } Vector3 origin; Vector3 direction; Vector3 inv_direction; // direction中各分量的倒数 int sign[3];\t// direction中各分量的符号 }; 然后是判交算法：\n// Box只有一个成员变量Vector3 parameters[2]，其中parameters[0]存储min点，parameters[1]存储max点 // 这里参数t0和t1是射线自身的有效参数区间。通过这两个t可以处理所有直线类型的求交： // * 直线：t0 = -FLT_MAX, t1 = FLT_MAX // * 射线：t0 = 0, t1 = FLT_MAX // * 线段: t0 = 0, t1 = 1（或射线长度） bool Box::intersect(const Ray \u0026amp;r, float t0, float t1) const { float tmin, tmax, tymin, tymax, tzmin, tzmax; // 求射线到x轴Slab的t // 这里一行解决了三种情况： // 1. r.origin在左Slab左边或者右边，这里都能算得最小值而不用判断射线方向 // 2. 如果r.direction平行于面，那inv_direction.x()是+inf，那么： // 1. origin.x \u0026lt; min.x：tmin = +inf, tmax = +inf // 2. origin.x \u0026gt; max.x: tmin = -inf, tmax = -inf // 2. origin.x在slab内: tmin = -inf, tmax = +inf // 3. 如果r.direction.x()是inf，而乘法左边是0，那么tmin = tmax = NaN tmin = (parameters[r.sign[0]].x() - r.origin.x()) * r.inv_direction.x(); tmax = (parameters[1-r.sign[0]].x() - r.origin.x()) * r.inv_direction.x(); // 求射线到y轴Slab的t tymin = (parameters[r.sign[1]].y() - r.origin.y()) * r.inv_direction.y(); tymax = (parameters[1-r.sign[1]].y() - r.origin.y()) * r.inv_direction.y(); // 提前退出，无论值是inf, NaN还是正常值都能正确处理，可以自己试一试 if ( (tmin \u0026gt; tymax) || (tymin \u0026gt; tmax) ) return false; if (tymin \u0026gt; tmin) tmin = tymin; if (tymax \u0026lt; tmax) tmax = tymax; // 再求z轴Slab的t tzmin = (parameters[r.sign[2]].z() - r.origin.z()) * r.inv_direction.z(); tzmax = (parameters[1-r.sign[2]].z() - r.origin.z()) * r.inv_direction.z(); // 继续判断 if ( (tmin \u0026gt; tzmax) || (tzmin \u0026gt; tmax) ) return false; if (tzmin \u0026gt; tmin) tmin = tzmin; if (tzmax \u0026lt; tmax) tmax = tzmax; return ( (tmin \u0026lt; t1) \u0026amp;\u0026amp; (tmax \u0026gt; t0) ); } 这就是为什么说Williams的算法是鲁棒的，他可以处理任意的数值。\n任意凸体的Raycast和Sweep算法 # Gino在论文2中提出了一种基于GJK的，针对任意凸体的射线检测算法。此算法只能得到$t$最小的那个交点及对应法线（无法拿到射线穿出时的交点），但Sweep算法可以被转化成此算法（物理引擎中几乎所有的Sweep算法都是利用此算法实现）。\n为了和论文统一下面还是使用论文的符号。\n定义射线$R = { \\vec{s} + \\lambda \\vec{r} : \\lambda \\ge 0}$。其中$\\vec{s}$是起点，$\\vec{r}$是方向。\n用$C$表示任意凸体。注意凸体上某点的法线不一定是唯一的（比如凸多面体顶点处法线）。只有某个点$\\vec{p}$附近光滑（或者用分析的说法是可微）的此点的法线才是唯一的。\n通过法线定义可以得到如下方程： $$ \\vec{n} \\cdot (\\vec{x} - \\vec{p}) \\le 0, \\forall \\vec{x} \\in C $$ 那么反过来就是所有不在$C$中的点$\\vec{x}$可以定义为： $$ \\begin{aligned} \u0026 \\vec{n} \\cdot (\\vec{x} - \\vec{p}) \\le 0 \u0026 \u0026(1) \\end{aligned} $$ 那么将$\\vec{x}$换成射线上任意点$\\vec{x} = \\vec{s} + \\lambda \\vec{r}$，就可以得到既在射线上又不在$C$上的点的条件： $$ \\begin{aligned} \\lambda \\vec{n} \\cdot \\vec{r} \\gt \\vec{n} \\cdot (\\vec{p} - \\vec{s}) \u0026 \u0026 (2) \\end{aligned} $$ 显然，射线与$C$的交点也满足这个方程，或者说的更明确一些：\n如果$\\vec{n} \\cdot \\vec{r} \\gt 0$，那么所有满足$\\lambda \\gt \\frac{\\vec{n}\\cdot(\\vec{p} - \\vec{s})}{\\vec{n}\\cdot \\vec{r}}$的点就都不是交点。此时$\\frac{\\vec{n}\\cdot(\\vec{p} - \\vec{s})}{\\vec{n}\\cdot \\vec{r}}$是$\\lambda$的上界 如果$\\vec{n} \\cdot \\vec{r} \\lt 0$，那么所有满足$\\lambda \\lt \\frac{\\vec{n}\\cdot(\\vec{p} - \\vec{s})}{\\vec{n}\\cdot \\vec{r}}$的点就都不是交点。此时$\\frac{\\vec{n}\\cdot(\\vec{p} - \\vec{s})}{\\vec{n}\\cdot \\vec{r}}$是$\\lambda$的下界 如果$\\vec{n} \\cdot \\vec{r} = 0$且$\\vec{n} \\cdot (\\vec{p} - \\vec{s}) \\lt 0$，那么这条射线就不和$C$相交 然后Gino又使用他的经典逼近思路：通过不停地裁切$\\lambda$可能取值的区间，将交点不可能存在的区间从中移除，直到找到交点。逼近的方法如下：\n假设现在的$\\lambda$下界是$\\lambda_i$，那么有点$\\vec{x_i} = \\vec{s} + \\lambda_i \\vec{r}$。此时$\\lambda \\in [0, \\lambda_i)$的所有点就被排除了。然后令$\\vec{c_i} \\in C$是距离$\\vec{x_i}$的最近点。那么显然：\n如果$\\vec{c_i} = \\vec{x_i}$，那么最近点就是$\\vec{c_i}$（算法结束条件）\n如果$\\vec{c_i}$只是$C$边界上一点，那么$\\vec{n_i} = \\vec{x_i} - \\vec{c_i}$是$\\vec{c_i}$这一点的法线。那么满足$(2)$式： $$ \\begin{aligned} \u0026 \\lambda \\vec{n_i} \\cdot \\vec{r} \\gt \\vec{n_i} \\cdot (\\vec{c_i} - \\vec{s}) \\\\ \u0026 \\lambda \\vec{n_i} \\cdot \\vec{r} \\gt \\lambda_i \\vec{n_i} \\cdot \\vec{r_i} - ||\\vec{n_i}^2|| \\\\ \\end{aligned} $$ 那么：\n如果$\\vec{n_i} \\cdot \\vec{r} = 0$，那么射线就不和$C$相交（因为不等式右边只有$-||\\vec{n}_i||^2 \\le 0$恒成立）。\n如果$\\vec{n}\\cdot\\vec{r} \\ne 0$。令$\\lambda_{i+1} = \\lambda_i - \\frac{||\\vec{n}_i||^2}{\\vec{n_i}\\cdot \\vec{r}}$。此时：\n若$\\vec{n} \\cdot \\vec{r} \\gt 0$，显然$\\lambda_{i+1} \\lt \\lambda_i$，那么根据假设$\\lambda \\in [0, \\lambda_i)$中的点都不是交点，所以$\\lambda_{i+1}$早已被剔除，此时射线和$C$就没有交点。 若$\\vec{n} \\cdot \\vec{r} \\lt 0$，显然$\\lambda_{i+1} \\gt \\lambda_i$，那么$\\lambda_{i+1}$等于是更大的$\\lambda$下界，这样就增大了下界。 那么就可以通过不停迭代$\\lambda_i$来不停增大下界，直到$\\vec{x_i} = \\vec{c_i}$找到交点或判断根本不相交。\n于是算法流程就是： $$ \\begin{aligned} \u0026 \\lambda = 0 \\\\ \u0026 \\vec{x} = \\vec{s} \\\\ \u0026 \\vec{n} = \\vec{0} \\\\ \u0026 \\vec{c} = \\text{\"the point of C closest to }\\vec{x}\\text{\"} \\\\ \u0026 \\text{while not \"}\\vec{x} \\text{ is not close enough to }\\vec{c}\\text{\" do} \\\\ \u0026 \\text{begin} \\\\ \u0026 \\quad \\vec{n} = \\vec{x} - \\vec{c} \\\\ \u0026 \\quad \\text{if }\\vec{n} \\cdot \\vec{r} \\ge 0 \\text{ then return false} \\\\ \u0026 \\quad \\text{else} \\\\ \u0026 \\quad \\text{begin} \\\\ \u0026 \\qquad \\lambda = \\lambda - \\frac{||\\vec{n}||^2}{\\vec{n}\\cdot \\vec{r}} \\\\ \u0026 \\qquad \\vec{x} = \\vec{s} + \\lambda \\vec{r} \\\\ \u0026 \\qquad \\vec{c} = \\text{\"the point of C closest to }\\vec{x}\\text{\"} \\\\ \u0026 \\quad \\text{end} \\\\ \u0026 \\text{end} \\\\ \\end{aligned} $$ 原论文随后证明了$\\lambda_i \\lt \\lambda_{i+1} \\le \\lambda_{hit}$是全局收敛的充要条件。这里就不写了。\n$\\vec{x_i}$足够接近$\\vec{c_i}$的条件可以使用他两的距离去判断，而每轮迭代中找到$\\vec{c}$就可以使用GJK。\n但每轮迭代都要从头算GJK，这是一个很慢的事。Gino随后将GJK与这整个算法合并得到一个更高效的形式：\n首先，$\\vec{n_i} = \\vec{x_i} - \\vec{c_i}$可以改成用Minkowski差和支撑映射的计算方式：$\\vec{n_i} = v({\\vec{x_i}} - C)$。\n然后，可以看到原算法并不能给出点$\\vec{c_i}$处的法线（他只是靠法线逐渐逼近）。但我们可以通过每一步迭代出的$\\vec{v}_k$逐渐逼近$\\vec{n_i}$。此处$\\vec{v_k}$是Simplex上距离原点最近点，那么通过此点找到的支撑点记为$\\vec{p_k} = s_C(\\vec{v_k})$。显然，$\\vec{v_k}$是$\\vec{p_k}$的法线，因为其满足$(1)$式中对法线的定义$\\vec{v_k} \\cdot (x - \\vec{p_k}) \\le 0$。那么将其带入$(2)$式就可以得到被排除的点的新条件： $$ \\lambda \\vec{v_k}\\cdot \\vec{r} \\gt \\vec{v_k} \\cdot (\\vec{p_k} - \\vec{s}) $$ 令$\\vec{x_i} = \\vec{s} + \\lambda_i \\vec{r}$是目前最大下界，那么将$\\vec{s} = \\vec{x_i} - \\lambda_i \\vec{r}$带入上式可得： $$ \\begin{aligned} \u0026 \\lambda \\vec{v_k}\\cdot \\vec{r} \\gt \\lambda_i \\vec{v_k}\\cdot \\vec{r} -\\vec{v_k}\\cdot \\vec{w_k} \\\\ \u0026 其中\\vec{w_k} = s_{\\{x_i\\} - C}(-\\vec{v_k}) = \\vec{x_i} - \\vec{p_k} \\end{aligned} $$ 那么如法炮制对$\\vec{v_k}\\cdot \\vec{r}$进行分析：\n$\\vec{v_k}\\cdot \\vec{r} = 0$且$\\vec{v_k}\\cdot \\vec{w_k} \\gt 0$，那么射线无交点 $\\vec{v_k}\\cdot \\vec{r} \\gt 0$那么$\\lambda_i - \\frac{\\vec{v_k}\\cdot \\vec{w_k}}{\\vec{v_k}\\cdot \\vec{r}}$同样位于不相交区间。那么此时射线无交点 $\\vec{v_k}\\cdot \\vec{r} \\lt 0$那么$\\lambda_i - \\frac{\\vec{v_k}\\cdot \\vec{w_k}}{\\vec{v_k}\\cdot \\vec{r}}$就是新下界。 那么现在算法就变成（这里要对照GinoGJK的论文过程看一下可能才能看明白怎么融合的）： $$ \\begin{aligned} \u0026 \\lambda = 0 \\\\ \u0026 \\vec{x} = \\vec{s} \\\\ \u0026 \\vec{n} = \\vec{0} \\\\ \u0026 \\vec{v} = \\vec{x} - \\vec{p}, \\text{any } \\vec{p} \\in C \\\\ \u0026 P = \\emptyset \\\\ \u0026 \\text{while } ||\\vec{v}||^2 \\gt \\epsilon^2 \\text{ do} \\\\ \u0026 \\text{begin} \\\\ \u0026 \\quad \\vec{p} = s_C(\\vec{v}) \\\\ \u0026 \\quad \\vec{w} = \\vec{x} - \\vec{p} \\\\ \u0026 \\quad \\text{if } \\vec{v}\\cdot \\vec{w} \\gt 0 \\text{ then} \\\\ \u0026 \\quad \\text{begin} \\\\ \u0026 \\qquad \\text{if } \\vec{v} \\cdot \\vec{r} \\ge 0 \\text{ then return false} \\\\ \u0026 \\qquad \\text{else} \\\\ \u0026 \\qquad \\text{begin} \\\\ \u0026 \\qquad \\quad \\lambda = \\lambda - \\frac{\\vec{v}\\cdot \\vec{w}}{\\vec{v} \\cdot \\vec{r}} \\\\ \u0026 \\qquad \\quad \\vec{x} = \\vec{s} + \\lambda \\vec{r} \\\\ \u0026 \\qquad \\quad \\vec{n} = \\vec{v} \\\\ \u0026 \\qquad \\text{end} \\\\ \u0026 \\quad \\text{end} \\\\ \u0026 \\quad Y = P \\cup \\{\\vec{p}\\} \\\\ \u0026 \\quad \\vec{v} = v(\\operatorname{co}(\\{\\vec{x}\\} - Y)) \\\\ \u0026 \\quad P = \\text{\"smallest }X \\subseteq Y \\text{ such that } \\vec{v} \\in \\operatorname{co}(\\{\\vec{x}\\} - X) \\\\ \u0026 \\text{end} \\\\ \u0026 \\text{return true} \\\\ \\end{aligned} $$ 论文最后提到还能做如下优化：\n原始GJK中，同一支撑点在多次迭代中有可能重复出现（ill condition），而本算法不能再依赖于这个检查。Gino在32位单精度实现中发现不需要再进行这个检查。 在Johnson子距离算法中，行列式$\\Delta_i(Y)$可能由于数值精度问题出错。此时就直接退出循环，将当前算出的$\\vec{x}$作为结果返回（类似GinoGJK中的处理方法） 终止容差$\\epsilon$（第6行的那个）可以用相对容差$||v_k|^2 \\le \\epsilon_{tol} \\max {||\\vec{x} - \\vec{p}||^2: \\vec{p} \\in P_k}$，其中$\\epsilon_{tol}$是std::numeric_limits\u0026lt;float\u0026gt;::epsilon() * 10。 算法可以提前退出：在Raycast中射线都是有长度的，一旦$\\lambda$超过长度$\\lambda_{max}$就直接退出算法，表示无交点 帧间相关性：判断不相交时返回的$\\vec{v}$是射线和$C$的分离轴，若物体连续移动，则可以作为下帧的热启动（这在连续碰撞检测中有用）。注意此时$\\vec{v_0}$不一定在$\\vec{x_0} - C$内，所以得跳过第一轮的$while$判断。 此算法也可以作为两个物体$C_1, C_2$的Sweep的算法。显然，此问题可以转化为以原点为起点，方向为$\\vec{d}$的射线与$C_1 - C_2$物体的射线检测。 而绝大部分物体的Sweep算法都是此算法（除了有数值解的球和球，球和胶囊）。\n在线演示 # 下面是目前文章提到的各种查询方法演示：\nGeometric Tools: About Geometric Tools for Computer Graphics\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nRay Casting against General Convex Objects with Application to Continuous Collision Detection\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nMöller–Trumbore intersection algorithm - Wikipedia\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nCramer\u0026rsquo;s rule - Wikipedia\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nKay-Kajiya: Ray Tracing Complex Scenes\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nAn Efficient and Robust Ray–Box Intersection Algorithm\u0026#160;\u0026#x21a9;\u0026#xfe0e;\njgt-code/Volume_10/Number_1/Williams2005/box.cc at master · erich666/jgt-code\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nBounding Volume Hierarchy: BVH (part 1)\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"27 August 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E4%B8%83/","section":"Posts","summary":"本章说明了射线检测Raycast算法。\n大部分Raycast都是使用向量方程的数值解直接得出，这种的公式大全1都有记载不再赘述。\n本文主要叙述：\n射线与凸多面体求交的Slab方法(Cyrus-Beck, Liang-Barsky和Kay-Kajiya) Gino编写的对任意凸体的Raycat算法的论文2解释。此算法也用来做任意凸体之间的Sweep算法。 ","title":"从0开始制作游戏物理引擎（七）","type":"posts"},{"content":"本文解释了EPA算法及其常见实现。\nEPA算法由Gino van den Bergen首次提出于其论文1。作为基于GJK的，用于找到穿透深度的扩展算法。\n临近查询(Proximity Query) # 表示对两个物体进行如下查询：\n找到最近距离和最近点对 判断两物体是否相交 找到穿透深度，以及在穿透深度下的一对可视点 找到一个公共点 其中1，2可以直接通过GJK得到。而3，4则需要使用EPA\nEPA(Extended Polytope Algorithm) # 扩展多边形算法。意为在GJK输出的Simplex上加以扩展。\n此算法十分简单。其输入和GJK一样，只需要两个物体的支撑映射。\n首先，Simplex内必须包含原点（显然成立，因为相交时原点在Simplex内），然后不断地找到距离原点最近的面$F$中的最近点$\\vec{v}$。用$\\vec{v}$的方向作为支撑映射方向找到支撑点$S_{A - B}(\\vec{v})$。然后用此支撑点扩展Simplex：\n图中最外圈的是整个CSO。而Simplex从最开始的BCE逐渐“膨胀”，直到最近面已经膨胀到不能再膨胀了（$S_{A-B}(\\vec{v})$落在现有面上）。此时$\\vec{v}$就是分离向量，而其长度就是穿透深度。\n但显然，对于二次曲面，必须像GJK一样使用容差让算法停止：\n显然，EPA总是给出下界$|v|$。而上界则是真正的CSO到原点的最近距离。在每次迭代中也就是$\\frac{v \\cdot w}{|v|}$。于是和GJK一样，我们可以用上下界逼近的方式让算法停止： $$ \\begin{aligned} \u0026 \\frac{v \\cdot w}{|v|} - |v| \\lt \\epsilon \\\\ \u0026 \\Rightarrow \\\\ \u0026 v \\cdot w - |v|^2 \\lt \\epsilon |v| \\end{aligned} $$ 这里$\\epsilon$是用户给定的容差。\n缝合(Suturing)/构造轮廓(Silhouette) # 指删除最近面后，使用剩下的面和顶点缝合成新Polytope的过程。\n大部分物理引擎中这一步叫Suturing（缝合面）。Gino的论文中叫Silhouette。\n这一步要注意的是，不能只利用删除的面剩下的三条边和顶点缝合。因为此时可能导致Simplex变成凹的。必须递归地看新点$\\vec{w}$是否在删除面$F$连接的各个面$F_i$内（假设$F_i$法向量朝外，那么$\\vec{w}$就得在$F_i$构成的负半空间内）。如果在正半空间，就必须也将$F_i$删掉，然后重复检查和其相邻的面。\n而且论文中还说，有时候会存在新点位于所在面的边上，此时需要找到边距离原点最近点来让边也分裂：\n但实际上Gino的实现中并没有做这个事情。如果遇到这种情况他就直接返回了。\nSOLID3的实现 # 函数位于SOLID32库中的penDepth。\nbool penDepth(const DT_GJK\u0026amp; gjk, const DT_Convex\u0026amp; a, const DT_Convex\u0026amp; b, MT_Vector3\u0026amp; v, MT_Point3\u0026amp; pa, MT_Point3\u0026amp; pb) { // 得到Simplex中的点。y是Simplex的点，p和q则是对应的原始点 int num_verts = gjk.getSimplex(pBuf, qBuf, yBuf); MT_Scalar tolerance = DT_Accuracy::tol_error * gjk.maxVertex(); num_triangles = 0; g_triangleStore.clear(); switch (num_verts) { case 1: // Simplex中只有一个点。两个物体是相贴，没有穿透深度 return false; case 2:\t{ // Simplex中只有一个线段。那么尝试增加新点将其构造成四面体 MT_Vector3 dir = yBuf[1] - yBuf[0]; dir /= length(dir); // furthestAxis：找到距离此向量最远的轴 // 其实就是找到分量最小的那个轴（分量越小，dir与此轴越垂直） // 0 - x axis, 1 - y axis, 2 - z axis int axis = dir.furthestAxis(); static const MT_Scalar sin_60 = sqrt(MT_Scalar(3.0)) * MT_Scalar(0.5); // 注意：四元数的sin是半角，这里实际是绕dir旋转120度 MT_Quaternion rot(dir[0] * sin_60, dir[1] * sin_60, dir[2] * sin_60, MT_Scalar(0.5)); MT_Matrix3x3 rot_mat(rot); // 首先得到距离此向量最远的轴，然后和dir叉乘 // 之所以要算最远的轴，是因为叉乘的结果最大，最不容易产生数值精度问题 MT_Vector3 aux1 = cross(dir, MT_Vector3(axis == 0, axis == 1, axis == 2)); // aux1绕dir旋转120度 MT_Vector3 aux2 = rot_mat * aux1; // aux1绕dir旋转240度 MT_Vector3 aux3 = rot_mat * aux2; // 上面三个aux其实是找到Simplex中线段垂直的平面上的三个均匀的方向 // 下面用这三个方向尝试找支撑点并鼓起Simplex为四面体 pBuf[2] = a.support(aux1); qBuf[2] = b.support(-aux1); yBuf[2] = pBuf[2] - qBuf[2]; pBuf[3] = a.support(aux2); qBuf[3] = b.support(-aux2); yBuf[3] = pBuf[3] - qBuf[3]; pBuf[4] = a.support(aux3); qBuf[4] = b.support(-aux3); yBuf[4] = pBuf[4] - qBuf[4]; // 检查原点是否在Simplex内（通过检查原点和各个面对面顶点是否在面同侧得到） // 分别检查新三点与原有线段两端点构成的四面体 if (originInTetrahedron(yBuf[0], yBuf[2], yBuf[3], yBuf[4]) == 0) { pBuf[1] = pBuf[4]; qBuf[1] = qBuf[4]; yBuf[1] = yBuf[4]; } else if (originInTetrahedron(yBuf[1], yBuf[2], yBuf[3], yBuf[4]) == 0) { pBuf[0] = pBuf[4]; qBuf[0] = qBuf[4]; yBuf[0] = yBuf[4]; } else { // 原点不在Simplex内，失败 return false; } num_verts = 4; } // Fall through allowed!! case 4: { // Simplex是四面体 // 先看原点是否在四面体内 // 此函数返回值： // 0 ：原点在四面体内 // 1~4：坏的那个顶点，即和原点不在同侧的那个点。比如1就是yBuf[0]是坏的 int bad_vertex = originInTetrahedron(yBuf[0], yBuf[1], yBuf[2], yBuf[3]); // 原点在四面体内 if (bad_vertex == 0) { // 构造四面体的四个面 // 当三角面退化成直线/点就会构造失败（通过Gram行列式发现线性相关时） // 构造的同时会计算面到原点的距离，以及面上距离原点最近的点（用重心坐标表示） Triangle *f0 = g_triangleStore.newTriangle(yBuf, 0, 1, 2); Triangle *f1 = g_triangleStore.newTriangle(yBuf, 0, 3, 1); Triangle *f2 = g_triangleStore.newTriangle(yBuf, 0, 2, 3); Triangle *f3 = g_triangleStore.newTriangle(yBuf, 1, 3, 2); // 这里有两种判断： // 1. 面f不能是空 // 2. 原点是否刚好落在面上（f-\u0026gt;getDist2() == MT_Scalar(0.0)，距离不会小于0） // 如果落在面上，意味着下一步的支撑点搜索方向是0向量，从而导致EPA死循环 if (!(f0 \u0026amp;\u0026amp; f0-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f1 \u0026amp;\u0026amp; f1-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f2 \u0026amp;\u0026amp; f2-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f3 \u0026amp;\u0026amp; f3-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0))) { return false; } // 构建四面体的拓扑结构：通过边将相离面连接起来 // 这里Edge的参数是面和边的其实点序号。比如Edge(f0, 0)就是取f0面的0, 1两个点构成的边 // 而Edge(f2, 2)就是取f2面的2, 0两个点构成的边 link(Edge(f0, 0), Edge(f1, 2)); link(Edge(f0, 1), Edge(f3, 2)); link(Edge(f0, 2), Edge(f2, 0)); link(Edge(f1, 0), Edge(f2, 2)); link(Edge(f1, 1), Edge(f3, 0)); link(Edge(f2, 1), Edge(f3, 1)); // 将面塞入优先队列（实现中是堆）中，以便于后面拿出距离原点最近的面 // 第二个参数是上界。当f距离原点的距离大于上界，不塞入队列 // 此外，面f到原点的最近点还必须严格在面上 addCandidate(f0, MT_INFINITY); addCandidate(f1, MT_INFINITY); addCandidate(f2, MT_INFINITY); addCandidate(f3, MT_INFINITY); break; } // 删除坏的点（这里是将第四个点替换坏的点），现在Simplex只含三个点 if (bad_vertex \u0026lt; 4) { pBuf[bad_vertex - 1] = pBuf[4]; qBuf[bad_vertex - 1] = qBuf[4]; yBuf[bad_vertex - 1] = yBuf[4]; } num_verts = 3; } // Fall through allowed!! case 3: { // 当Simplex只有三个点（只有一个三角面）时： // 使用面的正负法线找到支撑点，以构成一个含有五个点的双四面体 // 这是一个聪明的做法，原始GJK结束时，原点在数值上应该是得在三角面内。这时如果只是构建一个四面体， // 那么面到原点最近点向量就是0，这样就无法找到新的支撑点，算法会死循环。 // 那我不如直接构建一个六面体，原点肯定在此六面体内而且不再边上，就避免了这个问题。 MT_Vector3 v1 = yBuf[1] - yBuf[0]; MT_Vector3 v2 = yBuf[2] - yBuf[0]; MT_Vector3 vv = cross(v1, v2); pBuf[3] = a.support(vv); qBuf[3] = b.support(-vv); yBuf[3] = pBuf[3] - qBuf[3]; pBuf[4] = a.support(-vv); qBuf[4] = b.support(vv); yBuf[4] = pBuf[4] - qBuf[4]; Triangle* f0 = g_triangleStore.newTriangle(yBuf, 0, 1, 3); Triangle* f1 = g_triangleStore.newTriangle(yBuf, 1, 2, 3); Triangle* f2 = g_triangleStore.newTriangle(yBuf, 2, 0, 3); Triangle* f3 = g_triangleStore.newTriangle(yBuf, 0, 2, 4); Triangle* f4 = g_triangleStore.newTriangle(yBuf, 2, 1, 4); Triangle* f5 = g_triangleStore.newTriangle(yBuf, 1, 0, 4); if (!(f0 \u0026amp;\u0026amp; f0-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f1 \u0026amp;\u0026amp; f1-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f2 \u0026amp;\u0026amp; f2-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f3 \u0026amp;\u0026amp; f3-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f4 \u0026amp;\u0026amp; f4-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; f5 \u0026amp;\u0026amp; f5-\u0026gt;getDist2() \u0026gt; MT_Scalar(0.0))) { return false; } link(Edge(f0, 1), Edge(f1, 2)); link(Edge(f1, 1), Edge(f2, 2)); link(Edge(f2, 1), Edge(f0, 2)); link(Edge(f0, 0), Edge(f5, 0)); link(Edge(f1, 0), Edge(f4, 0)); link(Edge(f2, 0), Edge(f3, 0)); link(Edge(f3, 1), Edge(f4, 2)); link(Edge(f4, 1), Edge(f5, 2)); link(Edge(f5, 1), Edge(f3, 2)); addCandidate(f0, MT_INFINITY); addCandidate(f1, MT_INFINITY); addCandidate(f2, MT_INFINITY); addCandidate(f3, MT_INFINITY); addCandidate(f4, MT_INFINITY); addCandidate(f5, MT_INFINITY); num_verts = 5; } break; } // Simplex中没有三角面了。失败 if (num_triangles == 0) { return false; } Triangle *triangle = 0; MT_Scalar upper_bound2 = MT_INFINITY; // 上面步骤构建了合法的多面体。现在开始真正执行EPA算法 do { // 从堆中弹出距离原点最近的面 triangle = triangleHeap[0]; std::pop_heap(\u0026amp;triangleHeap[0], \u0026amp;triangleHeap[num_triangles], triangleComp); --num_triangles; // 判断面是否还有效。 // TriangleStore里面有一个静态200个元素大小的数组，为了性能考虑，删除面并不是真的删除而是标记为obsolete if (!triangle-\u0026gt;isObsolete()) { // 到达支持上限（200个点），失败 if (num_verts == MaxSupportPoints) { assert(false);\tbreak; } // 得到面上最近点，以此点为方向找到新支撑点 pBuf[num_verts] = a.support( triangle-\u0026gt;getClosest()); qBuf[num_verts] = b.support(-triangle-\u0026gt;getClosest()); yBuf[num_verts] = pBuf[num_verts] - qBuf[num_verts]; int index = num_verts++; // 得到穿透深度的上界 MT_Scalar far_dist = dot(yBuf[index], triangle-\u0026gt;getClosest()); assert(far_dist \u0026gt; MT_Scalar(0.0)); MT_Scalar far_dist2 = far_dist * far_dist / triangle-\u0026gt;getDist2(); // 更新upper_bound2为上界中较小的那个 GEN_set_min(upper_bound2, far_dist2); // 算法终止条件判断： // 1.上下界之差为 e|v|。这里为了防止舍入误差，还给了一个保底容差tolerance // 2.新点就是三角形顶点。这是一个严格判定。可以看到源码他也用#ifdef 1圈起来了，说明这个判断不是必须的。但建议还是写上因为这是个硬兜底。 MT_Scalar error = far_dist - triangle-\u0026gt;getDist2(); if (error \u0026lt;= GEN_max(DT_Accuracy::rel_error2 * far_dist, tolerance) #ifdef 1 || yBuf[index] == yBuf[(*triangle)[0]] || yBuf[index] == yBuf[(*triangle)[1]] || yBuf[index] == yBuf[(*triangle)[2]] #endif ) { break; } // 获得空闲的面下标 int i = g_triangleStore.getFree(); // 使用新点缝合面 // yBuf：当前顶点集合 // index：新点在yBuf中的下标 if (!triangle-\u0026gt;silhouette(yBuf, index, g_triangleStore)) { break; } // 遍历所有新面，将面塞入堆中 while (i != g_triangleStore.getFree()) { Triangle *newTriangle = \u0026amp;g_triangleStore[i]; addCandidate(newTriangle, upper_bound2); ++i; } } } while (num_triangles \u0026gt; 0 \u0026amp;\u0026amp; triangleHeap[0]-\u0026gt;getDist2() \u0026lt;= upper_bound2); // 返回对应值 v = triangle-\u0026gt;getClosest(); pa = triangle-\u0026gt;getClosestPoint(pBuf); pb = triangle-\u0026gt;getClosestPoint(qBuf); return true; } 这里比较复杂的可能是轮廓构造部分，可以细看一下：\nbool Triangle::silhouette(const MT_Vector3 *verts, Index_t index, TriangleStore\u0026amp; triangleStore) { int first = triangleStore.getFree(); // 要缝合的面肯定是已经被遗弃的，直接删除 setObsolete(true); // 对相邻三角面也做缝合操作（会判断相邻面是否需要缝合） // 注意这里调用的是Edge::silhouette，不是本函数的递归 bool result = m_adjEdges[0].silhouette(verts, index, triangleStore) \u0026amp;\u0026amp; m_adjEdges[1].silhouette(verts, index, triangleStore) \u0026amp;\u0026amp; m_adjEdges[2].silhouette(verts, index, triangleStore); // 再将所有需要的边和新顶点缝合起来 if (result) { int i, j; for (i = first, j = triangleStore.getFree()-1; i != triangleStore.getFree(); j = i++) { Triangle *triangle = \u0026amp;triangleStore[i]; half_link(triangle-\u0026gt;getAdjEdge(1), Edge(triangle, 1)); if (!link(Edge(triangle, 0), Edge(\u0026amp;triangleStore[j], 2))) { return false; } } } return result; } 而Edge::silhouette则是：\n// 返回值表示此操作是否成功 bool Edge::silhouette(const MT_Vector3 *verts, Index_t index, TriangleStore\u0026amp; triangleStore) const { if (!m_triangle-\u0026gt;isObsolete()) { // 看新点是否在面外侧。这里用已经记录的面上距离原点的最近点到原点的向量作为面法线来判断 if (!m_triangle-\u0026gt;isVisibleFrom(verts, index)) { // 直接用边和新点缝合成新三角面 Triangle *triangle = triangleStore.newTriangle(verts, index, getTarget(), getSource()); if (triangle) { // half_link就是将某个面连接到某个已有边 half_link(Edge(triangle, 1), *this); return true; } return false; }\telse { // 新点在面外侧，删除当前面， m_triangle-\u0026gt;setObsolete(true); int backup = triangleStore.getFree(); // 递归地，对其另外条边都做缝合操作。这个if和后面else if都是一样的操作只是对不同的边 if (!m_triangle-\u0026gt;getAdjEdge(circ_next(m_index)).silhouette(verts, index, triangleStore)) { // 如果相邻边的缝合操作没成功，那得保留下当前面以防出错 m_triangle-\u0026gt;setObsolete(false); // 并且，本来和已删除面相连的那条边，此时得和新点缝合成新面 Triangle *triangle = triangleStore.newTriangle(verts, index, getTarget(), getSource()); if (triangle) { // 新面连接到此边 half_link(Edge(triangle, 1), *this); return true; } return false; } // 对另外一条边做一样的操作 else if (!m_triangle-\u0026gt;getAdjEdge(circ_prev(m_index)).silhouette(verts, index, triangleStore)) { m_triangle-\u0026gt;setObsolete(false); triangleStore.setFree(backup); Triangle *triangle = triangleStore.newTriangle(verts, index, getTarget(), getSource()); if (triangle) { half_link(Edge(triangle, 1), *this); return true; } return false; } } } return true; } 在线演示 # 下面是目前文章提到的各种查询方法演示。其中GJK在GJKTetrahedrons例子中，EPA则在MTDTetrahedrons中。\nProximity Queries and Penetration Depth Computation on 3D Game Objects\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nGithub Solid3\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"25 August 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E5%85%AD/","section":"Posts","summary":"本文解释了EPA算法及其常见实现。\n","title":"从0开始制作游戏物理引擎（六）","type":"posts"},{"content":"","date":"6 August 2026","externalUrl":null,"permalink":"/tags/%E5%AF%BB%E8%B7%AF%E5%92%8Cai/","section":"Tags","summary":"","title":"寻路和AI","type":"tags"},{"content":"","date":"6 August 2026","externalUrl":null,"permalink":"/series/%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E4%B8%AD%E7%9A%84%E5%AF%BB%E8%B7%AF/","section":"Series","summary":"","title":"游戏开发中的寻路","type":"series"},{"content":"本文说明了游戏开发中的寻路算法。从BFS到Dijkstra到A*以及A*的变体等。\n大部分参考这篇由浅入深的文章1\nBFS # 寻路算法大部分都是从图论算法中发展而来。其中最简单的就是BFS。这也是在学习寻路算法时最先遇到的算法。\nBFS的优点：\n写法简单 给定起点，可以同时算得图中所有点到起点的最短路径 缺点：\n性能不够好。要遍历整个图中所有的点O(mn) 只能在无权图中生效。这包含两个条件： 任意格子到相邻格子的耗时必须一样（一般都记为1） 对于Tilemap这种格子都是方的来说，只能四向移动不能八向移动（因为斜角的格子耗时是$\\sqrt{2}$倍，导致含有额外权重） 这些缺点导致BFS的应用场景极少。但BFS是所有寻路算法的基石。其他算法都是从BFS上扩展出来的。\n我们都知道，BFS其实是通用的图搜索算法，具体用这个算法做什么是看具体场景。所以我们有一个通用BFS实现：\ntemplate \u0026lt;typename T\u0026gt; void BFS(const Graph\u0026lt;T\u0026gt;\u0026amp; graph, std::queue\u0026lt;T*\u0026gt;\u0026amp; queue, std::function do_something_with_node) { while (!queue.empty()) { const T* node = queue.front(); for (Graph::Node* nearby_node : graph.GetNearbyNodes(value)) { if (nearby_node-\u0026gt;m_visited) { continue; } nearby_node-\u0026gt;m_visited = true; do_something_with_node(nearby_node, ...);\t// do something here queue.push_back(nearby_node); } queue.pop(); } } 这就是纯粹的BFS的实现逻辑：通过将某个节点的临近节点推入队列中来实现。已经推入过的节点要标记其被访问了（m_visited = true）以防止其某个邻居节点再次将其推入造成死循环。\n这里一般会在do_something_with_node(node)里对当前弹出的node做一些操作。\n而对于寻路算法，do_something_with_node的实现是直接将当前节点指向其父节点：\nvoid do_something_with_node(Graph::Node* nearby_node, Graph::Node* parent) { nearby_node-\u0026gt;m_parent = parent; } // 在BFS中具体的调用为： do_something_with_node(nearby_node, node); 显然，随着BFS的“洪水”逐渐淹没整个图，所有的节点都会有自己的父节点。父节点则是洪水淹过来时的方向。\n下面是一个 BFS 寻路的交互式演示，你可以左键格子设置起点/终点，右键设置墙壁，然后逐步观察 BFS 的扩散过程：\nDijkstra算法 # BFS只能处理无权图。带有权重的图需要使用Dijkstra算法处理。\n具体可看参考2，很直观。\nDijkstra的唯一缺点是不能处理Cost为负数的节点（可使用Bellman-Ford算法进行处理）。\nDijkstra算法本质是贪心算法，其在BFS上进行扩展，使用优先队列而不是普通队列。优先队列每次弹出的是到起点路径最短的节点。\n所有节点记录自己到起点的Cost。所有节点初始的Cost为$+\\infty$，只有起点$S$的Cost是0。\n具体的算法是：\n遍历当前点$A$周围的4（或者8）个点$B_i$，计算他们到起点的$Cost_i = Cost_{S\\rightarrow A} + Cost_{A \\rightarrow B_i}$，如果$Cost_i$比当前$B_i$的Cost小，那么更新$Cost_{B_i} = Cost_i$并将$B_i$的父节点指向$A$，然后将$B_i$放入优先队列中 重复上述步骤直到优先队列为空 他的核心思路就是用优先队列替换原来BFS的普通队列，以贪心的方式每次都找到最近的路径。如果新路径比旧路径更短就用新路径替换旧路径。\n伪代码（只展示思路不是最优内存）为：\ntemplate \u0026lt;typename T\u0026gt; struct PQEntry { float m_cost{};\t// 离起点的Cost Graph\u0026lt;T\u0026gt;::Node* m_node{}; bool operator\u0026lt;(const PQEntry\u0026amp; o) const { return m_cost \u0026lt; o.m_cost; } }; using PriorityQueue = std::priority_queue\u0026lt;PQEntry, std::geater\u0026gt;;\t// 按离起点Cost最短排序 template \u0026lt;typename T\u0026gt; void Dijkstra(const Graph\u0026lt;T\u0026gt;\u0026amp; graph, const std::unordered_map\u0026lt;Graph\u0026lt;T\u0026gt;::Node*, PQEntry\u0026gt;\u0026amp; entries, // 所有节点和对应PQEntry的信息 PriorityQueue\u0026lt;PQEntry\u0026gt;\u0026amp; queue) { while (!queue.empty()) { PQEntry\u0026amp; data = queue.front(); for (auto\u0026amp; nearby_node : data.m_node-\u0026gt;GetNearbys()) { float cost = nearby_node-\u0026gt;GetCost(); // 从相邻节点走到这个节点的Cost float totle_cost = data.m_cost + cost; // 从起点走到当前节点的Cost PQEntry\u0026amp; nearby_node_pq_entry = entries[nearby_node]; // 判断是否cost更少一些？ if (totle_cost \u0026lt; nearby_node_pq_entry.GetCost()) { nearby_node_pq_entry.SetCost(totle_cost); // 只要找到更新的路径就放入优先队列 queue.push(nearby_node_pq_entry); } } queue.pop(); } } 这里和BFS寻路的区别为：\n使用优先队列而不是普通队列 不需要m_visited，使用cost比较替代了m_visited的功能 下面是一个 Dijkstra 寻路的交互式演示，地图上有三种不同 Cost 的地形（颜色越深 Cost 越高），你可以观察 Dijkstra 如何优先探索低 Cost 区域：\n斜角穿墙的处理 # 如果Dijkstra存在8方向寻路，那么可能存在斜角穿墙的问题:\n解决方法是在计算cost的时候看一下是不是正在斜着走。如果是，看一下旁边有没有墙，如果有墙就标记斜着走的这个块的Cost为$+\\infty$。这样Dijkstra可以从旁边绕过去（或者两边都有墙就不从这里走，此路不通了）：\n使用Heap代替PriorityQueue # 在Dijkstra算法中，可能存在同一个节点压入两次queue中：\n下面A~I都是空白的寻路地面，以3x3方式排列 ABC DEF GHI 假设现在Queue里面是E： 1. 弹出E 2. 找到E周围的8格 1. 假设先找到C。更新C的Cost然后压入Queue 2. 然后找到B，一样压入Queue 3. 其他节点依次操作 3. 弹出C。找C周围的8格。这个时候又找到B，并且E-\u0026gt;C-\u0026gt;B的Cost比E-\u0026gt;B的Cost小，那么要更新B的Cost再压入Queue 此时Queue里面就有两个B，重复 如果想要去重，可以考虑使用Heap数据结构。\nC++本身提供Heap的支持，但比较小众，我放在参考3了。\n注意：去重并不是理论上必做的事情。因为后压入的节点的Cost一定比之前的小，所以即使弹出之前的节点他也不会更新任何Cost，相当于没做事情。去重只是为了防止又找一遍老节点的nearby节点。\n提前退出 # Dijkstra是从BFS上扩展而来。BFS会找到图中所有点到起点的最短路径。Dijkstra也是如此。\n但在游戏开发中，我们经常只需要找到单点的最短路径。在使用Dijkstra时，如果已经找到目标点的最短路径，那么直接终止算法即可。这就是提前退出。\n注意和BFS的提前退出条件不一样：\nBFS：当发现某个点的邻居为目标点时就可以直接退出了（此时算法会设置目标点的父，然后直接退出，目标点不用压入队列） Dijkstra：当从queue中弹出的点为目标点时可直接退出（也就是说目标点压入过队列） 贪婪最佳优先搜索 # 对于单源最短路径。我们希望能尽可能地朝向目标点搜索以减少其他不必要的搜索。这时，我们可以给Dijkstra算法一个启发式函数(Heuristic Function)，这个启发式函数告诉Dijkstra当前搜索的点离目标点有多近。在Tilemap地图上可以使用曼哈顿距离：\nfloat HeuristicFn(Vec2I current_position, Vec2I target_position) { return std::abs(target_position.x - current_position.x) + std::abs(target_position.y - current_position.y); } 然后将此启发函数的返回值作为节点的Cost，堆/优先队列 每次弹出的是Cost最小的那个。\n这样就等于告诉Dijkstra：永远朝着距离目标最近的点进行贪婪搜索。\n但这种算法不一定能找到真的最短路径。但优点就是快。\n下面是一个贪婪最佳优先搜索的演示：\n下面是一个反例，此时无法找到最短的路径：\nA*算法 # 在基于贪婪最佳优先搜索的Dijkstra算法的基础上，通过修改启发式函数，我们可以直接得到A*算法。\nA*算法的启发式函数由三部分组成：\ng：当前节点到起点的确定Cost（即初始Dijkstra算法中的Cost） h(heuristic)：当前节点到终点的启发式搜索算法（一般是到终点的曼哈顿距离） f：$f = g + h$是节点的总Cost，我们最后使用的Cost就是f 当$h$总是0时，A*退回到Dijkstra算法。\n当$g$总是0是，A*退回到基于贪婪最佳优先搜索的Dijkstra算法。\n编码过程和Dijkstra没区别。\n差分启发式算法（ALT） # 可参考4。\n又称ATL(A* Landmark Triangle inequality)。通过选取更好的启发式函数来优化A*。\n传统的曼哈顿距离启发函数有一个问题。如果起点在胡同里，终点则在墙对面（就比如上面说的贪婪最佳优先搜索的反例）。这个时候，曼哈顿距离启发函数会让A*优先搜索离终点近的点（也就是先往墙那边搜索），这样会导致很多无效搜索。\n而ATL可以一定程度上解决这个问题。主要步骤为：\n在地图上预先标上点$L_i$ 使用Dijkstra或其他算法，构建网格上所有点到$L_i$的最短距离（不需要记路径，$i$个点有$i$份数据） 启发式函数的返回值是，对当前节点$n$和目标$goal$,$h(n, goal) = \\max(dist_{L_i \\rightarrow n} - dist_{L_i \\rightarrow goal})$ 这个算法直观的理解是，地标$L_i$是一个辅助算法判断搜索方向的东西。就好比你让朋友去天安门旁边的某个景点，你可以和他说先到天安门（或者往天安门的方向走），然后在天安门附近再找一样。这样就避免了曼哈顿距离带来的问题。\n这里启发式函数的推导由三角不等式得到：\n对于当前点$n$，地标$L$和终点$E$。这三者可以构成一个三角形。由三角不等式可得：\n$$ \\begin{aligned} \u0026 |LE| \\le |nL| + |nE| \\Rightarrow \\\\ \u0026 |LE| - |Ln| \\le |nE| \\end{aligned} $$也就是说当前点到终点的Cost，最低最低都是地标到终点的距离减去地标到当前点距离。那么我们就可以用这个保底作为启发式函数的返回值，以便于A*沿着这个路径走。\n那么当有多个地标$L_i$时，显然要计算他们之中的最大值作为保底。\n而$|LE|$和$|Ln|$可从预构建的地图上所有点到$L$的最短距离的数据中获得。\n最远点采样算法 # 地标可以自己手动标识。但也有自动化标定方法。\n最远点采样法就是这样：\n随机选取第一个地标$L_1$（一般在地图边缘选取，以保证$L_1$本身的效果足够好） 找到距离$L_1$最远的点作为第二个地标$L_2$（可用Dijkstra算法找到） 找到距离$L_1, L_2$最远点作为第三个地标$L_3$ 其他地标总是找到距离之前地标最远的点 那么如何找到距离前$i$个地标最远的点呢？\n定义$minDist(n) = \\min(dist(n, L_i))$为某个点$n$到所有地标的最近点。那么距离所有地标的最远点定义就是\n$$ \\max(minDist(n)) $$也就是距离所有地标最近距离都是最大的那一个点。\n算法步骤为：\n遍历图中每一个点$n_i$，计算$minDist_i = \\min(dist(n_i, L_i))$，然后找$\\max(minDist_i)$所在的那个点就是最远点了 这是游戏开发中常用的自动选点算法，高效效果又好。原始论文4不是用这个算法，感兴趣可以自己看。\n流场寻路算法（Flow Field） # 其实就是利用Dijkstra算法算出的结果进行多人寻路。\n流场算法要处理的问题是：给定终点，让地图上多个移动物体向终点寻路。\n如果使用A*的话，需要为每个移动物体都计算一次寻路，物体多了就慢。而Flow Field算法只是先用Dijkstra算出地图上所有点到终点的最短路径然后使用而已。\n跳点算法JPS（Jumping Point Search） # 这篇文章5说的很好，我这里只简单说一下。\nA*基于BFS。但在搜索路径时，两点之间的最短路径可能有很多条，而且这些路径都是等价的。由于A*基于BFS的特性，他有可能将这些等价路径都搜索到。这就浪费了时间。\nJPS就是尽量减少对等价路径的搜索。\nJPS要求网格是均匀的，即：\n同类方向代价一致\n就是上下左右四方向的所有格子Cost都一致。斜角的四个方向所有格子Cost一致。但是这两类之间的Cost不需要一样。\n所以JPS可以处理斜着走的问题。即使斜走的Cost是横着走的$\\sqrt{2}$倍。\n在均匀网格中JPS的性能比A*快非常非常多，开有大量开阔地带的地图尤其如此（听说有10~40倍的差距）。一般用在有多个房间或者大量空地，空地之间由走廊连起来的地图。对于那种紧凑的迷宫型地图性能没有那么好。\n动态地图寻路 # 当地图改变时（增加/删除了节点，或者节点Cost改变）原有的路径可能会失效，此时必须更新路径。\n对于A*来说大部分游戏就是直接重算。但也有其他方法：\n惰性A*重算 # 不是在地图改变时重算。而是当走到被堵住的地方时才重算。这种算法需要在人物移动的时候每帧检查下一个移动到的节点处是否被物体遮挡。如果遮挡了就以当前点为起点重算A*。\n这种方法的缺点是，有些时候堵住的是关键路径（比如人物要进入城堡，城堡有两个离得很远的入口$A,B$。此次寻路路径从$A$口进入。人物走到一半时$A$口被堵住了。此时人物不知情仍旧往$A$口走，直到走到$A$口发现被堵住才触发重算）。这个时候可以在非寻路层做一些操作（比如标识关键阻塞点，当这些点被阻塞就立刻通知需要的人进行重算）\nHPA* # 原论文名是\u0026quot;Near Optimal Hierarchical Path-Finding\u0026quot;。一个较好的教程是6，此教程不仅说明了HPA*，也说明了Annotated A*算法。\n从论文名称可以直到这个算法的特点：\n近似最优：不总是能找到最优解 分层：将地图分成多块进行搜索 这个算法主要是非常快速。\nD*与LPA* # 只是简单介绍一下，一般不会在游戏中使用。D*和LPA*可以使用现有的A*算法的数据，当地图改变时动态地计算新道路。\n一般不会在游戏中使用。这两个算法是给机器人用的。机器人面对的都是未知地图，其不停地移动，感知新地图细节。等价于运行时生成新地图信息了。而游戏中地图一般都是静态的，只有很少数的改变（比如开门，关门等）。使用D*和LPA*往往受益并不高。\n只有在那种超大地图，寻路很多并且地形不断变化的游戏中需要（比如FPS游戏，里面很多炮火可以导致地形变形等）。\n和物体大小相关的寻路 # A*只能告诉我们两个点之间的最短路径。但他不能告诉我们物体是否真的能走过这条路（比如寻路需要通过一条狭窄走廊，但大物体根本挤不进去）。\n这时就可以用Annotated A*算法。其本质是预先在地图上标记不同尺寸的物体能走过的路径，然后将这些信息传给A*。\n此文章6说的很详细了，不再赘述。\nA算法简介 \u0026mdash; Introduction to the A Algorithm\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n彻底搞懂 Dijkstra 最短路径算法_哔哩哔哩_bilibili\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nstd::make_heap - cppreference.com\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nImproving Heuristics\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nA Visual Explanation of Jump Point Search\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nClearance-based Pathfinding and Hierarchical Annotated A* Search | AiGameDev.com\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"6 August 2026","externalUrl":null,"permalink":"/posts/%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E4%B8%AD%E7%9A%84%E5%AF%BB%E8%B7%AF%E4%B8%80/","section":"Posts","summary":"本文说明了游戏开发中的寻路算法。从BFS到Dijkstra到A*以及A*的变体等。\n大部分参考这篇由浅入深的文章1\n","title":"游戏开发中的寻路（一）","type":"posts"},{"content":"本文解释了GJK算法及其常见实现。\nGJK算法来源于两份论文：\nGJK原始论文1：由Gilbert, Johnson, Keerth三人编写。对GJK算法的正确性进行了证明，并给出了算法。论文偏理论，对数值问题没有一个性能较好的解决方法。 GinoGJK2：由Gino van den Bergen编写。在原有GJK基础上解决了很多痛点，让算法性能更高以便于更好的用于实时环境。论文偏工程。此文章中的算法和其实现3是现代游戏物理引擎中用的最多的。 本文会将上述三个文章中的知识做总结，述说现在成熟的GJK方案并阅读Gino的GJK源码。\n现在大多数文章介绍的都是Ericson的GJK算法，他首次显式地提出在GJK中使用重心坐标并推广到游戏编程圈内。但从论文本身来说，和Ericson的方法（至少形式上）并不一样。\n多篇文章的符号不一样，但由于Gino的实现和其文章一致，所以我们用Gino文章中的符号。\nGJK算法 # 预备知识 # GJK算法能做很多事情：\n判断两个凸体是否相交 如果相交，获得穿透深度（GinoGJK） 如果不相交，算得两个物体之间的距离已经最近点（原始GJK） 首先是一些符号定义。\n距离 # 定义两个物体$A$和$B$间的距离为\n$$ d(A, B) = \\min \\{ |x - y|: x \\in A, y \\in B \\} $$GJK中使用欧几里得距离。\n半空间 # 半空间为使用平面分割的一半的空间。定义平面为\n$$ H(\\vec{n}, d) : \\vec{n_x}x +\\vec{n_y}y + \\vec{n_z}z + d = 0 $$其法向量$\\vec{n}$指定了平面正向。平面正向的空间记为$H^+$，而反向的记为$H^-$。\n仿射集和凸集 # 定义仿射集为：\n$$ \\operatorname{aff} X = \\{ \\sum_{i = 1}^l \\lambda_i x_i :x_i \\in X, \\lambda_1 + \\cdots + \\lambda_l = 1 \\} $$定义凸集为：\n$$ \\operatorname{co} X = \\{ \\sum_{i = 1}^l \\lambda_i x_i :x_i \\in X, \\lambda_1 + \\cdots + \\lambda_l = 1, \\lambda_i \\ge 0 \\} $$凸集是比较重要也是常用的概念。显然，凸集是仿射集的扩展，也是所有凸体的描述（满足任意集合内任意两点连线在集合内）。\n一般用$\\operatorname{co}X$表示$X$的凸包。显然，这也是GJK首要研究的对象。\n重心坐标是$|X| = 3$的集合的特例，所以也符合凸集的公式。\n再定义仿射无关(Affinely Independent)：\n对于集合$X = {x_1, x_2, \\cdots, x_n}$，如果集合\n$$ \\{x_2 - x_1, x_3 - x_1, \\cdots , x_n - x_1\\} $$线性无关，那么就叫$X$仿射无关。\n仿射无关的几何意义是，集合中没有多余的点落在此集合构成的凸包内（面上可以）。也就是说，集合内所有点共同支棱起一个凸包。\n支撑映射(Support Mapping) # 对于集合$X$，其某个方向$d$的支撑距离为\n$$ h_X(d) = \\max \\{x \\cdot d: x \\in X\\} $$即沿着这个方向在$X$上的最远距离。而满足$h_X(d)$中$x$的这个点就叫支撑点，记为$s_X(d)$。\n显然有：\n$$ \\begin{aligned} \u0026 h_X(d) = s_X(d) \\cdot d \\\\ \u0026 h_X(-d) = \\min \\{x \\cdot d : x \\in X\\} \\end{aligned} $$而在GJK算法中，一般研究离原点最近的点：\n$$ v(X) \\in X, |v(X)| = \\min \\{|x| : x \\in X\\} $$而由凸集的定义，显然有：\n$$ v(\\operatorname{co}X) = \\sum_{i = 1}^l \\lambda_i x_i, x_i \\in X, \\lambda_i \\gt 0, \\sum \\lambda_i = 1 $$接下来是一些常见几何体的支撑映射\n常见几何体的支撑映射 # Box # 可以利用其三个互相垂直的轴加速计算。\n假设此Box由三个轴上的半长$\\eta_x, \\eta_y, \\eta_z$表示。那么支撑点函数就是：\n$$ S_{Box}((x, y, z)) = (sgn(x)\\eta_x, sgn(y)\\eta_y, sgn(z)\\eta_z) $$ Sphere # 显然为：\n$$ S_{sphere}(\\vec{v}) = \\begin{cases} \\frac{r}{|\\vec{v}|}\\vec{v} \u0026 \\text{if } ||\\vec{v}|| \\ne 0 \\\\ 0 \u0026 \\text{otherwise} \\end{cases} $$ Capped Cone # 意指有界圆锥，即只有一个顶点和一个底面的圆锥。\n假设此圆锥轴心位于$y$轴，朝上（顶点在上底面在下），底面半径为$\\rho$，顶点在$y = \\eta$处，底面中心在$y = -\\eta$处。顶点处的半角为$\\alpha$满足$\\sin(\\alpha) = \\frac{\\rho}{\\sqrt{\\rho^2 + (2\\eta)^2}}$。\n令$\\sigma = \\sqrt{x^2 + z^2}$为任意点到$y$轴的距离，那么支撑点函数为：\n$$ S_{cone}((x, y, z)) = \\begin{cases} (0, \\eta, 0) \u0026 \\text{if } y \\gt ||(x, y, z)||\\sin(\\alpha) \\\\ (\\frac{\\rho}{\\sigma}x, -\\eta, \\frac{\\rho}{\\sigma}z) \u0026 \\text{else } \\sigma \\gt 0 \\\\ (0, -\\eta, 0) \u0026 \\text{otherwise} \\end{cases} $$ Capped Cylinder # 有界圆柱。轴为$y$轴，中心在原点。半高为$\\eta$，半径为$\\rho$。\n令$\\sigma = \\sqrt{x^2 + z^2}$为任意点到$y$轴的距离，那么支撑点函数为：\n其支撑点函数为：\n$$ S_{cylinder}((x, y, z)) = \\begin{cases} (\\frac{\\rho}{\\sigma}x, \\operatorname{sgn}(y)\\eta, \\frac{\\rho}{\\sigma}z) \u0026 \\text{if } \\sigma \\gt 0 \\\\ (0, \\operatorname{sgn}(y)\\eta, 0) \u0026 \\text{otherwise} \\end{cases} $$ 仿射变换后的几何体 # 定义仿射变换为$T(\\vec{x}) = B\\vec{x} + \\vec{c}$。那么对于一个物体$A$经过仿射变换之后的物体$T(A)$，其支撑点函数为：\n$$ S_{T(A)}(\\vec{v}) = T(S_A(B^T\\vec{v})) $$很易证。原论文有证明这里不写了。\n尖锐边角的处理 # 圆柱和圆锥的底边边角即带有弧又很尖锐，圆锥的顶角也同理。在我的实践中发现这很容易导致数值精度误差，会导致GJK算法在还未真正收敛的情况下退出。\nJoltPhysics给出了一个解决方法我觉得很好用：将这些地方变圆。比如对于圆柱体，其SupportMapping的代码为：\nVector3 CylinderSupportMapping(Vector3 dir) const { // m_convex_radius就是变圆的半径 Vector3 d = dir.normalized(); real dn = d.dot(m_axis); Vector3 radial = d - m_axis * dn; real hh = m_half_height - m_convex_radius; real rr = m_radius - m_convex_radius; Vector3 p = m_center + m_axis * (dn \u0026gt;= 0 ? hh : -hh); if (radial.squaredNorm() \u0026gt; 0) { p += radial.normalized() * rr; } return p + d * m_convex_radius; } Minkowski差和CSO # 在Gino的论文2中，将两个物体$A$和$B$的Minkowski差称为CSO(Configuration Space Obstacle)：\n$$ CSO_{A-B} = A - B = \\{a - b : a \\in A, b \\in B\\} $$由于会反复出现$A$和$B$的Minkowsi差，我们用$K = A - B$来表示。\n现在可以证明：\n两物体相交等价于原点在CSO内：显然，两物体相交区域内必有两个位置一样的点$a \\in A, b \\in B$，那么显然$a - b = \\vec{0}$，那么原点在CSO内。 计算两物体的距离等价于计算原点到Minkowski差的距离：显然，原点到Minkowski差的距离为$d(\\vec{0}, K) = \\min {|k - \\vec{0}|: k \\in K}$，而 $x = a - b, a \\in A, b \\in B $正是两物体之间距离。 接下来介绍GJK原始论文中的算法。而这个算法着重于求两物体间的距离。那么这个问题就转换为求$v(K)$。\n算法 # 由于GJK是对任意维度$R^m$都成立的算法，那么对其的证明自然围绕$R^m$。\n定理1 # 令$K = A - B, K \\in R^m$是紧致和凸的。那么定义$g_K :R^m \\rightarrow R$为：\n$$ g_K(x) = |x|^2 + h_K(-x) $$所谓“紧致(compact)”是拓扑学中的概念，意为有界且闭的。这里紧致+凸就是指一般的凸体或者说凸的刚体。\n如果假设$x \\in K$，那么定理1有四条性质：\n$g_K(x) \\ge 0$恒成立。这是显然的，因为$x\\cdot s_K(-x) = \\min{a: a \\cdot x, a \\in K } \\cdot x \\le x \\cdot x = |x| ^ 2$，而$g_K(x) = |x|^2 -x\\cdot s_K(-x) \\ge 0$显然就成立了。\n如果$g_K(x) \\gt 0$，那么在线段$\\operatorname{co} {x, s_K(-x)}$上一定有一点$z$满足$|z| \\lt |x|$（即$z$到原点的距离比$x$小）\n证明：\n先证$z$的存在性：\n显然在$|s_K(-x)| \\lt |x|$时，取$z = s_K(-x)$即可。\n那么考虑$|s_K(-x)| \\ge |x|$的情况：\n由于$z$是线段上一点，那么可以用凸集表示为：\n$$ \\begin{aligned} \u0026 z = (1 - \\lambda) x + \\lambda s_K(-x) = x + \\lambda(s_K(-x) - x) \\\\ \u0026 |z|^2 = (x + \\lambda(s_K(-x) - x))^2 = |x|^2 + 2\\lambda x(s_K(-x) - x) + \\lambda^2|s_K(-x) -x|^2 \\end{aligned} $$我们要求得最小的$|z|$，所以对$|z|^2$求导数，并令导数为0：\n$$ \\begin{aligned} \u0026 \\frac{d|z|^2}{d|z|} = 2x(s_K(-x) - x) + 2\\lambda|s_K(-x) -x|^2 = 0 \\\\ \u0026 \\Rightarrow \\\\ \u0026 \\lambda = \\frac{x(x - s_K(-x))}{|s_K(-x) - x|^2} \u0026 (1) \\end{aligned} $$那么问题就变成找到一个合格的$\\lambda$。\n而我们可以对$g_K(x)$变形得到：\n$$ g_K(x) = |x|^2 + h_K(-x) = |x|^2 +(-x)s_K(-x) = |x|^2 - xs_K(-x) = x(x - s_K(-x)) $$正是上面$(1)$式的分子。所以有：\n$$ \\lambda = \\frac{g_K(x)}{|s_K(-x) - x|^2} $$而：\n$$ \\begin{aligned} \u0026 |x - s_K(-x)|^2 = |x|^2 + |s_K(-x)|^2 - 2x\\cdot s_K(-x) \\\\ \u0026 2g_K(x) = 2(|x|^2 - x \\cdot s_K(-x)) = 2|x|^2 - 2x\\cdot s_K(-x) \\\\ \u0026 \\text{上述两式相减得:} \\\\ \u0026 |x - s_K(-x)|^2 - 2g_K(x) = |s_K(-x)|^2 - |x|^2 \\ge 0 \u0026(*) \\\\ \u0026 \\Rightarrow \\\\ \u0026 |x - s_K(-x)|^2 \\ge 2g_K(x) \\Rightarrow \\frac{g_K(x)}{|x - s_K(-x)|^2} \\le \\frac{1}{2} \\Rightarrow \\lambda \\le \\frac{1}{2} \\end{aligned} $$$(*)$式是因为，$s_K(-x) = {y : \\max{y \\cdot (-x)}} \\ge x \\cdot (-x)$。那么左右两边平方就有$|s_K(-x)|^2 \\ge |x|^2$。\n而又根据$\\lambda$的定义有$\\lambda \\gt 0$。所以综上，总能找到一个$\\lambda \\in [0, \\frac{1}{2}]$。那么也就总存在一个$z$了。\n综上，$z$的存在性证明完毕。\n然后证明$|z| \\lt |x|$，只需将$g_K(x) = \\lambda|x - s_K(-x)|^2$带入$|z|^2$式子中即可：\n$$ \\begin{aligned} |z|^2 \u0026 = |x|^2 + 2\\lambda x(s_K(-x) - x) + \\lambda^2|s_K(-x) - x|^2 \\\\ \u0026 = |x|^2 + 2\\lambda(-g_K(x)) + \\lambda(\\lambda|x - s_K(-x)|^2) \\\\ \u0026 = |x|^2 - 2\\lambda g_K(x) + \\lambda g_K(x) \\\\ \u0026 = |x|^2 - \\lambda g_K(x) \\end{aligned} $$因为$\\lambda \\gt 0$, $g_K(x) \\gt 0$（根据题设），所以有$|z|^2 \\lt |x|^2$成立。\n$x = v(K)$当且仅当$g_K(x) = 0$。也就是说$x$为整个Minkowski差中距离原点最近的点，当且仅当$g_K(x) = 0$。\n证明：\n先证必要性：\n$$ g_K(x) = 0 \\Rightarrow |x|^2 +h_K(-x) = 0 \\Rightarrow |x|^2 = -h_K(-x) = \\min \\{a \\cdot x: a \\in K\\} $$那么有\n$$ |x|^2 \\le |x|^2 + |a - x|^2 = |a|^2 + 2(|x|^2 - a \\cdot x) \\le |a|^2, a \\in K $$这样就证明了$|x|^2 \\le |a|^2$小于$K$中任意数的模的平方。那么$x$就是距离原点的最近点。\n而充分性可以用反证法：\n假设$g_K(x) \\gt 0$。那么根据性质2，一定有一个距离原点更小的$z$，违背条件$x = v(K)$。所以矛盾。\n$|x - v(K)|^2 \\le g_K(x)$\n证明：\n将左式展开：\n$$ \\begin{aligned} |x - v(K)|^2 \u0026 = |x|^2 - 2x\\cdot v(K) + v(K)^2 \\\\ \u0026 \\le |x|^2 - 2x \\cdot v(K) + x \\cdot v(K) \\\\ \u0026 = |x|^2 - x \\cdot v(K) \\\\ \u0026 \\le |x|^2 - x \\cdot s_K(-x) \\\\ \u0026 = g_K(x) \\end{aligned} $$证毕。\n距离算法 # 通过定理1可知，当$g_K(x) = 0$时，$x = v(K)$就能找到要求的距离原点最近点。这个是GJK算法的终止条件。\n先给出通过GJK求距离的算法：\n给出紧致的凸集$K \\in R^m, K = {y_1, y_2, \\cdots, y_v }, 1 \\le v \\le m+1$，算法步骤如下：\n令$V_0 = {y_1, \\cdots, y_v}$，令$k = 0$ 找到$v_k = v(\\operatorname{co} V_k)$ 如果$g_K(v_k) = 0$，那么显然$v(K) = v_k$，算法终止返回$|v_k|$作为距离 令$V_{k+1} = \\hat{V_k} \\cup {s_K(-v_k)}$，这里 $\\hat{V_k} \\subset V_k$，并且$|\\hat{V_k}| \\le m, v_k \\in \\operatorname{co}\\hat{V_k}$。然后$k = k+1$自增$k$，然后回到步骤2 翻译成人话：\n初始集合$V_0$是整个凸体的点集\n$k$其实是当前迭代次数，而$v_k$是第$k$步下距离原点最近点\n每一次迭代都算$V_k$中距离原点的最小值\n如果算法没终止，这里$\\hat{V_k}$的要求有三个：\n包含$v_k$ 元素个数不大于$m$ $\\hat{V_k} \\subset V_k$ 意思是每次都要在$V_k$中找到一个子集，这子集得包含$v_k$且元素个数不大于$m$。\n那么根据$V_{k+1}$的构造有$|V_{k+1}| \\le m$。并且$V_{k+1}$一定包含$s_K(-v_k)$。也就是说$V_{k+1}$一定包含两个点：\n$s_K(-v_k)$ $v_k$ 我们知道大部分GJK文章中说的做法都是：\n每步找Simplex距离原点最近的点$v_k$ 如果点在Simplex内，停止算法。否则，如果Simplex点数已满，删除$v_k$对面的点，然后将$v_k$加入Simplex 这里的Simplex其实就类似于$V_k$。而删点其实就类似于找到$\\hat{V_k}$。所以算法每一步都在构造新Simplex。\n我们后面可以看到，这就是对$R^3$下的算法进行的特化。数学上来说，此方法和论文中的方式是等价的。只是GJK本身能够处理$R^m$空间，所以论文中写了个更通用的算法。\n这里有几点要注意：\n第二步的$v_k = v(\\operatorname{co}V_k)$说明$v_k$可以在$\\operatorname{co}V_k$的面上或体内 那么第三步的意思其实是：原点在$\\operatorname{co}V_k$上，也就是原点在单纯形内，那么算法立刻退出 接下来要证明此方法是正确的（能够找到$v(K)$的）：\n首先证明此算法每一步都是可达的。主要是步骤4：\n在步骤4中，显然$g_K(x) \\gt 0$，那么根据定理1的性质3，$v_k \\ne \\vec{0}$（说明原点不在单纯形上或内）。那么$s_K(-v_k)$就一定能找到点，那么$\\hat{V_k}$就一定可构造了。\n然后证明$|v_{k+1}| \\lt |v_k|$，即每次找到的一定是离原点更近的点：\n根据定理1的性质2有：\n$$ |v_{k+1}| = |v(\\operatorname{co}V_{k+1})| \\le |v(\\operatorname{co}\\{v_k, s_K(-v_k)\\})| \\lt |v_k| $$ 然后证明此算法是可以在有限步骤内完成的。这被称为定理2：\n定理2 # 假设$Z \\subset R^m$是有限集。$K = \\operatorname{co}Z$。假设$\\forall \\eta \\in R^m, s_K(\\eta) \\in Z$，那么距离算法可以在有限步内找到$v(K)$\n证明：\n简单来说，因为每一步的$V_k$和之前的任何$V_k$都是不相同的（因为上面的证明2，$|v_k|$的大小是严格递减的，所以每个$v_k$肯定不一样，而$V_k$又一定要包含$v_{k-1}$，所以显然每个$V_k$也是不一样的）。而$K$又是有限集，也就是说他的子集个数是有限的。那么算法最坏情况下会遍历完其所有子集。而由之前Minkowski差转为寻找最近点问题的证明可知，一定存在$v(K)$。所以$v(K)$一定会在某个子集进入算法循环时被找到。\n这就证明了算法可在有限步（最坏情况下是$K$子集个数）中完成。\n距离子算法(Distance Subalgorithm) # 距离算法中的第二步和第四步中的$\\hat{V_k}$的选取并没有明确说明要怎么做。距离子算法就是来做这个事情的。\n距离子算法由于是Johnson发明的，所以又叫Johnson算法。\n我们假定每一步的$V_k$都是仿射无关的（如果某一步仿射相关，那整个GJK就崩了，算法执行失败）。为了方便记忆，后用$Y$表示：$Y = V_k$。\n令$Y$的非空集合个数为$\\sigma$个，定义数列$s = 1, \\cdots, \\sigma$为其集合个数的序列。而$|Y| = v$，定义元素在$Y$中的下标序列为$I = {1, \\cdots, v}$。对于$Y$的任意的子集$Y_s$，我们使用$I_s \\subset I$表示$Y_s$中元素下标的集合，而$I_s^{\u0026rsquo;}$为$I_s$在$I$中的补集（即不在$Y_s$中的元素下标）。\n首先，有$v(\\operatorname{aff}Y_s) = \\sum_{i = 2}^{r}\\lambda_ix_i$，且$\\lambda_1 = 1 - \\sum_{i = 2}^r \\lambda_i$。那么我们要找$v(\\operatorname{co}Y_s)$就是找到一系列$\\lambda_i$，使得$|v(\\operatorname{aff}Y_s)|$最小。那么定义函数$f(\\lambda_2, \\cdots, \\lambda_r)$为:\n$$ f(\\lambda_2, \\cdots, \\lambda_r) = |v(\\operatorname{co}(Y_s))|^2 = |\\sum_{i=1}^r \\lambda_i x_i|^2 = |x_1 + \\sum_{i = 2}^{r}\\lambda_i(x_i - x_1)|^2 $$我们要求这个$r$元函数的最小值。根据分析学的理论：\n函数导数为0的点可能是函数的最小值点（需要通过Hessian矩阵辅助判断） 但这里$f$是凸函数，这意味导数为0的点一定是最小值点。那么可以通过Jacobian矩阵表示其导数为：\n$$ \\begin{aligned} \u0026 A_s\\lambda = b \\\\ \u0026 A_s = \\begin{bmatrix} 1 \u0026 \\cdots \u0026 1 \\\\ (x_2 - x_1) \\cdot x_1 \u0026 \\cdots \u0026 (x_2 - x_1) \\cdot x_r \\\\ \\vdots \u0026 \u0026 \\vdots \\\\ (x_r - x_1) \\cdot x_1 \u0026 \\cdots \u0026 (x_r - x_1) \\cdot x_r \\\\ \\end{bmatrix} \\\\ \u0026 b = \\begin{bmatrix} 1 \\\\ 0 \\\\ \\vdots \\\\ 0 \\end{bmatrix} \\end{aligned} $$通过Cramer法则4可以解出所有$\\lambda_i = \\frac{\\Delta_i(Y_s)}{\\Delta(Y_s)}$。其中$\\Delta_i(Y_s)$是第$i$行替换$b$得到的矩阵，而$\\Delta(Y_s) = |A_s|$。\n当然这里可以硬算行列式。但论文中给出了一种更加快速的，利用旧行列式缓存的方式，以递增的方式去计算。\n具体的公式是：\n$$ \\begin{aligned} \u0026 \\Delta_i(\\{y_i\\}) = 1, \u0026 i \\in I \\\\ \u0026 \\Delta_j(Y_s \\cup \\{y_j\\}) = \\sum_{i \\in I_s}\\Delta_i(Y_s)(y_i \\cdot y_k - y_i \\cdot y_j), \u0026 i \\in I_s, k \\in I_s, j \\in I_s^{'} \\\\ \u0026 \\Delta(Y_s) = \\sum_{i \\in I}\\Delta_i(Y_s) \\end{aligned} $$即：\n含有单个元素的Cache为1 在原有行列式基础上增加一个点，可以通过增量形式计算这个新行列式 分母由所有$\\Delta_i(Y_s)$相加得到 我们可以举例说明这个算法的细节：\n$\\lambda_i$的增量计算过程 # 假设我们在$R^3$下运行此算法。并且此时$Y$里面没有点。所以我们先往里面填充点：\n第一步：\n加入$y_1$。此时$Y = Y_s = {y_1}$，由定义$\\Delta_1(Y_s) = \\Delta_i({y_1}) = 1$，$\\Delta(Y_s) = 1$。\n第二步：\n加入$y_2$，此时$Y = {y_1, y_2}$。注意$Y_s \\subset Y$按照$I_s$中的下标选取元素。所以这里有三种$Y_s$：\n$$ \\begin{aligned} \u0026 Y_1 = \\{y_1\\}, \u0026 I_1 = \\{1\\} \\\\ \u0026 Y_2 = \\{y_2\\}, \u0026 I_2 = \\{2\\} \\\\ \u0026 Y_3 = \\{y_1, y_2\\}, \u0026 I_3 = \\{1, 2\\} \\\\ \\end{aligned} $$这里，由于$y_2$是新增点，所以$I_s^{\u0026rsquo;} = {2}$。那么就是：\n$$ \\begin{aligned} \u0026 \\Delta_j(Y_1 \\cup \\{y_2\\}) = \\sum_{i \\in I_1}\\Delta_i(Y_1)(y_k - y_j) \\cdot y_i \\\\ \u0026 I = \\{1, 2\\} \\\\ \u0026 I_s = I_1 \\\\ \u0026 i = k \\in I_1 \\\\ \u0026 j \\in I_2^{'} \\end{aligned} $$那么有\n$$ \\Delta_2(Y_3) = \\Delta_2(Y_1 \\cup \\{y_2\\}) = \\Delta_1(Y_1)(y_1 - y_2) \\cdot y_1 $$可这为何能成立？因为这就是$|A_i|$的Laplace展开。当有两个点时：\n$$ \\begin{aligned} \u0026 |A_s| = \\begin{vmatrix} 1 \u0026 1 \\\\ (y_2 - y_1) \\cdot y_1 \u0026 (y_2 - y_1) \\cdot y_2 \\end{vmatrix} \\\\ \u0026|A_2| = \\begin{vmatrix} 1 \u0026 1 \\\\ (y_2 - y_1) \\cdot y_1 \u0026 0 \\end{vmatrix} \\end{aligned} $$将$|A_2|$按第二列展开就得到了。\n那么新问题来了。公式里有$\\Delta(Y_s) = \\sum_{i \\in I} \\Delta_i(Y_s)$。我们这里是$\\Delta(Y_3) = \\Delta_1(Y_3) + \\Delta_2(Y_3)$。我们有$\\Delta_2(Y_3)$，但是$\\Delta_1(Y_3)$从何而来？其实一样的，只是组成的集合不一样。\n对于$\\Delta_1(Y_3)$，$j = 1$，也就是说此时$I_s^{\u0026rsquo;} = {1}$。那么为了凑齐$I = {1, 2}$显然$I_s = {2}$。\n也就是说，$\\Delta_1(Y_3)$是视为从${y_2}$中增加${y_1}$点得到的结果。那么也是一样计算，会得到：\n$$ \\Delta_1(Y_3) = |A_1| = (y_2 - y_1) \\cdot y_2 $$那么为什么$\\Delta(Y_s) = |A_s| = \\sum_{i \\in I} \\Delta_i(Y_s)$成立呢？这其实是$|A_s|$按第一行展开的结果。\n所以这里你就可以看到，使用旧的$\\Delta_i(Y_s)$去加速计算新$\\Delta_j(Y_s)$的过程了。\n第三步：\n为了更好地看到这种加速，我们再加一个点。将$y_3$加入$Y$中得$Y = {y_1, y_2, y_3}$。那么此时有6个$Y_s$：\n$$ \\begin{aligned} \u0026 之前保留的Y_1 \\sim Y_3: \\\\ \u0026 Y_1 = \\{y_1\\}, \u0026 I_1 = \\{1\\} \\\\ \u0026 Y_2 = \\{y_2\\}, \u0026 I_2 = \\{2\\} \\\\ \u0026 Y_3 = \\{y_1, y_2\\}, \u0026 I_3 = \\{1, 2\\} \\\\ \u0026 新增的: \\\\ \u0026 Y_4 = {y_3}, \u0026 I_4 = \\{3\\} \\\\ \u0026 Y_5 = {y_2, y_3}, \u0026 I_5 = \\{2, 3\\} \\\\ \u0026 Y_6 = {y_1, y_2, y_3}, \u0026 I_6 = \\{1, 2, 3\\} \\\\ \\end{aligned} $$那么新增的$Y_4 \\sim Y_6$是否能用$Y_1 \\sim Y_3$加速计算呢？显然是可以的。首先根据定义$\\Delta(Y_4) = 1$。然后\n$$ \\begin{aligned} \u0026 |A_5| = \\begin{vmatrix} 1 \u0026 1 \\\\ (y_3 - y_2) \\cdot y_2 \u0026 (y_3 - y_2) \\cdot y_3 \\end{vmatrix} \\\\ \u0026 I = \\{2, 3\\} \\\\ \u0026 I_s = \\{2\\}, I_s^{'} = \\{3\\} 时 \\\\ \u0026 \\Delta_3(Y_5) = \\Delta_3(Y_2 \\cup \\{y_3\\}) = \\Delta_2(Y_2)(y_2 - y_3) \\cdot y_2 = (y_2 - y_3) \\cdot y_2 \\\\ \u0026 I_s = \\{3\\}, I_s^{'} = \\{2\\} 时 \\\\ \u0026 \\Delta_2(Y_5) = \\Delta_2(Y_4 \\cup \\{y_2\\}) = \\Delta_2(Y_4)(y_3 - y_2) \\cdot y_3 = (y_3 - y_2) \\cdot y_3 \\\\ \u0026 \\Delta(Y_5) = \\Delta_2(Y_5) + \\Delta_3(Y_5) \\end{aligned} $$而$Y_6$则是：\n$$ \\begin{aligned} \u0026 |A_6| = \\begin{vmatrix} 1 \u0026 1 \u0026 1 \\\\ (y_2 - y_1) \\cdot y_1 \u0026 (y_2 - y_1) \\cdot y_2 \u0026 (y_2 - y_1) \\cdot y_3 \\\\ (y_3 - y_1) \\cdot y_1 \u0026 (y_3 - y_1) \\cdot y_2 \u0026 (y_3 - y_1) \\cdot y_3 \\\\ \\end{vmatrix} \\\\ \u0026 I = \\{1, 2, 3\\} \\\\ \\\\ \u0026 I_s = \\{1, 2\\}, I_s^{'} = \\{3\\} 时 \\\\ \u0026 \\Delta_3(Y_6) = \\Delta_3(Y_3 \\cup \\{y_3\\}) = \\Delta_1(Y_3)(y_1 - y_3) \\cdot y_1 + \\Delta_2(Y_3)(y_1 - y_3) \\cdot y_2 \\\\ \\\\ \u0026 I_s = \\{2, 3\\}, I_s^{'} = \\{1\\} 时 \\\\ \u0026 \\Delta_1(Y_6) = \\Delta_1(Y_5 \\cup \\{y_1\\}) = \\Delta_2(Y_5)(y_2 - y_1) \\cdot y_2 + \\Delta_3(Y_5)(y_2 - y_1) \\cdot y_3 \\\\ \\\\ \u0026 I_s = \\{1, 3\\}, I_s^{'} = \\{2\\} 时 \\\\ \u0026 \\Delta_2(Y_6) = \\Delta_2(\\{y_1, y_3\\} \\cup \\{y_2\\}) = \\Delta_1(\\{y_1, y_3\\})(y_1 - y_2) \\cdot y_1 + \\Delta_3(\\{y_1, y_3\\})(y_1 - y_2) \\cdot y_3 \\end{aligned} $$可以看到确实可以通过旧值加速计算新值。这里只有$\\Delta_2(Y_6)$中的$\\Delta_i({y_1, y_3})$没有旧值，但仍旧可以使用递推公式算得旧值。\n当$Y$内点到达上限$m$时，此时新增点前需要删除点。新增的点仍旧可以用旧值加速计算（不再举例赘述）。\n这个递推公式的好处就是可以快速计算$\\lambda$，只需要使用旧值计算$\\Delta_i(Y_s)$，然后将各个$\\Delta_i(Y_s)$相加即可得到$\\Delta(Y_s)$，然后就可以用Cramer法则得到最后的$\\lambda$。免去了每次都要从头展开行列式带来的性能问题。\n定理3 # $Y_s$有效，当且仅当\n$\\Delta(Y_s) \\gt 0$ $\\Delta_i(Y_s) \\gt 0, i \\in I_s$ $\\Delta_j(Y_s \\cup {y_j}) \\le 0, j \\in I_s^{\u0026rsquo;}$ 最后，$\\lambda_i = \\frac{\\Delta_i(Y_s)}{\\Delta(Y_s)}$\n这三条分别说明：\n$Y_s$是仿射无关的。\n这是引理1：$Y_s$仿射无关当且仅当$\\Delta(Y_s) \\gt 0$\n证明：\n​\t因为$\\Delta(Y_s) = Q_s^TQ_s$，而$Q_s$则是：\n$$ Q_s = \\begin{bmatrix} (x_2 - x_1) \\cdots (x_r - x_1) \\end{bmatrix} $$而形如$Q_s^TQ_s$的矩阵叫做Gramian矩阵5。而只有当他正定时，$Q_s$中元素线性无关。而$Q_s$元素线性无关正是$Y_s$仿射无关的定义。\n$v(\\operatorname{co}Y_s)$是$\\operatorname{co}Y_s$的相对内点（即在$\\operatorname{co}Y_s$内部，不包含边但包含面。或者换句话说，$\\lambda_i \\ne 0$）。这意味着此$v(\\operatorname{co}Y_s)$无需再在$Y_s$的子集内搜索（因为如果点落在边上，说明还存在更小的子集需要搜索）\n这其实是个凸优化里面的结论：$Y_s$仿射无关时，$\\operatorname{co}(Y_s) = { \\sum\\lambda_i y_i : \\lambda_i \\gt 0, \\sum \\lambda_i = 1 }$。这里不再证明。\n$v(\\operatorname{co}Y_s) = v(\\operatorname{co}Y)$，即局部最小值就是全局最小值。\n距离子算法步骤 # 给出有限集$Y = {y_1, \\cdots, y_v } \\subset R^m$和其所有子集$Y_s, s = 1, \\cdots, \\sigma$。算法步骤如下：\n令 $s = 1$ 如果$\\Delta(Y_s) \\gt 0$且 $\\Delta_i(Y_s) \\gt 0, i \\in I_s$ 且 $\\Delta_j(Y_s \\cup {y_j}) \\le 0, j \\in I_s^{\u0026rsquo;}$。那么我们就找到了$v(\\operatorname{co}Y)$（使用$\\lambda_i$可计算出来）。算法终止。 如果$s \\lt \\sigma$，$s = s+1$返回步骤1 如果算法停止，返回失败 这里简单来说就是遍历$Y_s$所有子集，试图找满足条件2的$v(\\operatorname{co}Y)$。\n现在我们可以看一下，论文中的距离子算法和重心坐标的联系。\n以Simplex包含三个点为例（也就是三角形，这样简单一些）。假设现在Simplex中有三个点$Y = { y_1, y_2, y_3 }$。那么其一共有7个子集。分别是：\n$$ \\begin{aligned} \u0026 \\{y_1\\} \\Rightarrow \\Delta(\\{y_1\\}) = 1 \\\\ \u0026 \\{y_2\\} \\Rightarrow \\Delta(\\{y_2\\}) = 1 \\\\ \u0026 \\{y_3\\} \\Rightarrow \\Delta(\\{y_3\\}) = 1 \\\\ \\\\ \u0026 \\{y_1,y_2\\} \\Rightarrow \\begin{vmatrix} 1 \u0026 1 \\\\ (y_2 - y_1) \\cdot y_1 \u0026 (y_2 - y_1) \\cdot y_2 \\\\ \\end{vmatrix} \\\\ \u0026 \\{y_1, y_3 \\} \\Rightarrow \\cdots \\\\ \u0026 \\{y_2, y_3 \\} \\Rightarrow \\cdots \\\\ \\\\ \u0026 \\{y_1, y_2, y_3 \\} = \\begin{vmatrix} 1 \u0026 1 \u0026 1 \\\\ (y_2 - y_1) \\cdot y_1 \u0026 (y_2 - y_1) \\cdot y_2 \u0026 (y_2 - y_1) \\cdot y_3 \\\\ (y_3 - y_1) \\cdot y_1 \u0026 (y_3 - y_1) \\cdot y_2 \u0026 (y_3 - y_1) \\cdot y_3 \\\\ \\end{vmatrix} \\end{aligned} $$本质上，含有两个点的集合就是边（线段），含有三个点的集合就是三角面。\n而对每个子集判断$\\Delta_i(Y_s) \\gt 0, \\Delta(Y_s) \\gt 0$其实就等价于看原点是否在当前边/面的Voronoi域内。\n$\\Delta(Y_s)$我们知道是Gramian行列式，这个行列式在数学上就是计算由$y_i$个点组成的仿射无关的体的体积。\n$\\Delta(Y_s) \\gt 0$的判断，其实就是在说这$y_i$个点仿射无关，确定了Simplex的有效性。并且$\\Delta(Y_s)$本质上是平方量，不会出现小于0的情况。\n而$\\Delta_i(Y_s)$其实就是计算Simplex中子区域（以原点在Simplex上的最近点为顶点）的体积。所以两者的比值才是重心坐标。其实和重心坐标计算公式是完全等价的。\n然后$\\Delta_i(Y) \\gt 0$的判断，这里$\\Delta_i(Y)$是带符号的。等于在说离原点的最近点必须在$\\operatorname{co}(Y_s)$上。\n而$\\Delta_j(Y_s \\cup {y_j}) \\lt 0$的判断则是说明，原点与新点$y_j$必须在$\\operatorname{co}Y_s$的异侧（等价于Ericson GJK中，说新点要“越过”原点）。\n所以可以看到，原始GJK在数学上的表述和Ericson GJK是完全等价的。\n距离子算法的工程实现 # GJK原始论文1的理论已搞懂，但工程上到底要如何实现呢？Gino在他的论文2中给出了答案。\n我们只考虑$R^3$下的情况（但理论上Gino的方法可以推广到$R^m$）。由于Simplex最多只有4个点（这里Simplex指$Y$），而含有4个点的集合最多只有16个，所以可以用一个16x4的矩阵存储$\\Delta_i(Y_s)$。\n那么我们可以用一个4位的位掩码来表示这16个集合。甚至可以根据此掩码的1的位置表示此集合中包含的顶点。比如mask = 0100就是第4个子集，此子集包含第三个顶点。而mask = 0101就是第5个子集，此子集包含两个顶点（第一个和第三个）。\n有了这个表示法，$\\Delta_i(Y_s)$的增量计算就好算了，直接套论文中的公式即可。\n而在实际编码中3，Gino甚至对$\\Delta_j(Y_s \\cup {y_j}) = \\sum_{i \\in I_s}\\Delta_i(Y_s)(y_k - y_j) \\cdot y_i$中反复用到的$y_k - y_j$也做了缓存（将其正负值各存一份）用于加速计算。\n从CSO中反向计算最近点 # 在整个GJK算法结束后，我们可以找到CSO上距离原点的最近点$v(K)$。此时，我们可以通过此点反向计算出在$A$和$B$上的最近点对$a, b$。只需要使用重心坐标$\\lambda_i$计算即可。因为：\n$$ v(K) = \\sum \\lambda_i k_i = \\sum \\lambda_i (a_i - b_i) = \\sum \\lambda_i a_i - \\sum \\lambda_i b_i $$那么可以用$\\sum \\lambda_i a_i$得到$A$中最近点，$\\sum \\lambda_i b_i$得到$B$中最近点。\n所以算法中不仅要记录CSO上的点，还得记录这个点的原始两个点。\n适用于二次曲面的GJK # 原始的GJK论文只讨论了凸多面体。但后人发现可以应用于任何的凸体，包括含有二次曲线的（比如球，球扫略体等）。并且还证明了GJK算法只需要两个物体的支撑映射，而不需要全部的顶点信息。\n那么有一个问题：对于含有二次曲面的GJK，其曲面上的顶点是无数个。这有可能导致GJK不停地逼近最近点，或者由于数值精度问题在最近点疯狂摇摆。此时必须增加容差。\n记每次迭代中距离原点的最近点为$\\vec{v_k}$，在此次迭代中新加入Simplex的点为$\\vec{w_k}$（使用$\\vec{v_k}$找到）。那么这两个点构成一个支撑平面：\n$$ H(-\\vec{v_k}, \\vec{w_k}\\cdot \\vec{v_k}) $$ GJK算法本身只会给出$d(A, B)$的上界$|v(A - B)|$（因为GJK一直在尝试减少$|v(A - B)|$所以是上界）。但并没有给出下界。\n而根据刚才的支撑平面可知，$|v(A - B)|$的下界就是点$\\vec{w_i}$到原点的距离，即：\n$$ \\delta_k = \\frac{\\vec{w_k}\\cdot \\vec{v_k}}{|\\vec{v_k}|} \\le |\\vec{v_k}| $$那么当上界界逐渐逼近的时候，显然GJK算法会越来越准确。那么我们认为，当\n$$ |\\vec{v_k}| - \\delta_k \\le \\epsilon $$的时候，GJK算法就找到了正确的最近点。这里$\\epsilon$是一个用户给定值。\n但这里有个问题：此算法不一定能终止，虽然上界$|v_k|$是单调递减的，但下界$\\delta_k$并不是一个单调递增的数值，所以可能导致算法一直无法满足。\n解决方法是找到所有$\\delta_k$中的最大的那个：\n$$ \\mu_k = \\max \\{0, \\delta_0, \\cdots, \\delta_k\\} $$那么$\\mu_k$显然是递增的，那么总是有：\n$$ |\\vec{v_k}| - \\mu_k \\le \\epsilon $$这就是最后的终止条件。\n这里的夹逼其实就是原始条件$g_K(v(K)) = 0$的相对容差版本：\n$$ \\begin{aligned} \u0026 |v_k| - \\mu_k \\le \\epsilon |v_k| \\\\ \u0026 \\Rightarrow |v_k| - \\max \\{\\frac{v_k \\cdot w_k}{|v_v|} \\} \\le \\epsilon |v_k| \\\\ \u0026 \\Rightarrow |v_k|\\max \\{|v_k|\\} - \\max \\{ v_k \\cdot w_k \\} \\le \\epsilon |v_k| \\max \\{ |v_k| \\} \\\\ \\end{aligned} $$其实左边是$g_K(v_k) = |v_k|^2 - s(-x)$的近似版本，所以所以这个式子其实就是$g_K(x)$的容差版本，只是容差不太一样而已（$|v_k|^2$和$|v_k|\\max { |v_k| }$之间的差距可以通过选取适当的$\\epsilon$抹去）。\nGino发现，在物体特别大的时候这个终止条件会有浮点数精度问题，所以需要一个相对容差而非绝对容差：\n$$ |\\vec{v_k}| - \\mu_k \\le \\epsilon |\\vec{v}| $$而当物体特别小的时候，会出现浮点数下溢导致$\\vec{v}$变成0向量，导致算法死循环。所以他的建议是再给一个和0相关的容差$\\omega$：\n$$ |\\vec{v}| \\le \\omega $$而在实际代码中，他使用的是相对容差：\n$$ |\\vec{v}|^2 \\le \\epsilon |\\max \\{ \\vec{v} \\}|^2 $$当满足这个容差时，算法也终止，同时认为当前$\\vec{v}$就是最近点。\n而Gino在论文里说过，原式有$|v_k|$，为了在工程上不进行昂贵的开方操作，不如将等式左右两边再乘上$\\max {|v_k|}$变成：\n$$ |\\vec{v_k}|\\max \\{|v_k|\\} - \\max \\{ \\vec{v_k}\\cdot {\\vec{w_k}} \\} \\le \\epsilon |\\vec{v}| \\max \\{|v_k|\\} $$这也和上面说到的公式一模一样，反向证明了GinoGJK的终止条件等价于原始GJK的终止条件。\n数值精度问题以及Gino的鲁棒性优化 # 如果几何体偏离原点太远，可能由于大浮点数运算问题导致精度问题。原始论文的方法是利用其几何中心连线的中点$\\rho$进行偏移：\n$$ \\begin{aligned} \u0026 z_1 = \\frac{\\sum_i^N{z_{1i}}}{N} \\\\ \u0026 z_2 = \\frac{\\sum_i^N{z_{2i}}}{N} \\\\ \u0026 \\rho = \\frac{z_1 + z_2}{2} \\end{aligned} $$ 在判断$g_K(x) = 0$的时候，可以使用一个相对容差进行判断：\n$$ \\begin{aligned} \u0026 g_K(x) \\le \\epsilon(D(K)^2) \\\\ \u0026 D(K) = \\max(|z| : z \\in K) \\end{aligned} $$其中$\\epsilon$是一个用户给定的极小值。\n而如果你使用Gino的终止条件，则无需做任何调整。\n在做Johnson距离子算法时，可能会由于反复的浮点数计算导致所有子集中$\\Delta(Y_s) \\gt 0, \\Delta_i(Y_s) \\gt 0,\\Delta_j(Y_s \\cup {y_j}) \u0026lt; 0$的判断失效，从而导致算法异常退出。原始论文解决的方法是采用一个兜底程序（Backup Procedure）：暴力遍历所有子集，找到满足$\\Delta(Y_s) \\gt 0, \\Delta_i(Y_s) \\gt 0$的距离原点的最近点作为返回值。这总是可以找到的（比如只含有一个点的集合）。这个方法叫Johnson Robust。\n但Gino在他的论文中说，他发现每次触发兜底程序返回的$v(K)$，几乎就是上一帧找到的$v_k$。所以他建议直接就返回上一帧的$v_k$不要再跑这个笨重的兜底程序。\n或者直接报错（但在我个人的实践中，这种情况在刚体不旋转的情况下还是较为常见的，所以我不建议直接报错）\n病态情况(ill-condition)：当大小相差几个数量级的多面体彼此非常接近的时候，这些对象的CSO可能产生及其狭长的面从而导致算法无限循环（详见Gino论文）。这时，新算出的$\\vec{w_k}$其实是Simplex中已有的点。这样会导致算法死循环。\n解决方法就是每次都判断$\\vec{w_k} \\in W_{k-1} \\cup {\\vec{w_{k-1}}}$是否成立。如果成立说明遇到了病态情况，那么算法直接结束走兜底程序。\n利用GJK进行相交测试 # 上面说了如何利用GJK计算得到两物体之间的距离已经最近点对。接下来再上述算法的基础上稍加改动即可利用GJK进行相交测试。此方法记录在GinoGJK论文2中。\n只要$|\\vec{v_k}|$的下界$\\delta_k \\gt 0$，那么显然两物体就是相离的。否则就是相交/相贴的。\n等价于：\n$$ \\vec{v_k} \\cdot \\vec{w_k} \\gt 0 $$并且这里的$\\vec{v_k}$就是分离轴。所以此时算法就变成：\n$$ \\begin{aligned} \u0026 \\vec{v} = \\text{\"arbitary vector\"} \\\\ \u0026 W = \\emptyset \\\\ \u0026 \\text{repeat} \\\\ \u0026 \\text{\t}\\vec{w} = s_{A - B}(-\\vec{v}) \\\\ \u0026 \\text{\tif } \\vec{v} \\cdot \\vec{w} \\gt 0 \\text{ then return false} \\\\ \u0026 \\text{\t} \\vec{v} = v(\\operatorname{co}(W \\cup \\{\\vec{w}\\})) \\\\ \u0026 \\text{\t} W = \\text{\"smallest }X \\subseteq W \\cup \\{\\vec{w}\\} \\text{ such that } \\vec{v} \\in \\operatorname{co}(X) \\text{\"} \\\\ \u0026 \\text{until } \\vec{v} = \\vec{0} \\\\ \u0026 \\text{return true} \\\\ \\end{aligned} $$注意这里的$\\vec{v}$不再是CSO上的任意顶点的方向，而是任意方向。这很有利于帧间一致性。\n帧间一致性(Frame coherence)/热启动(Warm start) # 指实时模拟中相邻两帧之间物体只移动/旋转了一点点，几何配置变化很小——所以上一帧的结果（分离轴、支撑点、最近特征等）在本帧大概率仍然有效或非常接近，可以作为本帧迭代的热启动起点。\n在游戏物理引擎中也叫做热启动。\nGinoGJK中就提出了帧间一致性：当Polytope使用爬山法构造时（爬山法详见“从0开始制作游戏物理引擎（二）”），可以利用上一帧最后找支撑点的方向作为下一帧的开始方向查找，一般会大大减少爬山法搜索的时长。\n而在使用GJK进行相交测试时，由于$\\vec{v}$的初值可以是任意值。那么我们可以利用上一帧找到的分离轴作为方向来加速查找。这样也能加快GJK的执行。\nSOLID3中的GJK实现 # 接下来阅读Gino的GJK实现3。主要是intersect和closest_points两个函数。其他的函数需要EPA算法支持。\n着眼于closest_points函数。intersect函数大体上差不多。\n剔除多余的代码，intersect的大体流程如下：\n// max_dist2 表示感兴趣的距离的平方，超过这个距离就不给精确值或直接返回函数了。这个是用于空间划分技术中过滤过于远的物体的。 MT_Scalar closest_points(const DT_Convex\u0026amp; a, const DT_Convex\u0026amp; b, MT_Scalar max_dist2, MT_Point3\u0026amp; pa, MT_Point3\u0026amp; pb) { // v初始值是0，表示选取a, b中的第一个点（所有点的SupportMapping都是0）。 MT_Vector3 v(MT_Scalar(0.0), MT_Scalar(0.0), MT_Scalar(0.0)); DT_GJK gjk; MT_Scalar dist2 = MT_INFINITY; do { // 计算SupportMapping MT_Point3 p = a.support(-v);\tMT_Point3 q = b.support(v); // 得到Minkowski差 MT_Vector3 w = p - q; MT_Scalar delta = v.dot(w); // 两物体相离 // delta \u0026gt; MT_Scalar(0)是“利用GJK进行相交测试”中的下界\u0026gt;0的情况 // 而后半部分则是下界超出max_dist2的情况。 // 这里其实是 delta * delta / dist2 \u0026gt; max_dist2，即下界v·w/|v|^2 \u0026gt; 感兴趣距离的平方 if (delta \u0026gt; MT_Scalar(0.0) \u0026amp;\u0026amp; delta * delta \u0026gt; dist2 * max_dist2) { // 直接返回无效值表示根本不相交 return MT_INFINITY; } // ill-condition判断，或上界-下界的差小于给定值（|v|^2 - w·v \u0026lt;= e|v|^2) if (gjk.inSimplex(w) || dist2 - delta \u0026lt;= dist2 * DT_Accuracy::rel_error2) { break; } // 将新点加入Simplex gjk.addVertex(w, p, q); // 如果GJK此时仿射相关，那直接退出 if (gjk.isAffinelyDependent()) { break; } // Johnson距离子算法，在当前Simplex中找到距离原点的最近点v // 这里v是输出参数不参与计算 if (!gjk.closest(v)) { break; } // 用于安全退出 #ifdef SAFE_EXIT MT_Scalar prev_dist2 = dist2; #endif dist2 = v.length2(); #ifdef SAFE_EXIT // 这里是Gino的判断浮点数舍入的手段：用之前的距离平方-当前距离平方 // 因为GJK确保dist2每一步下是单调递减的。但存在极端情况，每次都递减一点点，那么最后可能由于舍入误差 // 导致在某个步骤反而变成递增的了。这就可能导致算法错误。 // 这里这个式子就是当递减量过小时，直接启用备用程序返回一个v和dist2。 // 这里MT_EPSILON就是std::numeric_traits\u0026lt;float\u0026gt;::epsilon()是机器浮点数精度。 if (prev_dist2 - dist2 \u0026lt;= MT_EPSILON * prev_dist2) { // 为什么这里出错就要调用backup程序而之前的不需要？因为之前的计算没有污染上一帧留下来的v。 // 而这里，由于使用gjk.closest(v)计算过v了，此时这个v可能并不可靠，所以必须跑backup程序。 // backup程序即原始GJK论文中说的那个backup程序 gjk.backup_closest(v); dist2 = v.length2(); break; } #endif } // 退出条件：GJK中Simplex存在四个点，或者dist2是否等于0 // 这里后半部分是 |v|^2 \u0026lt;= e·w^2 的判断。这里gjk.maxVertex()返回的是w^2 while (!gjk.fullSimplex() \u0026amp;\u0026amp; dist2 \u0026gt; DT_Accuracy::tol_error * gjk.maxVertex()); // Simplex中必须存在点 assert(!gjk.emptySimplex()); // 如果距离在感兴趣距离内，那我们计算最近点 if (dist2 \u0026lt;= max_dist2) { gjk.compute_points(pa, pb); } // 算法退出后，总是用最近后算得的最近点的距离(dist2)作为返回值，就算是算法出错也用这个兜底 return dist2; } 而GJK结构体中就是一些和论文一模一样的GJK算法。不再赘述。\nA fast procedure for computing the distance between complex objects in three-dimensional space\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nA Fast and Robust GJK Implementation for Collision Detection of Convex Objects\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nGithub Solid3\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nCramer Ruler\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n格拉姆矩阵 | Bohrium\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"19 July 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E4%BA%94/","section":"Posts","summary":"本文解释了GJK算法及其常见实现。\n","title":"从0开始制作游戏物理引擎（五）","type":"posts"},{"content":"这一章描述了判交和MTD算法，包括SAT等。\n大部分的判交算法都在公式大全1中有推导。这里只记录一些难理解的。\nMoller的三角形判交算法 # 源于Tomas Moller的论文《A Fast Triangle-Triangle Intersection Test》2。\n思路是既然两个三角形相交，那么他们不是平行就是所在面相交。对于后者，算法首先计算共面的线，然后通过此线和两个三角形相交得到相交区间，然后判断相交区间是否重叠。\n这种算法有个优点是可以得到两三角形的Contact点（就是相交区间的两端点）。\n不过现在物理引擎里面大部分还是SAT或GJK。PhysX5的三角形判交GuIntersectionTriangleTriangle是走的这个算法。\nSAT算法 # 分离轴定律（Separate Axis Theory）是常见的判断两个凸多面体是否相交，同时可以求出最小挤出向量（MTD, Minimal Translation Direction）的算法。和GJK作为两大求解MTD算法。\n分离轴定律基于一个事实：用一束平行光照射两个凸体，如果存在一个光的朝向，使他们在墙上（墙垂直于光）的投影不相交，那么这两个物体就不相交。\n那么这个墙就叫做测试轴或者投影轴。本质上是在选定墙之后，将两个凸体投影到墙体上看投影是否相交。\n在程序实现中不可能找“任意的”墙。对于凸体，需要：\n将每个面的法线当做墙进行投影 将两个凸体中的每两对边的叉乘结果作为墙进行投影 如果存在任意投影不相交，那么这两个物体就不相交。\n否则相交，并且投影之间的重叠距离就是最小挤出距离。投影轴的方向就是挤出方向。\n对于两个OBB来说，一共需要对这些轴进行测试：\n第一个OBB的三个轴 第二个OBB的三个轴 每两对边（3x3 = 9）个轴（去掉朝向一样/相反的轴） 所以一共是15次测试。\n这里有个问题：为什么要对边的叉乘结果也进行测试？详见此回答3。\nSupport点 # 支撑点是指：给定某一个方向，找到这个方向上几何体中最远的点。\n在概念上，经常用支撑点来描述SAT算法：给出一个投影轴，在这个轴的正方向和负方向上找到物体的支撑点。然后算两个物体各自支撑点所包含的区域是否相交。\n但实际情况下不会真的调用两次计算支撑点的算法。因为Convex的支撑点算法是遍历所有顶点，将点投影在轴上，找到投影距离最大的那个点作为支撑点。\n如果你要算正向和反向的支撑点，中间会有很多重复的投影。\nSAT的应用场合 # 虽然SAT的概念很好理解，实现起来也很简单。但遗憾的是，在3D中几乎不会使用SAT。\nSAT最耗时的地方是往不同的轴上投影。轴越多投影越多。在2D场景下，边的法线十分容易计算，而且也只需要边法线作为轴，不需要两个物体的一对边的叉乘作为轴。这个时候SAT的速度还是很快的。像Box2D中所有的几何几乎都采用SAT算法。\n但在3D中情况有变：\n计算凸体需要面法线，那你必须将面法线提前记录下来，因为得知道哪些顶点构成面。或者你使用凸体的拓扑形式，这样可以知道所有面然后根据边算面法线。但就算这样，要计算的面法线也越来越多 随着凸体边数增多，要计算两边叉乘产生的轴也越来越多（理论上是$V_{num1} * V_{num2}$是一个平方数量级），这导致投影操作大大增加 所以基本上除了OBB和OBB的判交可以用SAT（此时SAT和GJK+EPA的差别不大，但是更好写），以及空间中三角形和三角形判断用SAT之外，所有的凸体算法中SAT都要比GJK+EPA慢。很多物理引擎中（比如PhysX，Bullet）都是AABB之间和OBB之间的MTD算法使用SAT。其他都使用GJK+EPA。\nOBB和OBB的SAT特化算法 # 由于OBB“方方正正”的特性（邻边互相垂直，对边互相平行），可以不对OBB的每个顶点进行投影。而是对轴进行投影。通过将每个轴投影，找到正向/反向最远的值作为支撑点在此轴上的坐标即可。这样大大简化了需要投影的点的个数（投影轴是15个，投影点本来是(8 + 8) * 15。现在是(3 + 3) * 15）。\n凸体和球体的SAT # 球体没有边。所以投影轴有两种：\n凸体的面法线 球体到凸体顶点的连线 但不会有人在球和凸体上用SAT的。还是投影轴太多。\n工程中使用的凸体和球体的判交/MTD算法 # 对于判交，工程中直接化成最近点算法：找到球体和凸体的最近点，然后看最近点是否在球体内。\nMTD算法则直接使用GJK+EPA。但对于球体和OBB的MTD计算，可以先算最近点，然后看最近点是不是在OBB内。如果不在，可以一步算出MTD：\n$$ \\begin{aligned} \u0026 \\vec{d} = \\vec{C_{circle}} - \\vec{NearestPt} \\\\ \u0026 \\vec{MTD} = (||\\vec{d}|| - r)\\frac{\\vec{d}}{||\\vec{d}||} \\end{aligned} $$如果在内部。那就得使用点到面距离来判断离四个面谁最近。这个时候挤出方向就是面法线，大小就是球心到面的距离+半径。\n注意：只需要判断球心到面的距离，不需要判断球心到边的距离。因为总可以对面作垂线构造直角三角形，来证明到面的距离总是短于到边的距离:\n判断球体到四面体也是一样的方法。但这个时候就要判断到边的距离了（因为四面体不那么“方正”）\n其他MTD求解方法 # 大部分使用GJK。等到GJK的章节再说。其他的参考公式大全1。\n参考 # 实时碰撞检测算法技术 (豆瓣)\n公式大全：Geometric Tools: About Geometric Tools for Computer Graphics\nGeometric Tools: About Geometric Tools for Computer Graphics\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nTomas Moller: A Fast Triangle-Triangle Intersection Test\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nseparating axis theorem - How many and which axes to use for 3D OBB collision with SAT - Game Development Stack Exchange\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"19 July 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E5%9B%9B/","section":"Posts","summary":"这一章描述了判交和MTD算法，包括SAT等。\n大部分的判交算法都在公式大全1中有推导。这里只记录一些难理解的。\n","title":"从0开始制作游戏物理引擎（四）","type":"posts"},{"content":"这一章描述了各种几何体的最近点计算\n大部分的算法在公式大全1中都有详细推导。我只记录一些较难理解的。\n最近点计算 # 最近点计算通过给入一个点$p$和一个几何体$D$，要求找到$D$上距离$p$的最近点。\n我们假定$p$在几何体外部。内部点某些算法可能会得到不正确的结果。\n大部分的最近点算法都很简单。可以直接看Ericson的书2或公式大全1。我这里只记录一些有巧思的和比较复杂的。\n到OBB的最近点 # 一般的想法是将点旋转成相对于OBB的位置，将问题转换为求点到AABB最近点问题。然后算出最近点后再旋转回去。\n但其实可以通过OBB的轴直接计算：\n对于点$P$，直接计算$\\vec{CP}$投影到$\\vec{u_0}, \\vec{u_1}$的距离$d_0, d_1$。然后将$d_0, d_1$都clamp在OBB的半长内：\n$$ \\begin{cases} d^{'}_0 = clamp(d_0, -e_0, e_0) \\\\ d^{'}_1 = clamp(d_1, -e_1, e_1) \\\\ \\end{cases} $$这样就得到相对于OBB局部坐标的最近点位置了，然后转换成全局坐标即可：\n$$ P_{nearest} = (d^{'}_0 \\vec{u_0}, d^{'}_1 \\vec{u_1}) $$ 到三角形的最近点 # 需要通过Barycentric计算得到。首先推导Barycentric的计算公式：\n按照最原始的定义，重心坐标使用面积进行计算：\n$$ \\begin{cases} \\alpha = A_a / A \\\\ \\beta = A_b / A \\\\ \\gamma = A_c / A \\end{cases} $$重心坐标有效的条件是：\n$$ \\begin{cases} \\alpha + \\beta + \\gamma = 1 \u0026 (1) \\\\ \\alpha \\ge 0 \\\\ \\beta \\ge 0 \\\\ \\gamma \\ge 0 \\end{cases} $$注意到$(1)$式中可以使用任意两个量表示另外一个量。所以重心坐标本质上只需要两个量就可以了。\n使用面积计算的缺点是，当点在三角形外时，无法知道其在三角形的哪个Voronoi域中。但我们通过选取按照特定顺序的向量计算来获取这个信息：\n我们首先需要计算$\\vec{D} = \\vec{AB} \\times \\vec{BC}$（注意是按照顶点顺序，代码上是cross(pts[1]-pts[0], pts[2]-pts[1])。这个向量后面帮我们判断三角形面积的符号。\n我们假设$\\vec{D}\\ne 0$。\n然后通过叉乘（同样要按照顶点顺序）得到三个子三角形的面积：\n$$ \\begin{aligned} \u0026 \\vec{D_a} = \\vec{PB} \\times \\vec{PC} \\\\ \u0026 \\vec{D_b} = \\vec{PC} \\times \\vec{PA} \\\\ \u0026 \\vec{D_c} = \\vec{PA} \\times \\vec{PB} \\\\ \u0026 S = ||\\vec{D}||^2 = \\vec{D}\\cdot\\vec{D} \\\\ \u0026 S_a = \\vec{D_a}\\cdot\\vec{D} \\\\ \u0026 S_b = \\vec{D_b}\\cdot\\vec{D} \\\\ \u0026 S_c = \\vec{D_c}\\cdot\\vec{D} \\\\ \u0026 \\alpha = \\frac{\\vec{D_a}\\cdot\\vec{D}}{\\vec{D}\\cdot\\vec{D}} = \\frac{\\frac{1}{2}|\\vec{D_a}||\\vec{D}|\\cos{\\theta}}{\\frac{1}{2}|\\vec{D}|^2} = \\pm \\frac{\\frac{1}{2}|\\vec{D_a}||\\vec{D}|}{\\frac{1}{2}|\\vec{D}|^2} = \\pm \\frac{||\\vec{D_a}||}{||\\vec{D}||} = \\pm \\frac{A_a}{A} \\\\ \u0026 同理可以得到： \\\\ \u0026 \\beta = \\pm \\frac{||D_b||}{||D||} \\\\ \u0026 \\gamma = \\pm \\frac{||D_c||}{||D||} \\\\ \\end{aligned} $$这样得到的重心坐标会带有符号（注意$\\alpha$的计算中，由于$\\vec{D_a}$和$\\vec{D}$不是同向就是异向，所以$\\cos{\\theta}$只能是$\\pm1$）。而这个符号正是我们判断Voronoi域的关键。注意观察上图：\n如果P在三角形内，那么满足重心坐标有效的条件 如果P在某个边的Voronoi域内，那么$S_i$中有一个值是负的，其他两个是正的（也就是说$\\alpha,\\beta,\\gamma$里有一正两负）。P在负的那个对应边的Voronoi域。比如图上$S_a$就是负的，那么P就在$A$点的对边的Voronoi域中 如果P在某个点的Voronoi域内，那么$S_i$中有两个值是负的，一个是正的。P在正的那个对应角的Voronoi域。比如图上$S_a$和$S_b$都是负的，但是$S_c$是正的。那么P就在C点的Voronoi域中 得知了P在哪个Voronoi域中，我们可以非常清晰地知道如何求P到三角形的最近点：\n如果在边的Voronoi域中，直接求P到此边（线段）的最近点 如果在角的Voronoi域中，那最近点就是这个角 其实真正实现上，如果P在边的Voronoi域中（即只要找到任意一个$S_i \\lt 0$），那么直接求这个边的最近点即可。因为此情况包含了角Voronoi域的判断（角的最近点可以退化成求和这个角连接的任意一条边的临近点）。这样算法可能可以提前退出，减少运算量。\n那么对于三维空间中任意点到三角形的最近点。首先将点投影到三角形所在平面上，然后用上述方法找最近点即可。\n四面体(Tetrahedron)最近点算法 # 可以对每个三角面做一次三角形最近点算法，在结果中取最近点。\n但更好的方法是使用推广到3D中的Barycentric公式，配合推广的三角形最近点算法得到：\n假设四棱锥顶点为ABCD，求到P点的最近点。\n首先必须假定：从外部看，所有三角面的顶点顺序是一致的（CW或CCW）\n然后为了方便理解，假定底面的向量叉积得到的法线是指向四棱锥内部的（这一点代码中无需保证。只要所有面的叉积产生的法线一致朝外/内就行，而这一点已由上面的假设确定。我们最后只看异号的数量）\n可以仿照三角形的重心坐标，使用向量混合积得到所有体积：\n$$ \\begin{aligned} \u0026 A_A = \\vec{BC}\\times \\vec{CD} \\cdot \\vec{BP} \\\\ \u0026 A_D = \\vec{AB}\\times \\vec{BC} \\cdot \\vec{AP} \\\\ \u0026 A_B = \\vec{AC}\\times \\vec{CD} \\cdot \\vec{AP} \\\\ \u0026 A_C = \\vec{AB}\\times \\vec{BD} \\cdot \\vec{AP} \\\\ \u0026 A = \\vec{AB} \\times \\vec{BC} \\cdot \\vec{AD} \\\\ \u0026 \\lambda_A = \\frac{A_A}{A} \\\\ \u0026 \\lambda_B = \\frac{A_B}{A} \\\\ \u0026 \\lambda_C = \\frac{A_C}{A} \\\\ \u0026 \\lambda_D = \\frac{A_D}{A} \\\\ \\end{aligned} $$注意底面的两个向量也要按照面环绕顺序选取，以保证最后符号的准确性。\n那么根据上面的假设，如果$\\lambda_i \\gt 0$，那么表示P点在朝向四棱锥内的方向。如果所有$\\lambda_i$都大于0。那么P就在四棱锥内部。此时重心坐标成立。\n如果存在负号的重心坐标。那么显然，P在Voronoi域内。注意四棱锥的Voronoi域有16个：\nFace Regions (4)：每个三角形面的正上方空间 Edge Regions (6)：从 6 条边向外延伸的空间 Vertex Regions (4)：从每个 4 个角顶点向外延伸的空间 上图中橘色是四棱锥，红色是面的Voronoi域（只画了三个）。蓝色是角的Voronoi域。除此之外的全是边的Voronoi域：\n蓝色的是边的Voronoi域，沿着边的两端无限延伸。只是这里画的像是有界限一样。\n那么我们就可以通过$\\lambda i$和$S{abcd}$的异号数目来判断点P在哪个Voronoi域内：\n0个异号：点在四面体里面，重心坐标有效 1个异号：点在对应面的Voronoi域内 2个异号：点在对应边的Voronoi域内 3个异号：点在对应顶点的Voronoi域内 和三角形最近点算法一样，当检测到存在异号之后，就退化成点P到使用对应面所在的三角形的最近点问题，这样可以提前退出算法。\n注意，我们之前假设从外部看所有面的顶点顺序都是一致的。但由于四面体的性质，这不可能成立。至少会有一个面的顺序和别的面不一样。\n你可以在每次算$S_i$的叉乘时看其和$\\vec{AB} \\times \\vec{BC}$的方向是否一致，不一致就取反。但有一个更聪明的方法:\n选定某个顶点（比如$A$） 计算$S$的时候，使用从此顶点出发的两个边向量（比如$\\vec{AB},\\vec{AC}$）进行叉乘 计算$S_i$的时候，叉乘中不使用面上的两个向量，而也是从此顶点出发，取一个边向量（如$\\vec{AB}$）以及和P点连接的向量（这里是$\\vec{AP}$）进行叉乘（叉乘的顺序也有讲究，要保证当P在四面体时结果为正，这点可以直接从几何图形上得到）： $$ \\begin{aligned} \u0026 A = \\vec{AB} \\times \\vec{AC} \\cdot \\vec{AD} \\\\ \u0026 V = \\vec{AP} \\\\ \u0026 A_A = \\vec{V}\\times \\vec{AC} \\cdot \\vec{AD} \\\\ \u0026 A_D = \\vec{AB}\\times \\vec{V} \\cdot \\vec{AD} \\\\ \u0026 A_B = \\vec{AB}\\times \\vec{AC} \\cdot \\vec{V} \\end{aligned} $$有了前三个和$A$点相关的子四面体的面积，我们可以得到前三个重心坐标。最后一个利用公式：\n$$ \\lambda_4 = 1 - \\lambda_1 - \\lambda_2 - \\lambda_3 $$可以得到。\n知道在哪个Voronoi域中，我们就可以选择和这个域相邻的某个面。此时，算法就退化成点P到三角形的情况。\n在实际计算时，只要存在$\\lambda_i \u0026lt; 0$，就用对应三角面做点P到三角面的最近点算法就行。\n重心坐标的退化 # 退化指原本的几何体降低了维度。导致原本的公式失效：\n2D重心坐标中，如果三个点构成直线或点 3D重心坐标中，四个点构成三角面/直线/点 这个时候，相当于丢失了一个点的信息，此时重心坐标无法计算。\n这时只能特殊情况特殊判断。但一般不会在重心坐标算法本身中处理这个事情。一般会返回一个标志标识是否退化。上层检测到了之后再根据自己的需求处理这个情况。\n一般凸多面体最近点算法 # 使用GJK算法。这个算法很重要，得后面单独开一章说。\n距离计算 # 绝大部分距离计算都可以转换为点到几何体最近点的距离问题（或几何体到几何体最近点的距离问题）。变相地将问题化简为最近点问题（或寻找两物体上的最近点对问题）。\n公式大全：Geometric Tools: About Geometric Tools for Computer Graphics\u0026#160;\u0026#x21a9;\u0026#xfe0e;\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n实时碰撞检测算法技术 (豆瓣)\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","date":"18 July 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E4%B8%89/","section":"Posts","summary":"这一章描述了各种几何体的最近点计算\n大部分的算法在公式大全1中都有详细推导。我只记录一些较难理解的。\n","title":"从0开始制作游戏物理引擎（三）","type":"posts"},{"content":"这一章描述了各种几何体表示法，以及BV构建。\n几何体表示 # 首先要确定几何体的表示。某些几何体有多种表示，不同表示法适用于不同的情景。需要根据场景选择。\n一般都会使用向量形式而非数学上的方程形式。向量形式计算非常方便，很多算法也是基于向量形式的。\n球体：母庸质疑，使用点和半径表示\nstruct Sphere { Vector3 m_center; float m_radius; } AABB：有两种表示\n// 使用中心和半长表示 struct AABB { Vector3 m_center; Vector3 m_half_extent; }; // 最小最大值表示 struct AABB { Vector3 m_min; Vector3 m_max; }; 一般使用第一种表示法。第二种可以用在快速判交里面。\nOBB：一般使用中心和旋转表示：\nstruct OBB { Vector3 m_center; Vector3 m_half_extent; std::array\u0026lt;Vector3, 3\u0026gt; m_axises; }; // 或者直接将half_extent并入m_axises也行： struct OBB { Vector3 m_center; std::array\u0026lt;Vector3, 3\u0026gt; m_axises; // 每个轴不是单位的，含有长度 }; // 或者直接存旋转 struct OBB { Vector3 m_center; Quaternion m_rotation; }; 从上往下空间越来越少，但计算使用的时间可能越来越长。一般要有利于计算的是第一或者第二种。\n胶囊体：使用中心，半高和半径表示\nstruct Capsule { Vector3 m_center; Vector3 m_axis; float m_half_height;\t// 不是全高，是圆柱体的半高 float m_radius; // 两端半球的半径 }; // 或者用两端点法表示。但一般不这样，因为不利于旋转计算 struct Capsule { Vector3 m_p1, m_p2; // 圆柱体上下底面中心点 float m_height; }; 使用圆柱体半高是为了方便某些算法的计算（很多算法和圆柱中轴线相关）。\n3D单型（四面体）：\nstruct Tetrahedron { std::array\u0026lt;Vector3, 4\u0026gt; m_pts; }; 平面：使用点法式，很多计算都可以方便地用上。一般式基本上没什么用。\nstruct Plane { Vector3 m_p; Vector3 m_normal; }; 线段：使用点向式\nstruct Segment { Vector3 m_p; Vector3 m_axis; float m_len; }; 当然也可以用两个端点表示。但优势不大。点向式可以让m_len \u0026lt; 0来表示直线，同时也可以表示射线（m_len带有方向）\n凸多边形：\n// 直接记录所有点 struct Polygon { std::vector\u0026lt;Vector3\u0026gt; m_vertices; } // 拓扑方式 struct Edge { size_t m_index1, m_index2;\t// 记录两端点在Polygon::m_vertices内的下标 }; struct Vertex { Vector3 m_position; std::vector\u0026lt;size_t\u0026gt; m_edges; // 记录边在Polygon::m_edges内的下标 } struct Polygon { std::vector\u0026lt;Vertex\u0026gt; m_vertices; std::vector\u0026lt;Edge\u0026gt; m_edges; }; // 方便做旋转的方式 struct Polygon { Vector3 m_center; std::vector\u0026lt;Vector3\u0026gt; m_dir;\t// 中心点到其他点的方向 std::vector\u0026lt;float\u0026gt; m_lens; // 中心点到其他点的半径 Quaternion m_rotation; }; 拓扑方式方便使用爬山法构造BV。而最后一种方式方便平移和旋转。注意m_dir一般不进行旋转，就是原始位置。计算的时候再进行旋转。这样有利于直接设置旋转（不然会丢失顶点的原始位置，每次直接设置旋转，还得将m_dir反算回原始位置，这样既耗时，也可能产生精度损失。注意这和OBB不一样。OBB的轴的原始轴是确定的（x, y, z轴），所以不需要反算）。\nBV构建 # BV是Bounding Volume（包围盒）的简写。现在常用的包围盒就是AABB和球包围盒（尽管很多资料上还提到OBB包围盒，N-DOP包围盒和凸包包围盒。但不常用）。\n球体包围盒存储上更少，算法上也更快，但是对于长条形物体不够“紧“。而AABB包围盒则相反。\n但对于沿着中心旋转的几何体来说，球体包围盒不需要每次重新构建。而AABB需要重新构建。\n具体使用需要根据场景选取，或者融合使用。\nAABB包围盒构建 # 基于原点的构造 # 用于初次构造AABB。最简单的AABB构造法：遍历每个点，找到每个轴上最前/最后的点构造包围盒\n爬山法构造 # 当对顶点很多的凸多面体或三角网格构造时，遍历所有顶点显然麻烦。这时，可以通过其拓扑表示法，利用爬山法进行构造。\n爬山法的过程：\n选取任意顶点 沿着顶点的边找到下一个沿着某轴正向/反向最远的点 重复步骤2，直到找到最远的点对（正方向和负方向各一个） 对每个轴做2,3步，找到所有最远点，从而构建AABB 通过拓扑结构可以省去遍历很多点。本质上是一个贪心算法。\n旋转AABB的构造 # 如果已经有一个AABB，可以轻松构造在此基础上相对原点旋转的AABB（AABB本身不一定在原点）。但构造完的AABB不是紧密贴合的。\n方法是将这个AABB视为在原点的，并直接对这个AABB进行旋转，而不是对物体旋转后再求AABB：\n// position是AABB中心 void GetRotatedAABB(Vector3 half_len, Vector3 position, Quaternion rot, Vector3\u0026amp; out_min, Vector3\u0026amp; out_max) { Vector3 half_extent = std::abs(rot * half_len); // 注意，我们计算的是旋转后AABB的大小，所以要加绝对值 Vector3 world_center = rot * position; out_min = world_center - half_extent; out_max = world_center + half_extent; } （注：《实时碰撞检测算法技术》中是手动将矩阵乘法展开，每个量分开算。这里直接用矩阵乘法可以更高效利用SIMD）\n这里origin_min和origin_max最好使用最原始的AABB。如果每次在之前的AABB上旋转，可能由于浮点数误差越转越大。\nK-DOP # K-DOP（K-Discrete Oriented Polygon，有向离散多面体)是指用$\\frac{k}{2}$对个平面构成的BV。每一对的平面都互相平行，只是位置不同。\n存储使用：\ntemplate \u0026lt;size_t K\u0026gt; struct DOP { std::array\u0026lt;float, K\u0026gt; m_mins; std::array\u0026lt;float, K\u0026gt; m_maxs; std::array\u0026lt;Vector3, K/2\u0026gt; m_normals; // 可选的平面法线方向，一般不用存储 }; 其中m_mins是多边形所有点投影到第$i$个平面法线指向轴(m_normals[i/2])中的最短距离（即到平面的距离）。几何体上任意一点一定满足m_mins[i] \u0026lt;= dot(pt[i], UNIT_X) \u0026lt;= m_maxs[i]。\n每对平面的法线方向，是因为对于K-DOP来说，当K较少时一般我们有约定俗成的法线方向。比如三维的6-DOP就等价于AABB，法线方向就是三个轴的正向。\n对于三维的16-DOP，法线方向除了三轴的正轴，还有 $(\\pm x, \\pm y, \\pm z)$ 8个轴。\n对于二维的8-DOP，除了三轴正轴外还有 $(\\pm x, \\pm y)$ 4轴。\nK-DOP的构造 # 和构造AABB一样，探测轴上最远和最近的点然后存下来就行。\n当然也可以用爬山法构造。\nK-DOP的相交测试 # 两个K-DOP相交测试的前提是，K值是一样的。\n其实就是AABB的相交测试的扩展。对每一个方向的轴，如果存在min1[i] \u0026gt; max2[i] || max1[i] \u0026lt; min2[i]那么就是不相交的。\n球包围盒构建 # 基于AABB的构造 # 首先计算物体的AABB。然后通过AABB的中心计算球的半径（找到最远点距离中心的长度）。\n或者直接得到AABB对角线的一半作为半径也可以，但这样不太贴合。\nWelzl算法构建最小包围球 # TODO\n工程上的考量 # float还是double # 大部分物理引擎都可以进行float和double的配置切换。我们也可以照猫画虎：\n// config.hpp #ifdef TOY_PHYSICS_USE_DOUBLE using real = double; #else using real = float; #endif 以后就用real表示浮点数。\n那么数学库也要能够配置：\nusing Vector3 = Eigen::Vector3\u0026lt;real\u0026gt;; // ... 其他alias 算法函数的编写 # 在开发前期你可能不清楚究竟用哪种几何表示法更好（后面可能会更改表示法）。这个时候函数可以将参数全部摊开，以最有利于计算的方式编写：\n// 不好的写法。因为可能后面AABB要换表示形式，这样这里的算法就得改 bool IsAABBIntersect(const AABB\u0026amp; aabb1, const AABB\u0026amp; aabb2); // 将参数摊开，这适用于所有表示法。不同的表示法在外面转换成这个参数即可。丑是丑了点但耐用 bool IsAABBIntersect(Vector3 min1, Vector3 max1, Vector3 min2, Vector3 max2); 同时，摊平参数还可以防止在结构体中不小心引入的padding导致SIMD利用寄存器传递失效（见下一小节）。\nSIMD优化 # 我们不自己造数学库，而是使用含有SIMD优化的数学库Eigen。那么就要注意有些写法看上去更慢，但实际是能够利用上SIMD的反而更快，比如判断AABB是否相交，如果逐分量判断可能会破坏SIMD（但你开-O3优化编译器说不定又给你优化成SIMD了。这里只是举个例子提醒要注意这种写法）：\n// 正常写法 bool IsAABBIntersect(Vector3 min1, Vector3 max1, Vector3 min2, Vector3 max2) { return !(min1.x() \u0026gt; max2.x() || min1.y() \u0026gt; max2.y() || min1.z() \u0026gt; max2.z() || max1.x() \u0026lt; min2.x() || max1.y() \u0026lt; min2.y() || max1.z() \u0026lt; min2.z()); } // 利用SIMD的写法（这里的条件通过将上面的not条件拆开得到） return (min1.array() \u0026lt; max2.array()).all() \u0026amp;\u0026amp; (min2.array() \u0026lt; max1.array()).all(); 这里的小于比较Eigen可能会优化成一条SIMD指令同时比较三个分量。\n另外一个考量是，由于存在SIMD优化，整个Vector3/Vector4/Quaterion等可以优化成一份SIMD数据。所以函数传参时比起传引用，不如直接传值让SIMD利用寄存器传递更快。Eigen官方的说法是固定大小且小于16字节的推荐直接传值。\n参考 # ","date":"17 July 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E4%BA%8C/","section":"Posts","summary":"这一章描述了各种几何体表示法，以及BV构建。\n","title":"从0开始制作游戏物理引擎（二）","type":"posts"},{"content":"这一章描述了物理引擎的结构，并对整个系列文章的路线进行介绍\n游戏物理概述 # 常见的物理引擎由如下几部分组成：\n最基础的：\n数学库：或者说线性代数库。基本上都需要支持SIMD，因为物理是需要高性能的系统。市面上大部分开源的都是自研（PhysX, Jolt, Bullet, UE的Chaos）。我这个系列就直接调库了（Eigen库）\n几何和空间划分：\n几何：几何体信息和碰撞检测相关算法：\n几何体构建：比如从点云构建凸包的凸包算法，快速构建AABB等BV的BV构建算法 碰撞检测相关：包括几何体之间的判交，最小穿透深度计算，支撑点计算，射线和扫略检测等 几何是最基本的功能，无论是Query还是Simulation solver都需要他提供信息\n空间划分：用来加速碰撞检测相关算法的辅助功能\n模拟：使用物理定律进行物理模拟。这里的核心是模拟解算器（Simulate Solver）。不同引擎会使用不同的模拟算法，一般针对刚体使用基于冲量的算法及其变体（比如TGS, PGS等），刚体算法中还有约束求解器（用于碰撞分离和Joint结构）。对于软体和流体使用PBD/XPBD算法。不过现在PBD算法也可以用于刚体模拟（比如UE的Chaos）\n模拟大部分时间是在做表现相关的事情（纯提供视觉效果的）\n查询：包含三大查询算法——Raycast, Sweep, Overlap。在游戏中查询往往用的比模拟更广泛，比如物体判定，人物被障碍物阻挡判定，FPS中子弹判定，空气墙，Trigger等。\n查询使用纯粹的几何算法，和物理定律无关\n基于物理引擎的各种上层实现：这里所有的功能都是用户可以使用物理引擎实现的。只不过有些实现是引擎自己就有的（比如PhysX对载具，破坏物体，布料模拟都支持）。常见的为：\nRagdoll：布娃娃系统。一般只使用模拟 破坏系统：一般也只使用模拟，用来模拟碎块 布料/流体模拟 基于物理的动画：可能使用模拟（比如物理和动画融混）或者查询 载具：可以完全使用模拟，也可以模拟+查询（比如PhysX的载具轮子是用查询做的） CCT：人物控制器，一般是纯查询（但JoltPhysics也提供了基于模拟的CCT） 其他的游戏项目相关功能 本系列的路线 # ToyPhysics（开发中） # Github地址：ToyPhysics\n引擎自带一个sandbox，里面显示各种各样的例子。使用raylib创建可视化。\n路线 # 从组成图的下往上实现。首先是几何和空间划分算法。然后是Query系统（因为Query只依赖几何不依赖模拟）。然后尝试各种模拟算法，主要是刚体模拟（不包含PBD）和约束求解等。然后实现PDB算法，并且实现布料模拟。然后反过来，使用PBD模拟刚体。最后做剩下的上层应用（CCT，载具，破坏物）。\n整个架构和API应该会很像PhysX。因为PhysX是我用的最多的物理引擎。\n","date":"16 July 2026","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E6%B8%B8%E6%88%8F%E7%89%A9%E7%90%86%E5%BC%95%E6%93%8E%E4%B8%80/","section":"Posts","summary":"这一章描述了物理引擎的结构，并对整个系列文章的路线进行介绍\n","title":"从0开始制作游戏物理引擎（一）","type":"posts"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/c++/","section":"Tags","summary":"","title":"C++","type":"tags"},{"content":"在使用C++ atomic原子变量的时候，需要指定内存序(memory order)，比如memory_order_relaxed，memory_order_acquire等。我一直没搞明白这些内存序是在做什么。这几天深入理解了一下于是写下这份文章。\nCache相关 # Cache类型 # 这一部分是扫盲，也算是备忘。Cache分为三级。L3 Cache, L2 Cache, L1 Cache：\nL3 Cache：向下直接和内存交互，向上和L2 Cache交互，一般只有一条L3，对所有L2 Cache可见 L2 Cache：向下直接和L3 Cache交互，向上和L1 Cache交互。可能所有核心共享一份L2 Cache，也可能不是，得看CPU架构 L1 Cache：向下和L2 Cache交互，向上和CPU核心交互。每个核心都有自己的L1 Cache，不互相共享。L1 Cache又分为存储数据的L1d Cache和存储指令的L1I Cache 从L3到L1 Cache，容量越来越小，速度越来越快，造价越来越贵。此外CPU内还有寄存器，速度最快。\nCache映射算法 # 每次访问数据时，需要检查数据是否在Cache中。如果不在需要从主存-\u0026gt; L3 -\u0026gt; L2 -\u0026gt; L1这样一步一步将数据放到Cache里面，这样CPU才能看到值（刷新Cache Line）。如果数据在Cache中称为缓存命中。给定一个内存地址，通过映射算法找到在Cache中对应的数据（如果不存在就从主存内取）。\nCache的最小单位不是字节，而是Cache line，一般为64字节。\n有三种映射算法：\n直接映射（Direct Mapped）（已淘汰）：将地址拆分成三部分—— |Tag标记|Index|Offset|\nIndex：当前地址应该存储在哪个Cache line中\nOffset：当前地址指定的值在这个Cache line中的偏移量\nTag标记：当前地址是否对应这个Cache Line\n假设地址A是64位的，现在有8个Cache Line，每个Cache Line 64字节，这样Offset是6位(2^6 = 64)。Index是3位（2^3 = 8），剩下的55位就是Tag标记。\n首先，计算这个地址应该对应哪个Cache Line，通过 (A \u0026raquo; 6) \u0026amp; 0b111得到当前应该在哪个Cache Line。\n然后，看对应的Tag (A \u0026raquo; 9)和当前Cache Line记录的Tag（没错Cache Line除了记录64位数据还得记录Tag，甚至可能有一些元数据），如果两个Tag相等，说明此时地址对应的数据已经在此Cache Line中，那么不刷新此Cache Line（注意Cache Line也可能失效，因为主存内的数据比Cache Line中的新，这个时候元数据里会记录这个状态，会刷新Cache Line），否则进行Cache Line刷新。那么接下来要从此Cache Line中取得数据，通过 A \u0026amp; 0b111111 得到地址在Cache line中的偏移量，返回给CPU。\n这个缺点很明显：如果两个常用块映射到同一个Cache Line，则这个Cache Line会不停地刷新（被反复替换），就算有空的Cache Line也用不上。\n全相连映射（Fully Associate）（已淘汰）：将地址拆成两份——|Tag标记|Offset|\n查找时将所有Cache Line的Tag与要求的地址Tag并行比较（通过硬件实现）。优点是速度更快，缺点则是硬件昂贵。\n组相连映射（Set Associate）：把Cache Line分为若干组，每组N行（称为N路组相连）。地址拆成三份——|Tag标记|Set Index|Offset|\n假设分为4组，每组8个Cache Line，每个Cache Line也是64字节。那么Set Index是2位，Offset则是6位，剩下56位是Tag标记。\n对于64位地址A，首先确定其在哪个组，(A \u0026raquo; 6) \u0026amp; 0b11，然后对组内所有Cache Line判断哪个Cache Line的Tag相等（硬件并行），找到对应的Cache line，然后通过Offset在这个Cache Line中找到对应位置。\n如果缓存未命中，则执行粗略的LRU算法，将长时间不用的Cache Line替换。这样就比直接映射更好，减少了同一个Cache Line被重复替换的情况。比起全相连映射，硬件比较器的成本也更低。\nMESI # 刚才说了Cache映射算法。有一种情况未讨论，就是当目标地址和Cache Line都对的上，但是对应主存内的数据比Cache Line要新，此时Cache Line也要更新。那Cache怎么知道主存内数据更新呢？这就是MESI（CPU缓存一致性协议）的用途。\nMESI由四种状态组成：\nM(Modified)：此Cache Line的数据被CPU更改过，与主存值不同，如果其他Cache Line要读主存这块数据，此Cache Line必须先写回主存 E(Exclusive)：此Cache Line数据与内存一致，且只有此Cache Line有这份数据（独占） S(Shared)：此Cache Line数据与内存一致，但有多个Cache Line持有这份地址上的数据（共享） I(Invalid)：该Cache Line无效（可能是开机启动时还没写入值，或者被别人写入失效） MESI本质上是个状态机。具体如下（直接抄的Wiki，Wiki上说的很清楚，但是图好像画错了，我改了一下）：\n处理器对Cache Line的请求：\nPrRd：处理器请求读一个Cache Line PrWr：处理器请求写一个Cache Line 总线对Cache Line的请求：\nBusRd：总线指出有一个其他CPU核心对Cache Line要读取 BusRdX：总线指出CPU核心请求写一个此核心不拥有的内存 BusUpgr/BusRd(S)：总线指出CPU核心请求写一个该核心拥有的内存 Flush：总线指出某个Cache Line请求将数据写回主存 FlushOpt：总线指出某个Cache Line要将数据发给另外一个Cache Line 图中边上的操作，/前是CPU主动发出的操作，后面是发出之后总线立刻发出的请求。\n注意：WIKI上的图和他说的操作不一致。图是MESI基本版的，但是操作却是优化版的。不要再看WIKI上的图了。\n比如M状态，此时CPU无论是读(PrRd)还是写(PrWr)，因为Cache Line此时就是最新值，那么直接读写，不会跳转到其他状态，总线也不会报任何请求。\n对于E状态，如果CPU要求读取(PrRd)，那么就直接从Cache Line中读。但如果要写(PrWr)，那么显然E状态要转变为M状态，此时此Cache Line内的值是新的，且没写回主存。但此时总线依旧不进行请求（因为根本没人读这块内存）。\n但是对于S状态，如果CPU进行PrWr的写请求，此时需要从S状态切换到M状态（表示我有最新数据），并且总线发出BusUpgr请求，表示我这个Cache Line开始写了。并且其他所有数据都应该失效。那么所有原本位于S状态的Cache Line都需要切到I状态标识无效，直到在I状态发出PrRd或PrWr请求才从内存中读取新值。其他状态同理。\nStore Buffers # MESI虽然很可靠，但是会带来更多的延迟。比如某个Cache Line在S状态，需要将自己的信息写入变为M状态。这个时候发出的BusUpgr信号会将所有S状态都变为I状态，并等待这个状态做完后再执行PrWr。这时间是非常长的。\n为了降低这种延迟，为每个CPU核心增加一个Store Buffer，在每次写入值时，先将值写入Store Buffer，然后去处理其他事情。当所有失效的确认都收到，数据才从Store Buffer里提交出去。\nStore Buffers有两个风险：\n处理器自读自写的时候会读到旧值（因为写直接写入Store Buffers而非Cache，读却从Cache中读）。处理方法是多读一次Store Buffer（叫store-to-load forwarding或者 store forwarding）。不过这一步对程序员是透明的，我们写码时不用关心。\n跨核心数据不可见：CPU核心之间的Store Buffer是互相不可见的，这意味着不同核之间仍旧会读到其他核对应Cache中的旧值。举个例子：\n初始: x = 0; // 在核1缓存中有效 y = 0; // 在核2缓存中有效 核1： x = 1; r1 = y; 核2： y = 1; r2 = x; 此时，核1执行 x = 1 时，塞入Store Buffer，然后继续做下一条 r1 = y。此时他根本不知道和2的y有没有塞到主存里面（因为可能核2也赛道Store Buffer里面了）。所以此时r1可能是0。 那么同时，核2执行 y = 1 时， 塞入Store Buffer，然后下一条 r2 = x，这时也可能读到x的旧值0。 这样就有问题。这就是memory order要解决的一个问题。\nInvalidate Queue # 某个Store Buffer写入主存的时候,需要等待其他Cache Line的invalidate响应消息，等他们应答之后再继续。核心的应答是将自己对应的Cache Line标记为Invalid。但其他核可能在忙迟迟不应答。这个时候，如果发起核还在不停写Store Buffer给他写满了，那到时候就真的只能等Store Buffer写入主存后再执行指令了（不能越过Store Buffer继续执行后面指令了）。\n所以为了加快invalid响应，其他核在收到invalidate消息时，会先将消息请求丢到Invalidate Queue中排队，然后立刻返回一个invalidate响应。至于自己什么时候真正处理这个Invalidate消息那是他自己后面再找时间。\n这样发起核可以尽快排空Store Buffer。\n但是问题在于，其他核由于没有及时做Invalidate操作，可能发起核的Store Buffer排空，所有数据都已经写入主存了，这时本核还认为自己的Cache Line是有效的，又从自己的Cache Line里面读旧值（核心只会查Cache，不会查Invalidate Queue来看他自己有没有无响应操作）。具体例子就是：\n初始：data = 0, flag = 0 核A： data = 42; flag = 1; 核B： while (flag == 0 ) {} r = data; 假设A的 data 写被B放入Invalidate Queue中。然后flag的写被B读到了。 与此同时，B的while已经跳出了，但是r读的data值仍旧是旧值0. 这样也会有问题。这就是memory order要解决的另外一个问题。\nMESI与Data Racing # 注意，MESI管辖的范围非常小，他只管多个Cache之间的一致性(Cache Coherency)。而Data Racing发生在内存一致性(Memory Coherency)，是发生在主存上的。MESI并管不到他。\nMESI甚至对Store Buffers和Invalidate Queues没有感知。也就是说就算MESI规定了每个Cache之间的值一致性，但Store Buffers和Invalidate Queues造成的数据往主存写入的时机可能会撞车（比如两个Store Buffers同时往一个地址上写，当然由于主存所在Bus只有一条，严格意义上说往主存写的操作同一时间只能有一个。那么第一个Store Buffer往里面写入，后一个Store Buffer再写入就会覆盖掉之前的值，造成数据竞争）。\nMemory Order # memory order真正在做的事情有两件：\n对于同一线程的指令，防止编译器指令重排：因为编译器可能会将不想关的指令重新排列，比如:\nint data = 0; std::atomic\u0026lt;bool\u0026gt; ready{false}; // 生产者线程 data = 42; // 普通写 ready = true;\t// 假设这里没有memory order约束 // 消费者线程 while (!ready) {} // 假设这里没有memory order约束 assert(data == 42); // 可能失败! 这里，生产者线程和消费者线程都可能进行指令重排。这个时候变成：\n// 生产者线程 ready = true;\t// 假设这里没有memory order约束 data = 42; // 普通写 // 消费者线程 assert(data == 42); // 失败！ while (!ready) {} 显然这里会出问题（当然生产者线程重排，消费者线程没重排也会出问题。有多种出错方式）。\n所以要阻止指令重排。\n对于不同线程的指令，约束Store Buffer和Invalidate Queue（也可以认为是防止硬件重排，因为现代CPU还有更多的小优化都会破坏一致性）。\n真正意义上来说，memory order其实是控制内存操作顺序和跨线程可见性。\n注意，memory order只会禁止重排自己线程的指令（软件和硬件上），不会影响别的线程。\n那么就可以真正来看一下各个memory order到底在做什么了：\nrelaxed：不做任何保证，不防止指令和硬件重排。一般用于纯计数功能。\nrelease：只用在写操作上。在他之前的指令不能重排到他之后，硬件上则在执行此指令前排空Store Buffer,保证前面的写入均被其他线程可见。与acquire配对使用。\nacquire：只用在读操作上。在他之后的指令不能重排到他之前，硬件上则保证在执行此指令之后，刷Invalidata Queue（让release之前的所有值都在acquire处可见）。与release配对使用\nacq_rel：仅RMW操作使用，因为RWM即读又写，所以这个操作其实是同时做了acquire和release\nseq_cst：是acquire + release的增强版，可以同时用在读和写操作上。在原子指令写后/读前排空Store Buffer/Invalidate Queue，让原子操作在写完之后立刻可见，以及在读前可见别的线程的值。\n这意味着代码就是按照你写的方式执行，没有任何重排，缓存也必须及时刷新。正如其名称，这种严格一致性被称为“顺序一致性”(Sequential Consistency)，是所有一致性中最严苛的，也是最昂贵的。其原始定义为：\n多线程执行的结果，等同于把所有线程的操作【交错排进某一条单一的全局顺序】里逐个执行；且每个线程自己的操作，在这条全局顺序里保持它的【程序顺序】\n从指令重排的角度思考memory order # 任意两条不相关的读写指令都可能重排，存在四种指令重排：\n类型 含义 示例 LoadLoad 两个读指令之间的重排 load A; load B;重排为 LoadB; LoadA; LoadStore 读写指令重排 LoadA; Store B; 重排为 Store B; Load A; StoreStore 写指令重排 Store A; Store B; 重排为 Store B; Store A; StoreLoad 写读指令重排 Store A; Load B; 重排为 Load B; Store A; 不同架构允许不同的指令重排。\n在x86上，只有StoreLoad被允许。在其他弱内存架构（ARM, RISC-V等）中可能允许其中四种。\n那么也就是说，当你使用acquire/release禁止前三种重排时，在x86上几乎没代价（硬件根本不会重排）。但第四种几乎是在任何平台上都会产生代价的重排。\n各种memory order约束的指令重排如下：\nmemory order 约束的重排 relaxed 无约束 acquire LoadLoad + LoadStore （后面的读写不能排到前面） release StoreStore + LoadStore （前面的读写不能排到后面） seq_cst 约束所有的重排（也是唯一能约束StoreaLoad的） 再次举例为什么需要seq_cst：假设下面的例子造成的StoreLoad情况：\na = 123 x.store(2, release) y.load(acquire) b = 456 这时只是a不能迁移到x下面，b不能迁移到y上面，但是x和y本身可以互相重排，这导致这种重排结果：\na = 123 y.load(acquire) x.store(2, release) b = 456 甚至是这种重排结果：\ny.load(acquire) b = 456 a = 123 x.store(2, release) 这就是为什么需要seq_cst，使用下面的代码会避免重排：\na = 123 x.store(2, seq_cst) y.load(seq_cst) b = 456 其他杂项 # 互斥锁的指令重排 # Herb Sutter在他关于Atomic的演讲中（见参考1）说了这样一个例子：\nx = \u0026#34;life\u0026#34; mut.lock() y = \u0026#34;universe\u0026#34; mut.unlock() z = \u0026#34;everything\u0026#34; 这段代码可以被重排为：\nmut.lock() x = \u0026#34;life\u0026#34; y = \u0026#34;universe\u0026#34; z = \u0026#34;everything\u0026#34; mut.unlock() 但不能被重排为：\nz = \u0026#34;everything\u0026#34; mut.lock() y = \u0026#34;universe\u0026#34; mut.unlock() x = \u0026#34;life\u0026#34; 从语义上理解，是因为这里y可能是一个flag，当其他线程看到y被设置值之后，就认为x已经被设置值了。最下面的重排破坏了这个意思。\n从实现上理解，在实现互斥量的时候，其实内部就用到了acquire和release操作。上面的代码其实含有如下意思：\nx = \u0026#34;life\u0026#34; acquire() y = \u0026#34;universe\u0026#34; release() z = \u0026#34;everything\u0026#34; 那么从memory order的角度考虑，显然x和z不能随意重排（当然真实mutex实现更复杂）。\n（同时注意这里是先acquire后release，和之前的seq_cst例子不一样）\n参考 # C++ and Beyond 2012: Herb Sutter - atomic Weapons 1 of 2（看之前需要了解Store Buffer） CppCon 2017: Fedor Pikus “C++ atomics, from basic to advanced. What do they really do?” ","date":"21 June 2026","externalUrl":null,"permalink":"/posts/c++%E5%8E%9F%E5%AD%90%E5%86%85%E5%AD%98%E5%BA%8F%E5%92%8Cmesi/","section":"Posts","summary":"在使用C++ atomic原子变量的时候，需要指定内存序(memory order)，比如memory_order_relaxed，memory_order_acquire等。我一直没搞明白这些内存序是在做什么。这几天深入理解了一下于是写下这份文章。\n","title":"C++原子内存序和MESI","type":"posts"},{"content":"","date":"21 June 2026","externalUrl":null,"permalink":"/tags/%E5%A4%9A%E7%BA%BF%E7%A8%8B/","section":"Tags","summary":"","title":"多线程","type":"tags"},{"content":"","date":"2 August 2025","externalUrl":null,"permalink":"/tags/game-physics/","section":"Tags","summary":"","title":"Game Physics","type":"tags"},{"content":"本文基于PhysX5.5.0源码分析（不过PhysX在这方面从4.0开始几乎没有什么改动）。讲述了PhysX CharacterController的设计实现，以及其中存在的问题。\n注：PhysX的CCT实现非常简陋（如果不是工作原因不建议看源码，有这时间不如去看看UE或者Jolt这种物理引擎的实现），而且存在很多问题。我是因为工作原因才要学习，没办法。\nCCT是什么 # CCT（CharacterConTroller）是角色控制器。一般用于控制玩家角色。使用纯粹的物理模拟会有很多的问题，比如如何让人物站在斜坡上，根据物理定律，需要摩擦力在静摩擦范围内物体才能停留在斜坡上。但不同的斜坡摩擦系数可能不相同，而在游戏开发时不太可能针对每个不同的斜坡调整玩家物理刚体的摩擦系数。同样，刚体模拟的底层算法可能会造成物体的抖动（比如Box2D使用的Sequential Impulse，是在刚体碰撞时使用冲量将物体推开，当物体从高处落到地面时，解算器其实是需要几帧的时间才能将物体推开，这时可能会带来几帧的物体抖动。虽然表面上看上去能够接受，但当制作第一人称游戏时，这种抖动会通过摄像机直接反馈给玩家，体验极差）。而且人物可能会做很多反物理的行为（比如蹬墙跳），这时使用纯粹的物理模拟会增大开发难度。\n而CCT就是完全为角色量身定制的功能。CCT和游戏玩法强相关，虽然现代物理引擎基本上都会提供一个CCT功能，但大部分时候还是要根据游戏进行魔改或者完全重写。绝大部分的CCT不使用物理模拟，CCT本质上是一个几何形体（一般是胶囊体，立方体或者圆柱体），使用场景查询(SceneQuery)功能配合一些碰撞检测算法（用于在物体相交时进行挤出）实现。\n通用CCT一般处理如下问题\nMoveAndSlide：最核心的功能，分为两步： Move：对CCT进行移动 Slide：移动过程中使用几何扫略（Sweep），如果碰到了物体到物体之后贴着物体移动（Slide） 处理爬坡问题：可以爬坡，但太陡峭的坡爬不上去，或者站在上面会滑下来 处理上楼梯问题：可以爬低矮的楼梯，但太高的楼梯无法爬上去 处理移动平台问题：当人物站在移动平台上，应该要随着平台一起移动 处理和其他CCT碰撞的问题：角色之间如果碰撞应该如何解决（直接穿过去还是阻挡等） 处理和非CCT的动态物碰撞问题：比如角色被巨石挤开 PhysX CCT原理与实现 # PhysX CCT本身是一个不进行模拟的Kinematic刚体，实现总共分为三步：\n检测当前是否站在平台上，处理移动平台问题\n做爬坡和爬楼梯（以后统称为爬坡）\n如果爬坡梯失败，使用正常的MoveAndSlide： 我们这里只分析对胶囊体的移动（PhysX还支持立方体，但是情况比胶囊体简单所以不再分析）。\n胶囊体移动的源码在\n// CctCharacterController.cpp PxControllerCollisionFlags Controller::move(SweptVolume\u0026amp; volume, const PxVec3\u0026amp; originalDisp, PxF32 minDist, PxF32 elapsedTime, const PxControllerFilters\u0026amp; filters, const PxObstacleContext* obstacleContext, bool constrainedClimbingMode) PhysX不是直接移动CCT所用的刚体，而是先构造一个SweepTest（里面存储着CCT形状和其他信息），对整个SweepTest做移动，最后再将位置同步回CCT刚体。\n其主要流程为：\n使用findTouchedObject（以及后面moveCharacter中DOWN_PASS）找到现在站的移动平台，并且使用rideOnTouchedObject处理移动 第一次moveCharacter处理爬坡 如果第一次moveCharacter失败，将胶囊体回滚到最开始的位置，进行第二次moveCharacter走正常move and slide 处理移动平台问题 # 接触到的物体保存在SweepTest::mTouchedActor和SweepTet::mTouchedShape中。接触物体的位置保存在SweepTest::mTouchedPosShape_World和SweepTet::mTouchedPosShape_Local中。因为这是move的开头，所以此时保存的是上一次move的接触物体。\n检查接触物是否有效，以及找到新接触物 # 首先判断当前接触物体是否还生效：\n查看mTouchedActor和mTouchedShape是否还存在，以及mTouchedShape是否还在mTouchedActor上 使用用户自定义的Filter过滤 检查mTouchedShape是否还能进行场景查询（PxShapeFlag::eSCENE_QUERY_SHAPE） 如果经过检查发现失效，使用findTouchedObject获得当前的接触物体以及物体位置。\nfindTouchedObject只会检测动态物。方法是从胶囊体的中心向下打射线，长度为胶囊体半高。然后取得第一个打到的物体。\n如果有UserObstacle，再对UserObstacle进行同样操作。\n进行移动平台操作 # 此时如果仍旧存在接触物，使用rideOnTouchedObject进行跟随接触物移动操作。很简单，就是把物体当前的位置和之前记录的位置差值加到originalDisp上。也就是说这一步并不真正的移动胶囊体，而是改变它的移动向量。\nmoveCharacter # 接下来要做moveCharacter。moveCharacter内部其实是个状态机，根据设置的状态，里面依次有至多五次的doSweepTest：\nUP_PASS：当CCT需要向上移动时，做垂直向上的doSweepTest（其实是沿着CCT的up_direction） SIDE_PASS：当CCT需要向前移动时，做向前的doSweepTest SENSOR_PASS：当CCT的攀爬模式是ConstarintClimbing时，额外做一次向前的探测（但是不影响CCT位置） DOWN_PASS：当CCT需要下落时，做向下的doSweepTest WALK_EXPERIMENT：如果当前是WALK_EXPRERIMENT状态，做一次向上的恢复移动 其中1, 2, 3, 4会出现在爬坡的moveCharacter中。而4, 5会出现在第二次moveCharacter中\ndoSweepTest # doSweepTest其实是做Move，然后看情况要不要Slide。它的步骤如下：\n使用computeTemporalBox得到胶囊体前向方向的扫略框，然后使用 updateTouchedGeoms找到可能碰撞的物体\n对扫略到的所有碰撞体进行Sweep查询。找到最近碰撞到的物体\n如果没有碰撞到物体，移动胶囊体到指定位置\n如果开启了OverlapRecovery，则将物体从重叠的物体中挤出（使用computeMTD得到最小分离向量，然后使用这个向量移动CCT。\n如果是DOWN_PASS，记录移动平台相关信息\n执行move and slide\ndoSweepTest缺陷 # 这里有一些问题需要注意：\ndoSweepTest的上述逻辑是套在一个while循环中：\nPxVec3 current_position = swept_volume.center PxVec3 target_position = swept_volume.center while (max_iter--) { 找到附近的碰撞体 if (!碰撞到物体) { current_position = target_position; break; } if (CCT和其他物体重合) { 计算MTD并移动CCT retur true; } if (DOWN_PASS) { 记录移动平台相关信息 } if(sweepPass==SWEEP_PASS_DOWN \u0026amp;\u0026amp; !stopSliding) { if(!NbCollisions) max_iter += 9; } if(C.mDistance\u0026gt;DynSkin) // (*1) add(current_position, currentDirection*(C.mDistance-DynSkin)); 执行move and slide，结果位置存在target_position中 } swept_volume.center = current_position 如果max_iter == 1，那么CCT就只是移动，而不进行slide（如果没碰到物体走步骤3，如果碰到了走(*1)逻辑移动currentPosition）。\n这里只有步骤4会直接设置CCT位置并返回。但是重叠恢复操作也不是很正确。执行此操作的前提是：\nif(mUserParams.mOverlapRecovery \u0026amp;\u0026amp; C.mDistance==0.0f) 用户需要开启重叠恢复功能并且有物体重叠。而C.mDistance == 0.0f取决于步骤2中的碰撞体查询：\nif(!CollideGeoms(this, swept_volume, mGeomStream, currentPosition, currentDirection, C, !mUserParams.mOverlapRecovery)) { // no collision found =\u0026gt; move to desired position currentPosition = targetOrientation; break; } 在CollideGeoms中会找到碰撞到的物体，当某个物体和CCT重合时，会使用shouldApplyRecoveryModule函数检测此物体是否能被视为挤出物体。\n然而shouldApplyRecoveryModule的逻辑却是这样的：\nstatic bool shouldApplyRecoveryModule(const PxRigidActor\u0026amp; rigidActor) { const PxType type = rigidActor.getConcreteType(); if(type==PxConcreteType::eRIGID_STATIC) return true; if(type!=PxConcreteType::eRIGID_DYNAMIC) return false; return static_cast\u0026lt;const PxRigidBody\u0026amp;\u0026gt;(rigidActor).getRigidBodyFlags() \u0026amp; PxRigidBodyFlag::eKINEMATIC; } 只有在物体是Static或者Kinematic的时候才被视为可以挤出，对于任何动态物都不会做这个操作！这意味着CCT会和场景中的动态物穿过去！\n将这里的最后一行改为返回true就可以了：\n爬坡 # 了解了doSweepTest之后我们可以来看爬坡操作了 爬坡操作是做三次doSweepTest。首先将CCT移动的向量disp按up_direction正交拆分成normal_disp和side_disp。UP_PASS和DOWN_PASS都只向normal_disp方向（或反方向）移动。SIDE_PASS和SENSOR_PASS会按照side_disp移动。\n由于doSweepTest到底做不做Slide是受到max_iter影响的，所以这里我们要着重看这个量的值\n对于UP_PASS是这样的：\nif(mUserParams.mPreventVerticalSlidingAgainstCeiling) maxIterUp = 1; else maxIterUp = isAlmostZero(SideVector) ? maxIter : 1; PreventVerticalSlidingAgainstCeiling的用法是如果头顶的天花板是斜的，那么不会被沿着天花板的斜面move and slide从而导致人物跳跃之后向前移动。\n对于SIDE_PASS是这样的：\nconst PxU32 maxIter = MAX_ITER; const PxU32 maxIterSides = maxIter; MAX_ITER是常量为10，也就是说SIDE_PASS一定会做move and slide\nSENSOR_PASS不改变CCT位置，暂时不讨论。\n对于DOWN_PASS是这样的：\nconst PxU32 maxIterDown = ((mFlags \u0026amp; STF_WALK_EXPERIMENT) \u0026amp;\u0026amp; mUserParams.mNonWalkableMode==PxControllerNonWalkableMode::ePREVENT_CLIMBING_AND_FORCE_SLIDING) ? maxIter : 1; STF_WALK_EXPERIMENT只有在进入第二次moveCharacter时才会设置。PREVENT_CLIMBING_AND_FORCE_SLIDING是一个用于设置，用途是当CCT站在斜坡上时，强制让CCT下滑（这个后面会说）。\n这段逻辑是说在爬坡逻辑中永远为1，在第二次moveCharacter中如果你接受强制下滑操作，设为10做move and slide。\n但要注意到，在doSweepTest中也有影响max_iter的因素：\nwhile (max_iter--) { // 其他操作 if(sweepPass==SWEEP_PASS_DOWN \u0026amp;\u0026amp; !stopSliding) { if(!NbCollisions) max_iter += 9; } // 其他操作 } 这里如果stopSliding == false也会强制下滑。这个stopSliding是当碰到物体（非UserBox和UserCapusle）时：\nif(sweepPass!=SWEEP_PASS_SENSOR) { const PxU32 behaviorFlags = shapeHitCallback(userHitData, C, currentDirection, Length); stopSliding = (behaviorFlags \u0026amp; PxControllerBehaviorFlag::eCCT_SLIDE)==0;\t// (*) } 而shapeHitCallback则为：\nPxU32 Cct::shapeHitCallback(const InternalCBData_OnHit* userData, const SweptContact\u0026amp; contact, const PxVec3\u0026amp; dir, float length) { Controller* controller = static_cast\u0026lt;const PxInternalCBData_OnHit*\u0026gt;(userData)-\u0026gt;controller; // 这些是当碰撞到物体时调用用户的回调函数进行report PxControllerShapeHit hit; fillCCTHit(hit, contact, dir, length, controller); hit.shape\t= const_cast\u0026lt;PxShape*\u0026gt;(reinterpret_cast\u0026lt;const PxShape*\u0026gt;(contact.mGeom-\u0026gt;mTGUserData)); hit.actor\t= const_cast\u0026lt;PxRigidActor*\u0026gt;(contact.mGeom-\u0026gt;mActor); hit.triangleIndex\t= contact.mTriangleIndex; if(controller-\u0026gt;mReportCallback) controller-\u0026gt;mReportCallback-\u0026gt;onShapeHit(hit); // 这里才是决定是否下滑的逻辑 PxControllerBehaviorCallback* behaviorCB = controller-\u0026gt;mBehaviorCallback; return behaviorCB ? behaviorCB-\u0026gt;getBehaviorFlags(*hit.shape, *hit.actor) : defaultBehaviorFlags; } 最后一行的behaviorCB-\u0026gt;getBehaviorFlags是用户定义的CCT Behavior操作。默认是stopSliding的。\n也就是说，用户的行为会覆盖掉ePREVENT_CLIMBING_AND_FORCE_SLIDING设置（因为他会强制max_iter += 9从而move and slide）。\n爬坡的结果存储 # 如果爬坡失败。整个CCT会回到爬坡之前（也就是最开始的位置），然后做第二次moveCharacter。但是爬坡依旧记录了一些有用的信息。主要是STF_XXX标志位：\nSTF_VALIDATE_TRIANGLE_SIDE：做SIDE_PASS时碰到了物体\n// doSweepTest的while循环中： if(sweepPass==SWEEP_PASS_SIDE || sweepPass==SWEEP_PASS_SENSOR) { if((touchedActor-\u0026gt;getConcreteType() == PxConcreteType::eRIGID_STATIC) \u0026amp;\u0026amp; (C.mInternalIndex!=PX_INVALID_U32)) { // 如果碰到了物体，记录下信息 mFlags |= STF_VALIDATE_TRIANGLE_SIDE; const PxTriangle\u0026amp; touchedTri = mWorldTriangles.getTriangle(C.mInternalIndex); touchedTri.normal(mContactNormalSidePass); if(mUserParams.mPreventVerticalSlidingAgainstCeiling \u0026amp;\u0026amp; mContactNormalSidePass.dot(mUserParams.mUpDirection)\u0026lt;0.0f) preventVerticalMotion = true; } } STF_VALIDATE_TRIANGLE_DOWN：做DOWN_SIDE时碰到了物体\n// doSweepTest的while循环中： if(sweepPass==SWEEP_PASS_DOWN) { mFlags \u0026amp;= ~(STF_TOUCH_OTHER_CCT|STF_TOUCH_OBSTACLE); #ifdef USE_CONTACT_NORMAL_FOR_SLOPE_TEST mFlags |= STF_VALIDATE_TRIANGLE_DOWN; mContactNormalDownPass = C.mWorldNormal; #else if((touchedActor-\u0026gt;getConcreteType() == PxConcreteType::eRIGID_STATIC) \u0026amp;\u0026amp; (C.mInternalIndex!=PX_INVALID_U32)) { // 这里记录下DOWN_PASS时碰到的物体 mFlags |= STF_VALIDATE_TRIANGLE_DOWN; const PxTriangle\u0026amp; touchedTri = mWorldTriangles.getTriangle(C.mInternalIndex); const PxVec3\u0026amp; upDirection = mUserParams.mUpDirection; const float dp0 = touchedTri.verts[0].dot(upDirection); const float dp1 = touchedTri.verts[1].dot(upDirection); const float dp2 = touchedTri.verts[2].dot(upDirection); float dpmin = dp0; dpmin = physx::intrinsics::selectMin(dpmin, dp1); dpmin = physx::intrinsics::selectMin(dpmin, dp2); float dpmax = dp0; dpmax = physx::intrinsics::selectMax(dpmax, dp1); dpmax = physx::intrinsics::selectMax(dpmax, dp2); PxExtendedVec3 cacheCenter; getCenter(mCacheBounds, cacheCenter); const float offset = upDirection.dot(toVec3(cacheCenter)); mTouchedTriMin = dpmin + offset; mTouchedTriMax = dpmax + offset; touchedTri.normal(mContactNormalDownPass); } #endif touchedShapeOut = const_cast\u0026lt;PxShape*\u0026gt;(touchedShape); touchedActorOut = touchedActor; const PxTransform shapeTransform = getShapeGlobalPose(*touchedShape, *touchedActor); const PxVec3 worldPos = toVec3(C.mWorldPos); mTouchedPosShape_World = worldPos; mTouchedPosShape_Local = shapeTransform.transformInv(worldPos); } STF_TOUCH_OTHER_CCT和STF_TOUCH_OBSTACLE：是否碰到了其他的CCT或用户自定义障碍物。也是在DOWN_PASS的时候：\n// doSweepTest的while循环中： if(sweepPass==SWEEP_PASS_DOWN) { if(touchedObstacle) { mFlags |= STF_TOUCH_OBSTACLE; mTouchedObstacleHandle = touchedObstacleHandle; if(!gUseLocalSpace) { mTouchedPos = toVec3(touchedObstacle-\u0026gt;mPos); } else { mTouchedPosObstacle_World = toVec3(C.mWorldPos); mTouchedPosObstacle_Local = worldToLocal(*touchedObstacle, C.mWorldPos); } } else { mFlags |= STF_TOUCH_OTHER_CCT; } } 滑坡/无法上台阶 # 在爬坡的moveCharacter中，做完DOWN_PASS之后会进行一次能否爬坡的检测：\nif(doSweepTest(userData, userHitData, userObstacles, volume, DownVector, SideVector, maxIterDown, \u0026amp;NbCollisions, min_dist, filters, SWEEP_PASS_DOWN, touchedActor, touchedShape, contextID)) { if(NbCollisions) { if(dir_dot_up\u0026lt;=0.0f) CollisionFlags |= PxControllerCollisionFlag::eCOLLISION_DOWN; // 站在CCT或者自定义障碍物上不算滑坡 if(mUserParams.mHandleSlope \u0026amp;\u0026amp; !(mFlags \u0026amp; (STF_TOUCH_OTHER_CCT|STF_TOUCH_OBSTACLE))) { // 如果SIDE_PASS碰到了物体，并且testSlope成功,说明无法爬坡 // 注意testSlope的功能是检测是否不能爬坡。这里使用SIDE_PASS时碰到的面法向量做检测 if((mFlags \u0026amp; STF_VALIDATE_TRIANGLE_SIDE) \u0026amp;\u0026amp; testSlope(mContactNormalSidePass, upDirection, mUserParams.mSlopeLimit)) { // 然后检测是否能爬楼梯 if(constrainedClimbingMode \u0026amp;\u0026amp; PxExtended(mContactPointHeight) \u0026gt; originalBottomPoint + PxExtended(stepOffset)) { mFlags |= STF_HIT_NON_WALKABLE; if(!(mFlags \u0026amp; STF_WALK_EXPERIMENT)) return CollisionFlags; } } } } } 注意点：\ntestSlope的算法是使用碰撞的面法线和用户设置的能够爬坡斜率做判断。并且这里使用的是mContactNormalSidePass而不是mContactNormalDownPass（下文会解释） 如果坡和楼梯都上不去，设置STF_HIT_NON_WALKABLE标识碰到了无法行走的地方 第二次moveCharacter # 经过爬坡逻辑之后，如果发现碰到了无法走到的区域，回滚CCT位置，使用最开始的移动方向做move and slide：\nif(mCctModule.mFlags \u0026amp; STF_HIT_NON_WALKABLE) { // STF_WALK_EXPERIMENT只有在第二次moveCharacter才会设置 mCctModule.mFlags |= STF_WALK_EXPERIMENT; // 回滚CCT位置 volume.mCenter = Backup; // 这个操作后面会说，是个有问题的操作 PxVec3 xpDisp; if(mUserParams.mNonWalkableMode==PxControllerNonWalkableMode::ePREVENT_CLIMBING_AND_FORCE_SLIDING) { PxVec3 tangent_compo; decomposeVector(xpDisp, tangent_compo, disp, upDirection); } else xpDisp = disp; // 执行第二次moveCharacter collisionFlags = mCctModule.moveCharacter(\u0026amp;findGeomData, \u0026amp;userHitData, volume, xpDisp, userObstacles, minDist, filters, constrainedClimbingMode, standingOnMoving, touchedActor, touchedShape, getContextId()); mCctModule.mFlags \u0026amp;= ~STF_WALK_EXPERIMENT; } 先来看第二次moveCharacter做了什么。由于设置了STF_WALK_EXPERIMENT，导致内部逻辑变化了：\n做SIDE_PASS 做DOWN_PASS 如果仍旧无法爬坡，做WALK_EXPERIMENT的doSweepTest（这个后面再解释）去解决一些问题 WALK EXPERIMENT在做什么 # 前面说过，DOWN_PASS后检测爬坡的方式是使用mContactNormalSlidePass，可能有人会问那如果SIDE_PASS时没碰到物体，DOWN_PASS时碰到了斜坡不是不会下滑了吗（因为爬坡检测通过了）。这就是WALK_EXPERIMENT做的事情。\n这个时候WALK_EXPERIMENT的doSweepTest强制把CCT往下移动，移动距离正是他移动前后的高度差：\n// ==========[ WALK EXPERIMENT ]=========================== mFlags |= STF_NORMALIZE_RESPONSE; const PxExtended tmp = dot(volume.mCenter, upDirection); // 这里算出Delta是 此时高度 - 初始高度（未做moveCharacter之前的高度） float Delta = tmp \u0026gt; originalHeight ? float(tmp - originalHeight) : 0.0f; Delta += fabsf(direction.dot(upDirection)); float Recover = Delta; NbCollisions=0; const float MD = Recover \u0026lt; min_dist ? Recover/float(maxIter) : min_dist; PxVec3 RecoverPoint(0,0,0); // 向下移动 RecoverPoint = -upDirection*Recover; if(doSweepTest(userData, userHitData, userObstacles, volume, RecoverPoint, SideVector, maxIter, \u0026amp;NbCollisions, MD, filters, SWEEP_PASS_UP, touchedActor, touchedShape, contextID)) { } mFlags \u0026amp;= ~STF_NORMALIZE_RESPONSE; 这样就解决了这个问题\nPhysX CCT缺陷 # 钻墙的缺陷 # 首先，最显而易见的是钻矮墙会钻不过去。由于他是先做爬坡逻辑，而爬坡会将CCT向上移动再向前，这使得CCT在钻矮墙的时候可能被SIDE_PASS挡住从而钻不过去。即使CCT本身的高度并没有那么高。\nPreventClimbing带来的问题 # 首先是PreventClimbing和PreventClimbingAndForceSliding的缺陷。\nPreventClimbing会阻止CCT爬坡。但同时他也不会让CCT落下。于是CCT会一直停在坡上。这显然是不合逻辑的：\n这里是因为用户自己没有设置下滑的逻辑：\nif(sweepPass!=SWEEP_PASS_SENSOR) { // 这里shapeHitCallback内会调用用户的CCTBehavior回调。你没设置默认返回0，stopSliding就是true，会阻止下滑 const PxU32 behaviorFlags = shapeHitCallback(userHitData, C, currentDirection, Length); stopSliding = (behaviorFlags \u0026amp; PxControllerBehaviorFlag::eCCT_SLIDE)==0; } 只要设置上就可以了。\n那么设置了PreventClimbingAndForceSliding呢？确实会滑下来。但是在滑下来的过程中你贴着墙按水平方向的移动是无效的。他会一直下滑直到落到可以站立的地方，这时你才能左右移动。因为在代码里把水平位移去掉了：\n// 在第一次moveCharacter之后，第二次moveCharacter之前 if(mCctModule.mFlags \u0026amp; STF_HIT_NON_WALKABLE) { // A bit slow, but everything else I tried was less convincing... mCctModule.mFlags |= STF_WALK_EXPERIMENT; volume.mCenter = Backup; // 这里去掉了水平位移 PxVec3 xpDisp; if(mUserParams.mNonWalkableMode==PxControllerNonWalkableMode::ePREVENT_CLIMBING_AND_FORCE_SLIDING) { PxVec3 tangent_compo; decomposeVector(xpDisp, tangent_compo, disp, upDirection); } else xpDisp = disp; collisionFlags = mCctModule.moveCharacter(\u0026amp;findGeomData, \u0026amp;userHitData, volume, xpDisp, userObstacles, minDist, filters, constrainedClimbingMode, standingOnMoving, touchedActor, touchedShape, getContextId()); mCctModule.mFlags \u0026amp;= ~STF_WALK_EXPERIMENT; } 就很离谱。只能说是PhysX的一种设计吧。\n爬楼梯的缺陷 # 爬楼梯的计算可能有误，在DOWN_PASS之后判断爬楼梯的逻辑是：\nif(constrainedClimbingMode \u0026amp;\u0026amp; PxExtended(mContactPointHeight) \u0026gt; originalBottomPoint + PxExtended(stepOffset)) 注意这里是用接触点的位置和stepOffset进行对比。但我们用的是胶囊体不是圆柱体，这样可能导致误差：\n这在圆柱体非常宽大的时候误差会很大。\nSENSOR_PASS在做什么 # 如果你指定了攀爬模式为ConstraintClimbingMode，PhysX会在SIDE_PASS未碰到物体时再做一次SENSOR_PASS。这是为了避免在移动距离很短的情况下，非常小物体一直阻挡着CCT移动：\n这里可能会判定到无法越过（如果StepOffset很小甚至为0的话）。\n而这时通过SENSOR_PASS延长了横向的探测距离，在DOWN_PASS之后的爬坡检测就会通过，CCT就可以越过障碍物。\n","date":"2 August 2025","externalUrl":null,"permalink":"/posts/physx-cct%E8%AE%BE%E8%AE%A1%E4%B8%8E%E7%BC%BA%E9%99%B7/","section":"Posts","summary":"本文基于PhysX5.5.0源码分析（不过PhysX在这方面从4.0开始几乎没有什么改动）。讲述了PhysX CharacterController的设计实现，以及其中存在的问题。\n注：PhysX的CCT实现非常简陋（如果不是工作原因不建议看源码，有这时间不如去看看UE或者Jolt这种物理引擎的实现），而且存在很多问题。我是因为工作原因才要学习，没办法。\n","title":"PhysX CCT设计与缺陷","type":"posts"},{"content":"","date":"2 August 2025","externalUrl":null,"permalink":"/tags/%E6%BA%90%E7%A0%81%E9%98%85%E8%AF%BB/","section":"Tags","summary":"","title":"源码阅读","type":"tags"},{"content":"Quickjs中绑定C++\n","date":"15 June 2025","externalUrl":null,"permalink":"/books/","section":"books","summary":"","title":"books","type":"books"},{"content":"本文述说了动态反射库Ponder中的组织结构。\nValue # 所有的动态反射库中，总是有一种能够存储任意类型的类（用来对标std::any）。meta中就叫any，而Ponder中是Value。\n这种any类最简单的实现通常包含两个数据：\nclass any { public: ... private: void* data;\t// 用于存储擦除了类型的数据 TypeInfo typeinfo; // 用于存储此数据对应的类型信息 }; 而在Ponder中的实现则更加复杂：\n// include/ponder/value.hpp 72 class PONDER_API Value { public: ... 一些操作函数，不是重点，忽略 static const Value nothing; private: typedef mapbox::util::variant\u0026lt; NoType, bool, long, double, ponder::String, EnumObject, UserObject \u0026gt; Variant; Variant m_value; // Stored value ValueKind m_type; // Ponder type of the value }; Variant是对标std::variant的类型，存储了Value所有可能的类型\nValueKind则是一个枚举，用来告知m_value目前代表什么类型：\n// include/ponder/type.hpp 65 enum class ValueKind { None, ///\u0026lt; No type has been defined yet Boolean, ///\u0026lt; Boolean type (`bool`) Integer, ///\u0026lt; Integer types (`unsigned`,`signed` `char` `short` `int` `long`) Real, ///\u0026lt; Real types (`float`, `double`) String, ///\u0026lt; String types (`char*`, `ponder::String`) Enum, ///\u0026lt; Enumerated types Array, ///\u0026lt; Array types (`T[]`, `std::vector`, `std::list`) User ///\u0026lt; User-defined classes }; 各种类型的定义 # 注意到Value中Variant\u0026lt;...\u0026gt;的模板参数，除了布尔，整数（统一用long存储），浮点数（统一用double存储）之外，还有三种自定义类型：\nponder::String：用于存储字符串类型，其具体类型由用户通过定义宏自己选择（std::string或string_view），具体实现见include/ponder/detail/idtraits.hpp。这里的string_view是作者自己实现的，因为Ponder的标准是C++14。 EnumObject：对枚举类型特化的类，用于存储枚举值 UserObject：对类类型特化的类，用于存储类对象 EnumObject与Enum # EnumObject是很简单的结构，用于存储枚举值和其名称，可以获得其值和名称：\n// include/ponder/enumobject.hpp class PONDER_API EnumObject { public: ... 一些操作，不细说了 private: long m_value; /// 此枚举量的值 const Enum* m_enum; ///\u0026lt; 此枚举值对应的枚举类型信息 }; Enum则是存储一个枚举类型信息的类，主要成员为：\n// include/ponder/enum.hpp class PONDER_API Enum : public Type { PONDER__NON_COPYABLE(Enum);\t// 这个宏指定此类不可被拷贝，其实是禁用了类的拷贝\u0026amp;复制运算符 typedef long EnumValue; // 要存储的枚举值的类型 ... 一些操作，不细说了 private: Enum(IdRef name);\t// 构造函数，给入这个枚举类型的名称 typedef detail::Dictionary\u0026lt;Id, IdRef, EnumValue\u0026gt; EnumTable; Id m_name; ///\u0026lt; 枚举类型的名称 EnumTable m_enums; ///\u0026lt; 此枚举类型里面的枚举值们 }; 所有的枚举值使用EnumTable存储，这是作者自己造的一个哈希表，文档中说对Cache更友好。里面存储着枚举值的名称（默认是字符串，也是可以通过宏更改）和枚举值。\nUserObject与Class # UserObject的结构也很简单：\n// include/ponder/userobject.hpp class PONDER_API UserObject { public: ... 一些接口，不细说了 private: friend class Property; // 将一个值设置给类的属性 void set(const Property\u0026amp; property, const Value\u0026amp; value) const; UserObject(const Class* cls, detail::AbstractObjectHolder* h) : m_class(cls) , m_holder(h) {} /// 类的类型信息 const Class* m_class; /// 类对象的存储类 std::shared_ptr\u0026lt;detail::AbstractObjectHolder\u0026gt; m_holder; }; 这里Class存储类的类型信息。detaul::AbstractObjectHolder则是抽象类，用于存储一个类对象。\nClass的定义如下：\n// include/ponder/class.hpp class PONDER_API Class : public Type { PONDER__NON_COPYABLE(Class); // Structure holding informations about a base metaclass struct BaseInfo { const Class* base; int offset; }; // These are shared_ptr as the objects can be inherited. When this happens the // pointers are copied. typedef std::shared_ptr\u0026lt;Constructor\u0026gt; ConstructorPtr; typedef std::shared_ptr\u0026lt;Property\u0026gt; PropertyPtr; typedef std::shared_ptr\u0026lt;Function\u0026gt; FunctionPtr; typedef std::vector\u0026lt;BaseInfo\u0026gt; BaseList; typedef std::vector\u0026lt;ConstructorPtr\u0026gt; ConstructorList; typedef detail::Dictionary\u0026lt;Id, IdRef, PropertyPtr\u0026gt; PropertyTable; typedef detail::Dictionary\u0026lt;Id, IdRef, FunctionPtr\u0026gt; FunctionTable; typedef void (*Destructor)(const UserObject\u0026amp;, bool); typedef UserObject (*UserObjectCreator)(void*); std::size_t m_sizeof; // Size of the class in bytes. Id m_id; // Name of the metaclass FunctionTable m_functions; // Table of metafunctions indexed by ID PropertyTable m_properties; // Table of metaproperties indexed by ID BaseList m_bases; // List of base metaclasses ConstructorList m_constructors; // List of metaconstructors Destructor m_destructor; // Destructor (function able to delete an abstract object) UserObjectCreator m_userObjectCreator; // Convert pointer of class instance to UserObject ... 一些操作，不细说了 }; Class中存储着：\nm_sizeof：此类的大小 m_id：此类的ID（一般是字符串，也就是此类的名称） m_functions：此类中的函数，使用作者自制的哈希表存储 m_properties：此类中的成员变量，使用作者自制的哈希表存储 m_bases：父类信息 m_constructors：构造函数信息 m_destructor：析构函数信息 m_userObjectCreator： Property信息 # Property是存储类中成员变量信息的类：\nclass PONDER_API Property : public Type { PONDER__NON_COPYABLE(Property); public: virtual ~Property(); IdReturn name() const; ValueKind kind() const; virtual bool isReadable() const; virtual bool isWritable() const; Value get(const UserObject\u0026amp; object) const; void set(const UserObject\u0026amp; object, const Value\u0026amp; value) const; virtual void accept(ClassVisitor\u0026amp; visitor) const; protected: template \u0026lt;typename T\u0026gt; friend class ClassBuilder; friend class UserObject; Property(IdRef name, ValueKind type); virtual Value getValue(const UserObject\u0026amp; object) const = 0; virtual void setValue(const UserObject\u0026amp; object, const Value\u0026amp; value) const = 0; private: Id m_name; // Name of the property ValueKind m_type; // Type of the property }; 存储的是成员变量的名称和类型。我特意将set()和get()函数的声明留下，这说明这个类是可以设置/获得成员变量值的类型的。但是他本身是没有存储变量名称的，所以有一些函数\n","date":"11 November 2023","externalUrl":null,"permalink":"/posts/%E6%A8%A1%E6%9D%BF%E5%85%83%E7%BC%96%E7%A8%8B%E5%92%8C%E5%8F%8D%E5%B0%84ponder%E4%B8%AD%E7%9A%84%E7%BB%84%E7%BB%87%E7%BB%93%E6%9E%84/","section":"Posts","summary":"本文述说了动态反射库Ponder中的组织结构。\n","title":"【模板元编程和反射】：Ponder中的组织结构","type":"posts"},{"content":"","date":"11 November 2023","externalUrl":null,"permalink":"/tags/cpp/","section":"Tags","summary":"","title":"Cpp","type":"tags"},{"content":"本文述说了基于约束的物理求解器，这是很多游戏物理引擎的核心。\n理解约束 # **约束(constraints)**是游戏物理引擎中的核心概念。\n当物体在做曲线运动时，设定曲线的方程为$C(x, y)$，当物体在曲线上时满足$C(x,y)=0$\n这叫做位置约束，物体在运动时要满足这个约束方程才行。\n物理引擎中的约束求解器不能求解位置约束，但可以求解速度约束。求$C(x,y)$的全导数可以得到速度：\n$$ \\dot{C}(x,y) = 0 $$ 求解纯圆周运动的约束 # 那么接下来通过一个具体场景来计算约束：物体被连杆连接在固定点，物体做圆周运动：\n很显然，这里的位置约束为：物体$P_1$到固定点$P_2$的距离总是连杆的长度$l$，那么有：\n$$ C(x, y):\\lVert P_1 - P_2 \\rVert-l = 0 $$这里$P_1 - P_2$在物理中是物体相对于固定点$P_2$的相对位置我们记为$P_{21}$。那么约束变为：\n$$ C(P_{21}):\\lVert P_{21} \\rVert -l =0 $$我们的约束求解器只能对速度求解，所以对$C(P_{21})$对时间求导得到速度：\n$$ \\dot{C}(P_{21}):(\\dot{\\lVert P_{21} \\rVert - l}) =\\dot{\\lVert P_{21} \\rVert}=\\dot{\\sqrt{P_{21} \\cdot P_{21}}}=\\frac{2P_{21}\\dot{P_{21}}}{2\\sqrt{P_{21} \\cdot P_{21}}} = 0 $$那么可以知道\n$$ \\dot{C}(P_{21}):P_{21}\\dot{P_{21}}=(P_1 - P_2)(\\dot{P_1}-\\dot{P_2})=0 \\tag{*} $$由于\n$$ \\dot{P_i} = v_i + \\omega_i \\times r_i $$（$\\dot{P_i}$是位置的导数也就是速度，$v_i$是物体本身速度，$\\omega_i \\times r_i$是旋转的线速度)，所以我们得到：\n$$ \\dot{C}(v_1,q_1,v_2,q_2):(P_1-P_2)\\cdot(v_1 + \\omega_1 \\times r_1 - v_2 - \\omega_2 \\times r_2) = 0 $$把里面的东西进行移项(注：使用了混合积定律$A\\cdot (B\\times C)=C\\cdot (A\\times B)=B\\cdot (C\\times A)$)，得到：\n$$ (P_1 - P_2)\\cdot v_1 + (r_1 \\times (P_1 - P_2)) \\cdot \\omega_1 - (P_1 - P_2)\\cdot v_2 + (r_2 \\times (P_1 - P_2)) \\cdot \\omega_2 = \\begin{bmatrix} (P_1 - P_2) \\\\\\\\ (r_1 \\times (P_1 - P_2)) \\\\\\\\ -(P_1 - P_2) \\\\\\\\ -(r_2 \\times (P_1 - P_2)) \\end{bmatrix}^T \\begin{bmatrix} v_1 \\\\\\\\ \\omega_1 \\\\\\\\ v_2 \\\\\\\\ \\omega_2 \\end{bmatrix} = J_cv = 0 $$这里的$J_c$被称为Jacobian。$v$则是一个囊括了$P_1,P_2$的线速度，角速度的矩阵。$\\lambda$则被称为拉格朗日乘子( Lagrange multiplier)。因为$J_c\\cdot v =0$，所以$J_c$一定垂直于$v_1$($v_2$)，并且从$P_1$指向$P_2$($P_2$指向$P_1$)。\n这里，$*$式中的$P_1 - P_2$被称为约束轴(constraint axis)，$\\dot{P_1} - \\dot{P_2}$则是两者的相对速度。这里将相对速度投影到约束轴上来看约束轴到底能多快地被破坏。而这个方程等于0则说明我们希望最后在约束轴上的相对速度保持不变，以此来保持约束不被破坏：\n加入$\\beta$ # 就算物体满足了约束$C(x, y)=0$，他也没办法将物体恢复到正确的位置上（我们仅仅是解出这个方程而已，物体的位置仍然没变）。所以需要有一个速度给物体拉回去，那么$J_c$方向上的就不应该是0。那么让我们添加一个$J_c$方向上的速度$v_2$：\n$$ J_c \\cdot (v +v_2) = J_c \\cdot v +J_c \\cdot v_2 = 0 $$这里$J_c \\cdot v_2$一定不是0，令其为$\\epsilon$，得到最后的式子：\n$$ J_c \\cdot (v +v_2) = J_c \\cdot v + \\epsilon = 0 $$$\\epsilon$在这里的理解是当前速度到约束下速度之间的误差。\n那么这个$\\epsilon$在我们的连杆系统下到底是多少呢？要想将物体拉回去，物体的速度应该是：\n$$ v = -\\frac{\\lVert P_{12} \\rVert - l}{\\Delta t} $$但你不能真的使用这个速度，因为在实际编码中（下面说到的$SI$求解器中）会在一帧内使用多次这个速度。所以我们需要适当地降低这个速度。提供一个$\\beta \\in (0, 1)$来减弱$v$：\n$$ \\epsilon = -\\frac{\\lVert P_{12} \\rVert - l}{\\Delta t} \\beta $$这个方法一般被称为Baumgarte stabilization，他的通用表示和约束方程$C$有关：\n$$ \\epsilon = -\\frac{C}{\\Delta t} \\beta $$一般来说，为了之后求解方便，会将$\\epsilon$移动到等号右边去（我们下面也按照这个公式推导）：\n$$ J_c\\cdot v = \\epsilon \\\\ \\epsilon = \\frac{C}{\\Delta{t}} \\beta, \\beta \\in [0, 1] $$ 求解最后应施加的力 # 最后我们来求解整个约束应该施加给物体的力。\n在曲线运动下，只有一个约束力$F_c$从$P_1$指向$P_2$。巧了，我们的$J_c$也是这个方向，那么显然有一个常数$\\lambda$，使得\n$$ F_c = \\lambda J_c^T \\tag{1} $$（注：为什么是$J_c^T$？，因为$J_c$是行向量，我们这里所有的向量表示都是列向量）由牛顿第二定律，又有：\n$$ F = M\\dot{V}\\\\ F_c +F_{ext} = M\\dot{V} \\tag{2} $$$F_{ext}$是除了约束力之外的力。$\\dot{V}$在这里是物体线速度，角速度的矩阵导数：\n$$ \\dot{V} = \\begin{bmatrix} v_1 \\\\\\\\ \\omega_1 \\\\\\\\ v_2 \\\\\\\\ \\omega_2 \\end{bmatrix}' $$注意，因为$\\dot{V}$中存在角速度$\\omega_i$，所以$F_c$和$F_{ext}$其实是表示两种力——力和力矩：\n$$ F = \\begin{bmatrix} F_1 \\\\\\\\ \\tau_1 \\\\\\\\ F_2 \\\\\\\\ \\tau_2 \\end{bmatrix} $$那么有：\n$$ \\lambda J_c^T = M\\dot{V} \\\\\\\\ \\lambda J_c^T + F_{ext} = M\\dot{V} $$矩阵$M$则是和线速度，角速度对应的质量矩阵：\n$$ M=\\begin{bmatrix} m_1 \u0026 0 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 I_1 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 m_2 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 0 \u0026 I_2 \\end{bmatrix} $$其中$m_i$是质量， $I_i$是转动惯量。这四个元素分别对应$\\dot{V}$里的线速度和角速度。\n那么$\\dot{V}$如何解？假设两帧之间时间极短，那么可以用平均速度代替导数：\n$$ \\dot{V}=\\frac{V_{new} - V_{old}}{\\Delta t} $$其中$V_{new}$是下一帧应该有的速度，$V_{old}$是这一帧的速度。根据“加入$\\beta$”一节，我们知道$J_c\\cdot V_{new} = \\epsilon$，所以有：\n$$ \\Delta{t} \\dot{V} = V_{new} - V_{old} \\\\\\\\ J_c\\Delta{t} \\dot{V} = J_c\\cdot V_{new} - J_c\\cdot V_{old} \\\\\\\\ J_c\\cdot \\dot{V} \\Delta{t} + J_c\\cdot V_{old} = \\epsilon \\\\\\\\ \\tag{3} $$然后由$1$，$2$式可得$\\dot{V}$：\n$$ \\lambda J_c^T + F_{ext} = M\\dot{V} \\\\\\\\ \\dot{V} = M^{-1}(\\lambda J_c^T + F_{ext}) $$然后把这个式子带入$3$式：\n$$ J_c\\cdot V_{old} + J_cM^{-1}(\\lambda J_c^T + F_{ext})\\Delta{t} = \\epsilon \\\\\\\\ J_cM^{-1}J^{T}\\lambda = \\frac{\\epsilon}{\\Delta{t}}-J_c(\\frac{V_{old}}{\\Delta{t}}+M^{-1}F_{ext}) $$那么令：\n$$ A = J_cM^{-1}J^{T} \\\\\\\\ b = \\frac{\\epsilon}{\\Delta{t}}-J_c(\\frac{V_{old}}{\\Delta{t}}+M^{-1}F_{ext}) $$这样问题就变成了求解函数：\n$$ A\\lambda = b $$注意：实际求解的时候$M^{-1}$其实就是：\n$$ M^{-1} =\\begin{bmatrix} 1/m_1 \u0026 0 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 1/I_1 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 1/m_2 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 0 \u0026 1/I_2 \\end{bmatrix} $$不需要真的按公式计算逆矩阵（什么，你的质量是0？那给个1就行了）。\n在计算机中，解这个方程可以用Gauss–Seidel方法或者Jacobi等方法。我看的所有参考中都是用Gauss-Seidel算法解的，所以就用这个方法解吧。\nSI(Sequential-Impulse)求解器 # 使用上述方法求解的求解器称为SI求解器：\n首先，我们的公式是在圆周运动下推导的。而任意曲线运动在某一时刻都可以视为圆周运动，所以这个推导过程是可以在每一帧使用的。\n其次，我们在算$\\dot{V}$的时候说“在时间很小的情况下可以用近似”，所以增加物理引擎的精度的一个方法就是将一份时间拆成多段迭代计算，段数越多越准。\n具体做法是：\n将所有非约束力全部应用在物体上，得到一个猜测的速度 依次计算所有$\\lambda$并将所有约束力应用在物体上，用来矫正速度 使用最后的速度来更新物体位置 上面的第二步在一帧内要一直做，直到满足当中一个条件：\n没有约束力产生或约束力特别小 达到了一定步数（避免死循环或时间过长） 约束类型 # 距离约束（Distance Constraint） # 这种约束需要两个物体总是保持一定距离，比如我们上面举的连杆的例子。这里再稍稍拓宽一点：之前都是将$P_1$和$P_2$视为质点。这里如果加上刚体的话$P_1 - P_2$则是连杆两端的距离：\n接触约束（Contact Constraint） # 这种约束是当物体产生接触时，将两个物体分离开的约束。\n其中$P_1 - P_2$被替换成接触面的法向量。那么$J_c$变为：\n$$ J_c = \\begin{bmatrix} -n \\\\\\\\ -(r_a \\times n) \\\\\\\\ n \\\\\\\\ (r_b \\times n) \\end{bmatrix}^T $$那这里的恢复向量就是：\n$$ v = \\frac{d}{\\Delta t}bias $$$d$是相交深度。\n如果物体b是静态物体（无论怎样都不移动的），$J_c$可变为：\n$$ J_c = \\begin{bmatrix} -n \\\\\\\\ -(r_a \\times n) \\\\\\\\ 0 \\\\\\\\ 0 \\end{bmatrix}^T $$ A的求解细节 # 我在看Box2D-Lite源码的时候发现了他对A的求解方法：\nvoid Arbiter::PreStep(float inv_dt) { const float k_allowedPenetration = 0.01f; float k_biasFactor = World::positionCorrection ? 0.2f : 0.0f; for (int i = 0; i \u0026lt; numContacts; ++i) { Contact* c = contacts + i; Vec2 r1 = c-\u0026gt;position - body1-\u0026gt;position; Vec2 r2 = c-\u0026gt;position - body2-\u0026gt;position; // Precompute normal mass, tangent mass, and bias. float rn1 = Dot(r1, c-\u0026gt;normal); float rn2 = Dot(r2, c-\u0026gt;normal); float kNormal = body1-\u0026gt;invMass + body2-\u0026gt;invMass;\t// kNormal就是A kNormal += body1-\u0026gt;invI * (Dot(r1, r1) - rn1 * rn1) + body2-\u0026gt;invI * (Dot(r2, r2) - rn2 * rn2); c-\u0026gt;massNormal = 1.0f / kNormal; ... } 看17行，这里没有用叉乘算$r_a \\times n$，因为$r_a \\times n = \\lVert r_a \\rVert \\sin{\u0026lt;r_a, n\u0026gt;}$，所以他算出三角形斜边$\\lVert r_a \\rVert$和临边$r_a \\cdot n$然后用三角函数直接解出来了，没有用叉乘（代码中是Dot(r1, r1) - rn1 *rn1）。\n参考 # 必看！一文将所有知识点全部说完，但有些地方可能不是很容易理解，可看辅助材料：\nMTamis_ConstraintBasedPhysicsSolver.pdf (mft-spirit.nl)\n其他辅助材料：\nModeling and Solving Constraints (box2d.org)\nVideo Game Physics Tutorial - Part III: Constrained Rigid Body Simulation | Toptal®\n2017 Tutorial 3 - Constraints.pdf (ncl.ac.uk)\nPhysics - Constraints and Solvers.pdf\n","date":"27 October 2023","externalUrl":null,"permalink":"/posts/%E5%9F%BA%E4%BA%8E%E7%BA%A6%E6%9D%9F%E7%9A%84%E7%89%A9%E7%90%86%E6%B1%82%E8%A7%A3/","section":"Posts","summary":"本文述说了基于约束的物理求解器，这是很多游戏物理引擎的核心。\n","title":"基于约束的物理求解","type":"posts"},{"content":"本文述说了基于匹配的模板以及常见模板小工具type_list。\n本章的type_list实现代码在mirrow的type_list.hpp中。可自行参考。\n基于匹配的模板 # C++的模板是基于匹配的，也就是说在实例化的时候会尝试匹配所有存在的模板。直到所有模板都不匹配才会报错。\n类型萃取 # 类型萃取就是使用这种规则的模板：\n// (1) template \u0026lt;typename T\u0026gt; struct remove_one_pointer { using type = T; }; // (2) template \u0026lt;typename T\u0026gt; struct remove_one_pointer\u0026lt;T*\u0026gt; { using type = T; }; template \u0026lt;typename T\u0026gt; using remove_pointer_t = typename remove_one_pointer\u0026lt;T\u0026gt;::type; int main() { using type1 = remove_pointer_t\u0026lt;int\u0026gt;;\t// type -\u0026gt; int using type2 = remove_pointer_t\u0026lt;int*\u0026gt;;\t// type -\u0026gt; int using type3 = remove_pointer_t\u0026lt;int**\u0026gt;;\t// type -\u0026gt; int* return 0; } “萃取”是化学中的术语，意思是将某种混合物中的东西分离出来。这里的remove_one_pointer会移除一层指针，如果类型不是指针则会保留原本类型。\n这里的type1给入的类型是int，编译器会先去匹配特化模板*(2)，发现不能将int作为其模板参数（其模板参数需要一个指针T*），于是去匹配(1)*这个非特化模板，得到类型type = int。\n而type2给入的类型是int*，匹配*(2)的时候传入一个int*，这个int*会匹配到9行的struct remove_on_pointer\u0026lt;T*\u0026gt;中的T*，而(2)*中的模板参数（我指typename T这个T）是T，所以T*会是int*而T变为int。这样就成功萃取出来了。\ntype3也是同理，9行的struct remove_on_pointer\u0026lt;T*\u0026gt;中的T*会匹配到int**，然后注意到T是去掉一层指针（如果写struct remove_on_pointer\u0026lt;T**\u0026gt;就是去掉两个），所以T会是int*而不是int。\n萃取有个固定写法：\n确定你需要对哪个类进行萃取，比如我们需要对一个特定的类std::list\n确定你要对他的哪个模板参数进行萃取。std::list的声明是：\ntemplate\u0026lt; class T, class Allocator = std::allocator\u0026lt;T\u0026gt; \u0026gt; class list; 有两个模板参数T和Allocator，比如我们要得到T\n将需要萃取的类型的所有模板参数写在template \u0026lt;\u0026gt;声明中，并在特化部分将此类型用上，然后在结构体内用using type = XXX得到你想要的类型：\ntemplate \u0026lt;typename T\u0026gt; // 要萃取T，我们这里写一个T struct get_list_element_type;\t// 先写一个声明，声明不做任何事情 template \u0026lt;typename T, typename Allocator\u0026gt; struct get_list_element_type\u0026lt;std::list\u0026lt;T, Allocator\u0026gt;\u0026gt; { using type = T; // using allocator = Allocator;\t// 想要保存Allocator的类型？也可以 }; 使用时，直接将类型放入你写的萃取中就行了：\nusing type = typename get_list_element_type\u0026lt;std::list\u0026lt;int\u0026gt;\u0026gt;::type;\t// int // 一般会给一个更加方便的using来节省打字时间： template \u0026lt;typename T\u0026gt; using get_list_element_type_t = typename get_list_element_type\u0026lt;T\u0026gt;::type; using type = get_list_element_type_t\u0026lt;std::list\u0026lt;int\u0026gt;\u0026gt;; 当然这里只是个例子，对于std::list来说，标准库很贴心地在其内部早就存下了元素类型std::list\u0026lt;T\u0026gt;::value_type和内存分配器类型std::list\u0026lt;T\u0026gt;::allocator_type。\n将模板看做编译期的函数 # 上面的get_list_element_type我们可以看做是对类型操作的函数：\nusing type = get_list_element_type_t\u0026lt;your-list\u0026gt;; // 你不觉得很像： auto type = get_list_element_type_t(your-list); 模板参数作为入参，模板内的type或者value作为返回值。\n不只是萃取，你可以写其他的模板来对类型/编译期值进行操作：\ntemplate \u0026lt;size_t Idx\u0026gt; struct inc_idx { static constexpr size_t value = Idx + 1; }; template \u0026lt;size_t idx\u0026gt; constexpr size_t inc_idx_v = inc_idx\u0026lt;Idx\u0026gt;::value; 这里inc_idx会将value+1。\n标准库也有一些类似的做法，比如std::conditional_t可以做编译时if。\ntype_list小工具 # 在很多反射和模板元编程框架中(Refl-Cpp，Ubp.a USRefl)都会有type_list小工具。这个工具顾名思义，是编译时存储和操作一组类型的列表。\n模板元编程在很大程度上和函数式编程有着极为密切的关系。而我们这里的type_list实现也是仿造函数式编程，尤其是Haskell中list的实现。\ntype_list的用法 # 首先来看一下这个工具的用途（一部分）：\nusing list = type_list\u0026lt;int, char, float, double\u0026gt;;\t// 声明一个类型列表 // list_element_t 用于从 type_list中获得第n个元素 static_assert(std::is_same_v\u0026lt;list_element_t\u0026lt;list, 0\u0026gt;, int\u0026gt;); static_assert(std::is_same_v\u0026lt;list_element_t\u0026lt;list, 1\u0026gt;, char\u0026gt;); static_assert(std::is_same_v\u0026lt;list_element_t\u0026lt;list, 2\u0026gt;, float\u0026gt;); static_assert(std::is_same_v\u0026lt;list_element_t\u0026lt;list, 3\u0026gt;, double\u0026gt;); // list_head_t 是仿照函数式编程中 head 函数，用于获得list开头的元素 static_assert(std::is_same_v\u0026lt;list_head_t\u0026lt;list\u0026gt;, int\u0026gt;); // list_size_v 用于获得list的大小 static_assert(list_size_v\u0026lt;list\u0026gt; == 4); // 一个谓词（谓词：函数式编程术语，即返回布尔值的函数），用于判断某个类型T是否是`int`，使用基于匹配的方法编写 template \u0026lt;typename T\u0026gt; struct IsInt { static constexpr bool value = false; }; template \u0026lt;\u0026gt; struct IsInt\u0026lt;int\u0026gt; { static constexpr bool value = true; }; // disjunction_v，仿照std::disjunction，用于判断列表中是否至少有一个元素使得谓词返回true static_assert(disjunction_v\u0026lt;list, IsInt\u0026gt;); // conjunction_v，仿照std::conjunction，用于判断列表中是否所有元素使得谓词返回true static_assert(!conjunction_v\u0026lt;list, IsInt\u0026gt;); // list_filter_t，使用谓词过滤列表，所有满足谓词的元素会被保留下来 static_assert(std::is_same_v\u0026lt;list_filter_t\u0026lt;list, std::is_integral\u0026gt;, type_list\u0026lt;int, char\u0026gt;\u0026gt;); static_assert(std::is_same_v\u0026lt;list_filter_t\u0026lt;type_list\u0026lt;\u0026gt;, std::is_integral\u0026gt;, type_list\u0026lt;\u0026gt;\u0026gt;); type_list的实现 # type_list声明 # 首先是type_list的声明：\ntemplate \u0026lt;typename... Ts\u0026gt; struct type_list {}; 是一个空类，所有的类型信息全部记录在不定模板参数Ts中。\nlist_size_v的实现 # 接下来实现第一个函数，也是最简单的，list_size_v，用于获得type_list中元素个数：\nnamespace detail { // (1) template \u0026lt;typename\u0026gt; struct list_size; // (2) template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, typename... Ts\u0026gt; struct list_size\u0026lt;ListType\u0026lt;Ts...\u0026gt;\u0026gt; { static constexpr size_t value = sizeof...(Ts); }; }\t// namespace detail template \u0026lt;typename T\u0026gt; static constexpr size_t list_size_v = detail::list_size\u0026lt;T\u0026gt;::value; 模板元编程中，习惯性的做法是将实现放在命名空间detail或者impl中，用户一般是不允许接触这种命名空间的（当然编译器并没有强制禁止，只是口头约定），然后在外部暴露一个便捷方法（这里是list_size_v）。\n这里的实现也很简单，依旧是基于匹配的模板方法：将要匹配到的模板类型中所有需要的类型参数（这里是type_list需要的参数，是*(2)*中的Ts）写在template \u0026lt;\u0026gt;中，然后在特化处struct list_size\u0026lt;\u0026gt;写上你需要的真正类型。\n这里其实可以写成：\n// (2) template \u0026lt;typename... Ts\u0026gt; struct list_size\u0026lt;type_list\u0026lt;Ts...\u0026gt;\u0026gt; { static constexpr size_t value = sizeof...(Ts); }; 告诉list_size我们需要的参数是固定类型type_list。这里之所以写了模板模板参数template \u0026lt;typename...\u0026gt; typename ListType是因为这样写可以匹配到std::tuple（因为tuple的模板声明也是template \u0026lt;typename... Ts\u0026gt; class tuple{ ... };，通用性更好一些。如果你不需要就不用这样写。\n内部实现的话使用sizeof...对不定模板参数计数就行了。\n如果传入的类型T不可以接受不定模板参数Ts，那会匹配到*(1)*处的声明，编译器会发现类没有实现，所以会报一个类缺少实现的编译时错误。\nlist_head_t的实现 # 接下来实现一个复杂一点的。list_head_t通过给入一个type_list可以得到此list的第一个元素。如果type_list是空则编译无法通过。\n// (1) template \u0026lt;typename\u0026gt; struct list_head; // (2) template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, typename T, typename... Remains\u0026gt; struct list_head\u0026lt;ListType\u0026lt;T, Remains...\u0026gt;\u0026gt; { using type = T; }; template \u0026lt;typename T\u0026gt; using list_head_t = typename list_head\u0026lt;T\u0026gt;::type; *(1)处仍然是声明。(2)*处将type_list内元素拆分成两部分：第一个元素T以及剩下的所有元素Remains，然后通过匹配拿到第一个元素。\nlist_add_to_first的实现 # list_add_to_first\u0026lt;List, T\u0026gt;会将元素T插入List的第一个位置上：\ntemplate \u0026lt;typename List, typename T\u0026gt; struct list_add_to_first; template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, typename... Ts, typename T\u0026gt; struct list_add_to_first\u0026lt;ListType\u0026lt;Ts...\u0026gt;, T\u0026gt; { using type = ListType\u0026lt;T, Ts...\u0026gt;; }; template \u0026lt;typename List, typename T\u0026gt; using list_add_to_first_t = typename list_add_to_first\u0026lt;List, T\u0026gt;::type; 具体实现中通过将新元素T和老元素们Ts放在一个ListType中并返回。\nlist_element_t的实现 # 看了上面三个例子后，对如何使用模板参数以及如何匹配想必已经有一定的了解了。接下来看一点不一样的。\nlist_element_t\u0026lt;ListType, Idx\u0026gt;可以取得ListType中第Idx个元素：\ntemplate \u0026lt;typename T, size_t Idx\u0026gt;\t// 这里的模板参数没用到可以省略，但我还是写出来以便于下面解说 struct list_element; // (1) template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, typename T, typename... Ts, size_t N\u0026gt; struct list_element\u0026lt;ListType\u0026lt;T, Ts...\u0026gt;, N\u0026gt; : list_element\u0026lt;ListType\u0026lt;Ts...\u0026gt;, N - 1\u0026gt; {}; // (2) template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, typename T, typename... Ts\u0026gt; struct list_element\u0026lt;ListType\u0026lt;T, Ts...\u0026gt;, 0\u0026gt; { using type = T; }; 这里用了递归式模板：*(2)*中定义递归结束条件：当Idx==0的时候，直接返回ListType的第一个元素。\n而*(1)*中则进行递归：我们构造传入ListType的子列表作为下一次递归的开始（这个列表只是将第一个元素移除了），并将Idx - 1：\n假设有type_list: tl = int char float double 我们要拿到下标为2的元素，也就是float： Idx = 2 调用list_element_t: list_element\u0026lt;type_list\u0026lt;int, char, float, double\u0026gt;, 2\u0026gt; 第一次递归的调用： list_element\u0026lt;type_list\u0026lt;char, float, double\u0026gt;, 1\u0026gt; 第二次递归的调用： list_element\u0026lt;type_list\u0026lt;float, double\u0026gt; 0\u0026gt; 这时Idx == 0，匹配到(2)处的特化模板，现在list_element结构体里有T = float了。 这里*(1)处的继承并没有任何面向对象里as-is*的意思，单纯地就是将数据聚拢在一起。一般在模板元编程中，类如果都是空的话，比较趋向于使用集成将信息组合到一起。\nlist_foreach_t的实现 # 接下来我们要更加贯彻将模板视为编译期函数的原则。\nlist_foreach_t\u0026lt;List, Pred\u0026gt;通过给入一个Pred模板类，将这个类当做函数用在List的所有元素上从而创造一个新的List：\ntemplate \u0026lt;typename List, template \u0026lt;typename\u0026gt; typename F\u0026gt; struct list_foreach { }; // (2) template \u0026lt;template \u0026lt;typename...\u0026gt; typename ListType, template \u0026lt;typename\u0026gt; typename F, typename... Ts\u0026gt; struct list_foreach\u0026lt;ListType\u0026lt;Ts...\u0026gt;, F\u0026gt; { using type = ListType\u0026lt;typename F\u0026lt;Ts\u0026gt;::type ...\u0026gt;; }; template \u0026lt;typename List, template \u0026lt;typename\u0026gt; typename F\u0026gt; using list_foreach_t = typename detail::list_foreach\u0026lt;List, F\u0026gt;::type; *(2)*中的函数类型是template \u0026lt;typename\u0026gt; typename F，表示接受一个模板参数，并且在7行的typename F\u0026lt;Ts\u0026gt;::type也要求其内部有一个type类型作为返回值。比如：\ntemplate \u0026lt;typename T\u0026gt; struct AddPointer { using type = T*; }; 就是合法的，可以这样用：\nlist_foreach_t\u0026lt;type_list\u0026lt;int, char\u0026gt;, AddPointer\u0026gt; 其他 # 对于type_list还有很多函数可以编写，比如筛出其中某个元素，将type_list倒置等。但最基本的写法和例子都写在上面了。完整代码可去github上看一下。\n","date":"7 October 2023","externalUrl":null,"permalink":"/posts/%E6%A8%A1%E6%9D%BF%E5%85%83%E7%BC%96%E7%A8%8B%E5%92%8C%E5%8F%8D%E5%B0%84%E4%B8%80type_list/","section":"Posts","summary":"本文述说了基于匹配的模板以及常见模板小工具type_list。\n本章的type_list实现代码在mirrow的type_list.hpp中。可自行参考。\n","title":"【模板元编程和反射】（一）：type_list","type":"posts"},{"content":"","date":"24 August 2023","externalUrl":null,"permalink":"/tags/entt/","section":"Tags","summary":"","title":"EnTT","type":"tags"},{"content":"本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS中的核心数据结构storage。\nstorage # storage是在sparse_set上进行封装的类，主要用于将组件(component)和实体(entity)关联起来。\n有两种storage，第一种是最通用的一种，用于关联实体和组件：\n//\u0026gt; src/entt/storage.hpp 235 template\u0026lt;typename Type, typename Entity, typename Allocator, typename\u0026gt; class basic_storage: public basic_sparse_set\u0026lt;Entity, typename std::allocator_traits\u0026lt;Allocator\u0026gt;::template rebind_alloc\u0026lt;Entity\u0026gt;\u0026gt; { ... } 回忆一下sparse set的结构，存在一个packed数组和一个sparse数组。而storage则是在其上加了一个payload数组用于存储组件：\nstorage: payloads: _ _ _ packed : _ _ _ sparse : _ _ _ 插入实体1和组件V: payloads: V _ _ packed : 1 _ _ sparse : _ 0 _ 插入的组件会放在和实体一样下标的payloads数组中。删除实体的时候也会一并删除。也就是说，相同下标的payloads和packed数组元素是一一对应的（组件和实体一一对应）。\n理论上是这样，但为了更高效地分配/释放内存，EnTT还是使用了分页的机制去分配payload，也就是说会一次性分配一页（默认是1024个（在src/entt/config/config.hpp 34处的ENTT_PACKED_PAGE））。\n第二种是用于纯粹存储实体的storage，使用偏特化实现：\n//\u0026gt; src/entt/storage.hpp 955 class basic_storage\u0026lt;Entity, Entity, Allocator\u0026gt; : public basic_sparse_set\u0026lt;Entity, Allocator\u0026gt; { ... } 里面的函数基本上都是basic_sparse_set的函数包装了一下，但是做了实体的复用操作。\n其实还有一个偏特化，是用于“当组件为空类”的特殊情况的。这个时候并没有payloads数组（因为组件是空类，没必要实例化），主要是为了节省内存。这种实现对于tag类来说很有帮助：\n// 一个tag类，只是为了标记某个entity是玩家操控的而已 struct Player {}; registry.emplace\u0026lt;Player\u0026gt;(entity); // 之后可在system中对含有Player类的entity进行特殊操作（比如按键响应等） 偏特化声明如下：\n//\u0026gt; src/entt/storage.hpp 780 template\u0026lt;typename Type, typename Entity, typename Allocator\u0026gt; class basic_storage\u0026lt;Type, Entity, Allocator, std::enable_if_t\u0026lt;component_traits\u0026lt;Type\u0026gt;::page_size == 0u\u0026gt;\u0026gt; 注意到最后那个component_traits\u0026lt;Type\u0026gt;::page_size == 0u就是用来判断组件类型Type是否是空类。\n细节实现 # component_traits # 先看一下component_traits，这个东西会决定组件分配的页大小，所有的代码都在src/entt/entity/component.hpp下：\ntemplate\u0026lt;typename Type, typename = void\u0026gt; struct component_traits { static_assert(std::is_same_v\u0026lt;std::decay_t\u0026lt;Type\u0026gt;, Type\u0026gt;, \u0026#34;Unsupported type\u0026#34;); /*! @brief Component type. */ using type = Type; /*! @brief Pointer stability, default is `false`. */ static constexpr bool in_place_delete = internal::in_place_delete\u0026lt;Type\u0026gt;::value; /*! @brief Page size, default is `ENTT_PACKED_PAGE` for non-empty types. */ static constexpr std::size_t page_size = internal::page_size\u0026lt;Type\u0026gt;::value; }; type：就是组件本身的类型\nin_place_delete：是否可以就地删除，实现是:\ntemplate\u0026lt;typename Type, typename = void\u0026gt; struct in_place_delete: std::bool_constant\u0026lt;!(std::is_move_constructible_v\u0026lt;Type\u0026gt; \u0026amp;\u0026amp; std::is_move_assignable_v\u0026lt;Type\u0026gt;)\u0026gt; {}; 主要是要求组件类型必须可以移动构造和移动赋值。\npage_size：组件分配一页的大小。实现是：\ntemplate\u0026lt;typename Type, typename = void\u0026gt; struct page_size: std::integral_constant\u0026lt;std::size_t, !std::is_empty_v\u0026lt;ENTT_ETO_TYPE(Type)\u0026gt; * ENTT_PACKED_PAGE\u0026gt; {}; template\u0026lt;\u0026gt; struct page_size\u0026lt;void\u0026gt;: std::integral_constant\u0026lt;std::size_t, 0u\u0026gt; {}; template\u0026lt;typename Type\u0026gt; struct page_size\u0026lt;Type, std::enable_if_t\u0026lt;std::is_convertible_v\u0026lt;decltype(Type::page_size), std::size_t\u0026gt;\u0026gt;\u0026gt; : std::integral_constant\u0026lt;std::size_t, Type::page_size\u0026gt; {}; 对于任意非void类型，使用!std::is_empty_v\u0026lt;ENTT_ETO_TYPE(Type)\u0026gt; * ENTT_PACKED_PAGE得到一页的大小（其实就是如果Type是空类，那么大小为0，否则为ENTT_PACKED_PAGE）\n对于void类型直接为0（在第一个全特化版本中实现）\n最后一个使用SFINEA，当组件类型Type自己指定了页大小Type::page_size的时候，使用他的大小而不是默认的ENTT_PACKED_PAGE（当然Type::page_size要能转换为size_t）。\n非空组件类型的stroage的内存分配实现 # 由于storage的算法原理和sparse set一样，只是加了个payloads数组，所以算法部分就不再重复。我们着重关注一下EnTT是如何高效利用内存的。\n首先还是简单看一下类型声明：\ntemplate\u0026lt;typename Type, typename Entity, typename Allocator, typename\u0026gt; class basic_storage: public basic_sparse_set\u0026lt;Entity, typename std::allocator_traits\u0026lt;Allocator\u0026gt;::template rebind_alloc\u0026lt;Entity\u0026gt;\u0026gt; { using alloc_traits = std::allocator_traits\u0026lt;Allocator\u0026gt;; static_assert(std::is_same_v\u0026lt;typename alloc_traits::value_type, Type\u0026gt;, \u0026#34;Invalid value type\u0026#34;); using container_type = std::vector\u0026lt;typename alloc_traits::pointer, typename alloc_traits::template rebind_alloc\u0026lt;typename alloc_traits::pointer\u0026gt;\u0026gt;; using underlying_type = basic_sparse_set\u0026lt;Entity, typename alloc_traits::template rebind_alloc\u0026lt;Entity\u0026gt;\u0026gt;; using underlying_iterator = typename underlying_type::basic_iterator; static constexpr bool is_pinned_type_v = !(std::is_move_constructible_v\u0026lt;Type\u0026gt; \u0026amp;\u0026amp; std::is_move_assignable_v\u0026lt;Type\u0026gt;); 模板参数解释如下：\nType：组件类型 Entity：实体类型 Allocator：内存分配器 最后的typename指定的类型用于SFINEA，类里面并不会用到 using部分解释如下\nalloc_traits：使用标准库的allocator_traits得到的内存分配器类型信息 container_type：payloads的类型，是一个std::vector，元素是一页组件类型（默认是1024个Type*） underlying_type：其父类类型 underlying_iterator：其父类的迭代器类型 is_pinned_type_v：描述组件类是否是固定在内存中的类（即不可移动） 接下来看一下内存分配\n首先要知道traits_type：\n//\u0026gt; src/entt/entity/storage.hpp 404 using traits_type = component_traits\u0026lt;value_type\u0026gt;; 然后着重看一下assure_at_least，这个函数确保在创建组件的时候payloads数组能有足够空间容纳：\n//\u0026gt; src/entt/entity/storage.hpp 249 auto assure_at_least(const std::size_t pos) { const auto idx = pos / traits_type::page_size; if(!(idx \u0026lt; payload.size())) { auto curr = payload.size(); allocator_type allocator{get_allocator()}; payload.resize(idx + 1u, nullptr); ENTT_TRY { for(const auto last = payload.size(); curr \u0026lt; last; ++curr) { payload[curr] = alloc_traits::allocate(allocator, traits_type::page_size); } } ENTT_CATCH { payload.resize(curr); ENTT_THROW; } } return payload[idx] + fast_mod(pos, traits_type::page_size); } 由于payloads内存的是一页，所以第三行先算一下页的下标。\n然后第五行判断一下payloads内的页数是否够用，够的话21行直接算出页内元素然后返回（顺便说一句，页内元素类型是Type*也就是组件的指针类型）。\n不够的话，68行对payload大小进行扩容，然后1113行的for循环对扩容后的页进行内存分配。这里顺便说一下这里的allocate函数，第一个参数是内存分配器，第二个参数是要分配的个数。这里是分配一页的组件。\n16~17行则是对异常的处理，如果内存分配抛出异常，则将payload回滚到之前的大小，并传递异常（很细节，在可能抛出异常的地方处理掉）。\n这里我就顺便说一下EnTT对于异常的态度。几乎90%的函数都不能抛出异常（使用noexcept制约），如果能否抛出异常和用户实现有关（EnTT内有很多地方允许用户给入自定义的类型），会使用type traits进行约束（比如noexcept(std::is_nothrow_constructable_v\u0026lt;T\u0026gt;)这种）。剩下的就是自己内部设计中会抛出异常的地方，这种函数不会有noexcept修饰。这里强调的是使用type traits配合noexcept达到跟随用户类型异常状态而改变自身函数抛出异常状态的灵活设计。\n然后看一下真正添加组件的地方：\n//\u0026gt; src/entt/entity/storage.hpp 271 template\u0026lt;typename... Args\u0026gt; auto emplace_element(const Entity entt, const bool force_back, Args \u0026amp;\u0026amp;...args) { const auto it = base_type::try_emplace(entt, force_back); ENTT_TRY { auto elem = assure_at_least(static_cast\u0026lt;size_type\u0026gt;(it.index())); entt::uninitialized_construct_using_allocator(to_address(elem), get_allocator(), std::forward\u0026lt;Args\u0026gt;(args)...); } ENTT_CATCH { base_type::pop(it, it + 1u); ENTT_THROW; } return it; } 这里第7行得到elem，然后第8行对这一块内存进行初始化（Allocator创建的内存是不会初始化的，这里会调用Type的构造函数进行初始化），简单看一下：\ntemplate\u0026lt;typename Type, typename Allocator, typename... Args\u0026gt; constexpr Type *uninitialized_construct_using_allocator(Type *value, const Allocator \u0026amp;allocator, Args \u0026amp;\u0026amp;...args) { return std::apply([value](auto \u0026amp;\u0026amp;...curr) { return new(value) Type(std::forward\u0026lt;decltype(curr)\u0026gt;(curr)...); }, internal::uses_allocator_construction\u0026lt;Type\u0026gt;::args(allocator, std::forward\u0026lt;Args\u0026gt;(args)...)); } 这里本质上是使用了emplacement new方法，在原有的内存上初始化类对象。EnTT在删除的时候也不是真的删除，而是调用类的析构函数，内存还是保留的。这样就增加了内存的复用率，减少了new/delete的开销。\n存储Entity的Storage对Entity的复用 # 对于纯粹存储Entity的storage，他使用了对Entity的复用。因为sparse set中删除整数并不会使整数的值减少，sparse数组中的内存会越来越多：\n增加元素1 packed: 1 sparse: _ 0 增加元素2 packed: 1 2 sparse: _ 0 1 删除元素1 packed: 2 sparse: _ _ 0 增加元素3 packed: 2 3 sparse: _ _ 0 1 可以发现增加元素3之后，sparse的容量还是会增大，这和是否删除元素无关，只要元素以递增的方式插入进来，sparse就会无限增大。\n之前我们说过EnTT中对实体的定义，实体是一个整数，被分为ID部分和版本号部分。复用的秘密就在这个版本号中。\nstorage有一个单独的length变量用于存储当前所有实体的个数（正在使用的+可复用的），整个复用流程如下：\nlength = 0 packed: sparse: 创建三次实体： 创建实体时，如果没有可复用实体，会将packed.size()的值作为实体插入。所以创建三次会插入0, 1, 2 length = 3 packed: 0 1 2 sparse: 0 1 2 删除 1 length = 2 packed: 0 2 1/1\t这里的1/1代表ID为1，版本为1的实体。删除并不会真的删掉，而是将元素移动到 length-1 处并自增版本号，同时sparse也会更改 sparse: 0 2 1 创建新实体： 这时存在可复用实体，那么直接返回length处值即可 length = 3 packed: 0 2 1/1 sparse: 0 2 1 这里对的核心点在于，删除操作时会将删除的实体版本号+1然后移动到length-1处。在创建新实体时，会判断length \u0026lt; packed.size()，如果小于，说明存在复用实体，就直接lenghth + 1并返回packed[length - 1]就行。\n","date":"24 August 2023","externalUrl":null,"permalink":"/posts/entt%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%E5%9B%9B/","section":"Posts","summary":"","title":"EnTT源码分析【四】：storage","type":"posts"},{"content":"本文分析了开源游戏框架Raylib v4.5.0中的batch rendering实现。\nRaylib是一个非常易用的游戏框架。其中90%的代码都是调库代码，只有渲染部分，作者自己封装了一个rlgl作为OpenGL的抽象层，里面比较有技术含量的就是他的Batch Rendering部分。\n注意：Raylib对各个版本的OpenGL（gl2,gl3,gles）都做了统一封装（在src/rlgl.h中），我们只分析gl3的代码。gl2的太老了就不看了，gles和gl3差不多，原理一样也就不分析了。\n什么是Batch Rendering # 直译过来是“批渲染”，就是一次性渲染一堆数据。\n假设现在有一个TileMap，他需要从一张图上取多个区域绘制在屏幕上，每一个区域是矩形。如果不使用Batch Rendering，函数的可能形式如下：\nvoid RenderOneTile(const Texture\u0026amp; texture, const Rect\u0026amp; region, const Rect\u0026amp; dst) { // 将数据传给GPU glBindBuffer(GL_ARRAY_BUFFER, RenderContext.buffer); glBufferData(GL_ARRAY_BUFFER, CreateRectData(region, dst), GL_STATIC_DRAW); // 绑定element buffer glBindBuffer(GL_ELEMENT_BUFFER, RenderContext.index_buffer); // 绑定Texture glBindTexture(GL_TEXTURE0, texture.id); // 准备渲染 glUseProgram(RenderContext.program); // 渲染 glDrawElements(GL_TRIANGLES, 0, 6, 0); } 也就是说，每一次渲染都需要将矩形的数据传递给GPU，然后绑定Texture，进行渲染。\n这里的性能缺陷主要是：\n数据传递给GPU：如果的多个Rect使用的是同一个Texture，那么我们可以将他们的数据全部存储起来，等到最后一并传给GPU 多次的DrawCall：这里每一个Rect都需要一次DrawCall。我们可以将数据全部存储起来，然后调用一次关于Rect的DrawCall就可以绘制全部。 Raylib中的Batch Rendering # 重要数据结构 # raylib自定义了可以绘制的类型：\n// Primitive assembly draw modes #define RL_LINES 0x0001 // GL_LINES #define RL_TRIANGLES 0x0004 // GL_TRIANGLES #define RL_QUADS 0x0007 // GL_QUADS 有三种。在绘制的时候，raylib会自行将相关的数据存储在一起，最后进行统一绘制。\nrlglData # 重要的数据结构如下：\ntypedef struct rlglData { rlRenderBatch *currentBatch; // Current render batch rlRenderBatch defaultBatch; // Default internal render batch ... } rlglData; rlglData中存储着整个Batch Rendering需要的数据，其最后是一个全局变量：\nstatic rlglData RLGL = { 0 }; 其中对Batch Rendering的数据定义是结构体rlRenderBatch：\n// rlRenderBatch type typedef struct rlRenderBatch { int bufferCount; // Number of vertex buffers (multi-buffering support) int currentBuffer; // Current buffer tracking in case of multi-buffering rlVertexBuffer *vertexBuffer; // Dynamic buffer(s) for vertex data rlDrawCall *draws; // Draw calls array, depends on textureId int drawCounter; // Draw calls counter float currentDepth; // Current depth value for next draw } rlRenderBatch; 成员解释如下：\nvertexBuffer,bufferCount和currentBuffer：分别指定了存储顶点数据的buffer(s)，以及当前的buffer下标，和vertexBuffer中buffer的数量 draws,drawCounter：draws代表一个DrawCall，而drawCounter则存储draws中有多少个rlDrawCall currentDepth：当前的深度值。当你在绘制2D图像的时候，后绘制的图像应该在先绘制的图像后面（z值要更大）。每次绘制的时候，顶点的z值就会应用这里的currentDepth，然后currentDepth会自动变大一些以实现前面的功能。 rlVertexBuffer # 然后是存储顶点数据的rlVertexBuffer：\n// Dynamic vertex buffers (position + texcoords + colors + indices arrays) typedef struct rlVertexBuffer { int elementCount; // Number of elements in the buffer (QUADS) float *vertices; // Vertex position (XYZ - 3 components per vertex) (shader-location = 0) float *texcoords; // Vertex texture coordinates (UV - 2 components per vertex) (shader-location = 1) unsigned char *colors; // Vertex colors (RGBA - 4 components per vertex) (shader-location = 3) #if defined(GRAPHICS_API_OPENGL_11) || defined(GRAPHICS_API_OPENGL_33) unsigned int *indices; // Vertex indices (in case vertex data comes indexed) (6 indices per quad) #endif #if defined(GRAPHICS_API_OPENGL_ES2) unsigned short *indices; // Vertex indices (in case vertex data comes indexed) (6 indices per quad) #endif unsigned int vaoId; // OpenGL Vertex Array Object id unsigned int vboId[4]; // OpenGL Vertex Buffer Objects id (4 types of vertex data) } rlVertexBuffer; 结构解释如下：\nelementCount： vertices：顶点数据，由x,y,z三个分量组成 texcoords：顶点的纹理坐标，由u,v两个分量组成 colors：顶点颜色，由r,g,b,a四个分量组成 indices：顶点的索引 vaoId：OpenGL中Vertex Attributes的ID vboId[4]：OpenGL中的Buffer的ID，分别对应vertices，texcoords，colors和indices（也就是说前三个类型是GL_ARRAY_BUFFER，最后一个是GL_ELEMENT_BUFFER） 可以看出Raylib对Vertex的各信息组织是分开的，并用四个Buffer存储，不像Learning OpenGL教程中放在一起。\nrlDrawCall # 然后看一下rlDrawCall：\n// Draw call type // NOTE: Only texture changes register a new draw, other state-change-related elements are not // used at this moment (vaoId, shaderId, matrices), raylib just forces a batch draw call if any // of those state-change happens (this is done in core module) typedef struct rlDrawCall { int mode; // Drawing mode: LINES, TRIANGLES, QUADS int vertexCount; // Number of vertex of the draw int vertexAlignment; // Number of vertex required for index alignment (LINES, TRIANGLES) //unsigned int vaoId; // Vertex array id to be used on the draw -\u0026gt; Using RLGL.currentBatch-\u0026gt;vertexBuffer.vaoId //unsigned int shaderId; // Shader id to be used on the draw -\u0026gt; Using RLGL.currentShaderId unsigned int textureId; // Texture id to be used on the draw -\u0026gt; Use to create new draw call if changes //Matrix projection; // Projection matrix for this draw -\u0026gt; Using RLGL.projection by default //Matrix modelview; // Modelview matrix for this draw -\u0026gt; Using RLGL.modelview by default } rlDrawCall; 各结构解释如下：\nmode：绘制的类型，就是一开始说的三种类型 vertexCount：绘制所需的顶点数目，之后会直接应用在glDrawArrays()或glDrawElements()函数中 vertexAlignment：顶点的对齐 textureId：要绘制的图像 最后绘制的时候，对于每一个rlDrawCall，都会有一个DrawCall一次性将其中的所有数据全部绘制。\n这里要说一下vertexAlignment的作用。之前说过rlVertexBuffer.vboId[4]中最后一个Buffer是索引Buffer，但是不总是能用到这个Buffer（比如你要绘制不连续的三角形，或者很多条不连续的直线时，这个Buffer就完全无用（Raylib中就是当你的mode为RL_LINES或RL_TRIANGLES时），调用glDrawArrays()就好了）。那么如果用不到，常见的做法是在将顶点数据放入rlVertexBuffer中的时候，同时置索引Buffer处的索引为一个固定值：\nrlVertexBuffer::vertices : (1.0, 2.0, 3.0) (5.0, 6.0, 8.0) (10.0, 11.0, 12.0, 13.0) rlVertexBuffer::indices : -1 -1 6 7 8 7 8 9 是否使用： 不使用 不使用 使用，绘制一个矩形 但是Raylib使用了另一种方法：直接将索引Buffer初始化为绘制多个矩形的Buffer：\nrlVertexBuffer::indices ： 0 1 2 1 2 3 4 5 6 5 6 7 8 9 10 9 10 11 ... 每六个元素代表绘制一个矩形。那么你将数据放入rlVertexBuffer::vertices/texcoords/colors中的时候，就需要和indices对齐。比如我现在要绘制一个矩形，那我直接将数据放入即可：\nrlVertexBuffer::vertices : v0 v1 v2 v3 rlVertexBuffer::indices : 0 1 2 1 2 3 4 5 6 5 6 7 是否使用indces:\t会使用 如果我接下来要绘制一个单独的三角形呢？依旧将数据放入：\nrlVertexBuffer::vertices : v0 v1 v2 v3 v4 v5 v6 rlVertexBuffer::indices : 0 1 2 1 2 3 4 5 6 5 6 7 8 9 10 9 10 11 是否使用indces:\t会使用 不会使用 那这个时候我又要绘制矩形呢？这个时候矩形应该能够利用到indices中的索引才行，所以我不能直接放入vertices末尾，如果直接放入，顶点对应的索引下标会变为7 8 9 8 9 10而不是我们要的8 9 10 9 10 11。所以这个时候就要对齐，Raylib会设置rlDrawCall::vertexAlignment为1以指定偏移量：\nrlVertexBuffer::vertices : v0 v1 v2 v3 v4 v5 v6 [v7] v8 v9 v10 v11 rlVertexBuffer::indices : 0 1 2 1 2 3 4 5 6 5 6 7 8 9 10 9 10 11 是否使用indces:\t会使用 不会使用 会使用 注意这里的v7（即vertices[7]）是没有数据的，只是用来占位的。\n而是否使用indices由rlDrawCall::mode来决定。是RL_QUAD就调用glDrawElements并使用索引Buffer。否则调用glDrawArray()。\nBatch Rendering的源码 # 初始化RLGL部分 # 初始化RLGL部分的代码在：\n//\u0026gt; src/rlgl.h 2477 rlRenderBatch rlLoadRenderBatch(int numBuffers, int bufferElements) { rlRenderBatch batch = { 0 }; #if defined(GRAPHICS_API_OPENGL_33) || defined(GRAPHICS_API_OPENGL_ES2) // Initialize CPU (RAM) vertex buffers (position, texcoord, color data and indexes) //-------------------------------------------------------------------------------------------- batch.vertexBuffer = (rlVertexBuffer *)RL_MALLOC(numBuffers*sizeof(rlVertexBuffer)); for (int i = 0; i \u0026lt; numBuffers; i++) { ...(1) int k = 0; // Indices can be initialized right now for (int j = 0; j \u0026lt; (6*bufferElements); j += 6) { batch.vertexBuffer[i].indices[j] = 4*k; batch.vertexBuffer[i].indices[j + 1] = 4*k + 1; batch.vertexBuffer[i].indices[j + 2] = 4*k + 2; batch.vertexBuffer[i].indices[j + 3] = 4*k; batch.vertexBuffer[i].indices[j + 4] = 4*k + 2; batch.vertexBuffer[i].indices[j + 5] = 4*k + 3; k++; } RLGL.State.vertexCounter = 0; } ...(2) } (1)处省略的是batch中各个数组vertices, indices, texcoords, colors的内存初始化。紧接着的代码是初始化indices的部分。然后(2)处省略的是对vaoId和vboId[4]的初始化和绑定，以及对draws成员的初始化。省略的代码都是常规操作。\n这个函数在一开始初始化窗口（void InitWindow(int width, int height, const char *title)）时会调用（InitGraphicsDevice()-\u0026gt;rlglInit()-\u0026gt;rlLoadRenderBatch()）。最后返回值会给RLGL。\n需要注意的是，这个函数传入的参数决定了batch buffer和buffer中能够存储的vertex数目。这个数目之后是不会变的。\nBatch部分 # 通过刚才的初始化我们知道，RLGL.currentBatch-\u0026gt;vertexBuffer和RLGL.currentBatch-\u0026gt;draws的数目初始化后是固定不变的。那么Raylib在何时会真正调用DrawCall绘制呢？答案是：\n当顶点数据超出vertexBuffer时，调用所有存储的drawcall然后清除draws和vertexBuffer中数据 当draws已满时，调用所有drawcall然后清除draws和vertexBuffer中数据 调用void EndDrawing(void)时，代表游戏循环中的渲染全部结束了，会将剩下的batch全部绘制掉 那么何时会出现新对的drawcall呢？答案如下：\n需要绘制的元素类型和当前drawcall类型（即mode成员）不一致时 改变了texture时 典型raylib绘制程序结构分析 # 一个典型的Raylib程序如下：\n// 精简过后的examples/shapes/shapes_basic_shapes.c #include \u0026#34;raylib.h\u0026#34; int main(void) { const int screenWidth = 800; const int screenHeight = 450; InitWindow(screenWidth, screenHeight, \u0026#34;raylib [shapes] example - basic shapes drawing\u0026#34;); while (!WindowShouldClose()) { BeginDrawing(); ClearBackground(RAYWHITE); // Circle shapes and lines DrawCircle(screenWidth/5, 120, 35, DARKBLUE); DrawCircleGradient(screenWidth/5, 220, 60, GREEN, SKYBLUE); DrawCircleLines(screenWidth/5, 340, 80, DARKBLUE); // Rectangle shapes and lines DrawRectangle(screenWidth/4*2 - 60, 100, 120, 60, RED); DrawRectangleGradientH(screenWidth/4*2 - 90, 170, 180, 130, MAROON, GOLD); DrawRectangleLines(screenWidth/4*2 - 40, 320, 80, 60, ORANGE); EndDrawing(); } CloseWindow(); return 0; } 通常渲染以一对BeginDraw()，EndDrawing()包括，里面有所有的渲染调用函数。\n以DrawCircle为例，他的实现如下（最里面调用的是DrawCircleSector）：\nvoid DrawCircleSector(Vector2 center, float radius, float startAngle, float endAngle, int segments, Color color) { ...(1) #if defined(SUPPORT_QUADS_DRAW_MODE) rlSetTexture(texShapes.id); rlBegin(RL_QUADS); // NOTE: Every QUAD actually represents two segments for (int i = 0; i \u0026lt; segments/2; i++) { rlColor4ub(color.r, color.g, color.b, color.a); rlTexCoord2f(texShapesRec.x/texShapes.width, texShapesRec.y/texShapes.height); rlVertex2f(center.x, center.y); rlTexCoord2f(texShapesRec.x/texShapes.width, (texShapesRec.y + texShapesRec.height)/texShapes.height); rlVertex2f(center.x + sinf(DEG2RAD*angle)*radius, center.y + cosf(DEG2RAD*angle)*radius); rlTexCoord2f((texShapesRec.x + texShapesRec.width)/texShapes.width, (texShapesRec.y + texShapesRec.height)/texShapes.height); rlVertex2f(center.x + sinf(DEG2RAD*(angle + stepLength))*radius, center.y + cosf(DEG2RAD*(angle + stepLength))*radius); rlTexCoord2f((texShapesRec.x + texShapesRec.width)/texShapes.width, texShapesRec.y/texShapes.height); rlVertex2f(center.x + sinf(DEG2RAD*(angle + stepLength*2))*radius, center.y + cosf(DEG2RAD*(angle + stepLength*2))*radius); angle += (stepLength*2); } // NOTE: In case number of segments is odd, we add one last piece to the cake ...(2) rlSetTexture(0); #else rlBegin(RL_TRIANGLES); for (int i = 0; i \u0026lt; segments; i++) { rlColor4ub(color.r, color.g, color.b, color.a); rlVertex2f(center.x, center.y); rlVertex2f(center.x + sinf(DEG2RAD*angle)*radius, center.y + cosf(DEG2RAD*angle)*radius); rlVertex2f(center.x + sinf(DEG2RAD*(angle + stepLength))*radius, center.y + cosf(DEG2RAD*(angle + stepLength))*radius); angle += stepLength; } rlEnd(); #endif } (1)处省略了一些针对扇形计算的代码。(2)处则省略了对特殊情况的处理。\n我们主要看结构，可以看到首先会使用rlSetTexture()设置纹理，然后渲染代码总以一对rlBegin(XXX),rlEnd()包裹，在里面通过rlColor4ub(),rlVertex2f(),rlTexCoord2f()指定顶点的数据。注意rlVertex2f()一定要在最后调用。因为rlColor4ub()和rlTexCoord2f()是将数据存在RLGL中。\n改变texture # 改变texture会导致产生新drawcall：\n// Set current texture to use void rlSetTexture(unsigned int id) { if (id == 0) { #if defined(GRAPHICS_API_OPENGL_11) rlDisableTexture(); #else // NOTE: If quads batch limit is reached, we force a draw call and next batch starts if (RLGL.State.vertexCounter \u0026gt;= RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].elementCount*4) { rlDrawRenderBatch(RLGL.currentBatch); } #endif } else { #if defined(GRAPHICS_API_OPENGL_11) rlEnableTexture(id); #else if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].textureId != id) { if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount \u0026gt; 0) { // Make sure current RLGL.currentBatch-\u0026gt;draws[i].vertexCount is aligned a multiple of 4, // that way, following QUADS drawing will keep aligned with index processing // It implies adding some extra alignment vertex at the end of the draw, // those vertex are not processed but they are considered as an additional offset // for the next set of vertex to be drawn if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode == RL_LINES) RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexAlignment = ((RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount \u0026lt; 4)? RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount : RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount%4); else if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode == RL_TRIANGLES) RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexAlignment = ((RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount \u0026lt; 4)? 1 : (4 - (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount%4))); else RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexAlignment = 0; if (!rlCheckRenderBatchLimit(RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexAlignment)) { RLGL.State.vertexCounter += RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexAlignment; RLGL.currentBatch-\u0026gt;drawCounter++; } } if (RLGL.currentBatch-\u0026gt;drawCounter \u0026gt;= RL_DEFAULT_BATCH_DRAWCALLS) rlDrawRenderBatch(RLGL.currentBatch); RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].textureId = id; RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount = 0; } #endif } } 22行在判断当前texture是否改变。如果改变了，24行的if内会计算alignment，然后35~41行会新增drawcall数据结构，43行时会将信息存入。\n这里面的rlDrawRenderBatch()是真正进行batch rendering的地方。后面会说。\n开始进行渲染rlBegin # // Initialize drawing mode (how to organize vertex) void rlBegin(int mode) { // Draw mode can be RL_LINES, RL_TRIANGLES and RL_QUADS // NOTE: In all three cases, vertex are accumulated over default internal vertex buffer if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode != mode) { if (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount \u0026gt; 0) { // Make sure current RLGL.currentBatch-\u0026gt;draws[i].vertexCount is aligned a multiple of 4, // that way, following QUADS drawing will keep aligned with index processing // It implies adding some extra alignment vertex at the end of the draw, // those vertex are not processed but they are considered as an additional offset // for the next set of vertex to be drawn rlDrawCall* draw = \u0026amp;RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1]; if (draw-\u0026gt;mode == RL_LINES) draw-\u0026gt;vertexAlignment = ((draw-\u0026gt;vertexCount \u0026lt; 4)? draw-\u0026gt;vertexCount : draw-\u0026gt;vertexCount%4); else if (draw-\u0026gt;mode == RL_TRIANGLES) draw-\u0026gt;vertexAlignment = ((draw-\u0026gt;vertexCount \u0026lt; 4)? 1 : (4 - (draw-\u0026gt;vertexCount%4))); else draw-\u0026gt;vertexAlignment = 0; if (!rlCheckRenderBatchLimit(draw-\u0026gt;vertexAlignment)) { RLGL.State.vertexCounter += draw-\u0026gt;vertexAlignment; RLGL.currentBatch-\u0026gt;drawCounter++; } } if (RLGL.currentBatch-\u0026gt;drawCounter \u0026gt;= RL_DEFAULT_BATCH_DRAWCALLS) rlDrawRenderBatch(RLGL.currentBatch); RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode = mode; RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount = 0; RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].textureId = RLGL.State.defaultTextureId; } } 这里每一行都很重要:\n第6行的if判断mode是否改变了。如果改变了，说明要新增一个drawcall了。\n第8行的if及里面的代码是在算当前vertex的alignment，并存储得到draw-\u0026gt;vertexAlignment中（说是alignment，倒不如说是padding，是当前数据到之前数据之间的空隙个数。所以RLGL.State.vertexCounter也要加上这个空隙个数）。\n然后27行看一下draws是否已满，满了触发batch rendering，之后就将数据放入新的draws中。\n将数据存放入buffer中 # // Define one vertex (color) void rlColor4ub(unsigned char x, unsigned char y, unsigned char z, unsigned char w) { RLGL.State.colorr = x; RLGL.State.colorg = y; RLGL.State.colorb = z; RLGL.State.colora = w; } // rlTexCoord2d()同理，不贴了 然后rlVertex2f()会将所有数据放入buffer中：\n// Define one vertex (position) void rlVertex2f(float x, float y) { rlVertex3f(x, y, RLGL.currentBatch-\u0026gt;currentDepth); } // Define one vertex (position) // NOTE: Vertex position data is the basic information required for drawing void rlVertex3f(float x, float y, float z) { float tx = x; float ty = y; float tz = z; // Transform provided vector if required if (RLGL.State.transformRequired) { tx = RLGL.State.transform.m0*x + RLGL.State.transform.m4*y + RLGL.State.transform.m8*z + RLGL.State.transform.m12; ty = RLGL.State.transform.m1*x + RLGL.State.transform.m5*y + RLGL.State.transform.m9*z + RLGL.State.transform.m13; tz = RLGL.State.transform.m2*x + RLGL.State.transform.m6*y + RLGL.State.transform.m10*z + RLGL.State.transform.m14; } // WARNING: We can\u0026#39;t break primitives when launching a new batch. // RL_LINES comes in pairs, RL_TRIANGLES come in groups of 3 vertices and RL_QUADS come in groups of 4 vertices. // We must check current draw.mode when a new vertex is required and finish the batch only if the draw.mode draw.vertexCount is %2, %3 or %4 if (RLGL.State.vertexCounter \u0026gt; (RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].elementCount*4 - 4)) { if ((RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode == RL_LINES) \u0026amp;\u0026amp; (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount%2 == 0)) { // Reached the maximum number of vertices for RL_LINES drawing // Launch a draw call but keep current state for next vertices comming // NOTE: We add +1 vertex to the check for security rlCheckRenderBatchLimit(2 + 1); } else if ((RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode == RL_TRIANGLES) \u0026amp;\u0026amp; (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount%3 == 0)) { rlCheckRenderBatchLimit(3 + 1); } else if ((RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode == RL_QUADS) \u0026amp;\u0026amp; (RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount%4 == 0)) { rlCheckRenderBatchLimit(4 + 1); } } // Add vertices RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].vertices[3*RLGL.State.vertexCounter] = tx; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].vertices[3*RLGL.State.vertexCounter + 1] = ty; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].vertices[3*RLGL.State.vertexCounter + 2] = tz; // Add current texcoord RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].texcoords[2*RLGL.State.vertexCounter] = RLGL.State.texcoordx; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].texcoords[2*RLGL.State.vertexCounter + 1] = RLGL.State.texcoordy; // TODO: Add current normal // By default rlVertexBuffer type does not store normals // Add current color RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].colors[4*RLGL.State.vertexCounter] = RLGL.State.colorr; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].colors[4*RLGL.State.vertexCounter + 1] = RLGL.State.colorg; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].colors[4*RLGL.State.vertexCounter + 2] = RLGL.State.colorb; RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].colors[4*RLGL.State.vertexCounter + 3] = RLGL.State.colora; RLGL.State.vertexCounter++; RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].vertexCount++; } 上面代码中，16行那个if表示如果需要进行变换，会乘上变换矩阵。\n26行那个if是在看RLGL.currentBatch-\u0026gt;vertexBuffer能否容纳新加入的顶点。如果不能，就触发BatchRendering先将所有点绘制掉，然后清空以存储新点（这一过程在rlCheckRenderBatchLimit(count)中）：\nbool rlCheckRenderBatchLimit(int vCount) { bool overflow = false; #if defined(GRAPHICS_API_OPENGL_33) || defined(GRAPHICS_API_OPENGL_ES2) if ((RLGL.State.vertexCounter + vCount) \u0026gt;= (RLGL.currentBatch-\u0026gt;vertexBuffer[RLGL.currentBatch-\u0026gt;currentBuffer].elementCount*4)) { overflow = true; // Store current primitive drawing mode and texture id int currentMode = RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode; int currentTexture = RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].textureId; rlDrawRenderBatch(RLGL.currentBatch); // NOTE: Stereo rendering is checked inside // Restore state of last batch so we can continue adding vertices RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].mode = currentMode; RLGL.currentBatch-\u0026gt;draws[RLGL.currentBatch-\u0026gt;drawCounter - 1].textureId = currentTexture; } #endif return overflow; } 第6行在判断能否容纳。如果不行，15行会触发Batch Rendering（里面会置RLGL.currentBatch-\u0026gt;drawCounter为1，就是清空draws），然后18-19行将数据存到draws中。\n真正的Batch Rendering：rlDrawRenderBatch() # 真正的Batch Rendering代码巨多，这里简化一下贴上来：\nvoid rlDrawRenderBatch(rlRenderBatch *batch) { #if defined(GRAPHICS_API_OPENGL_33) || defined(GRAPHICS_API_OPENGL_ES2) // Update batch vertex buffers //------------------------------------------------------------------------------------------------------------ // NOTE: If there is not vertex data, buffers doesn\u0026#39;t need to be updated (vertexCount \u0026gt; 0) // TODO: If no data changed on the CPU arrays --\u0026gt; No need to re-update GPU arrays (change flag required) if (RLGL.State.vertexCounter \u0026gt; 0) { // Activate elements VAO if (RLGL.ExtSupported.vao) glBindVertexArray(batch-\u0026gt;vertexBuffer[batch-\u0026gt;currentBuffer].vaoId); ...(1) // Unbind the current VAO if (RLGL.ExtSupported.vao) glBindVertexArray(0); } //------------------------------------------------------------------------------------------------------------ // Draw batch vertex buffers (considering VR stereo if required) //------------------------------------------------------------------------------------------------------------ Matrix matProjection = RLGL.State.projection; Matrix matModelView = RLGL.State.modelview; int eyeCount = 1; if (RLGL.State.stereoRender) eyeCount = 2; for (int eye = 0; eye \u0026lt; eyeCount; eye++) { if (eyeCount == 2) { ...(2) } // Draw buffers if (RLGL.State.vertexCounter \u0026gt; 0) { ...(3) if (RLGL.ExtSupported.vao) glBindVertexArray(batch-\u0026gt;vertexBuffer[batch-\u0026gt;currentBuffer].vaoId); else { ...(4) } ...(5) for (int i = 0, vertexOffset = 0; i \u0026lt; batch-\u0026gt;drawCounter; i++) { // Bind current draw call texture, activated as GL_TEXTURE0 and Bound to sampler2D texture0 by default glBindTexture(GL_TEXTURE_2D, batch-\u0026gt;draws[i].textureId); if ((batch-\u0026gt;draws[i].mode == RL_LINES) || (batch-\u0026gt;draws[i].mode == RL_TRIANGLES)) glDrawArrays(batch-\u0026gt;draws[i].mode, vertexOffset, batch-\u0026gt;draws[i].vertexCount); else { #if defined(GRAPHICS_API_OPENGL_33) // We need to define the number of indices to be processed: elementCount*6 // NOTE: The final parameter tells the GPU the offset in bytes from the // start of the index buffer to the location of the first index to process glDrawElements(GL_TRIANGLES, batch-\u0026gt;draws[i].vertexCount/4*6, GL_UNSIGNED_INT, (GLvoid *)(vertexOffset/4*6*sizeof(GLuint))); #endif #if defined(GRAPHICS_API_OPENGL_ES2) glDrawElements(GL_TRIANGLES, batch-\u0026gt;draws[i].vertexCount/4*6, GL_UNSIGNED_SHORT, (GLvoid *)(vertexOffset/4*6*sizeof(GLushort))); #endif } vertexOffset += (batch-\u0026gt;draws[i].vertexCount + batch-\u0026gt;draws[i].vertexAlignment); } if (!RLGL.ExtSupported.vao) { glBindBuffer(GL_ARRAY_BUFFER, 0); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); } glBindTexture(GL_TEXTURE_2D, 0); // Unbind textures } if (RLGL.ExtSupported.vao) glBindVertexArray(0); // Unbind VAO glUseProgram(0); // Unbind shader program } ...(6) // Reset RLGL.currentBatch-\u0026gt;draws array for (int i = 0; i \u0026lt; RL_DEFAULT_BATCH_DRAWCALLS; i++) { batch-\u0026gt;draws[i].mode = RL_QUADS; batch-\u0026gt;draws[i].vertexCount = 0; batch-\u0026gt;draws[i].textureId = RLGL.State.defaultTextureId; } // Reset active texture units for next batch for (int i = 0; i \u0026lt; RL_DEFAULT_BATCH_MAX_TEXTURE_UNITS; i++) RLGL.State.activeTextureId[i] = 0; // Reset draws counter to one draw for the batch batch-\u0026gt;drawCounter = 1; //------------------------------------------------------------------------------------------------------------ // Change to next buffer in the list (in case of multi-buffering) batch-\u0026gt;currentBuffer++; if (batch-\u0026gt;currentBuffer \u0026gt;= batch-\u0026gt;bufferCount) batch-\u0026gt;currentBuffer = 0; #endif } 这里(1)处是将顶点信息vertices,texcoords等的数据传入对应buffer。\n(2)处的eyeCount如果是2，代表要用VR（从两个眼睛处看物体，并绘制两幅图），做一些用于VR绘制的准备操作（主要是设置矩阵）。\n(3)处使用着色器，并初始化一些矩阵。\n(4)处，如果不能够绑定VAO（因为OpenGLES2不支持），那么会使用GLES2的方式处理各个Buffer以正确绑定（不过如果有GL_ARB_vertex_array_object拓展支持也可以绑定（也就是RLGL.ExtSupported.vao指定的事情））。\n(5)处设置一些着色器的Uniform变量。\n(6)处渲染已经完毕了，重置一些变量的值。\n真正的渲染在53行处开始：如果是RL_LINES或RL_TRIANGLES，则调用glDrawArray()直接绘制。否则调用glDrawElements进行绘制。然后vertexOffset通过vertexCount和vertexAlignment指向下一批数据。\n总结 # batch rendering的优点：减少数据送往GPU的次数，减少drawcall\nraylib的batch rendering做法：将数据尽可能地先存在本地，相同类型的绘制使用一个drawcall一次性绘制完毕。\n导致新增drawcall的情况：\n改变了texture 改变了绘制类型 这就说明，如果要利用到batch rendering，就需要相同类型，相同texture的元素放在一起绘制。这也就是为什么游戏引擎中会使用图集的原因：同一类UI全部使用一个texture，texture不变。UI中能使用矩形就使用矩形，减少绘制类型的改变次数，以充分利用batch rendering。\n","date":"12 August 2023","externalUrl":null,"permalink":"/posts/raylib%E7%9A%84batchrendering/","section":"Posts","summary":"本文分析了开源游戏框架Raylib v4.5.0中的batch rendering实现。\n","title":"Raylib的BatchRendering","type":"posts"},{"content":"","date":"12 August 2023","externalUrl":null,"permalink":"/tags/%E5%9B%BE%E5%BD%A2%E5%AD%A6/","section":"Tags","summary":"","title":"图形学","type":"tags"},{"content":" 模板参数推导 # 模板参数有三种写法：\ntemplate \u0026lt;typename T\u0026gt; void Foo(T) { static_assert(false); } // 直接使用T template \u0026lt;typename T\u0026gt; void FooWithRRef(T\u0026amp;) { static_assert(false); } // 使用T\u0026amp; template \u0026lt;typename T\u0026gt; void FooWithLRef(T\u0026amp;\u0026amp;) { static_assert(false); } // 使用T\u0026amp;\u0026amp; 根据《C++ Templates 2》的说法，第一种写法会decay（等同于调用std::decay），即：\n所有的const, volatile限定符会被移除 所有的引用（不管左值右值）会被移除 数组会退化为指向数组的指针，函数会退化为指向函数的指针 所以会有：\nFoo(1) -\u0026gt; 参数真正类型:int\u0026amp;\u0026amp; T推导结果：int int a; Foo(a) -\u0026gt; 参数真正类型:int T推导结果：int int\u0026amp;\u0026amp; a = 123; Foo(std::move(a)) -\u0026gt; 参数真正类型:int\u0026amp;\u0026amp; T推导结果：int const int\u0026amp; a = b; Foo(a) -\u0026gt; 参数真正类型:const int\u0026amp; T推导结果：int 第二种写法会保留所有参数原本的类型，const不会被丢弃，引用也会被保留。\n但是有个问题：引用折叠，即当存在多个引用时，各个引用之间会发生折叠：\n类型1 类型2 结果类型 T T\u0026amp; T\u0026amp; T\u0026amp; T\u0026amp; T\u0026amp; T\u0026amp;\u0026amp; T\u0026amp; T\u0026amp; T\u0026amp;\u0026amp; T\u0026amp;\u0026amp; T\u0026amp;\u0026amp; 也就是说，只有两个类型都是右值引用的时候，最后推导结果才会是左值引用。否则全部变为左值引用。\n那么由于引用折叠的存在，FooWithRRef()的参数类型一定是右值引用（因为左值会被折叠掉变为右值）。\n那么要想做到传递左值，就必须使用FooWithLRef()并且传入参数是左值引用类型，这样引用折叠才会折叠为左值引用。\n完美转发 # 使用FooWithLRef(T\u0026amp;\u0026amp;)仍旧有一个问题，就是在此函数内将参数传递给另一个函数时会有问题：\nvoid anotherFunc(int\u0026amp; a); void anotherFunc(int\u0026amp;\u0026amp; a); template \u0026lt;typename T\u0026gt; void FooWithLRef(T\u0026amp;\u0026amp; value) { anotherFunc(value);\t// 调用的是哪个？ } 这里，如果传递左值FooWithLRef(std::move(a))，那么T会被推导为int，那么value的类型就是int\u0026amp;\u0026amp;。这样传入的是左值引用。\n但是左值引用本身是右值，所以在传入anotherFunc的时候，仍旧是按照右值传递，这样会调用第一个函数，永远不会调用第二个函数。\n当然，可以强制使用anotherFunc(std::move(a))进行左值版本调用。那这样右值版本将永不调用。\n这个时候就需要使用完美转发std::forward\u0026lt;T\u0026gt;(a)，它会将左值引用再次变为左值，而右值引用仍旧是右值。\n其他 # 关于FooWithLRef(T\u0026amp;\u0026amp;)还有一个注意点，就是如果传入一个左值，如：\nFooWithLRef(1); T会被推导为int，所以value的类型是T\u0026amp;\u0026amp;-\u0026gt;int\u0026amp;\u0026amp;。\n而如果传入右值或普通值：\nint\u0026amp; a = b;\t// 或int a = 123; FooWithLRef(a); 那么T会被推导为int\u0026amp;，根据引用折叠，value类型是int\u0026amp; \u0026amp;\u0026amp; -\u0026gt;int\u0026amp;。\n而很离谱的是，如果你使用的是FooWithRRef(T\u0026amp;)，传入普通类型并不会使T变为int\u0026amp;而是int，最后value会被推导为int\u0026amp;。\n","date":"10 August 2023","externalUrl":null,"permalink":"/posts/%E5%BC%95%E7%94%A8%E6%8A%98%E5%8F%A0%E5%92%8C%E5%AE%8C%E7%BE%8E%E8%BD%AC%E5%8F%91/","section":"Posts","summary":"","title":"引用折叠和完美转发","type":"posts"},{"content":"本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS中的Entity部分。\nEntity # Entity的本质 # EnTT中的Entity是正整数，或者更严谨一点，是enum class：\nenum class entity : id_type {}; //\u0026gt; src/entt/entity/fwd.hpp using id_type = ENTT_ID_TYPE; //\u0026gt; src/entt/core/fwd.hpp //\u0026gt; src/entt/config/config.h #ifndef ENTT_ID_TYPE # include \u0026lt;cstdint\u0026gt; # define ENTT_ID_TYPE std::uint32_t #endif 所以总的来说就是正整数类型的强枚举。\n之所以使用强枚举是因为这样可以避免用户拿到Entity之后胡乱当做整数进行运算。每次操作Entity的时候其实都会将其强转到id_type的，本质上还是当整数去操作。\nentity traits # traits用于限制Entity的类型，并且定义一些字段：\n//\u0026gt; src/entt/entity/entity.hpp namespace internal { ... /*(1)*/ template\u0026lt;typename, typename = void\u0026gt; struct entt_traits; /*(2)*/ template\u0026lt;typename Type\u0026gt; struct entt_traits\u0026lt;Type, std::enable_if_t\u0026lt;std::is_enum_v\u0026lt;Type\u0026gt;\u0026gt;\u0026gt; : entt_traits\u0026lt;std::underlying_type_t\u0026lt;Type\u0026gt;\u0026gt; { using value_type = Type; }; /*(3)*/ template\u0026lt;typename Type\u0026gt; struct entt_traits\u0026lt;Type, std::enable_if_t\u0026lt;std::is_class_v\u0026lt;Type\u0026gt;\u0026gt;\u0026gt; : entt_traits\u0026lt;typename Type::entity_type\u0026gt; { using value_type = Type; }; /*(4)*/ template\u0026lt;\u0026gt; struct entt_traits\u0026lt;std::uint32_t\u0026gt; { using value_type = std::uint32_t; using entity_type = std::uint32_t; using version_type = std::uint16_t; static constexpr entity_type entity_mask = 0xFFFFF; static constexpr entity_type version_mask = 0xFFF; }; template\u0026lt;\u0026gt; struct entt_traits\u0026lt;std::uint64_t\u0026gt; { using value_type = std::uint64_t; using entity_type = std::uint64_t; using version_type = std::uint32_t; static constexpr entity_type entity_mask = 0xFFFFFFFF; static constexpr entity_type version_mask = 0xFFFFFFFF; }; ... } EnTT中有很多类似这样的操作：首先(1)处声明一个模板，但是不实现它。等到后面对其进行特化。这样只有满足特化的模板参数才可以通过编译，其余的一律是不支持的模板参数，变相地限制了模板参数（C++20 concept我想你了555）。\n(2)和(3)是在做如下事情：\n如果Type是类，那它要求Type中有一个entity_type，并且这个entity_type也必须是类或枚举，然后将这个类型递归地进行萃取 如果Type是枚举，得到他对应的数字类型（EnTT中就是id_type是uint32_t）并且通过继承聚合此类型相关的信息 (4)处开始真正的Entity信息定义。这里通过全特化指定只有uint32_t和uint64_t能够有类型，其余的数字类型一律编译失败。\nEntity由两部分组成：id部分和version部分。version部分主要是为了复用entity。\n那么我们可以看到，Entity应该有如下信息：\nvalue_type：Entity真正的数字类型 entity_type：id部分的类型 version_type：version部分的类型 entity_mask：id部分的掩码 version_mask：version部分的掩码 比如对于uint32_t类型来说，其Entity组成如下：\nversion id 0000 0000 0001 000 0000 0000 0000 0001 version在高位id在低位。\n接下来的basic_entt_traits则通过组合的方式增加了一些对Entity的操作：\ntemplate\u0026lt;typename Traits\u0026gt; class basic_entt_traits { static constexpr auto length = internal::popcount(Traits::entity_mask); /*(1)*/ static_assert(Traits::entity_mask \u0026amp;\u0026amp; ((typename Traits::entity_type{1} \u0026lt;\u0026lt; length) == (Traits::entity_mask + 1)), \u0026#34;Invalid entity mask\u0026#34;); static_assert((typename Traits::entity_type{1} \u0026lt;\u0026lt; internal::popcount(Traits::version_mask)) == (Traits::version_mask + 1), \u0026#34;Invalid version mask\u0026#34;); public: using value_type = typename Traits::value_type; using entity_type = typename Traits::entity_type; using version_type = typename Traits::version_type; static constexpr entity_type entity_mask = Traits::entity_mask; static constexpr entity_type version_mask = Traits::version_mask; [[nodiscard]] static constexpr entity_type to_integral(const value_type value) noexcept { ... } [[nodiscard]] static constexpr entity_type to_entity(const value_type value) noexcept { ... } [[nodiscard]] static constexpr version_type to_version(const value_type value) noexcept { ... } ... } 这里的模板参数Traits就是上面的entt_traits。然后类里面重新using了traits里的类型。\n增加的一些操作也很好懂，在这里就不分析了，注释写的很详细。主要是得到Entity的版本号/ID号，通过版本号\u0026amp;ID好拼一个Entity，通过两个Entity拼一个Entity等等。\n这里稍微看一下(1)处的两个static_assert，要求entity_mask和version_mask必须满足所有位全为1的条件。我也是第一次知道static_assert可以直接放在类里面。\n最后，通过entt命名空间中的entt_traits继承basic_entt_traits得到最后的traits：\ntemplate\u0026lt;typename Type\u0026gt; struct entt_traits: basic_entt_traits\u0026lt;internal::entt_traits\u0026lt;Type\u0026gt;\u0026gt; { using base_type = basic_entt_traits\u0026lt;internal::entt_traits\u0026lt;Type\u0026gt;\u0026gt;; static constexpr std::size_t page_size = ENTT_SPARSE_PAGE; }; 并且在下面将basic_entt_traits的static函数封装成全局函数以便于调用。\n小结一下：\n首先通过entt_traits进行类型萃取，要求Entity的类型必须是枚举，并且枚举的底层数字类型必须是uint32_t/uint64_t（但可通过增加全特化版本来扩展支持类型） 然后通过basic_entt_traits在原本的信息上增加控制这些数据的函数 最后使用全局函数封装basic_entt_traits以方便函数调用（basic_entt_traits需要一个entt_traits作为模板参数。entt_traits需要一个Entity作为模板参数。封装成全局函数可直接通过Entity模板参数调用函数） null和tombstone # null代表空，而tombstone代表“死了”，两者虽然实现非常相似，但不是一个东西（之后的文章会说用法）。\n这里对null和tombstone的实现也很有意思，可以学习学习。这两者实现几乎一样，而且也非常地好懂：\n//\u0026gt; src/entt/entity/entity.hpp struct null_t { template\u0026lt;typename Entity\u0026gt; [[nodiscard]] constexpr operator Entity() const noexcept { using traits_type = entt_traits\u0026lt;Entity\u0026gt;; constexpr auto value = traits_type::construct(traits_type::entity_mask, traits_type::version_mask); return value; } [[nodiscard]] constexpr bool operator==([[maybe_unused]] const null_t other) const noexcept { return true; } [[nodiscard]] constexpr bool operator!=([[maybe_unused]] const null_t other) const noexcept { return false; } template\u0026lt;typename Entity\u0026gt; [[nodiscard]] constexpr bool operator==(const Entity entity) const noexcept { using traits_type = entt_traits\u0026lt;Entity\u0026gt;; return traits_type::to_entity(entity) == traits_type::to_entity(*this); } template\u0026lt;typename Entity\u0026gt; [[nodiscard]] constexpr bool operator!=(const Entity entity) const noexcept { return !(entity == *this); } }; template\u0026lt;typename Entity\u0026gt; [[nodiscard]] constexpr bool operator==(const Entity entity, const null_t other) noexcept { return other.operator==(entity); } template\u0026lt;typename Entity\u0026gt; [[nodiscard]] constexpr bool operator!=(const Entity entity, const null_t other) noexcept { return !(other == entity); } 实现很简单。首先，这个类是一个空类。其次有如下三种函数：\n隐式转换到Entity 和任意的null_t类型比较，总是为true 和Entity比较，只有特定情况（Entity所有位全是1）的时候为true，其他的全为false 真正的做到了0开销抽象原则。\n","date":"10 August 2023","externalUrl":null,"permalink":"/posts/entt%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%E4%BA%8C/","section":"Posts","summary":"","title":"EnTT源码分析【二】：Entity","type":"posts"},{"content":"本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS中的核心数据结构sparse set。\nsparse set # 结构介绍 # sparse set是一个数据结构，用于极快速地对正整数进行增删改查并能够较好地利用CPU Cache。而EnTT中的Entity部分正式用正整数实现的。\nsparse set由两部分组成：\npacked：一个线性表，用于紧密存储所有正整数。也是真正保存所有数据的地方。 sparse：一个线性表，比较稀疏，用于存储packed中整数在packed数组中的下标，主要是为了建立值和下标的映射以加快查找。 需要注意的是，packed和sparse必须是内存连续的线性表，这样才能发挥它易命中Cache的优势。\n基本操作 # 增加 # 增加元素的操作如下，对于任意正整数A\n将A无条件放在packed数组末尾 得到A在packed数组中的下标I（其实就是packed数组的长度-1） 将I放在sparse数组的，以A为下标的位置处 下面用一个例子说明：\n0. 一开始的sparse set: packed: 空 sparse: 空 现在要插入一个正整数3： 1. 将3放入packed最后 packed: 3 sparse: 空 2. 得到3在packed数组的下标，为0 3. 将0存入sparse[3]处： packed: 3 sparse: _ _ _ 0 查询 # 很简单：对于任意元素A，看sparse[A]处是否有值即可。\n如果有值，还可以通过sparse[A]得到A元素在packed数组中的下标。\n删除 # 删除的话也很简单，对于任意元素A：\n通过sparse[A]得到A在packed中的下标I 将packed[A] 和 packed最后一位互换，记packed最后一位元素为L 更新L在sparse中的索引：sparse[L] = I 置sparse[A]为空删除其索引 弹出packed末尾元素（就是之前换到末尾去的A） 迭代元素 # packed数组中紧密存储着所有元素，所以直接迭代packed数组就行了（EnTT的迭代器sparse_set_iterator就是直接拿到packed数组的迭代器进行迭代）。\n顺便说一句，sparse set是无序容器。\n和HashMap的对比 # sparse set的增删查复杂度都是O(1)，而HashMap也是。但HashMap的效率总体来说不如sparse set高。因为sparse set总能在确定步数内完成操作，而HashMap因为冲突的问题，可能需要多次使用散列函数，真正的步骤是不确定的。而如果使用拉链法解决冲突，则更会导致难以命中Cache的问题。\nsparse set的缺点就是只能对正整数进行操作。\n源码分析 # 源码位于src/entity/sparse_set.hpp中。\n分页的sparse数组 # 这个类是个模板类：\ntemplate\u0026lt;typename Entity, typename Allocator\u0026gt; class basic_sparse_set { ... }; Entity模板参数是EnTT中实体的类型。Allocator是内存分配器。\n需要注意的是，basic_sparse_set的sparse数组不是一维数组，是二维的（用的时候其实还是视为一位数组，会将二维摊开成一维），差不多是std::vector\u0026lt;Entity[PageSize]\u0026gt;这个类型。本质上是将一位数组分为多个“页”(Page)，每个页大小就是PageSize最终的页大小在src/entt/config/config.h中有定义：\n#ifndef ENTT_SPARSE_PAGE # define ENTT_SPARSE_PAGE 4096 #endif 分页的原因应该是考虑到CPU的分页机制，当内存过大时方便CPU按照这个大小换页。也有可能是为了方便内存分配器Allocator一次分配这么多内存。\n所以当你插入一个元素A的时候，他会把下标放在sparse[A / PageSize][A % PageSize]处。\n成员变量和一些using # 成员变量和其using如下：\ntemplate\u0026lt;typename Entity, typename Allocator\u0026gt; class basic_sparse_set { using alloc_traits = std::allocator_traits\u0026lt;Allocator\u0026gt;; static_assert(std::is_same_v\u0026lt;typename alloc_traits::value_type, Entity\u0026gt;, \u0026#34;Invalid value type\u0026#34;); using sparse_container_type = std::vector\u0026lt;typename alloc_traits::pointer, typename alloc_traits::template rebind_alloc\u0026lt;typename alloc_traits::pointer\u0026gt;\u0026gt;; using packed_container_type = std::vector\u0026lt;Entity, Allocator\u0026gt;; ... private: sparse_container_type sparse; packed_container_type packed; const type_info *info; entity_type free_list; deletion_policy mode; }; sparse_container_type和packed_container_type是std::vector这在意料之中，但是sparse_container_type的成员有些不明朗，是Allocator分配出的指针类型。这个类型在src/entt/entity/fwd.hpp中有说明：\ntemplate\u0026lt;typename Entity = entity, typename = std::allocator\u0026lt;Entity\u0026gt;\u0026gt; class basic_sparse_set; 就是使用的标准库的allocator，旨在分配一个Entity。但实际的sparse_container_type中则是分配的alloc_traits::pointer，即Entity*，并且使用rebind_alloc将此allocator重绑定以让其分配Entity*（对std::allocator不熟悉可以看这个文章）。\n某些函数简述 # 内部函数的话我觉得没什么好说的，毕竟算法已经说明白了，代码也就是实现的事。简单说一下我比较感兴趣的函数吧：\n[[nodiscard]] auto sparse_ref(const Entity entt) const：得到sparse[entt / PageSize][entt % PageSize]元素的引用 [[nodiscard]] auto sparse_ptr(const Entity entt) const：得到sparse[entt / PageSize][entt % PageSize]元素的指针。没有这个元素返回nullptr [[nodiscard]] auto \u0026amp;assure_at_least(const Entity entt)：保证当前sparse数组可以容纳entt（会自动扩容），并返回容纳entt的那个元素。这个函数是个很好的辅助函数，因为你可以在任何插入entt的地方使用assure_at_least(entt) = entt插入entt倒sparse中，不需要进行很多if判断 template\u0026lt;typename Compare, typename Sort = std_sort, typename... Args\u0026gt; void sort_n(const size_type length, Compare compare, Sort algo = Sort{}, Args \u0026amp;\u0026amp;...args)：对开头的length个元素排序。EnTT是允许对sparse set排序的，这样遍历的时候会有一个顺序，在某些场景比较有用。 ","date":"10 August 2023","externalUrl":null,"permalink":"/posts/entt%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%E4%B8%89/","section":"Posts","summary":"","title":"EnTT源码分析【三】：sparse set","type":"posts"},{"content":"本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS架构。\nECS架构 # ECS的用法 # ECS是一个典型的组合优于继承的架构。\n即Entity, Component, System架构：\nEntity：游戏中的某个实体，通常用正整数实现 Component：游戏中的组件，用于附加在Entity上。例如物理系统中的RigidBody，渲染部分的PBRMaterial等。一般是不含有方法的纯粹数据 System：用于处理附加在Entity的Component。 一个ECS例子如下：\n// 以下源码仅为示例，并不严谨 void UpdatePhysics(entt::registry\u0026amp; reg) { auto entities = reg.view\u0026lt;RigidBody\u0026gt;(); for (auto\u0026amp; [entity, rigidbody] : entities) { // 在这里更新物理 rigidbody.acc = rigidbody.force * rigidboyd.mass; rigidbody.vel = rigidbody.acc * entt::resource_cache\u0026lt;Timer, TimerLoader\u0026gt;{}[0].time(); } } int main() { entt::registry reg; // EnTT中用于管理整个ECS的结构 auto entity = reg.create(); // 创建一个Entity reg.emplace(entity, RigidBody::Create(math::Vec2(100.0, 200.0))); // 创建一个物理组件并附加到Entity上 // 游戏循环中 while (!shouldClose) { // ... UpdatePhysics(reg); // ... } } 一般来说。一个ECS系统包含如下几个部分：\nWorld：即管理整个ECS数据的地方，在EnTT中是entt::registry Querier：查询器，用来得到含有某个特定组件的实体们，或者从实体得到组件。在EnTT中是entt::view Resource： \\[可选\\]，资源。本质上是组件，但不附加在任何实体上，且一般全局只有一份。EnTT中是entt::resource_cache System：系统，在游戏循环中被调用用来对组件进行实际操作。EnTT中没有特定类型，自己写函数进行组件操作即可。Bevy中有StartupSystem用于程序启动时调用一次，以及UpdateSystem用于每帧调用。 还可以分的更细致，比如Bevy中将一些功能从World中抽出：\nCommands：创建实体/将组件附加在实体/删除实体 的帮助类 Resources：用于方便地从World中得到资源 ECS的优点 # 相比传统的OO，ECS优点如下：\nOO编程通过类继承来组合数据/功能。这有可能导致产生冗余数据。但ECS可以将数据分为不相干的多个部分，并通过组件插拔的方式灵活地组合。 OO编程对CPU Cache不友好。摩尔定律指出，CPU每18个月性能翻倍。但内存性能增长速度没有跟上CPU，这导致利用CPU Cache数据是提升性能非常必要的手段。在近几年，由于物理原因，摩尔定律在慢慢失效，导致现在CPU提升性能的常见方法是堆核（多核CPU）。所以利用多核的优势进行并行编程也是必要的手段。而ECS通过将数据分为多个无关部分，各部分的处理更有利于并行。在Bevy游戏引擎中，就已经实现不相关系统并行运行的功能。 由于EnTT的System部分由用户自己编写，所以EnTT并没有提供任何System并行支持，本系列也不会说。并行支持部分可自行参阅flecs源码。\nEnTT的工程结构 # 下面列举了src/文件夹下的结构（src下是源码部分）。可选部分指可以使用，不使用的话不会参与编译（独立模块）。\nconfig：用于通过宏控制EnTT的某些配置 container：一些通用容器 core：核心算法，traits，any，内存分配器和一些工具 entity：整个EnTT最核心的部分，是entt::registry实现的地方，有着ECS大部分功能 graph： \\[可选\\]。 用于辅助开发者做出管线流图的东西。不会在本系列中分析 locator： \\[可选\\]。服务定位器 meta： \\[可选\\]。动态反射，不会在本系列中分析 platform：平台相关 poly： \\[可选\\]。用于做静态多态的东西。不会在本系列中分析 process： \\[可选\\]。文档里没说，应该是用于任务调度的东西。不会在本系列中分析 resource： \\[可选\\]。用于提供Resource支持 signal：信号和委托部分，用于提供信号，事件分发\u0026amp;接收。本质上是个委托框架 EnTT的单元测试做的很好，所有模块对应的单测在test/目录下。\n","date":"10 August 2023","externalUrl":null,"permalink":"/posts/entt%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90%E4%B8%80/","section":"Posts","summary":"","title":"EnTT源码分析【一】：什么是ECS","type":"posts"},{"content":"因为最近开始工作了，995的那种（周末有时加班），所以基本上没什么时间写博客。我现在有时间会在B站上录一些技术视频，主要记录工作中遇到的一些技术问题和自己额外学习的一些学习内容，以及一些简单教程。\n写博客太麻烦了，码字的效率不如视频高，所以改做视频了。博客的话有时间也会更新一些短小的，如果是很耗费精力的技术分享我大概率就做视频了。\n大家可以去我的B站空间支持一波。\n","date":"1 June 2022","externalUrl":null,"permalink":"/posts/%E5%85%B3%E4%BA%8E%E9%80%90%E6%B8%90%E8%BF%81%E7%A7%BB%E8%87%B3b%E7%AB%99%E7%9A%84%E8%AF%B4%E6%98%8E/","section":"Posts","summary":"","title":"关于逐渐迁移至B站的说明","type":"posts"},{"content":"","date":"1 June 2022","externalUrl":null,"permalink":"/categories/%E6%9D%82%E9%A1%B9/","section":"Categories","summary":"","title":"杂项","type":"categories"},{"content":"","date":"1 June 2022","externalUrl":null,"permalink":"/tags/%E6%9D%82%E9%A1%B9/","section":"Tags","summary":"","title":"杂项","type":"tags"},{"content":"最近在看UE4中的反射机制，学了一些反射的使用方式，但并不知道其实现方式。这两天上知乎看到了Ubp.a大神写的99行静态反射，所以拿来分析一下源码。\n其知乎文章在这里。\n一些小工具 # 这里先来看一些小工具，以方便后面接触反射核心代码。\nTStr # 首先是TStr，这是个编译期字符串，其实现如下：\ntemplate\u0026lt;typename C, C... chars\u0026gt; struct TStr { using Char = C; template\u0026lt;typename T\u0026gt; static constexpr bool Is(T = {}) { return std::is_same_v\u0026lt;T, TStr\u0026gt;; } static constexpr const Char* Data() { return data; } static constexpr std::size_t Size() { return sizeof...(chars); } static constexpr std::basic_string_view\u0026lt;Char\u0026gt; View() { return data; } private: static constexpr Char data[]{ chars...,Char(0) }; }; 创建一个字符串的方法是这样：\nTStr\u0026lt;char, \u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;, \u0026#39;w\u0026#39;\u0026gt; str; 这里的模板参数C是指字符的类型，chars...则是字符串中所有的字符。\n首先看data[]的定义，他就是一个字符数组，并且在末尾增加了0，这里是将模板中的字符串存储了下来。\n接下来看Is(T)函数，这个函数判断两个TStr是否相等。注意这里虽然字符串存到了data[]中但不能使用strcmp比较，因为strcmp只能运行在运行期。这里使用std::is_same_v进行判断。 这是因为对于两个不同的字符串，他们的类型是不一样的：\nTStr\u0026lt;char, \u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;\u0026gt; str1; -\u0026gt; 类型为 TStr\u0026lt;char, \u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;\u0026gt; TStr\u0026lt;char, \u0026#39;o\u0026#39;, \u0026#39;w\u0026#39;, \u0026#39;t\u0026#39;, \u0026#39;r\u0026#39;\u0026gt; str2; -\u0026gt; 类型为 TStr\u0026lt;char, \u0026#39;o\u0026#39;, \u0026#39;w\u0026#39;, \u0026#39;t\u0026#39;, \u0026#39;r\u0026#39;\u0026gt; 接下来要看一下两个创建TStr的函数，但是在此之前要介绍一下std::index_sequence：\nstd::index_sequence是integer_sequence\u0026lt;size_t\u0026gt;的模板别名，他存储着编译期的一个序列。std::make_index_sequence则可以生成他。\ntemplate\u0026lt;std::size_t... Ints\u0026gt; using index_sequence = std::integer_sequence\u0026lt;std::size_t, Ints...\u0026gt;; 注意，这个类型主要是为了用在模板参数中，它并不像数组一样，他只有一个成员函数size()，这意味着你不能通过seq[0]这种方式得到他的值。\n要想得的值只能使用一些模板技术（例子来自cppreference）：\ntemplate\u0026lt;typename T, T... ints\u0026gt; void print_sequence(std::integer_sequence\u0026lt;T, ints...\u0026gt; int_seq) { std::cout \u0026lt;\u0026lt; \u0026#34;The sequence of size \u0026#34; \u0026lt;\u0026lt; int_seq.size() \u0026lt;\u0026lt; \u0026#34;: \u0026#34;; ((std::cout \u0026lt;\u0026lt; ints \u0026lt;\u0026lt; \u0026#39; \u0026#39;),...); // 这里是Fold Expression std::cout \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; } int main() { print_sequence(std::integer_sequence\u0026lt;unsigned, 9, 2, 5, 1, 9, 1, 6\u0026gt;{}); } 现在回头来这两个函数：\ntemplate\u0026lt;typename Char, typename T, std::size_t... Ns\u0026gt; constexpr auto TSTRHI(std::index_sequence\u0026lt;Ns...\u0026gt;) { return TStr\u0026lt;Char, T::get()[Ns]...\u0026gt;{}; } template\u0026lt;typename T\u0026gt; constexpr auto TSTRH(T){ return TSTRHI\u0026lt;typename decltype(T::get())::value_type,T\u0026gt;(std::make_index_sequence\u0026lt;T::get().size()\u0026gt;{}); } T::get()[NS]...是指从T类型中调用静态函数get()，这个get会返回一个可索引的对象，然后将此对象的内容在这里展开：\nTSTRHI(std::index_sequence\u0026lt;1, 2, 3\u0026gt;()); // 展开成 return TStr\u0026lt;Char, T::get()[1], T::get()[2], T::get()[3]\u0026gt; 其他的编译期函数 # template\u0026lt;class L, class F\u0026gt; constexpr std::size_t FindIf(const L\u0026amp;, F\u0026amp;\u0026amp;, std::index_sequence\u0026lt;\u0026gt;) { return -1; } template\u0026lt;class L, class F, std::size_t N0, std::size_t... Ns\u0026gt; constexpr std::size_t FindIf(const L\u0026amp; l, F\u0026amp;\u0026amp; f, std::index_sequence\u0026lt;N0, Ns...\u0026gt;) { return f(l.template Get\u0026lt;N0\u0026gt;()) ? N0 : FindIf(l, std::forward\u0026lt;F\u0026gt;(f), std::index_sequence\u0026lt;Ns...\u0026gt;{}); } 这两个函数是经典的递归式模板函数，用于在一个index_sequence中找到特定的数。其中f是谓词函数，用于对数字进行条件判断。\n其他的函数也大同小异（只是功能不同），就不细说了。\n核心的反射实现 # Field的实现 # Field是保存类中成员的结构，是反射的核心，它是存储着类中成员变量和函数的容器：\ntemplate\u0026lt;class Name, class T, class AList\u0026gt; struct Field : FTraits\u0026lt;T\u0026gt;, NamedValue\u0026lt;Name, T\u0026gt; { AList attrs; constexpr Field(Name, T v, AList as = {}) : NamedValue\u0026lt;Name, T\u0026gt;{ v }, attrs{ as } {} }; 可以看出他存储了NamedValue和attrs。其中attrs是AList类型，这是用于给类型附加用户自定义信息的，属于这个反射系统中可有可无的东西，我们不管他。\n先看继承的第一个类FTraits：\ntemplate\u0026lt;bool s, bool f\u0026gt; struct FTraitsB { static constexpr bool is_static = s, is_func = f; }; // [1] template\u0026lt;class T\u0026gt; struct FTraits : FTraitsB\u0026lt;true, false\u0026gt; {}; // default is enum // [2] template\u0026lt;class U, class T\u0026gt; struct FTraits\u0026lt;T U::*\u0026gt; : FTraitsB\u0026lt;false, std::is_function_v\u0026lt;T\u0026gt;\u0026gt; {}; // [3] template\u0026lt;class T\u0026gt; struct FTraits\u0026lt;T*\u0026gt; : FTraitsB\u0026lt;true, std::is_function_v\u0026lt;T\u0026gt;\u0026gt;{}; // static member FTraits类型系列是用于自动判断类型T（和U）是类中的哪种成员。s代表是否是类中静态成员，f代表是否是函数。\n[1]处的是默认值，即默认是类的静态变量。\n[2]处的T U::*是指向类成员指针的表示形式(不清楚的看我的这篇文章），所以置s为false，但到底是不是函数还得用std::is_function_v判断一下。\n[3]处的T*一定是静态成员，所以置s为true，但可能是指向变量的指针或者函数指针，所以要额外对f进行判断。\n然后再看NamedValue：\ntemplate\u0026lt;class Name, class T\u0026gt; struct NamedValue : NamedValueBase\u0026lt;Name\u0026gt; { T value; static constexpr bool has_value = true; constexpr NamedValue(T v) : value{ v } {} template\u0026lt;class U\u0026gt; constexpr bool operator==(U v) const { if constexpr (std::is_same_v\u0026lt;T, U\u0026gt;) return value == v; else return false; } }; template\u0026lt;class Name\u0026gt; struct NamedValue\u0026lt;Name, void\u0026gt; : NamedValueBase\u0026lt;Name\u0026gt; { static constexpr bool has_value = false; template\u0026lt;class U\u0026gt; constexpr bool operator==(U) const { return false; } }; NamedValue从广义上来说就是存储了个有名字的变量value（名字在其父类NamedValueBase的name成员中）。在这里它是存储着成员字段。\n然后看一下他的父类NamedValueBase，其实他的父类很简单，就是存了一个std::string_view，即成员的名称：\ntemplate\u0026lt;class Name\u0026gt; struct NamedValueBase { using TName = Name; static constexpr std::string_view name = TName::View(); }; 最后再通过Field的用法来彻底弄清楚这个玩意的用法：\nstruct Vec { float x; float y; float norm() const { return std::sqrt(x*x + y*y); } }; template\u0026lt;\u0026gt; struct Ubpa::USRefl::TypeInfo\u0026lt;Vec\u0026gt; : TypeInfoBase\u0026lt;Vec\u0026gt; { static constexpr FieldList fields = { Field {TSTR(\u0026#34;x\u0026#34;) , \u0026amp;Type::x }, Field {TSTR(\u0026#34;y\u0026#34;) , \u0026amp;Type::y }, Field {TSTR(\u0026#34;norm\u0026#34;), \u0026amp;Type::norm}, }; }; 这里的Field的Name模板参数是TSTR(\u0026quot;x\u0026quot;)是个TStr，T则是float Vec::*也就是我们说的指向类成员的指针。那么这个时候NamedValue类就存储了成员的名字（TSRT(\u0026quot;x\u0026quot;)中的data成员，只不过是通过std::string_view的方式得到的，存在其父类NamedValueBase的name中）和指向成员的指针（NamedValue中的value）。\n然后FTraits父类此时也存储了能够判断此成员是否静态，是否是函数的信息。所以总结下来，Field就是存储了指向类成员指针和名字的类，并且还能判断指向的是静态的还是非静态的，是函数还是变量。\n上面代码中有一个FieldList，它是ElemList的子类，存储着所有的Field并且提供了查找，增加功能。\nElemList # ElemList是作者自己造的一个能够存储任意类型的列表，其内部使用了tuple实现。简单来说就是个tuple的封装，但比起tuple提供了更多的操作（如查找，询问是否包含，对每个元素进行操作和增加元素等）。操作主要是利用经典的递归模板技术，我们这里主要着眼于使用tuple存储各个类型的技巧：\ntemplate\u0026lt;typename...Es\u0026gt; struct ElemList { std::tuple\u0026lt;Es...\u0026gt; elems; static constexpr std::size_t size = sizeof...(Es); constexpr ElemList(Es... elems) : elems{ elems... } {} //... }; tuple可谓是实现反射中的核心和大哥大，因为它能够存储不同类型的变量。这里ElemList就是将Es类型存放到了tuple中。\nTypeInfoBase # TypeInfoBase主要是存储着父类的信息。其存储的类型都是Base类的子类：\ntemplate\u0026lt;class T, bool IsVirtual = false\u0026gt; struct Base { static constexpr auto info = TypeInfo\u0026lt;T\u0026gt;{}; static constexpr bool is_virtual = IsVirtual; }; 可以通过这个类得到类的信息，以及是否是虚类。\n我们简单地看看TypeInfoBase：\ntemplate\u0026lt;class T, typename... Bases\u0026gt; struct TypeInfoBase { using Type = T; static constexpr BaseList bases{ Bases{}... }; // ... }; 他将所有的基类全部存储到BaseList，这是ElemList的子类：\ntemplate\u0026lt;typename...Bs\u0026gt; struct BaseList : ElemList\u0026lt;Bs...\u0026gt; { constexpr BaseList(Bs... bs) : ElemList\u0026lt;Bs...\u0026gt;{ bs... } {} }; 这里的Base是你使用此库时手动加上去的：\ntemplate\u0026lt;\u0026gt; struct Ubpa::USRefl::TypeInfo\u0026lt;C\u0026gt; : TypeInfoBase\u0026lt;C, Base\u0026lt;A\u0026gt;\u0026gt; // \u0026lt;--- 这里 { static constexpr AttrList attrs = {}; static constexpr FieldList fields = { Field {TSTR(\u0026#34;c\u0026#34;), \u0026amp;Type::c}, }; }; 而且你得先对A类进行反射（也Base类要知道A类的反射信息）。\n其他的TypeInfoBase成员函数就是普通的查找，增加等操作了（都是ElemList中的操作），没什么可说的。\n总结 # UML如下\n其中TypeInfo是一个空类，用户需要全特化他并且将自己类型的信息放入（使用Field）。\n其实说难也不难，都是模板的常见操作，但是合起来就觉得挺麻烦的了。\n","date":"13 April 2022","externalUrl":null,"permalink":"/posts/usrefl%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/","section":"Posts","summary":"最近在看UE4中的反射机制，学了一些反射的使用方式，但并不知道其实现方式。这两天上知乎看到了Ubp.a大神写的99行静态反射，所以拿来分析一下源码。\n其知乎文章在这里。\n","title":"USRefl源码分析","type":"posts"},{"content":"","date":"13 April 2022","externalUrl":null,"permalink":"/categories/%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/","section":"Categories","summary":"","title":"源码分析","type":"categories"},{"content":" Infection for LD50 # Infection by VisualGMQ 第一次参加Ludum Dare比赛的作品，参赛类型为COMP。\n游戏使用python + pygame编写。\nSpaceWar # 参加频道内举办的1MGames游戏开发大赛做的一款太空游戏，要求游戏本体大小1M内（不含VC运行时库）：\n舞狮游戏 # 为了庆贺2022年虎年到来写的，除夕夜赶工的😁。通过网页汇编将SDL程序编译成网页，即开即玩。\n游戏入口\n通过点击让舞狮跳到对应的木桩上，连续成功三次就会出现新年祝福。\n源代码在这里\nXenEngine # 本科毕设编写的玩具游戏引擎，使用C++和SDL。物理部分使用ODE物理引擎。\n生命游戏 # 康威的生命游戏：游戏入口\nVHD写入工具 # 使用纯C++编写的VHD写入工具（不能读取），因为MacOSX上找不到VHD写入工具就自己写了一个，用于学习编写内核时使用。\nJsonParser # 使用纯C++11编写的Json文件解析器（不能生成Json文件）。是个只有600多行的单头文件解析器。\nCrazyBlock # 初学Python时制作的小游戏，使用Pygame。\n","date":"18 March 2022","externalUrl":null,"permalink":"/projects/","section":"projects","summary":"","title":"projects","type":"projects"},{"content":"本文解释了TBN矩阵的推导和在顶点着色器中对切线空间变换的推导。\nTBN矩阵推导 # TBN即切线，副切线，法线的缩写(Tangent, Bitangent, Normal)，是用在法线贴图中用于将贴图中的法线转换到正确坐标的矩阵。\n蓝色方框表示法线贴图的大小，红色三角形是我们要将发现贴图贴上去的三角形。我们的目标是求出T向量和B向量，然后使用叉乘可以得到N向量。这样TBN矩阵就完成了。 假设$P_i = (x_i,y_i,z_i)$，在$P_i$点的UV坐标为$(U_i, V_i)$, 那么有：\n$$ \\begin{align} \\vec{P_2P_1} \u0026= (U_1 - U_2)\\vec{T} + (V_1 - V_2)\\vec{B} \\\\ \\vec{P_2P_3} \u0026= (U_3 - U_2)\\vec{T} + (V_3 - V_2)\\vec{B} \\\\ \\end{align} $$这里设$\\vec{T} = (T_x, T_y, T_z)，\\vec{B} = (B_x, B_y, B_z)$，那么有方程组：\n$$ \\begin{align} \\vec{P_2P_1} \u0026= (U_1 - U_2)(T_x, T_y, T_z) + (V_1 - V_2)(B_x, B_y, B_z) \\\\ \\vec{P_2P_3} \u0026= (U_3 - U_2)(T_x, T_y, T_z) + (V_3 - V_2)(B_x, B_y, B_z) \\\\ \\end{align} $$显然，这可以写成矩阵形式：\n$$ \\begin{bmatrix} \\vec{P_2P_1}_x \u0026 \\vec{P_2P_1}_y \u0026 \\vec{P_2P_1}_z \\\\ \\vec{P_2P_3}_x \u0026 \\vec{P_2P_3}_y \u0026 \\vec{P_2P_3}_z \\\\ \\end{bmatrix} = \\begin{bmatrix} U_1 - U_2 \u0026 V_1 - V_2 \\\\ U_3 - U_2 \u0026 V_3 - V_2 \\\\ \\end{bmatrix} \\begin{bmatrix} T_x \u0026 T_y \u0026 T_z \\\\ B_x \u0026 B_y \u0026 B_z \\\\ \\end{bmatrix} $$这个方程很好解，直接左右同乘右边第一个方程的逆即可：\n$$ \\begin{bmatrix} T_x \u0026 T_y \u0026 T_z \\\\ B_x \u0026 B_y \u0026 B_z \\\\ \\end{bmatrix} = \\begin{bmatrix} U_1 - U_2 \u0026 V_1 - V_2 \\\\ U_3 - U_2 \u0026 V_3 - V_2 \\\\ \\end{bmatrix}^{-1} \\begin{bmatrix} \\vec{P_2P_1}_x \u0026 \\vec{P_2P_1}_y \u0026 \\vec{P_2P_1}_z \\\\ \\vec{P_2P_3}_x \u0026 \\vec{P_2P_3}_y \u0026 \\vec{P_2P_3}_z \\\\ \\end{bmatrix} $$这样T和B就解出来了，叉乘之后N也就解出来了。\n顶点着色器中对TBN的变换 # 最简单使用TBN矩阵的方法是先在顶点着色器中随着模型矩阵一起变换，然后传入片段着色器。但是我们知道片段着色器的开销很大，所以这里有个方法是反过来做：使用TBN矩阵的逆矩阵乘上光源位置，片段位置和观察位置，将他们变换到切线空间中，这一步可以直接在顶点着色器中进行计算，从而节省片段着色器的开销：\n// 组装TBN矩阵 vec3 T = normalize(vec3(model * vec4(tangent, 0.0))); vec3 B = normalize(vec3(model * vec4(bitangent, 0.0))); vec3 N = cross(T, B); mat3 TBN = mat3(T, B, N); // 得到矩阵的逆（正交矩阵的逆为其转置） TBN = transpose(TBN); // 将光照信息转换到切线空间： lightDir = TBN * lightDir; viewDir = TBN * viewDir; // ...","date":"16 March 2022","externalUrl":null,"permalink":"/posts/%E5%88%87%E7%BA%BF%E7%A9%BA%E9%97%B4%E8%AF%A6%E8%A7%A3/","section":"Posts","summary":"本文解释了TBN矩阵的推导和在顶点着色器中对切线空间变换的推导。\n","title":"切线空间详解","type":"posts"},{"content":"本文描述了3D空间中的旋转，包括使用欧拉角旋转，四元数旋转，绕坐标轴轴旋转，绕旋转轴旋转以及四元数插值。\n欧拉角 # 欧拉角是很直白的旋转方式，其思想就是先绕x轴旋转，再绕y轴旋转，再绕z轴旋转（这里x,y,z的顺序不是固定的）。这样的想法让我们将3D空间的旋转拆分为三个2D平面旋转的组合：\n$$ EulerRotateMat = \\\\ \\begin{bmatrix} \\cos{\\gamma} \u0026 -\\sin{\\gamma} \u0026 0 \u0026 0 \\\\ \\sin{\\gamma} \u0026 \\cos{\\gamma} \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\\\ \\end{bmatrix}\\begin{bmatrix} \\cos{\\beta} \u0026 0 \u0026 \\sin{\\beta} \u0026 0 \\\\ 0 \u0026 1 \u0026 0 \u0026 0 \\\\ -\\sin{\\beta} \u0026 0 \u0026 \\cos{\\beta} \u0026 0 \\\\ 0 \u0026 0 \u0026 0 \u0026 1\\\\ \\end{bmatrix} \\begin{bmatrix} 1 \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 \\cos{\\alpha} \u0026 -\\sin{\\alpha} \u0026 0 \\\\ 0 \u0026 \\sin{\\alpha} \u0026 \\cos{\\alpha} \u0026 0 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} $$欧拉角的优点是很简洁直观，缺点是由很多问题，这里有一个视频展示了使用欧拉角带来的这些问题（14:20 ~ 17:43）。\n万向节死锁问题 # 万向节死锁仅发生在动态旋转中。\n旋转分为两种：静态旋转，动态旋转。静态旋转是指每次旋转的旋转轴都是世界坐标系下的坐标轴（即绕x轴即绕$(1, 0, 0)$，绕y轴即绕$(0, 1, 0)$，z轴则是$(0, 0, 1)$）。而动态旋转则是按照旋转体当前的轴，即旋转体的局部坐标系的轴。\n绕任意轴旋转的矩阵 # 有了欧拉角，我们可以推导出绕任意轴旋转$\\theta$角的方法：\n假设$\\vec{a}$为旋转轴的单位向量，$p$是旋转的点，那么首先首先$\\vec{p}$在$\\vec{a}$上的投影为\n$$ proj_a{p} = (p\\cdot a)a $$那么与a，p在同一平面内，且垂直于a的轴为\n$$ p - proj_ap = p - (p\\cdot a)a $$然后我们再用叉乘求得垂直于a，(p·a)a的轴：\n$$ a \\times p $$因为和$a$平行的分量$proj_ap$不受旋转影响，那么这个问题就变成在$a\\times p$和$p - (p\\cdot a)a$平面中对p的垂直分量进行旋转。这是典型的2D旋转，可以很容易得到：\n$$ p^{'}_h = [p - (p\\cdot a)a]\\cos{\\theta} + (a\\times p)\\sin{\\theta} $$然后我们再加上$proj_ap$即可：\n$$ p^{'} = p^{'}_v + p^{'}_h = proj_ap + p^{'}_h = (a\\cdot p)a + [p - (p\\cdot a)a]\\cos{\\theta} + (a\\times p)\\sin{\\theta} $$化简得\n$$ p^{'} = p\\cos{\\theta} + (a\\times p)\\sin{\\theta} +(1-\\cos{\\theta})(a\\cdot p)a $$接下来将其变为矩阵形式。\n$proj_qp$的矩阵形式为\n$$ \\begin{bmatrix} q_x^2 \u0026 q_xq_y \u0026 q_xq_z \\\\ q_xq_y \u0026 q_y^2 \u0026 q_yq_z \\\\ q_xq_z \u0026 q_yq_z \u0026 q_z^2 \\\\ \\end{bmatrix} \\begin{bmatrix} p_x \\\\ p_y \\\\ p_z \\\\ \\end{bmatrix} \\frac{1}{|q|^2} $$而$p\\times q$的矩阵表示形式为：\n$$ p \\times q = \\begin{bmatrix} 0 \u0026 -p_z \u0026 p_y \\\\ p_z \u0026 0 \u0026 -p_x \\\\ -p_y \u0026 p_x \u0026 0 \\\\ \\end{bmatrix} \\begin{bmatrix} q_x \\\\ q_y \\\\ q_z \\\\ \\end{bmatrix} $$我们可以用如上的两个公式将绕轴旋转公式表示成矩阵形式，设$c = \\cos{\\theta}$，$s = \\sin{\\theta}$\n$$ \\begin{bmatrix} c+(1-c)a_x^2 \u0026 (1-c)a_xa_y - sa_z \u0026 (1-c)a_xa_z + sa_y \\\\ (1-c)a_xa_y + sa_z \u0026 c+(1-c)a_y^2 \u0026 (1-c)a_ya_z - sa_x \\\\ (1-c)a_xa_z - s a_y \u0026 (1-c)a_ya_z + sa_x \u0026 c + (1-c)a_z^2 \\\\ \\end{bmatrix} $$ 四元数 # 四元数可以说是完美解决3D空间旋转的一个绝佳办法，与欧拉角相比只需要多一些数学知识。它由哈密顿发现。当时哈密顿一直在寻找三维空间中的复数（即带有两个虚数单位的复数-三元数），但在经过一座桥时，他突然明白没有办法得到三元数，而是应该再升高一个维度，得到四元数。他当时就将它脑中所想的四元数公式刻在桥上，四元数就诞生了。\n基本公式 # 四元数通俗来说就是有三个虚数单位的复数，回想复数的定义：\n$$ \\begin{aligned} p \u0026 = a\\pmb{i} + b \\\\ \\pmb{i}^2 \u0026 = -1 \\\\ \\end{aligned} $$而四元数则是有四个虚数单位的复数：\n$$ \\begin{aligned} p = a\\pmb{i} + b\\pmb{j} + c\\pmb{k} + d \\\\ \\pmb{i}^2 = \\pmb{j}^2 = \\pmb{k}^2 = -1 \\\\ \\pmb{ij} = \\pm{k}, \\pmb{jk} = \\pmb{i}, \\pmb{ki} = \\pmb{j} \\\\ \\pmb{ji} = \\pmb{k}, \\pmb{kj} = -\\pmb{i}, \\pmb{ik} = -\\pmb{j} \\\\ \\end{aligned} $$后面的几条规则可以这样记忆：想象i,j,k分别是3D空间中的x,y,z轴的单位向量，他们的乘积结果总是剩下那个轴的所代表的的虚数单位，而结果的符号则由右手定则确定。而相同虚数单位和自己的乘积总是-1。\n一般将四元数记为：\n$$ q = \\begin{bmatrix} \\pmb{q}_v \u0026 q_s \\end{bmatrix} $$其中矢量部分为$\\pmb{q}_v = x\\pmb{i} + y\\pmb{j} + z\\pmb{k}$，而标量部分为$q_s = w$\n四元数比欧拉角更优秀的地方在于：\n没有万向节死锁问题 只需要存储四个浮点数，比矩阵表示更加简单 四元数求逆，串联等操作比矩阵更加高效 四元数和复数有着很多共通特性，比如，任意一个复数$x\\pmb{i}+y$可以在复平面内表示一个点$(x, y)$，而四元数作为四维空间的负数，其$x\\pmb{i}+y\\pmb{j}+z\\pmb{k}+w$表示的则是四维空间中的一个点$(x, y, z, w)$。\n再比如，对于复数，我们乘上一个虚数单位其实是将其对应点逆时针旋转了90度。同理，在四元数中乘上某一轴所代表的虚数单位，则是表示绕此轴逆时针旋转90度。\n四元数的乘法计算规则如下：\n$$ \\begin{aligned} p \u0026 = \\begin{bmatrix}\\pmb{p}_v \u0026 p_s\\end{bmatrix} \\\\ q \u0026 = \\begin{bmatrix}\\pmb{q}_v \u0026 q_s\\end{bmatrix} \\\\ p*q \u0026 = \\begin{bmatrix}(p_s\\pmb{q}_v + q_s\\pmb{p}_v + \\pmb{p}_v \\times \\pmb{q}_v) \u0026 (p_sq_s - \\pmb{p}_v \\pmb{q}_v)\\end{bmatrix} \\end{aligned} $$就是用乘法结合律拆开了计算。这叫做格拉斯曼积。其中$\\pmb{p}_v\\times\\pmb{q}_v$是将p，q的虚部看做向量进行叉积，而$\\pmb{p}_v\\pmb{q}_v$则是其对应向量的点积。\n四元数有很多种乘法，但是格拉斯曼积是最通用的形式。\n四元数满足：\n封闭性：四元数的四则运算结果还是四元数 结合律：$(pq)r = p(qr)$ 四元数的单位元素和逆元素：\n单位元素：$(1, 0, 0, 0)$，任何四元数乘上单位元素都等于自身。 逆元素：$q^{-1} = \\frac{q^*}{|q|^2}$，任何四元数和其逆相乘为单位元素。 其中$|q|^2$代表其模的平方（即$x^2 + y^2 + z^2 + w^2$），$q^{-1}$则是$q$的共轭（和复数共轭类似：$q^{-1} = w -xi - yj - zk$）。 共轭和逆还有如下性质：：\n$$ \\begin{aligned} q^*q = qq^* = |q|^2 \\\\ (qp)^* = p^*q^* \\\\ (qp)^{-1} = p^{-1}q^{-1} \\end{aligned} $$ 四元数表示旋转 # 只有单位四元数才能表示旋转。因为根据四元数的几何意义，$\\pmb{q}_v = x\\pmb{i}+y\\pmb{j}+k\\pmb{k}$代表着3维空间中的三条虚数轴，而$q_s = w$则代表第四维度中垂直于三维的一条轴。\n第四维度的轴对我们来说没什么用，我们只需要用到三维的虚数轴就可以了。\n首先要将单位四元数视为旋转矢量，其公式如下：\n$$ q = \\begin{bmatrix}\\pmb{q}_v \u0026 q_s\\end{bmatrix} = \\begin{bmatrix}\\vec{a}\\sin{\\frac{\\theta}{2}} \u0026 \\cos{\\frac{\\theta}{2}}\\end{bmatrix} = \\begin{bmatrix} a_x\\sin{\\frac{\\theta}{2}} \\\\ a_y\\sin{\\frac{\\theta}{2}} \\\\ a_z\\sin{\\frac{\\theta}{2}} \\\\ \\cos{\\frac{\\theta}{2}} \\end{bmatrix} $$$\\vec{a}$是旋转轴所在的单位矢量。旋转的方向根据右手定则（前提是你用的是右手系）决定（握住旋转轴，大拇指朝向$\\vec{a}$的朝向，四指环绕方向即为旋转方向。\n然后再用旋转矢量去旋转物体：\n$$ v^{'} = qvq^{-1} = qvq^* $$由于q是单位矢量，由公式$q^{-1} = \\frac{q^*}{|q|}$可知q的逆和其共轭是相等的。\n这里需要将点$v = [x\\ y\\ z\\ w]$也视为四元数$w + x\\pmb{i} + y\\pmb{j} + z\\pmb{k}$。\n这个公式可以化简成不将$v$转化成四元数的版本：\n$$ \\pmb{v^{'}} = \\pmb{v} + 2\\pmb{q}_v\\times(\\pmb{q}_v \\times \\pmb{v} + q_s\\pmb{v}) $$这里只需要单纯的将$\\pmb{v}$视为三维的向量即可，此公式还减少了运算量。\n和矩阵类似，旋转也可以串接：\n$$ \\begin{aligned} q_{net} \u0026 = q_3q_2q_1 \\\\ v^{'} \u0026 = q_{net}vq_{net}^{-1} = q_3q_2q_1vq_1^{-1}q_2^{-1}q_3^{-1} \\end{aligned} $$四元数的旋转也可以写成对应的矩阵形式，只需要将上面的旋转公式用矩阵形式表示即可：\n$$ \\pmb{R} = \\begin{bmatrix} 1-2y^2-2z^2 \u0026 2xy + 2zw \u0026 2xy - 2yw \\\\ 2xy - 2zw \u0026 1-2x^2 - 2z^2 \u0026 2yz + 2xw \\\\ 2xz + 2yw \u0026 2yz - 2xw \u0026 1-2x^2 - 2y^2 \\\\ \\end{bmatrix} $$ 用于旋转的四元数的存储优化 # 一般来说，四元数需要四个float变量来存储，但是由于用于旋转的四元数必须是单位长度的（$x^2+y^2+z^2+w^2 = 1$）,所以我们其实可以只存储三个元素，然后另一个元素通过计算得到：\n$$ w = \\pm \\sqrt{x^2 + y^2 + z^2} $$这里还有一个问题是无法确定w的符号。我们可以利用四元数在旋转时q和-q效果一样这个特性，看到w为负数时直接存储$-\\pmb{q}_v$即可。这样我们可以默认重现的w一定是正的。\n四元数旋转的线性插值 # 直接对四元数进行插值即可，假设要从$q_a$旋转到$q_b$，那么从a到b之间$\\beta$百分点的中间旋转$q_{LERP}$为：\n$$ q_{LERP} = LERP(q_a, q_b, \\beta) = \\frac{(1-\\beta)q_a + \\beta q_b}{|(1-\\beta)q_a + \\beta q_b|} = normalize( \\begin{bmatrix} (1-\\beta)q_{a_x} + \\beta q_{b_x} \\\\ (1-\\beta)q_{a_y} + \\beta q_{b_y} \\\\ (1-\\beta)q_{a_z} + \\beta q_{b_z} \\\\ (1-\\beta)q_{a_w} + \\beta q_{b_w} \\\\ \\end{bmatrix}) $$需要注意的是线性插值可能会改变四元数的长度，所以最后要归一化四元数。\n这个公式存在一定问题，因为四元数其实表示四维空间中的球体，上面的公式是在超球的弦上插值，这会导致当$\\beta$以恒定速率改变时，旋转却并非以很定角速率进行。我们需要在超球面上进行插值才可以：\n$$ SLERP(q_a, q_b, \\beta) = w_aq_a + w_pq_p $$其中\n$$ \\begin{aligned} w_a \u0026 = \\frac{\\sin{(1 - \\beta)}\\theta}{\\sin{\\theta}} \\\\ w_p \u0026 = \\frac{\\sin{\\beta \\theta}}{\\sin{\\theta}} \\\\ \\end{aligned} $$ 参考 # 四元数-基本概念-知乎 四元数和旋转 四元数和旋转（知乎） 《游戏引擎架构 第二版》\n","date":"11 March 2022","externalUrl":null,"permalink":"/posts/3d%E7%A9%BA%E9%97%B4%E4%B8%AD%E7%9A%84%E6%97%8B%E8%BD%AC/","section":"Posts","summary":"本文描述了3D空间中的旋转，包括使用欧拉角旋转，四元数旋转，绕坐标轴轴旋转，绕旋转轴旋转以及四元数插值。\n","title":"3D空间中的旋转","type":"posts"},{"content":"","date":"9 March 2022","externalUrl":null,"permalink":"/series/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8/","section":"Series","summary":"","title":"从0开始制作软渲染器","type":"series"},{"content":"本文说明了纹理映射。\n纹理映射 # 纹理映射也很简单，纹理是一张图像，纹理的左下角坐标为$(0, 0)$，右上角坐标为$(1, 1)$。在传入三角形三个顶点的时候传入坐标，渲染器会在片段着色器之前对其进行插值，从而得到对应的纹理坐标。\n这里主要要实现的是gl shader中的texture函数。这个函数接收一个纹理和一个纹理坐标，并返回这个坐标上的颜色信息：\ninline Color4 TextureSample(const Surface* const surface, Vec2 texcoord) { texcoord.x = Clamp\u0026lt;real\u0026gt;(texcoord.x, 0, 1); texcoord.y = Clamp\u0026lt;real\u0026gt;(texcoord.y, 0, 1); // nearest sample return surface-\u0026gt;GetPixel(texcoord.x * surface-\u0026gt;Width(), texcoord.y * surface-\u0026gt;Height()); } 这里首先对纹理坐标Clamp以确保其在$[0, 1]$中，然后对纹理进行采样，将数据返回。\n这里做的是临近采样，纹理还有双线性插值法，即当你的纹理坐标为小数的时候（比如$(2.2, 2.3)$）这个时候如果直接转换为整数采样就是临近采样，而双线性插值则是要在x方向和y方向分别取最临近的点（这里是$(2, 2), (3, 2)$和$(2, 2), (2, 3)$，然后对x方向和y方向进行线性插值（比如x方向就是$color_{2,2}+(color_{3,2}-color_{2,2})*0.2$）以得到更加平滑的效果。\n","date":"9 March 2022","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8%E5%9B%9B/","section":"Posts","summary":"本文说明了纹理映射。\n","title":"从0开始制作软渲染器（四）","type":"posts"},{"content":"本文开始编写渲染器的代码。\n本着色器很大程度上参考了韦易笑大神的RenderHelp渲染器。我的渲染器的理论是跟着Games101和《Fundamentals of Computer Graphics, Fourth Edition》这本书来的，和韦大神渲染器中的方程不太一样（尤其是透视投影，深度测试的地方），所以如果要跟着我这系列博客学习，请务必从头看到尾，以避免公式和细节不一致的情况出现。\n最后的渲染器项目在这里，gitee上也有同名镜像。\n开始编码前的准备工作 # 这一章我们开始编写渲染器的代码。编码前我们需要一个数学库和一个图形库。数学库用于计算矩阵，图形库用于渲染和保存结果。\n我自己是手写了一份数学库，然后图形库我使用的是SDL2。\n如果你不想自己写数学库，我推荐你使用armadillo或者Eigen，但不推荐使用glm，原因如下：\n我们会手动输入矩阵，这可以检验我们推导的矩阵的正确性\n也不能使用glm已有的矩阵，glm的某些矩阵和我们的不一样（比如投影矩阵对OpenGL特化，或者推导的方式不一样从而达到了形式不一样的矩阵）\n你可以使用glm但不使用他生成矩阵的函数，如果是这样，不如使用 armadillo和Eigen。他两使用SIMD进行加速，这对于软渲染这种效率低的东西来说是节省时间的好方法。\n对于图形库，这就看你的心情了，你甚至可以用平台API直接开干。我使用的是封装了SDL_Surface的Surface类，这里给出声明，以方便熟悉其各个功能：\nclass Surface final { public: // 加载位图，为了之后的纹理映射使用 Surface(const char *filename); // 按照大小，生成一份空的Surface Surface(int w, int h); Surface(const Surface \u0026amp;) = delete; ~Surface(); Surface \u0026amp;operator=(const Surface \u0026amp;) = delete; int Width() const; int Height() const; // 绘制点 void PutPixel(int x, int y, const Color4 \u0026amp;color); // 使用指定颜色清除 void Clear(const Color4 \u0026amp;color); // 保存到位图 void Save(const char *filename); }; 渲染器初步 # 我们先搭建一个简单的渲染器，他可以清屏，在Framebuffer上画点，并且可以将Framebuffer保存成位图：\n/*********************************** * Renderer ***********************************/ class Renderer final { public: Renderer(int w, int h) : drawColor_{0, 0, 0, 0} { framebuffer_.reset(new Surface(w, h)); } void SetDrawColor(const Color4 \u0026amp;c) { drawColor_ = c; } void SetClearColor(const Color4 \u0026amp;c) { clearColor_ = c; } std::shared_ptr\u0026lt;Surface\u0026gt; GetFramebuffer() { return framebuffer_; } // 给Framebuffer清屏 void Clear() { framebuffer_-\u0026gt;Clear(clearColor_); } // 在Framebuffer上绘制点 void DrawPixel(int x, int y) { if (IsPointInRect(Vec2{real(x), real(y)}, Rect{Vec2{0, 0}, framebuffer_-\u0026gt;Size()})) { framebuffer_-\u0026gt;PutPixel(x, y, drawColor_); } } // 设置Viewport，这里直接生成Viewport矩阵 void SetViewport(int x, int y, int w, int h) { viewport_ = Mat44::Zeros(); viewport_.Set(0, 0, w / 2); viewport_.Set(1, 1, -h / 2); viewport_.Set(3, 0, w / 2 + x); viewport_.Set(3, 1, h / 2 + y); viewport_.Set(2, 2, 0.5); viewport_.Set(3, 2, 1); viewport_.Set(4, 4, 1); } // 保存Framebuffer到位图 void Save(const char *filename) { framebuffer_-\u0026gt;Save(filename); } // 绘制三角形图元，这是最重要的函数 bool DrawPrimitive(); private: std::shared_ptr\u0026lt;Surface\u0026gt; framebuffer_; Color4 drawColor_; Color4 clearColor_; Mat44 viewport_; }; 完成之后你应该可以在Framebuffer上画点，清屏了，并且保存到位图了。\n让我们来做个测试：\nint main() { Renderer renderer(480, 360); // 设置绘制颜色 renderer.SetDrawColor(Color4{1, 0, 0, 1}); // 设置清屏颜色 renderer.SetClearColor(Color4{0.2, 0.2, 0.2, 1}); // 清屏 renderer.Clear(); // 画一条水平的直线 for (int i = 100; i \u0026lt; 400; i++) { renderer.DrawPixel(i, 180); } // 保存到位图 renderer.Save(\u0026#34;test_renderer.bmp\u0026#34;); return 0; } 你应该可以看到这个结果：\n着色器的编写 # 我们希望做一个可编程渲染管线的着色器，所以这里来做着色器：\n/*********************************** * Shader ***********************************/ struct ShaderContext { std::unordered_map\u0026lt;int, real\u0026gt; varyingFloat; std::unordered_map\u0026lt;int, Vec2\u0026gt; varyingVec2; std::unordered_map\u0026lt;int, Vec3\u0026gt; varyingVec3; std::unordered_map\u0026lt;int, Vec4\u0026gt; varyingVec4; void Clear() { varyingFloat.clear(); varyingVec2.clear(); varyingVec3.clear(); varyingVec4.clear(); } }; using VertexShader = std::function\u0026lt;Vec4(int index, ShaderContext \u0026amp;output)\u0026gt;; using FragmentShader = std::function\u0026lt;Vec4(ShaderContext \u0026amp;input)\u0026gt;; ShaderContext模拟OpenGL中的layout变量，而顶点着色器和片段着色器仅仅是函数对象。\n接下来让渲染器可以设置着色器：\nclass Renderer { public: void SetVertexShader(VertexShader shader) { vertexShader_ = shader; } void SetFragmentShader(FragmentShader shader) { fragmentShader_ = shader; } // ... private: VertexShader vertexShader_ = nullptr; FragmentShader fragmentShader_ = nullptr; // ... }; 光栅化步骤编写 # 渲染器需要的顶点数据 # 接下来我们要编写最复杂的光栅化部分，首先我们定义渲染器中一个顶点所需要的各项信息：\n// in class Renderer private: struct Vertex { ShaderContext context; real rhw; Vec4 pos; Vec3 spf; Vec2 spi; } vertices_[3]; context：是着色器上下文，里面存放着所有Uniform变量 rhw：是进行MVP变换后的w坐标的倒数 pos：是顶点在全局空间中的坐标 spf：是顶点经过MVP变换后的坐标 spi：是顶点经过MVP变换后的x, y坐标的整数值 运行顶点着色器 # 然后我们开始编写光栅化部分，根据我们第0章说的渲染管线，首先我们需要对于所有输入的点运行顶点着色器：\n// in class Renderer bool DrawPrimitive() { if (!vertexShader_) { return false; } // 对于每个顶点，我们要运行顶点着色器，进行裁剪和面剔除，进视口变换 for (int i = 0; i \u0026lt; 3; i++) { Vertex\u0026amp; vertex = vertices_[i]; // 1. 清空着色器上下文 vertex.context.Clear(); // 2. 运行顶点着色器 vertex.pos = vertexShader_(i, vertices_[i].context); // 得到w的倒数，注意预防除0错误 vertex.rhw = 1.0 / (vertex.pos.w == 0 ? 1e-5 : vertex.pos.w); // 接下来是面剔除和裁切 } 在运行顶点着色器之前，我们需要将ShaderContext里的Uniform变量全部清除。\n然后第二步，运行顶点着色器，并且记录下rhw的值。\n顶点着色器需要一个着色器上下文，它会将所有的Uniform变量存进去，然后返回此顶点经过处理后的值（相当于gl_Position）。\n这里可能会有疑问：渲染管线的第一步明明是顶点输入，你这顶点怎么输入呢？\n如果要完全仿照OpenGL，你可以定义自己的Vertex Buffer，Vertex Attribute Pointer和Element Index Buffer。但是我们这里从简，直接在顶点着色器里处理：\n// 这是渲染器完成后的使用方法，展示了如何进行顶点输入： // 定义一个Uniform变量的序号 enum UniformVar { Uniform_Color = 0, }; int main() { Renderer renderer(480, 320); renderer.SetClearColor(Color4{0.1, 0.1, 0.1, 1}); renderer.Clear(); renderer.SetViewport(0, 0, 480, 320); // 定义自己的顶点数据 struct { Vec4 pos; Vec4 color; } vs_input[3] = { //坐标 颜色 {Vec4{-0.5, -0.5, 1, 1}, Vec4{1, 0, 0, 1}}, {Vec4{0.5, -0.5, 1, 1}, Vec4{0, 1, 0, 1}}, {Vec4{0, 0.5, 1, 1}, Vec4{0, 0, 1, 1}}, }; // 顶点着色器是Lambda，直接捕获顶点数据完成顶点输入，然后处理顶点数据 renderer.SetVertexShader([\u0026amp;](int index, ShaderContext\u0026amp; output) { // 将颜色数据放入ShaderContext， output.varyingVec4[Uniform_Color] = vs_input[index].color; // 返回处理后的顶点 return vs_input[index].pos; }); renderer.SetFragmentShader([\u0026amp;](ShaderContext\u0026amp; input) { // 片段着色器从ShaderContext中取出Uniform变量 return input.varyingVec4[Uniform_Color]; }); renderer.DrawPrimitive(); renderer.Save(\u0026#34;hello_triangle.bmp\u0026#34;); return 0; } 面剔除和裁切 # 顶点着色器运行完之后应该运行细分着色器和片段着色器，但是我们这里为了方便就不写那两个着色器了。\n那么接下来就到了面剔除和裁剪的部分，面剔除使用向量叉积即可解决：\n// 6. face culling, cull the CCW face real result = Cross(Vec\u0026lt;2\u0026gt;(vertices_[1].pos - vertices_[0].pos), Vec\u0026lt;2\u0026gt;(vertices_[2].pos - vertices_[1].pos)); if (faceCull_ == CCW \u0026amp;\u0026amp; result \u0026gt;= 0) { return false; } else if (faceCull_ == CW \u0026amp;\u0026amp; result \u0026lt;= 0) { return false; } CCW代表要剔除逆时针面，CW要剔除顺时针面。这里使用向量叉积即可判断顺，逆时针。\n裁剪可以做的很复杂，比如使用Cohen-Sutherland algorithm算法进行裁剪。我们这里图简单使用简单裁剪，即只要三角形任意一个边超出屏幕，我们就直接丢弃整个三角形。\n由于经过投影矩阵后的点理论上在$[-1, 1]^3$中，而如果是透视投影，由于将$(x, y, z, 1)$转换为$(zx, zy, z^2, z)$，所以其被转换到$[-|z|, |z|]^3$上。我们只要判断点是否在这个区间外就可以了：\n// 3. clipping, if AABB not intersect with screen, clip it for (int i = 0; i \u0026lt; 3; i++) { real absw = std::abs(vertices_[i].pos.w); if (vertices_[i].pos.x \u0026lt; -absw || vertices_[i].pos.x \u0026gt; absw || vertices_[i].pos.y \u0026lt; -absw || vertices_[i].pos.y \u0026gt; absw) { return false; } } 透视除法和视口变换 # 面剔除和裁剪后要进行透视除法和变换，代码也很简单：\nfor (auto\u0026amp; vertex : vertices_) { // 4. perspective divide vertex.pos *= vertex.rhw; // 5. viewport transform and prepare to step into rasterization vertex.spf = Vec\u0026lt;3\u0026gt;(viewport_ * vertex.pos); vertex.spi.x = int(vertex.spf.x + 0.5f); vertex.spi.y = int(vertex.spf.y + 0.5f); } 这里vertex.spi是点在$XoY$平面上的坐标，这里加0.5是因为，根据定义，我们将像素看场一个小正方形，需要用其中心的坐标来计算。\n开始光栅化 # 经过了顶点着色器，裁剪，面剔除和视口变换后，终于来到了最终要的光栅化阶段。\n光栅化就是对屏幕上的所有点，判断点是否在三角形内：\nRect boundingRect = GetTriangleAABB(vertices_[0].spi, vertices_[1].spi, vertices_[2].spi); int minX = std::max\u0026lt;int\u0026gt;(boundingRect.pos.x, 0), minY = std::max\u0026lt;int\u0026gt;(boundingRect.pos.y, 0), maxX = std::min\u0026lt;int\u0026gt;(boundingRect.pos.x + boundingRect.size.w, framebuffer_-\u0026gt;Width()), maxY = std::min\u0026lt;int\u0026gt;(boundingRect.pos.y + boundingRect.size.h, framebuffer_-\u0026gt;Height()); // 7. rasterization for (int i = minX; i \u0026lt; maxX; i++) { for (int j = minY; j \u0026lt; maxY; j++) { Vec2 p{i + 0.5f, j+ 0.5f}; // [1] if (!IsPointInRect(p, boundingRect)) { //[2] continue; } // 7.1 barycentric calculate // [5] Vec3 barycentric = Barycentric(vertices_[0].spi, vertices_[1].spi, vertices_[2].spi, p); real rhw = vertices_[0].rhw * barycentric.alpha + vertices_[1].rhw * barycentric.beta + vertices_[2].rhw * barycentric.gamma; float w = 1.0f / ((rhw != 0.0f)? rhw : 1.0f); barycentric.alpha *= vertices_[0].rhw * w; barycentric.beta *= vertices_[1].rhw * w; barycentric.gamma *= vertices_[2].rhw * w; if (barycentric.alpha \u0026lt; 0 \u0026amp;\u0026amp; barycentric.beta \u0026lt; 0 \u0026amp;\u0026amp; barycentric.gamma \u0026lt; 0) { return false; } if (barycentric.alpha \u0026lt; 0 || barycentric.beta \u0026lt; 0 || barycentric.gamma \u0026lt; 0) { continue; } real z = 1.0 / rhw; // 7.2 update depth buffer(camera look at -z, but depth buffer store positive value, so we take the opposite of 1.0 / rhw) if (z \u0026lt;= depthBuffer_-\u0026gt;Get(i, j)) { // [6] continue; } depthBuffer_-\u0026gt;Set(i, j, z); // 下面要运行片段着色器了 首先得到三角形的AABB包围盒boundingRect，用来得到三角形AABB和屏幕相交的矩形，来减少遍历的点。然后一个双重for循环遍历屏幕上的点，注意这里的[1]语句同样将点偏移到像素的中心。\n然后[2]使用boundingBox快速判断点是否在三角形的AABB内，如果不在直接丢弃。\n然后是重心坐标的计算，这个在第二节中说了，有两种方法，我这里使用的是第二种方法：\ninline Vec3 Barycentric(const Vec2\u0026amp; v1, const Vec2\u0026amp; v2, const Vec2\u0026amp; v3, const Vec2\u0026amp; p) { Vec3 result; Vec3 c1{v1.x - v2.x, v1.x - v3.x, p.x - v1.x}, c2{v1.y - v2.y, v1.y - v3.y, p.y - v1.y}; result = Cross(c1, c2); if (result.z == 0) { // (-1, -1, -1) means a invalid condition, should discard this point return Vec3{-1, -1, -1}; } return Vec3{1 - result.x / result.z - result.y / result.z, result.x / result.z, result.y / result.z}; } 如果返回$(-1, -1, -1)$，则表示计算失败，三角形退化成直线了，那么我们直接丢弃整个三角形（即[3]处）。\n如果这个点不在三角形上，那重心坐标必定有一个值为负数，我们就丢弃这个点（即[4]处）。\n步骤[5]则是通过重心坐标插值出z坐标。这里有一个非常要注意的点，和你如何设计深度缓冲有关：\n如果你的深度缓冲是只存储各个片段的深度值，不像OpenGL那样要求他们在$[0, 1]$之间的话，你应该使用三角形的全局顶点去插值。 如果你想让深度缓冲存储的范围在$[0, 1]$中，那么你就必须使用视口变换后的点进行深度插值（也就是我这里做的），因为视口变换会直接将z坐标变换到$[0, 1]$中。 接下来的[6]则说明了如何更新深度缓存。这里你可能会有疑惑：网上的教程都是深度小的绘制，深度大的丢弃，你这里怎么反过来了？\n回忆上一章说的透视投影矩阵的作用，其是将$(x, y, z, w)$变换到$(zx, zy, z^2, zw)$，然后经过透视除法，令每个坐标在$[-1, 1]^3$中，然后经过视口变换，得到：\n$$ \\begin{align} x \u0026\\in [-\\frac{w}{2}, \\frac{w}{2}] \\\\ y \u0026\\in [-\\frac{h}{2}, \\frac{h}{2}] \\\\ z \u0026\\in [0, 1] \\\\ \\end{align} $$那么也就是说，z坐标从：$[-1, 1]$变换到了$[0, 1]$，即越靠近近平面变换后的z越靠近1，而越远离近平面的z越靠近0。也就是说，z值大的点应该在z值小的点的前面。\nOpenGL中之所以是z值小的在前面，是因为他的透视投影矩阵和我们的不太一样，从而导致这里的深度测试也不一样。\n所以如果你输出深度图的话，将会是离得近的点越白，远的点反而黑，这和OpenGL输出的深度图正好相反。\n然后还有一点要注意：常理来说，深度测试应该在片段着色器之后，我们这里是提前深度测试，这是在保证片段着色器不改变顶点z坐标的情况下才能做的优化（我们全程都不会改变z值，所以进行了优化）。如果你的片段着色器需要改变z值，请将深度测试放到片段着色器之后。\n对其他值进行插值并运行片段着色器 # 然后就是对ShaderContext中的所有值进行插值，然后运行片段着色器了：\n// 7.3 interpolation other varying properties ShaderContext input; ShaderContext\u0026amp; i0 = vertices_[0].context, i1 = vertices_[1].context, i2 = vertices_[2].context; for (auto\u0026amp; [key, value] : i0.varyingFloat) { input.varyingFloat[key] = i0.varyingFloat[key] * barycentric.alpha + i1.varyingFloat[key] * barycentric.beta + i2.varyingFloat[key] * barycentric.gamma; } for (auto\u0026amp; [key, value] : i0.varyingVec2) { input.varyingVec2[key] = i0.varyingVec2[key] * barycentric.alpha + i1.varyingVec2[key] * barycentric.beta + i2.varyingVec2[key] * barycentric.gamma; } // ... 其他的成员如法炮制，这里直接省略 // 8. run Fragment Shader Vec4 color{0, 0, 0, 0}; if (fragmentShader_) { // 运行片段着色器 color = fragmentShader_(input); // 绘制点到Framebuffer framebuffer_-\u0026gt;PutPixel(i, j, color); } } } return true; } 这里我们还有模板测试，Alpha测试和融混没有做。如果有时间，我会在以后的章节中补上。\n使用已完成的着色器渲染三角形 # 让我们尝试在透视投影下渲染一个三角形：\n#include \u0026#34;renderer.hpp\u0026#34; enum UniformVar { Color = 0, }; int main() { Renderer renderer(480, 320); renderer.SetClearColor(Color4{0.1, 0.1, 0.1, 1}); renderer.Clear(); // 设置Viewport renderer.SetViewport(0, 0, 480, 320); // 顶点属性 struct { Vec4 pos; Vec4 color; } vs_input[3] = { {Vec4{0.5, 0.5, -1, 1}, Vec4{1, 0, 0, 1}}, {Vec4{0.5, -0.5, -1, 1}, Vec4{0, 1, 0, 1}}, {Vec4{-0.5, -0.5, -1, 1}, Vec4{0, 0, 1, 1}}, }; // 生成透视投影矩阵，注意这里的near和far参数是近，远平面的坐标，而不是到原点的距离 auto perspMat = CreatePersp(M_PI * 0.5, 480.f/320.f, -0.1, -100); // 设置着色器 renderer.SetVertexShader([\u0026amp;](int index, ShaderContext\u0026amp; output) { output.varyingVec4[Color] = vs_input[index].color; return perspMat * vs_input[index].pos; }); renderer.SetFragmentShader([\u0026amp;](ShaderContext\u0026amp; input) { return input.varyingVec4[Color]; }); // 绘制三角形 renderer.DrawPrimitive(); // 保存结果到位图 renderer.Save(\u0026#34;persp_triangle.bmp\u0026#34;); return 0; } 运行成功后应该会生成如下的图片：\n你可能会发现图片是上下颠倒的。这是因为我们默认的坐标系是y轴向上，x轴向右，z轴朝向屏幕外面。而大多数窗体程序的坐标都是y轴向下的，这回导致图片上下颠倒。\n如果想要修复这种颠倒，可以在viewport矩阵中翻转y坐标。\n","date":"8 March 2022","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8%E4%B8%89/","section":"Posts","summary":"本文开始编写渲染器的代码。\n本着色器很大程度上参考了韦易笑大神的RenderHelp渲染器。我的渲染器的理论是跟着Games101和《Fundamentals of Computer Graphics, Fourth Edition》这本书来的，和韦大神渲染器中的方程不太一样（尤其是透视投影，深度测试的地方），所以如果要跟着我这系列博客学习，请务必从头看到尾，以避免公式和细节不一致的情况出现。\n最后的渲染器项目在这里，gitee上也有同名镜像。\n","title":"从0开始制作软渲染器（三）","type":"posts"},{"content":"本文介绍了基本的渲染管线知识。\n渲染管线 # 渲染管线是每个编写渲染器的人都逃不开的话题。这里我直接推荐这个博客，讲的非常详细。\n大体来说，在OpenGL中，渲染管线的流程如下图：\n顶点输入(Vertex Data Input) # 这一步就是将顶点传输给渲染器，在OpenGL中就是使用VBO，EBO，VAO然后将顶点数据填充给VBO。\n运行顶点着色器(Vertex Shader) # 接下来需要运行顶点着色器，顶点着色器会对每个输入的顶点进行操作。\n顶点着色器：\n输入： 顶点\n输出： 顶点\n一般在这里进行MVP矩阵运算。对图形学算法的优化的一个方法就是将在片段着色器中运行的代码尽可能地放到顶点着色器中，这样着色器处理的像素点就少。\n曲面细分着色器(Tessellation) # 然后顶点会传送到曲面细分着色器，在这一阶段进行曲面细分和曲面简化。\n曲面细分用于给面增加更多的顶点，以此来增加更多的细节。曲面简化则是相反的操作，会减少面数，减少细节。\n这常常用在LOD（细节层次技术）优化中。LOD简单来说就是对远处的物体使用低面数的模型，对近处的看的很清楚的物体使用高面数的模型。这个时候，如果你只有一个模型，但是想使用LOD，你就可以使用曲面细分和曲面简化来达到效果。\n这一阶段有两个着色器：曲面控制着色器，曲面求值着色器。\n曲面细分着色器：\n输入： 顶点\n输出： 顶点\n几何着色器(Geometry Shader) # 这个着色器用于产生新的图元。\n注意其和曲面细分着色器的区别：曲面细分着色器是对顶点操作，并且输出顶点。而几何着色器则是对图元操作，输出的是图元。\n几何着色器：\n输入： 图元\n输出： 图元\n图元装配(Primitive Setup) # 几何着色器输出的图元会进行图元装配。图元组装做如下事情：\n将输入的顶点组合成图元 面剔除和裁剪(Face Culling and Clipping) # 面剔除即剔除指定的面，通常是顺时针或逆时针的面。裁剪则是当三角形全部位于屏幕外面的时候就直接丢弃以减少计算时间。\n光栅化(Rasterization) # 光栅化就是对图元内的每个点进行采样，或者说将图元离散化，即屏幕上哪些点在图元内，哪些点不在。不在的点会被丢弃。\n光栅化之前会做两件事：\n进行透视除法\n进行视口变换\n光栅化会通过插值来计算出图元内部点的信息（比如z坐标），然后将此点传给片段着色器。\n片段着色器(Fragment Shader) # 片段着色器是对点进行着色的着色器，它是产生高级渲染效果的着色器，但同时也是最吃时间的着色器。\n因为在光栅化之前的所有着色器都是只对顶点进行操作（几何着色器其实也是增加或者丢弃顶点）。一个模型再复杂，顶点也就几千个。但是光栅化会将图元内部的所有点都计算出来传给着色器，这会大大增加点的数目，所以到达片段着色器的点数量会剧增。\n测试混合阶段 # 这是最后一个阶段，主要做两件事：\n进行测试，包括：\n深度测试\n模板测试\nalpha测试\n对于测试通过的点，还要进行alpha混合来达到透明效果\n注意，这里可能有提前深度测试优化，即将深度测试提前到光栅化阶段。因为如果片段着色器不改变点的深度值的话，点的深度值其实在光栅化时就已经确定了，这个时候光栅化阶段可以做深度测试将点丢弃，减少片段着色器的负担。\n输出到Framebuffer上 # 最后所有的点会被绘制到framebuffer上，然后系统会将framebuffer绘制到屏幕上，完成一次渲染。\n","date":"6 March 2022","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8%E9%9B%B6/","section":"Posts","summary":"本文介绍了基本的渲染管线知识。\n","title":"从0开始制作软渲染器（零）","type":"posts"},{"content":"本文说了有关光栅化的事情\n光栅化是什么 # 光栅化的基础单位是最简单的多边形：三角形。\n光栅化做的事就是如下：\n遍历屏幕中所有的点，判断点是否在三角形内\n如果在三角形内，通过重心坐标计算出此点的z坐标，并且对其他顶点属性进行插值\n将点传给片段着色器进行着色\n其主要功能就是对三角形进行采样，确定其中的点传给片段着色器。\n判断点是否在三角形内 # 首先要做的第一步就是判断点是否在三角形内。\n我们光栅化的做法是这样：假设三角形坐标为，对屏幕上的每个点，判断其是否在三角形对XoY平面投影的区域内。\n假设三角形的三个顶点是$(x_1, y_1, z_1)$，$(x_2, y_2, z_2)$，$(x_3, y_3, z_3)$，那么他对XoY的投影点是$(x_1, y_1)$,$(x_2, y_2)$,$(x_3, y_3)$，我们就要对屏幕上每个点判断其是否在这三个顶点所围成的三角形内部。\nEdge Equation # Edge Equation算法使用叉积来判断：\n当Q点在三角形外面时，$\\vec{P_0P_1}\\times \\vec{P_0Q}$,$\\vec{P_1P_2}\\times \\vec{P_1Q}$,$\\vec{P_2P_0}\\times \\vec{P_2Q}$这三者的值一定有至少一个和其他两个的值的符号不一样。而当Q在内部时，符号一定是一样的。\n注意这里向量的顺序一定是按照顺时针或逆时针，不要搞什么$\\vec{P_0P_2},\\vec{P_0P_1}$。\n使用重心坐标 # 重心坐标主要是用来插值出点的z坐标，但是这里也可以使用它来判断三角形在不在内部。\n首先看一下重心坐标的定义：\n如果一个点$P$在三角形$V_1, V_2, V_3$内部，那么有：\n$$ \\begin{aligned} \\vec{P} = \\alpha \\vec{V_1} + \\beta \\vec{V_2} + \\gamma \\vec{V_3} \\\\ \\alpha + \\beta + \\gamma = 1 \\end{aligned} $$这里第二个式子告诉我们，其实可以只使用$\\alpha$和$\\beta$来表示点P：\n$$ \\vec{P} = (1-\\beta - \\gamma) \\vec{V_1} + \\beta \\vec{V_2} + \\gamma \\vec{V_3} $$当$\\alpha \\beta \\gamma$中任意一者为0时点在三角形边上，任意两个为0时点在三角形顶点上。任意一个为负数则表示点在三角形外面。我们可以使用这个来判断点在不在三角形中间。\n那么接下来就是求解这三个未知数，有两种方法：\n使用面积比来计算 # 第一种是通过重心坐标的性质：\n$\\alpha,\\beta,\\gamma$的值为对应小三角形面积和整个三角形面积的比，所以我们就能使用向量叉乘的性质来算出这三个量：\n$$ \\begin{matrix} A = \\frac{\\vec{ca} \\times \\vec{ba}}{2} \\\\ A_a = \\frac{\\vec{pc} \\times \\vec{pb}}{2} \\\\ A_b = \\frac{\\vec{pa} \\times \\vec{pc}}{2} \\\\ A_c = \\frac{\\vec{pa} \\times \\vec{pb}}{2} \\\\ \\end{matrix} $$这样$\\alpha,\\beta,\\gamma$就出来了。注意这里可能产生的除0错误：A的值为0。这意味着三角形退化成线或者点了，我们直接抛弃三角形。\n推导出公式来计算 # 第二种是我们自己推导：\n$$ \\begin{matrix} \u0026 \\vec{P} = (1-\\beta - \\gamma) \\vec{V_1} + \\beta \\vec{V_2} + \\gamma \\vec{V_3} \\\\ \u0026 \\Downarrow \\\\ \u0026 \\vec{P} = \\vec{V_1}-\\beta \\vec{V_1} - \\gamma \\vec{V_1} + \\beta \\vec{V_2} + \\gamma \\vec{V_3} \\\\ \u0026 \\Downarrow \\\\ \u0026 \\vec{P} - \\vec{V_1} = \\beta(\\vec{V_2} - \\vec{V_1}) + \\gamma(\\vec{V_3} - \\vec{V_1}) \\\\ \u0026 \\Downarrow \\\\ \u0026\\vec{PV_1} = \\beta \\vec{V_2V_1} + \\gamma \\vec{V_3V_1} \\end{matrix} $$把左边的$\\vec{PV_1}$挪到右边，就有：\n$$ \\beta \\vec{V_2V_1} + \\gamma \\vec{V_3V_1} + \\vec{V_1P} = 0 $$因为这里的所有向量都是二维向量，所以我们有：\n$$ \\begin{cases} \\beta \\vec{V_2V_1}_x + \\gamma \\vec{V_3V_1}_x + \\vec{V_1P}_x = 0 \\\\ \\beta \\vec{V_2V_1}_y + \\gamma \\vec{V_3V_1}_y + \\vec{V_1P}_y = 0 \\\\ \\end{cases} $$任意的线性方程组都可以写成矩阵形式，那么我们有：\n$$ \\begin{cases} \\begin{bmatrix} \\beta \u0026 \\gamma \u0026 1 \\end{bmatrix} \\begin{bmatrix} \\vec{V_2V_1}_x \\\\ \\vec{V_3V_1}_x \\\\ \\vec{V_1P}_x \\end{bmatrix} = 0 \\\\ \\begin{bmatrix} \\beta \u0026 \\gamma \u0026 1 \\end{bmatrix} \\begin{bmatrix} \\vec{V_2V_1}_y \\\\ \\vec{V_3V_1}_y \\\\ \\vec{V_1P}_y \\end{bmatrix} = 0 \\end{cases} $$那么这就意味着$\\begin{bmatrix} \\beta \u0026amp; \\gamma \u0026amp; 1 \\end{bmatrix}$和另外两个向量的点乘为0，这意味着其和另外两个向量垂直。\n那就好做了，直接对上面方程组右边两个向量做叉积：\n$$ \\begin{bmatrix} \\vec{V_2V_1}_x \\\\ \\vec{V_2V_3}_x \\\\ \\vec{V_1P}_x \\end{bmatrix} \\times \\begin{bmatrix} \\vec{V_2V_1}_y \\\\ \\vec{V_2V_3}_y \\\\ \\vec{V_1P}_y \\end{bmatrix} = \\begin{bmatrix} a \u0026 b \u0026 c \\end{bmatrix} = c \\begin{bmatrix} \\beta \u0026 \\gamma \u0026 1 \\end{bmatrix} $$这里我们算出来的向量是$\\begin{bmatrix}\\beta \u0026amp; \\gamma \u0026amp; 1 \\end{bmatrix}$的c倍，所以我们得对其除以c。\n这个时候必须考虑$c = 0$的情况。由向量叉乘公式，c的计算过程其实是这样：\n$$ c = \\left| \\begin{matrix} \\vec{V_2V_1}_x \u0026 \\vec{V_2V_3}_x \\\\ \\vec{V_2V_1}_y \u0026 \\vec{V_2V_3}_y \\\\ \\end{matrix} \\right| = \\left| \\begin{matrix} \\vec{V_2V_1}_x \u0026 \\vec{V_2V_1}_y \\\\ \\vec{V_2V_3}_x \u0026 \\vec{V_2V_3}_y \\\\ \\end{matrix} \\right| $$上述行列式为0说明$\\vec{V_2V_1}\\times \\vec{V_2V_3} = 0$，这意味着这两个向量共线，即三角形退化成直线了。\n这里你可以抛弃三角形，或者绘制这条直线。\n显然，方法二比起方法一更好，他只需要做一次叉乘就能得到结果。\n使用三角形AABB包围盒加速判断 # 这里我们可以加速这一步骤：我们找到三角形的AABB包围盒，然后遍历屏幕上的点时先判断在不在包围盒内，不在就直接丢弃：\n对三角形内的每个点求出z坐标 # 我们确定了三角形内的所有点，现在需要对每个点计算其z坐标。\n平面方程法（推荐仅在平行投影中使用） # 可以使用平面方程来解：\n三角形所在平面的方程为：\n$$ A(x-x_1)+B(y-y_1)+C(z-z_1) = 0 $$而$(A, B, C)$又是此平面的法向量：\n$$ \\vec{V_1V_2} \\times \\vec{V_1V_3} = \\begin{bmatrix} A \u0026 B \u0026 C \\end{bmatrix} $$那么就可以很容易求得此三角形所在平面的方程。然后对屏幕上的点$P(x, y)$，我们可以将其带入方程求得z坐标：\n$$ z = \\frac{x_1+y_1+z_1-Ax-By}{C} $$当$C = 0$时意味着平面的法向量位于$XoY$平面内，这意味着从我们的视角看去，三角形变成了一条线。这个时候可以抛弃三角形。\nz坐标的透视校正（透视投影中使用） # 平面方程法只能在平行投影中使用，因为正交投影会让三角形“变形”，导致其z坐标和其他点并不是线性关系。所以我们需要对z坐标进行透视校正。\n上图解释了为何需要透视校正。在屏幕上，c点位于a，b的中间，但在透视投影的情况下真正的点C并不位于AB之间，如果这时候还用线性关系去运算就会带来错误的结果。\n接下来我们来算透视校正：\n我们先从侧面看整个场景，研究二维的情况，然后将其推广到三维情况。\n这里我们的摄像机看向z的正方向，近平面到摄像机的z距离为d。$s = \\frac{|ab|}{|ac|}$，$t = \\frac{|AC|}{|AB|}$。\n首先，通过相似三角形可得：\n$$ \\frac{X_1}{Z_1} = \\frac{u_1}{d} \\Rightarrow X_1 = \\frac{u_1}{d}Z_1 \\tag{1} $$$$ \\frac{X_2}{Z_2} = \\frac{u_2}{d} \\Rightarrow X_2 = \\frac{u_2}{d}Z_2 \\tag{2} $$$$ \\frac{X_t}{Z_t} = \\frac{u_s}{d} \\Rightarrow X_t = \\frac{u_s}{d}Z_t \\tag{3} $$然后由s的计算公式可以得到：\n$$ s = \\frac{u_2 - u_1}{u_s - u_1} \\Rightarrow u_s = u_1 + (u_2 - u_1)s \\tag{4} $$同理，通过t的计算公式可以得到：\n$$ X_t = X_1 + (X_2 - X_1)t \\tag{5} $$$$ Z_t = Z_1 + (Z_2 - Z_1)t \\tag{6} $$将$(4)$和$(5)$式代入$(3)$式可得：\n$$ Z_t = \\frac{d(X_1 + t(X_2 - X_1))}{u_1 + (u_2 - u_1)s} \\tag{7} $$再将$(1),(2)$代入$(7)$可得：\n$$ Z_t = \\frac{d(\\frac{u_1Z_1}{d}+t(\\frac{u_2Z_2}{d} - \\frac{u_1Z_1}{d}))}{u_1 + s(u_2 - u_1)} \\tag{8} $$将$(6)$代入$(7)$：\n$$ Z_1 + (Z_2 - Z_1)t = \\frac{d(\\frac{u_1Z_1}{d}+t(\\frac{u_2Z_2}{d} - \\frac{u_1Z_1}{d}))}{u_1 + s(u_2 - u_1)} \\tag{9} $$化简得\n$$ t = \\frac{sZ_1}{sZ_1 + (1-s)Z_2} \\tag{10} $$然后将$(10)$代入$(6)$：\n$$ Z_t = Z_1 + \\frac{sZ_1}{sZ_1 + (1-s)Z_2}(Z_2 - Z_1) $$化简得：\n$$ Z_t = \\frac{1}{\\frac{1}{Z_1}+s(\\frac{1}{Z_2} - \\frac{1}{Z_1})} $$然后对$Z_t$取倒数：\n$$ \\frac{1}{Z_t} = \\frac{1}{Z_1} + s(\\frac{1}{Z_2} - \\frac{1}{Z_1}) $$这说明z的倒数之间是成正比的，也就是说，记$W = \\frac{1}{Z}$，有：\n$$ W_t = W_1 + s(W_2 - W_1) = (1-s)W_1 + sW_2 $$这就是二维空间中的透视校正。\n推广到三维空间中，我们就需要找到和$s$相对应的量。注意这里$s$是点c到点a的距离比，并且$s \\in [0, 1]$。我们很容易想到一个和此有类似性质的东西：重心坐标。\n所以推广到3D的公式就是这样：\n$$ \\frac{1}{Z_t} = \\frac{\\alpha}{Z_1} + \\frac{\\beta}{Z_2} + \\frac{\\gamma}{Z_3} $$其中$\\alpha, \\beta, \\gamma$三角形投影到近平面上的重心坐标参数。\n重心坐标在透视投影的情况下会发生改变，那么其他的属性值也会发生改变。对于任意的属性值，我们可以用如下公式进行透视校正：\n$$ \\frac{1}{Z_t}I_t = \\frac{\\alpha}{Z_1}I_1 + \\frac{\\beta}{Z_2}I_2 + \\frac{\\gamma}{Z_3}I_3 $$ 编程小技巧 # 这里因为$\\frac{1}{Z}$之间成正比，所以我们一般会令$w = \\frac{1}{Z}$。而通过上一章的透视投影矩阵推导，我们可以知道经过透视投影变换后得到的点为：\n$$ \\begin{bmatrix} x \\\\ y \\\\ z \\\\ 1 \\end{bmatrix} \\Rightarrow \\begin{bmatrix} zx \\\\ zy \\\\ z^2 \\\\ z \\end{bmatrix} $$所以我们可以提前保留下$\\frac{1}{Z}$，不仅是为了进行线性插值，在后面做透视除法（即给透视之后的点除以$z$）也可以直接乘上$\\frac{1}{Z}$。\n# 参考 # OpenGL 和 DirectX 是如何在只知道顶点的情况下得出像素位置的？ - 知乎 (zhihu.com)\n十天自制软渲染器 DAY 03：画一个三角形（向量叉乘算法 \u0026amp; 重心坐标算法）卤蛋实验室-CSDN博客\n","date":"1 March 2022","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8%E4%BA%8C/","section":"Posts","summary":"本文说了有关光栅化的事情\n","title":"从0开始制作软渲染器（二）","type":"posts"},{"content":"本文讲述了MVP矩阵的意义,投影变换（透视投影和平行投影）矩阵的推导以及视口变换\n模型变换 # 模型变换是将物体从原点移动到他应该在的位置（世界坐标）\n视图变换 # 视图变换主要指摄像机变换。因为存在规定：\n摄像机坐标必须在原点\n摄像机看向$-z$轴\n所以我们需要一些变换作用在世界中的所有物体上，以便于将他们变换到摄像机坐标。\n投影变换 # 这是最后一步，将三维空间的物体（程序中记录的所有物体）投影到二维空间（屏幕）上从而显示他们。\n平行投影 # 平行投影很简单：\n我们的可视范围在这个最左边的长方体里面，我们的目标是将其变换成最右边的标准立方体（即$x, y, z \\in [-1, 1]$）。\n很简单，先将长方体中心平移到原点（图中间的情况），然后再对长方体进行缩放即可：\n$$ \\begin{bmatrix} \\frac{2}{right - left} \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 \\frac{2}{top - bottom} \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{2}{near - far} \u0026 0 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} \\begin{bmatrix} 1 \u0026 0 \u0026 0 \u0026 -\\frac{left + right}{2} \\\\ 0 \u0026 1 \u0026 0 \u0026 -\\frac{top + bottom}{2} \\\\ 0 \u0026 0 \u0026 1 \u0026 -\\frac{near + far}{2} \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} = \\\\ \\begin{bmatrix} \\frac{2}{right - left} \u0026 0 \u0026 0 \u0026 -\\frac{left + right}{right - left} \\\\ 0 \u0026 \\frac{2}{top - bottom} \u0026 0 \u0026 -\\frac{top + bottom}{top - bottom} \\\\ 0 \u0026 0 \u0026 \\frac{2}{near - far} \u0026 -\\frac{near + far}{near - far} \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} $$ 正交投影 # 正交投影的推导比较麻烦:\n我们采用从平行投影推导到正交投影的方法，你可以想像将透视投影（Frustum）的远平面进行挤压，从而让其四条线平行来得到平行投影。\n对于透视投影，我们有如下规定：\n挤压前后近平面和远平面位置保持不变（即透视投影和平行投影的近，远平面的z值是一样的）\n进平面上的点挤压前后不会发生变化\n远平面的中心点挤压前后不会发生变化\n有了上面两条规定，就可以推导出透视投影了，这里的公式为：\n$$ M_{persp} = M_{ortho}M_{persp\\_to\\_ortho} $$我们现在要求的就是$M_{persp_to_ortho}$矩阵。\n从侧面来看这个Frustum，$(x, y, z)$是视锥内的一点，$(x^{\u0026rsquo;},y^{\u0026rsquo;},z^{\u0026rsquo;})$是其经过变换后的点（确定这个点的方法是从原点做到$(x, y, z)$点的直线，和近平面的交点）\n显然，由相似三角形得知：\n$$ y^{'} = \\frac{n}{z}y $$那么同理，x的坐标也可以知道：\n$$ x^{'} = \\frac{n}{z}x $$那么我们现在已经知道两个坐标了，接下来就是解$z$坐标：\n$$ \\begin{pmatrix} x \\\\ y \\\\ z \\\\ 1 \\end{pmatrix} \\Rightarrow \\begin{pmatrix} \\frac{n}{z}y \\\\ \\frac{n}{z}x \\\\ ? \\\\ 1 \\end{pmatrix} $$首先根据齐次坐标性质，对齐次向量乘上常数不改变向量表示的点，那么我们直接乘上$z$，将除式消去：\n$$ \\begin{pmatrix} x \\\\ y \\\\ z \\\\ 1 \\end{pmatrix} \\Rightarrow \\begin{pmatrix} \\frac{n}{z}y \\\\ \\frac{n}{z}x \\\\ ? \\\\ 1 \\end{pmatrix} {multiply\\ z} \\Rightarrow \\begin{pmatrix} ny \\\\ nx \\\\ ? \\\\ z \\end{pmatrix} $$那么我们现在的$M_{persp_to_ortho}$就是：\n$$ M_{persp\\_to\\_ortho} = \\begin{bmatrix} n \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 n \u0026 0 \u0026 0 \\\\ a \u0026 b \u0026 c \u0026 d \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\end{bmatrix} $$其中$a, b, c, d$都是未知量。\n然后我们使用上面说的两条规定：\n规定二告诉我们，近平面上的点在挤压前后不会发生变化，这意味着当$z=n$时，有：\n$$ \\begin{pmatrix} x \\\\ y \\\\ n \\\\ 1 \\end{pmatrix} \\Rightarrow \\begin{pmatrix} nx \\\\ ny \\\\ n^2 \\\\ n \\end{pmatrix} $$也就是说通过$M_{persp_to_ortho}$矩阵的变换，有：\n$$ \\begin{pmatrix} a \u0026 b \u0026 c \u0026 d \\end{pmatrix} \\begin{pmatrix} x \\\\ y \\\\ n \\\\ 1 \\end{pmatrix} = n^2 $$显然，最后的结果$n^2$和$x,y$没有半毛钱关系，这意味着一定有\n$$ a = 0, b = 0 $$那么我们可以得到方程：\n$$ \\begin{pmatrix} 0 \u0026 0 \u0026 c \u0026 d \\end{pmatrix} \\begin{pmatrix} x \\\\ y \\\\ n \\\\ 1 \\end{pmatrix} = cn + d = n^2 $$规定三告诉我们，远平面的中心点挤压之后也不发生改变，这意味着有：\n$$ \\begin{pmatrix} 0 \u0026 0 \u0026 c \u0026 d \\end{pmatrix} \\begin{pmatrix} 0 \\\\ 0 \\\\ f \\\\ 1 \\end{pmatrix} = cf + d = f^2 $$由上述两个方程联立可解得：\n$$ \\begin{matrix} c \u0026 = n + f \\\\ d \u0026 = -nf \\end{matrix} $$这样结果就出来了：\n$$ M_{persp\\_to\\_ortho} = \\begin{bmatrix} near \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 near \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 near+far \u0026 -near far \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\end{bmatrix} $$$$ M_{persp} = M_{ortho}M_{persp\\_to\\_ortho} = \\\\ \\begin{bmatrix} \\frac{2}{right - left} \u0026 0 \u0026 0 \u0026 -\\frac{left + right}{right - left} \\\\ 0 \u0026 \\frac{2}{top - bottom} \u0026 0 \u0026 -\\frac{top + bottom}{top - bottom} \\\\ 0 \u0026 0 \u0026 \\frac{2}{near - far} \u0026 -\\frac{near + far}{near - far} \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} \\begin{bmatrix} near \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 near \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 near+far \u0026 -near\\ far \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\end{bmatrix} = \\begin{bmatrix} \\frac{2n}{r - l} \u0026 0 \u0026 -\\frac{r+l}{r-l} \u0026 0 \\\\ 0 \u0026 \\frac{2n}{t-b} \u0026 -\\frac{t+b}{t-b} \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{n+f}{n-f} \u0026 \\frac{2fn}{f-n} \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\end{bmatrix} $$OpenGL的做法则是对$n$和$f$取绝对值：\n$$ \\begin{bmatrix} \\frac{2|n|}{r - l} \u0026 0 \u0026 -\\frac{r+l}{r-l} \u0026 0 \\\\ 0 \u0026 \\frac{2|n|}{t-b} \u0026 -\\frac{t+b}{t-b} \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{|n|+|f|}{|n|-|f|} \u0026 \\frac{2|f||n|}{|n|-|f|} \\\\ 0 \u0026 0 \u0026 -1 \u0026 0 \\end{bmatrix} \\\\ $$更一般的做法是通过四个参数来确定投影矩阵：\nfov：纵向的视角大小 aspect：近平面的宽高比 zNear：近平面到摄像机距离（即到原点距离） zFar：远平面到摄像机距离（即到原点距离） 通过这些可以计算出：\n近平面高度H：$H = 2zNear\\times \\tan{\\frac{fov}{2}}$ 近平面宽度W：$W=2aspect\\times zNear\\times \\tan{\\frac{fov}{2}}$ 这样可以得到\n$t = \\frac{H}{2}$ $b = -\\frac{H}{2}$ $l = -\\frac{W}{2}$ $r = \\frac{W}{2}$ 注意zNear和zFar是到原点的距离，不是近，远平面的坐标，所以为了和上面平行投影矩阵合二为一，我们需要用到的是近远平面坐标。假设进平面坐标为$n$，远平面坐标为$f$，就有：\n$$ \\begin{aligned} H \u0026= 2|n|\\times \\tan{\\frac{fov}{2}} \\\\ W \u0026= 2aspect\\times |n|\\times \\tan{\\frac{fov}{2}} \\end{aligned} $$最终的结果就是：\n$$ \\begin{bmatrix} \\frac{2n}{W} \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 \\frac{2n}{H} \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{n + f}{n - f} \u0026 \\frac{2fn}{f - n} \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\\\ \\end{bmatrix} = \\\\ \\begin{bmatrix} \\frac{n}{|n|aspect\\times \\tan{0.5fov}} \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 \\frac{n}{|n|\\tan{0.5fov}} \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{n + f}{n - f} \u0026 \\frac{2fn}{f - n} \\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\\\ \\end{bmatrix} $$ Viewport # w前面我们使用了投影矩阵将场景归一化到$[-1, 1]^3$中，而视口变换则是将$[-1,1]^3$转换到$[x, x+width]\\times [y, y+height]\\times [0, d]$上，也就是我们真正的屏幕坐标中。\n$$ \\begin{bmatrix} \\frac{width}{2} \u0026 0 \u0026 0 \u0026 0 \\\\ 0 \u0026 \\frac{height}{2} \u0026 0 \u0026 0 \\\\ 0 \u0026 0 \u0026 \\frac{d}{2} \u0026 0 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} \\begin{bmatrix} 1 \u0026 0 \u0026 0 \u0026 \\frac{width}{2} + x \\\\ 0 \u0026 1 \u0026 0 \u0026 \\frac{height}{2} + y \\\\ 0 \u0026 0 \u0026 1 \u0026 1 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} = \\begin{bmatrix} \\frac{width}{2} \u0026 0 \u0026 0 \u0026 \\frac{width}{2} + x \\\\ 0 \u0026 \\frac{height}{2} \u0026 0 \u0026 \\frac{height}{2} + y \\\\ 0 \u0026 0 \u0026 \\frac{d}{2} \u0026 1 \\\\ 0 \u0026 0 \u0026 0 \u0026 1 \\end{bmatrix} $$一般的屏幕坐标是以窗口左上角为原点，x轴向右，y轴向下。现在我们视口的中心是在(0, 0)处也就是左上角原点位置，并且z值一般在(0, 1)之间。所以我们一般取$x = y = 0$，$d = 1$。\n","date":"1 March 2022","externalUrl":null,"permalink":"/posts/%E4%BB%8E0%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E8%BD%AF%E6%B8%B2%E6%9F%93%E5%99%A8%E4%B8%80/","section":"Posts","summary":"本文讲述了MVP矩阵的意义,投影变换（透视投影和平行投影）矩阵的推导以及视口变换\n","title":"从0开始制作软渲染器（一）","type":"posts"},{"content":"","date":"23 February 2022","externalUrl":null,"permalink":"/tags/%E7%AE%97%E6%B3%95/","section":"Tags","summary":"","title":"算法","type":"tags"},{"content":"这里介绍了一些随机数生成算法\n为何要自己编写随机数算法 # 显然，无论是哪个语言，都会有随机数生成器，差一点的像C语言这样只有一个rand()，好一点的像C++这样有一整套的random解决方案。那为何我们还要自己编写随机数算法呢？\n事情还得从SpaceWar说起。这个游戏中使用了大量的随机数，比如星星的绘制，友军对敌机的选择等。实现的方式就是使用的C++17 STL中的random。但是当我将此游戏移植到Windows下后，发现随机数和在我电脑上的表现不一致。\n这使得我的游戏在Windows上会造成一些bug（像是许多友军攻击一个敌机），而在我的电脑上（MacOS），友军对敌军的选择是平均的（因为我当时用的是std::random_device生成随机数，然后将随机数放入std::uniform_int_distribution中产生均匀分布的随机数，但是random_device的实现在不同平台下不一样，可能对结果造成很大的差异）。\n这种bug不是因为随机数种子导致的。我和我的朋友尝试了很多次，就是存在上述差异。这时我才知道，上次面试的时候面试官问我随机数生成算法的意义：自己编写的随机数生成算法是可控的，并且可以在各个平台造成一样的结果。\n自己实现随机数算法还有一个好处：在一些需要随机生成地图的游戏中（比如Minecraft），同一个种子会生成同一份地图。这个时候不得不使用自己编写的随机数算法来避免不同编译器实现的随机数算法的差异。\n线性同余方法（LCG) # 这是很常见的一种算法，就一个公式：\n$$ X_{n+1} = (aX_{n} + b) \\mod{m} $$这个算法有三个参数a,b,m。LCG的最大周期为m，但是往往达不到。\n这个算法严重依赖参数的取值，取得好可以产生很好的结果。而且这个算法算起来也快。\n参考博客的第一条中列举了不同编译器对参数的选择，这里只给出C++11minstd_rand()的参数：\na = 48271\nb = 0\nm = $2^{31}-1$\n混合同余法 # 混合同余法可以看做LCG的加强版：\n$$ \\begin{cases} x_{n+1} \u0026 = (\\lambda x_{n} + c) \\mod{m} \\\\ result \u0026 = \\frac{x_{n+1}}{m} ,\\ \\ result \\in [0, 1]) \\\\ \\end{cases} $$这里$\\lambda$, $c$和$m$都是参数，$x_0$是种子，$result$是返回的结果，在0~1之间。\n这几个参数选择也有要求的，如果随机选的话很容易得到重复序列。\n$\\lambda$应当是$2^k +1(k \u0026gt; 2)$\n$m$应当是$2^n(2 \\le n \\le 34)$\n$c$可以是任何整数\nk和n越接近，就会得到越重复的序列。经过我的实验，k=2,n=18和这附近的值表现得都还可以。\n这里给了个在线例子，可以进去看一看。这里$x_0$是time(nullptr)的返回值。这里每个点的x坐标是其在数组中的下标（生成了720个数），y坐标则是处理后的随机数（我将随机数乘以480以让其铺满整个屏幕）。\n和混合同余法有关的有两个算法，分别是他的低配和高配版：\n乘同余法：\n$$ \\begin{cases} x_{n+1} \u0026 = \\lambda x \\mod{m} \\\\ result \u0026= \\frac{x_{n+1}}{m} \\\\ \\end{cases} $$高配版\n$$ \\begin{cases} x_{n+1} \u0026 = (\\lambda_1 x_{n} + \\lambda_2 x_{n-1} \\cdots \\lambda_k x_{n+1-k} + c)\\mod{m} \\\\ result \u0026 = \\frac{x_{n+1}}{m} \\end{cases} $$ xorshift算法 # 这个算法是和代码相关的，所以我直接贴代码了：\nstd::uint32_t x = time(nullptr), y = time(nullptr), z = time(nullptr), w = time(nullptr); std::uint32_t xorshf32(void) { // period 2^128 - 1 std::uint32_t tmp = (x ^ (x \u0026lt;\u0026lt; 15)); x = y; y = z; z = w; w = (w^(w \u0026gt;\u0026gt; 21) ^ (tmp ^(tmp \u0026gt;\u0026gt; 4))); return w; } 这个算法需要四个参数x,y,z,w，并且他的周期很长（这里是$2^{128} -1$），并且只需要异或和位移运算就能得到不同的随机数，具体我也不知道为什么，不过我可以给你这个算法的论文以及wiki上的文献🐶。\n这个算法很快，而且也表现出了很好的随机性。\n实例网页在这里\n其他算法 # 比较有名的还有梅森旋转算法(Mersenne Twister),LFG(lagged-fibonacci-generator)。MT算法是太复杂了我没搞懂。LFG我压根就找不到相关资料，只有英文wiki。\n真正的随机数 # 众所周知，电脑产生的随机数都是伪随机数。但我们可以通过自然的力量得到真正的随机数。\nwww.random.org网站通过采集大气噪声来产生随机数。这种随机数可以应用在如彩票生成，密码生成器等程序中，用以避免伪随机数的周期性带来的不好的后果。\n得到遵从概率分布的随机数 # C语言中，只可以使用$rand()$函数产生随机数，但C++中还能产生依据某种概率分布的随机数：\nstd::random_device rd; // 随机数生成器 std::mt19937 gen(rd()); // 使用梅森素数缠绕算法给其一个初值 std::uniform_int_distribution\u0026lt;\u0026gt; dis(1, 6); // 均匀分布发生器 for (int n=0; n\u0026lt;10; ++n) std::cout \u0026lt;\u0026lt; dis(gen) \u0026lt;\u0026lt; \u0026#39; \u0026#39;; // 使用发生器产生[1, 6]之间服从均匀分布的随机数 这其中的原理如下：\n对于我们想要的分布$D(x)$，从概率分布函数的定义来看：\n$$ D(x) = \\int_{-\\inf}^x f(x) \\text{d} x $$其中$f(x)$是其概率密度函数。\n我们可以通过解反函数，来得到X到D(x)的映射：\n$$ X = D(X)^{-1} $$比如对于均匀分布，其概率密度函数为：\n$$ \\begin{cases} \\frac{1}{b - a}, \u0026 x \\in [a, b] \\\\ 0, \u0026 其他 \\end{cases} $$使用概率分布函数的定义公式，可积分得到：\n$$ \\begin{cases} 0, \u0026 x \u003c a \\\\ D(x) = \\frac{x - a}{b - a}, \u0026 x \\in [a, b] \\\\ 1, \u0026 x \u003e b \\end{cases} $$那么我们可以反解出x：\n$$ x = D(x)^{-1} = (b-a)D(x)+a $$这时，使用上面说的随机数生成算法生成出随机数d，然后将d替换$D(x)$就可以得到服从分布的x了。\n参考博客 # LCG(linear congruential generator): 一种简单的随机数生成算法\n随机数生成算法【详解，归纳】 - Angel_Kitty - 博客园 (cnblogs.com)\nxorshift算法生成随机数的原理是什么？ - 知乎 (zhihu.com)\n","date":"23 February 2022","externalUrl":null,"permalink":"/posts/%E9%9A%8F%E6%9C%BA%E6%95%B0%E7%94%9F%E6%88%90%E7%AE%97%E6%B3%95/","section":"Posts","summary":"这里介绍了一些随机数生成算法\n","title":"随机数生成算法","type":"posts"},{"content":"","date":"18 February 2022","externalUrl":null,"permalink":"/tags/game-development/","section":"Tags","summary":"","title":"Game Development","type":"tags"},{"content":"这里是SpaceWar游戏开发的一些感想。游戏视频可以在这里看到\n这个游戏是为了参加频道内1MGames游戏开发比赛而制作的。从2月2号开始铺底层，断断续续地做到18号，差不多做了十天。\n游戏内容方面 # 当时刚开始做这款游戏是想着做一个ASCII Sector的复刻版本。那个游戏也是我很喜欢的一款游戏，不过人家全屏都是字符，而且是回合制的（老Roguelike了），而且人家不是开源的，在2012年作者就断更了。所以我其实一直想做个他的实时的复刻版。所以这游戏一开始的命名是Space Sector。\n因为游戏只能在1M大小内，所以一开始就打算使用TileSheet，每个Tile都是8x8的，不含有声音。说白了就是打算纯纯靠代码硬抗（毕竟咱也只会写写代码）。\n我自己本来是有个SDLEngine，但是那个太大了编译出来就3M了（其实开Release和编译器优化，然后再拿upx压一压能压到400KB，不过当时还没接触这些），然后我就放弃了打算从头再整个小引擎，就选了glfw+glad的组合。花了四五天时间搞了个tinyengine。\n然后做着做着发现，ASCII Sector东西怎么这么多啊（我本以为有ECS在手可以很快地搞出来），其中主要是卡在了地牢生成的部分，因为ASCII Sector他的设定是有很多星球，玩家可以在这些星球之间跃迁，在各地进行交易和接任务。然后因为他的星球太多了，我要做的话还得整个地图编辑器，所以我就打算改变一下，整个随机地牢，玩家可以在地牢里面挖矿，然后拿到其他星球去卖（没错就是我DigAndFight游戏的内容😁），但是这方面花了我挺长时间（主要是迁移DigAndFight的代码）。然后我搞到一半的时候感冒了，在家躺了三四天🙄\n等我好的时候，我感觉这个计划8大行，没多少时间了，所以我放弃了复刻。当时已经做了飞机的移动，我就想要不干脆就做成空战得了，所以又做了子弹，然后两种飞机，雷达地图等东西，一直搞到18号完成。\n其实一开始是想做塔防类的，玩家有个能量塔不断地给玩家积攒能量，玩家可以花费能量建造防御工事，然后去进攻别人的能量塔（能量塔的图像废案还在TileSheet里面），但是时间实在是不大够，所以改成做空战了（当时还有许多bug没调）。\n其实游戏中的FreightShip翻译应该是运货机而不是防御机，因为一开始复刻的ASCII Sector需要飞船运货。\n做的过程中感觉只有这点东西不行，所以又把miniaudio抓来加了声音。\n我在开始做的时候一度认为会超出1M，要用upx压一压，结果做出来之后带资源才900KB多，属实是高估自己了。\n技术方面 # 这次制作只能说成也ECS败也ECS。众所周知ECS是个非常灵活的系统，开发者可以自由地为Entity添加组件。一开始的开发进度很快，这归功于ECS系统。\n但是等我代码多起来的时候就开始乱了，具体有三点表现：\n增加新组件的时候会有功能和其他组件重合，并且组件和系统之间的调用太杂乱了。这个我承认是我自己设计的问题。\n每次使用组件的时候，都需要先查询一次是否存在此组件，不然会有未定义行为（访问了未定内存）。这里有两点坏处：\n每次使用组件前都需要查询，查询会搜索一次Hash表，然后如果找到了，紧接着调用Get或者Use方法时会立刻再找一次表，很浪费时间（事后想了想其实可以缓存的，但是当时急于赶进度懒得加，后面这个时间浪费就表现出来了）\n程序中有很多指向Entity的指针，比如控制器。但是当一个Entity死亡后，他不会被销毁内存，而是放入内存池中，他的组件也会回到内存池，但是会被置为空。这就导致控制器其实并不知道自己控制的Entity是否死亡了，必须每回合通过IsAlive()方法来查询。这就导致增加了很多的IsAlive()语句。\n有时候会莫名其妙地使用重生的Entity，这是因为Entity回到内存池然后又被拿出来的原因。但是这个时候Entity所代表的的实体可能已经完全变化了（可能上次是飞船，这次是子弹），所以会导致程序混乱。主要是因为我已开始太自信了，没有加上IsAlive()在实体死亡的时候替换他。\n而且我在编写的过程中逐渐意识到，其实我并不需要ECS系统，我老老实实用Sprite和继承就可以了，因为我发现在代码逐渐增加的过程中，ECS本身就趋向于可以使用Sprite表现的情况，而由于ECS的存在我的代码反而更加凌乱了。\n然后是关于智能指针的思考。确实，我们应该尽量地使用智能指针。我现在已经开始大量用unique_ptr了，但是这次的制作让我看到了shared_ptr的力量。很多时候其实用智能指针就可以解决问题，但是我自己传递裸指针造成了很多麻烦。\n","date":"18 February 2022","externalUrl":null,"permalink":"/posts/spacewar%E5%88%B6%E4%BD%9C%E6%84%9F%E6%83%B3/","section":"Posts","summary":"这里是SpaceWar游戏开发的一些感想。游戏视频可以在这里看到\n","title":"SpaceWar制作感想","type":"posts"},{"content":"","date":"23 January 2022","externalUrl":null,"permalink":"/tags/opengl/","section":"Tags","summary":"","title":"OpenGL","type":"tags"},{"content":"","date":"23 January 2022","externalUrl":null,"permalink":"/tags/sdl2/","section":"Tags","summary":"","title":"SDL2","type":"tags"},{"content":"这里说明一下如何在SDL2中使用OpenGL。\nSDL + GLEW # 这个方法是使用SDL和GLEW两个库。GLEW是用来导入OpenGL函数的。\n首先我们需要导入头文件：\n#define GLEW_STATIC #include \u0026#34;GL/glew.h\u0026#34; #include \u0026#34;SDL/SDL.h\u0026#34; 这里定义了GLEW_STATIC，表示我们要使用glew的静态库。\n然后需要配置OpenGL：\nSDL_Init(SDL_INIT_EVERYTHING); SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3); //主版本3 SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3); //副版本3 SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); //核心库 window = SDL_CreateWindow(title.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width, height, SDL_WINDOW_OPENGL|SDL_WINDOW_SHOWN); SDL_GL_CreateContext(window); //将本窗口作为绘制载体 glewExperimental = GL_TRUE; glewInit(); //初始化glew库，一定要在窗口创建之后初始化 这里首先使用SDL_Init()初始化了SDL，然后需要使用SDL_GL_SetAttribute()函数来给SDL说明使用那个版本的SDL。这里使用的是3.3版的核心库。\n然后创建了SDL窗口用于作为绘制的容器。需要注意这里不再需要SDL_Renderer，因为我们直接用OpenGL绘制。然后需要使用SDL_GL_CreateContext()函数指定这个窗口为绘制载体。\n创建窗口的时候要指定SDL_WINDOW_OPENGL来启用窗体的OpenGL支持。\n最后需要初始化GLEW库，使用glewInit()即可。 这里需要注意的是：在Windows和Linux系统上，你需要在glewInit()之前加上glewExperimental = GL_TRUE;语句，不然在Linux上会报核心短转储的错误。在MacOSX下不用。\n主循环中的窗口更新 # 除了上面的配置之外，其他的就和OpenGL一样了，该怎么编写就怎么编写。在主循环中窗口更新的函数是SDL_GL_SwapWindow()。\nSDL + 系统OpenGL库 # 这个方法是使用SDL和系统的OpenGL库。这个方法不需要除SDL外的任何拓展库。缺点是SDL目前只支持到OpenGL4.4版本函数（SDL2.0.14），使用更高版本函数可能还得用GLEW（或者手动加载）\n其他的操作和上面一样，但是我们要包含一些其他头文件：\n#define GL_GLEXT_PROTOTYPES #include \u0026#34;SDL_opengl.h\u0026#34; 然后在编译的时候需要链接系统的OpenGL库。我这里是Mac系统，直接连接的OpenGL.framework:\ng++ main.cpp `sdl2-config --libs --cflags` -framework OpenGL 这样就可以使用了，很简单对吧。\n在Windows下应该是连接opengl32或opengl64库。在Linux下应该是GL库。\n添加SDL没有的OpenGL函数 # 如果你使用的OpenGL版本过高，导致有一些函数没法使用，但是你又不想引入其他辅助库，那么你可以自己手动引入这些函数。主要的方法是使用\nvoid* SDL_GL_GetProcAddress(const char *proc); 函数获得OpenGL的函数。\n在include了SDL_opengl.h的情况下，首先我们要声明自己的函数：\n// 这里以glGenBuffers()为例 // 首先声明函数指针 typedef void (APIENTRY * GLGENBUFFERSFN)( GLsizei n, GLuint* buffers ); // 然后声明函数 GLGENBUFFERSFN glGenBuffers; 然后我们要找到OpenGL函数并赋值给glGenBuffers:\nglGenBuffers = (GLGENBUFFERSFN)SDL_GL_GetProcAddress(\u0026#34;glGenBuffers\u0026#34;); // 如果没有找到，检查一下OpenGL拓展内有没有 // 检查ARB拓展 if (!glGenBuffers) { glGenBuffers = (GLGENBUFFERSFN)SDL_GL_GetProcAddress(\u0026#34;glGenBuffersARB\u0026#34;); } // ARB里面没有就检查一下EXT if (!glGenBuffers) { glGenBuffers = (GLGENBUFFERSFN)SDL_GL_GetProcAddress(\u0026#34;glGenBuffersEXT\u0026#34;); } // 如果还没找到，那就是真没了，报错吧 throw std::runtime_error(\u0026#34;can\u0026#39;t find glGenBuffers function\u0026#34;); 可以使用这个方法添加所有SDL原生不支持的OpenGL API。\n运行时打开OpenGL库 # 如果你想要运行时打开OpenGL库，你可以使用\nint SDL_GL_LoadLibrary(const char *path); 打开。传入参数为NULL时打开默认的库。返回0代表成功。\n这个函数必须在初始化Video模块之后，创建支持OpenGL的窗口之前调用。\n打开之后需要用上面添加OpenGL函数的方式将你要使用的gl函数找到（所以并不推荐用这种方法），这个时候，你不应该包含SDL_openg.h，因为他里面有很多gl函数声明，你的声明会和他冲突。\n程序结束后不要忘记关闭库：\nvoid SDL_GL_UnloadLibrary(void);","date":"23 January 2022","externalUrl":null,"permalink":"/posts/sdl2-%E4%BD%BF%E7%94%A8opengl/","section":"Posts","summary":"这里说明一下如何在SDL2中使用OpenGL。\n","title":"SDL2-使用OpenGL","type":"posts"},{"content":"我们都知道，const T\u0026amp;这种形式的参数可以避免拷贝，从而提高性能。但是几乎没有人会对基本数据类型写这样的参数。这是因为这样做并不能提高性能。\n在底层上，按引用传递其实还是通过传地址实现的，但是这就有一个问题：传入的地址上的值是可以被用户随意修改的。这就意味着每次处理完这个值后，缓存在寄存器中的此变量值将会无效，编译器不得不从Cache或者内存中再次得到这个值。这个操作的开销在基本数据类型的情况下可能会比拷贝产生的开销更大。\n你可能会说：我这里标了const了，明确了不会被改变了啊。但是编译器并不能做到这一点（或者说保证这一点），因为有一些方式可以改变值：\nvoid Modify(const int\u0026amp; value1) { a = 123; } // call: // a 是全局变量 // Modify(a); void Modify(const int\u0026amp; value) { int\u0026amp; b = const_cast\u0026lt;int\u0026amp;\u0026gt;(value); b = 32; } // call: Modify(a); 第一种情况是在函数中虽然传入了const引用，但是a是全局变量，在这种情况下const引用就没有意义。\n第二种情况是使用const_cast将常量引用转为普通引用，从而改变参数的值。\n但是在函数inline的时候，编译器可能可以通过调用者和被调用者的信息推断出参数是否可以被修改，尤其是对于模板函数。\n","date":"23 January 2022","externalUrl":null,"permalink":"/posts/c++-%E5%AF%B9%E5%9F%BA%E6%9C%AC%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B%E8%BF%9B%E8%A1%8Cconst-t%E6%9C%89%E6%84%8F%E4%B9%89%E5%90%97/","section":"Posts","summary":"","title":"C++对基本数据类型进行const T\u0026有意义吗","type":"posts"},{"content":"本文说明了程序热加载的原理。\n热加载原理很简单，就是让程序在运行时打开动态库，并且获得里面的新函数。\n这个技术也可以用在软件更新的时候，可以只将更新的动态库发布，用户下载之后在客户端重新打开库即可，这就不需要重新下载整个软件了。\n这里我们来实战一下：\nWindow上的函数是：\nvoid* LoadLibraryA(libname); // 加载动态库 void* GetProcAddress(handle, funcname); // 从动态库内获得函数 void FreeLibrary(handle); // 释放动态库 Linux上则是：\nvoid* dlopen(const char *filename, int flags); // flags为RTLD_NOW即可 void* dlsym(void *handle, const char *symbol); int dlclose(void *handle); 我这里直接用SDL给我封装好的函数了（编译平台是Linux）：\nvoid* SDL_LoadObject(const char *sofile); void* SDL_LoadFunction(void *handle, const char *name); void SDL_UnloadObject(void *handle); 首先我们写个库，这个库只有简简单单一个函数：\n// script.cpp #include \u0026lt;iostream\u0026gt; extern \u0026#34;C\u0026#34; { void ScriptSay() { std::cout \u0026lt;\u0026lt; \u0026#34;script say 1\u0026#34; \u0026lt;\u0026lt; std::endl; } } // 编译指令 // g++ script.cpp -shared -fPIC -o libscript.so 这里我用的C++编译，C++编译会将函数的符号导出成其他的，所以我们要加上extern \u0026quot;C\u0026quot;来确保其导出正确。。\n然后我们来写调用的此库的主程序：\n// main.cpp #include \u0026lt;iostream\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026#34;SDL.h\u0026#34; using namespace std; // 库的句柄 void* MyHandle = nullptr; // 要获得的函数指针类型 using MyFunc = void(*)(); int main(int argc, char** argv) { std::string str; // 加载库 MyHandle = SDL_LoadObject(\u0026#34;./libscript.so\u0026#34;); if (!MyHandle) { std::cout \u0026lt;\u0026lt; \u0026#34;load lib failed: \u0026#34; \u0026lt;\u0026lt; SDL_GetError() \u0026lt;\u0026lt; std::endl; } // 从库中导入需要的函数 MyFunc myFunc = (MyFunc)SDL_LoadFunction(MyHandle, \u0026#34;ScriptFunc\u0026#34;); while (cin\u0026gt;\u0026gt;str) { if (str == \u0026#34;reload\u0026#34;) { // 如果输入reload，我们就重新加载库和函数 SDL_UnloadObject(MyHandle); MyHandle = SDL_LoadObject(\u0026#34;./libscript.so\u0026#34;); MyFunc myFunc = (MyFunc)SDL_LoadFunction(MyHandle, \u0026#34;ScriptFunc\u0026#34;); } if (!myFunc) { std::cout \u0026lt;\u0026lt; \u0026#34;load func failed: \u0026#34; \u0026lt;\u0026lt; SDL_GetError() \u0026lt;\u0026lt; std::endl; } else { myFunc(); // 调用函数 } } SDL_UnloadObject(MyHandle); // 释放库句柄 return 0; } // 编译指令 // g++ main.cpp -o main.out `sdl2-config --libs --cflags` 这样就写好了，注意不需要在编译指令中加上链接库，那种方法是编译时连接。\n然后我们运行main.out，随便输入一些东西后程序会输出\nscript say 1 这个时候不要关闭程序，再次打开script.cpp修改其输出为script say 2，编译，然后我们在程序中输入reload，他就会输出script say 2了。\n","date":"10 January 2022","externalUrl":null,"permalink":"/posts/%E7%A8%8B%E5%BA%8F%E7%83%AD%E5%8A%A0%E8%BD%BD%E5%8E%9F%E7%90%86/","section":"Posts","summary":"","title":"程序热加载原理","type":"posts"},{"content":"本文介绍了如何在不使用SDL_mixer的情况下，只使用SDL2进行声音的播放和录制。\n我在移植我的引擎到安卓平台的时候发现SDL_mixer的CMake有问题，而且这个玩意经常整些bug。于是翻了翻SDL的wiki，百度了之后决定使用SDL2进行一个音频的播放。\n播放音频的原理 # SDL2播放音频的原理非常底层：首先需要打开一个音频设备，然后对这个设备写入音频数据就可以播放了。\n示例 # 在进行SDL_Init(SDL_INIT_AUDIO)初始化Audio模块后，我们首先载入一个WAV文件：\nUint8* soundData = nullptr; Uint32 soundLen = 0; SDL_AudioSpec soundSpec; if (!SDL_LoadWAV(\u0026#34;assets/pickup.wav\u0026#34;, \u0026amp;soundSpec, \u0026amp;soundData, \u0026amp;soundLen)) { SDL_Log(\u0026#34;wav load failed: %s\u0026#34;, SDL_GetError()); } SDL本身只能通过SDL_LoadWAV函数载入WAV格式文件。如果你想要加载Ogg可以使用libvorbis或者stb_vorbis。其他的格式找对应的库就行了。\n读入之后我们获得了声音的数据soundData以及数据的大小soundLen，和音频的格式soundSpec。不要忘记SDL_FreeWAV()来释放soundData。\n接下来我们打开一个音频输出设备。这里有两种方法：\n使用SDL_OpenAudio打开 # 这是推荐的方法，这个方法的优点是不易用错，缺点是只能打开一个音频设备，如果你电脑上有多个音频设备你想要打开，可以使用SDL_OpenAudioDevice。\nif (SDL_OpenAudio(\u0026amp;soundSpec, nullptr) \u0026lt; 0) { SDL_Log(\u0026#34;open audio deivce failed: %s\u0026#34;, SDL_GetError()); } 第一个参数是我们希望的AudioSpec，第二个参数是它有的AudioSpec，是一个输出参数。我们这里不需要这个参数，直接给NULL。\nSDL会自己帮我们从soundSpec转换为设备的格式，所以不需要担心。\n在文件结束的时候不要忘记SDL_CloseAudio()关闭设备。\n使用SDL_OpenAudioDevice打开 # 使用SDL_OpenAudioDevice的话，可以打卡多个音频设备。但是也会打开一些不存在的音频设备。在我的Mac电脑上就打开了不存在的音频设备，导致没办法输出声音。但是我看别人的视频确实是有成功的。\n这个函数原型如下：\nSDL_AudioDeviceID SDL_OpenAudioDevice( const char *device, int iscapture, const SDL_AudioSpec *desired, SDL_AudioSpec *obtained, int allowed_changes); device：要打开的设备的名字，为NULL就是打开一个默认的。 iscapture：要打开的设备是否是录音设备。在SDL2.0.5及之后，SDL可以打开录音设备来录音。 desired和obtained：和SDL_OpenAudio()的参数一样，一个是我们希望打开的设备格式，一个是设备真正的格式 allowed_changes：是否允许改变设备的某些格式。一般直接给0表示不允许。 这个函数返回的是一个设备ID，如果ID \u0026lt; 0就是打开失败了。其实ID总是大于等于2的。\n使用这个方法打开设备后，后面所有对设备操作的函数都要加上Device，比如SDL_CloseAudioDevice()，SDL_PauseAudioDevice()等。 不要忘记使用SDL_CloseAudioDevice(id)来关闭。\nSDL_AudioSpec # 打开音频我们需要一个SDL_AudioSpec，这里我建议你直接传通过SDL_LoadWAV得到的spec，这样不需要进行音频的格式转换。\n不过我们这里还是看一下初始化它时需要填充的各个成员，以便于后面讲格式转换：\nchannels：声道数 1： 单声道 2： 双声道（立体声） 4： 四声道 6： 5.1声道，是用于影院的那种。 format：设备接收的音频数据的格式，这里AUDIO_F32是指32位浮点数格式 freq：播放频率，即每秒送往音频设备的声音帧数。44100是CD频率，48000是DVD频率。不建议高于48000，这会造成更多的内存和CPU损耗。 samples：音频采样帧中的音频缓冲区大小，只能是2的倍数，一般给个4096就行了。 callback：回调函数，当音频设备没有音频播放的时候就会调用这个函数。我们可以在这个函数里面给他音频数据 userdata：用户自定义数据。 在回调函数中写入音频数据 # 接下来我们要配置回调函数，让其在音频空闲的时候写入数据。为此，我们需要一个结构体来封装我们需要的音频数据信息：\nstruct Sound { Uint8* data; // 音频的数据 Uint32 len; // 数据的大小 Uint32 curPos; // 当前播放到的位置 }; 然后配置AudioSpec的userdata:\nSound sound; sound.data = soundData; sound.len = soundLen; sound.curPos = 0; 然后编写回调函数：\n// 这里的stream就是音频设备的缓冲区了，要往里面写入音频数据。len是这个缓冲区的大小 void AudioCallback(void* userdata, Uint8* stream, int len) { Sound* sound = (Sound*)userdata; // 得到我们的Sound结构体 if (sound-\u0026gt;data \u0026amp;\u0026amp; sound-\u0026gt;curPos \u0026lt; sound-\u0026gt;len) { // 当我们的音频数据存在，且没有播放完这个音频时进行播放 SDL_memset(stream, 0, len); // SDL要求首先设置为0 int64_t remaning = sound-\u0026gt;len - sound-\u0026gt;curPos; // 算一下剩下的音频长度 if (remaning \u0026gt; len) { // 使用SDL_MixAudio对音频进行混合。这里因为我们只有一个音频，最后的参数就给SDL_MIX_MAXVOLUME来让此音频以最大声音播放。 // 如果你有多个音频，需要对这个参数进行调整来确定不同音频的播放声音(0~128) SDL_MixAudio(stream, sound-\u0026gt;data + sound-\u0026gt;curPos, len, SDL_MIX_MAXVOLUME); sound-\u0026gt;curPos += len; } else { SDL_MixAudio(stream, sound-\u0026gt;data + sound-\u0026gt;curPos, remaning, SDL_MIX_MAXVOLUME); sound-\u0026gt;curPos += remaning; } } } 这里我是让音频只播放一遍。你也可以选择让他循环播放。\n最后，我们要让音频设备开始工作：\nSDL_PauseAudio(0); 这个函数参数如果是1则是暂停音频设备。\n至此，所有的工作就完成了。然后你可以选择Delay个1.5秒来听听播放的声音。\n整个代码实例在这里\n音频格式的转换 # 当你打开的音频设备的格式和你的音频文件格式不一样的时候，你需要进行音频格式的转换。\nSDL2新推出了SDL_AudioStream，而老的SDL_AudioCVT不再推荐使用。\n这里有SDL的官方教程\n这里就简单说一下吧。首先创建一个AudioStream：\nSDL_AudioStream* stream = SDL_NewAudioStream(soundSpec.format, soundSpec.channels, soundSpec.freq, audioSpec.format, audioSpec.channels, audioSpec.freq); if (!stream) { SDL_Log(\u0026#34;create audio stream failed: %s\u0026#34;, SDL_GetError()); } 前三个参数是关于音频的各种格式，后三个是音频设备的各种格式。\n然后我们要把音频数据送给stream进行转换：\nif (SDL_AudioStreamPut(stream, soundData, soundLen) \u0026lt; 0) { SDL_Log(\u0026#34;resample sound failed: %s\u0026#34;, SDL_GetError()); } 然后得到转换后的数据大小(字节为单位)：\nint avali = SDL_AudioStreamAvailable(stream); 然后我们拿出数据：\nUint8* cvtData = new Uint8[avali]; if (SDL_AudioStreamGet(stream, cvtData, avali) \u0026lt; 0) { SDL_Log(\u0026#34;get converted audio failed: %s\u0026#34;, SDL_GetError()); } 这样数据就拿出来了。如果你分多次放入数据，你必须多次拿出数据，因为每次使用SDL_AudioStreamPut时SDL会将你的数据大小记下来，等到拿出来时也只是给你这一块数据的转换结果。\n录制声音 # 原理和播放声音一样：打开录音设备，然后从设备中读取音频信息即可。\n打开录音设备要使用SDL_OpenAudioDevice()。\n这里给个录音的回调函数作为例子：\nstruct Record { Uint8 buffer[1024 * 1024]; Uint32 len; }; void RecordCallback(void* userdata, Uint8* stream, int len) { Record* record = (Record*)userdata; memset(record-\u0026gt;buffer, 0, sizeof(record-\u0026gt;buffer)); memcpy(record-\u0026gt;buffer, stream, len); record-\u0026gt;len = len; } 然后你就可以对这个音频数据为所欲为了，比如传给播放设备播放出来，或者保存到本地等。\n","date":"26 November 2021","externalUrl":null,"permalink":"/posts/sdl%E6%92%AD%E6%94%BE%E5%A3%B0%E9%9F%B3/","section":"Posts","summary":"本文介绍了如何在不使用SDL_mixer的情况下，只使用SDL2进行声音的播放和录制。\n","title":"SDL播放/录制声音","type":"posts"},{"content":"这里说一下如何将SDL编译为App。参考文档是SDL源码下的docs/README-android.md。\n前期准备 # 需要准备：\nandroid sdk工具和ndk工具 JDK8（更高的应该也可以） SDL2的源代码（这里使用的是SDL2_2.0.14版本） make，CMake（如果你用的Android Studio或者Gradle就不需要CMake和make） Gradle（最新版本就行，我这里是7.2） Android SDK和NDK工具的配置 # 不会的看这里。\n最近MacOS上好像不能用sdk-manager了，直接下载SDK和NDK解压，然后设置环境变量就行了。\n要求 SDK \u0026gt;= 26, NDK \u0026gt;= r15c\n要设置如下四个环境变量：\n\u0026gt; echo $ANDROID_NDK_HOME $ANDROID_NDK_ROOT $ANDROID_SDK_ROOT $ANDROID_HOME /Users/visualgmq/Library/Android/sdk/ndk/21.0.6113669/ /Users/visualgmq/Library/Android/sdk/ndk/ /Users/visualgmq/Library/Android/sdk/ /Users/visualgmq/Library/Android/sdk/ 再给出目录结构，按照这个配就行了，用sdk-manager安装更方便：\nsdk | |-- android-ndk-r20b |-- ndk | |-- 21.0.6113669 |-- platform-tools |-- build-tools | |-- 27.0.3 |-- tools | ... 配置安卓工程 # 使用SDL自己的脚本自动生成 # 进入SDL源码下的build-scripts/，里面有个androidbuild.sh。执行这个文件就可以帮你自动生成工程了。\n这个文件有两个执行方法：\nandroidbuild.sh com.yourcompany.yourapp \u0026lt; sources.list androidbuild.sh com.yourcompany.yourapp source1.c source2.c ...sourceN.c 第一个参数是你的包签名。后面的参数是你的源文件（你也可以将文件名称写在sources.list中然后用第一条命令给他）。\n源文件我建议将头文件和源文件都给他，不然它会找不到头文件。\n执行结果：\n\u0026gt; ./androidbuild.sh com.visualgmq.test ../mysrc/main.c To build and install to a device for testing, run the following: cd /Users/visualgmq/Documents/program/playground/SDL2-2.0.14/build/com.visualgmq.test ./gradlew installDebug 它会给你提示，说在../build/下面已经生成了com.visualgmq.test工程了，进去后./gradlw installDebug就可以了。\n这里的gradlw命令会编译之后直接真机运行，如果你只是想编译打包成APK，那么请使用gradlw build。\n手动配置工程 # 自动生成的方法只有拥有Bash环境才能执行，Windows下不行。而且我们也需要搞清楚到底发生了什么，以便于更好地自定义工程。\n首先将SDL源码根目录下的android-project目录拷贝出来，这个就是你的Android工程目录（可以随意改名）。 然后将SDL源码link到Android工程目录下的app/jni目录下，或者直接将SDL源码下的include,src,Android.mk文件拷贝到app/jni/SDL目录下。 这是基本配置，然后你要根据使不使用CMake来进行配置:\n不使用CMake的工程 # 打开app/jni/src/Android.mk\nLOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := main SDL_PATH := ../SDL LOCAL_C_INCLUDES := $(LOCAL_PATH)/$(SDL_PATH)/include # Add your application source files here... LOCAL_SRC_FILES := YourSourceHere.c LOCAL_SHARED_LIBRARIES := SDL2 LOCAL_LDLIBS := -lGLESv1_CM -lGLESv2 -llog include $(BUILD_SHARED_LIBRARY) 将SDL_PATH := ../SDL修正为你刚刚link的SDL源码目录。\n增加你的源文件和头文件：Android.mk中的LOCAL_SRC_FILES变量就是你的源文件名称。这里改成你自己的源文件。头文件的搜索目录要加到LOCAL_C_INCLUDES中。\n注意源文件要放在app/jni/src目录下。\n使用CMake的工程 # 打开app/build.gradle，将里面的ndkBuild部分注掉(2024行，4345行)，然后将cmake部分解注(2530行，4648行)。 编辑app/jni/CMakeLists.txt来适配你的工程。这里有一些注意点： 注意11行add_subdirectory(SDL)指定了你刚刚link或copy过来的SDL源码目录，要改成一样的名字。 你真正的工程CMakeLists在app/jni/src下。 编译工程，生成APK，真机调试 # 如果你使用Android Studio，那你直接用AS打开工程就可以了。\n如果你用的gradle，那：\ngradlw build # 编译工程并生成APK gradlw clean # 清理工程 gradlw installDebug # 编译生成APK，然后将Debug APK真机调试 gradlw installRelease # 编译生成APK，然后将Release APK真机调试 其他对工程的DIY # 自定义App名称和包名 # 将app/src/main/AndroidManifest.xml中的package=\u0026quot;org.libsdl.app\u0026quot;一行设置为你自己的包名。\n然后在app/src下创建一个和包名一样的路径以及一个java文件（例如com/gamemaker/game/MyGame.java)\n在MyGame.java中增加如下内容：\npackage com.gamemaker.game; import org.libsdl.app.SDLActivity; /** * A sample wrapper class that just calls SDLActivity */ public class MyGame extends SDLActivity { } 改AndroidManifest.xml中的\u0026lt;activity android:name=\u0026quot;SDLActivity\u0026quot;中的SDLActivity改为你的类名（这里是MyGame）。\n更改App的图标 # 替换app/src/main/res下所有文件夹中的ic_launcher.png就可以了。\n使用STL # 如果你要使用C++ STL，在app/jni/Application.mk中解注APP_STL := c++_shared一行。\n编码的注意事项 # 资源加载 # 在app/src/main/assets文件夹下的东西会被视为资源一并打包到APK中。可以使用SDL_rwops.h中声明的函数来读取。\n暂停App # 如果你设置了SDL_HINT_ANDROID_BLOCK_ON_PAUSEHint，这样当App暂停时事件循环也会自动暂停。\n当App继续执行时，SDL将尝试自动恢复GL上下文。 在现代设备(Android 3.0及以上)中，这将最有可能获得成功。但是在一些老设备上会失败。\n窗口的创建和大小 # 创建窗口的正确姿势：\nSDL_CreateWindowAndRenderer(0, 0, 0, \u0026amp;window, \u0026amp;render); // 或者 SDL_CreateWindow(\u0026#34;\u0026#34;, 0, 0, 0, 0, SDL_WINDOW_SHOWN); 总之你不应该给窗口标题和大小。窗口的大小是由你的设备屏幕决定的，你应当在窗口创建成功后使用SDL_GetWindowSize()来获得大小。\n退出程序的正确姿势 # 在main函数中返回。 可能有什么操作导致你的程序要关闭，这个时候会发送一个SDL_QUIT事件给你。 注：不要使用exit()函数退出！这不是合法的退出方法。\n设置横屏 # App默认是竖屏显示，使用SDL_SetHint(SDL_HINT_ORIENTATIONS, \u0026quot;LandscapeLeft LandscapeRight\u0026quot;)即可横屏显示。\n加载第三方库 # 这里只说明如何通过CMake加载。\n打开app/jni/CMakeLists.txt，可以发现他已经给我们写好了：\n# Compilation of companion libraries # add_subdirectory(SDL_image) # add_subdirectory(SDL_mixer) # add_subdirectory(SDL_ttf) 意思就是说你要用什么库，就将这些库的源代码放在app/jni下，然后对相应的行解注（放置方法就和SDL一样，你也可以link过来）。\n需要注意的是，第三方库的代码请从github上的sdl-org用户下进行下载，那个是SDL最新的代码。在liblsdl.org/projects/下下载的代码中有些不包含CMakeLists.txt。\n","date":"19 November 2021","externalUrl":null,"permalink":"/posts/%E5%9C%A8android%E4%B8%AD%E5%B5%8C%E5%85%A5sdl2/","section":"Posts","summary":"这里说一下如何将SDL编译为App。参考文档是SDL源码下的docs/README-android.md。\n前期准备 # 需要准备：\nandroid sdk工具和ndk工具 JDK8（更高的应该也可以） SDL2的源代码（这里使用的是SDL2_2.0.14版本） make，CMake（如果你用的Android Studio或者Gradle就不需要CMake和make） Gradle（最新版本就行，我这里是7.2） ","title":"在Android中嵌入SDL2","type":"posts"},{"content":"本文简述了shared_ptr aliasing constructor。\n这是shared_ptr的一种独特的用法，主要是这个构造函数：\ntemplate\u0026lt; class Y \u0026gt; shared_ptr( const shared_ptr\u0026lt;Y\u0026gt;\u0026amp; r, element_type* ptr ) noexcept; 这里第一个参数是个shared_ptr，第二个参数是个raw pointer。\n官方最这个用法的解释是：\nAdditionally, shared_ptr objects can share ownership over a pointer while at the same time pointing to another object. This ability is known as aliasing (see constructors), and is commonly used to point to member objects while owning the object they belong to. Because of this, a shared_ptr may relate to two pointers:\nA stored pointer, which is the pointer it is said to point to, and the one it dereferences with operator. An owned pointer (possibly shared), which is the pointer the ownership group is in charge of deleting at some point, and for which it counts as a use.\nGenerally, the stored pointer and the owned pointer refer to the same object, but alias shared_ptr objects (those constructed with the alias constructor and their copies) may refer to different objects. 翻译过来就是：\n另外，shared_ptr对象可以共享指针的所有权，同时指向另一个对象。这种能力被称为别名(参见构造函数)，通常用于在拥有成员对象时指向它们所属的对象。因此，shared_ptr可能与两个指针相关:\n一个存储的指针，也就是它要指向的指针，它使用操作符*进行解引用。 拥有的指针(可能是共享的)，它是所有权组在某些时候负责删除的指针，并将其视为使用。 通常，存储的指针和拥有的指针引用相同的对象，但别名shared_ptr对象(使用别名构造函数及其副本构造的对象)可能引用不同的对象。 这里的意思是，新的shared_ptr指向r，负责r指向对象的内存管理。但是他不会管理ptr，但他使用的时候却被暴露成ptr。\n给个例子：\nstruct Value { int value = 0; }; int main(int argc, char** argv) { auto valuePtr = std::make_shared\u0026lt;Value\u0026gt;(); std::shared_ptr\u0026lt;int\u0026gt; valueAliasing(valuePtr, \u0026amp;valuePtr-\u0026gt;value); std::cout \u0026lt;\u0026lt; valuePtr.use_count() \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; valueAliasing.use_count() \u0026lt;\u0026lt; std::endl; auto anotherPtr = valuePtr; std::cout \u0026lt;\u0026lt; valuePtr.use_count() \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; valueAliasing.use_count() \u0026lt;\u0026lt; std::endl; valuePtr-\u0026gt;value = 12; std::cout \u0026lt;\u0026lt; *valueAliasing \u0026lt;\u0026lt; std::endl; valueAliasing.reset(); std::cout \u0026lt;\u0026lt; valuePtr.use_count() \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; valueAliasing.use_count() \u0026lt;\u0026lt; std::endl; return 0; } // 结果 2 2 3 3 12 2 0 这说明valueAliasing是管理valuePtr的，并且同时也会增加减少valuePtr的ref-counnt。valueAliasing是管不着value的，value是由valuePtr管理的。但是valueAliasing本身又是被视为value 。\n其实就是变项延长value的生命周期。\n","date":"8 October 2021","externalUrl":null,"permalink":"/posts/shared-ptr-aliasing-constructor/","section":"Posts","summary":"本文简述了shared_ptr aliasing constructor。\n","title":"shared_ptr aliasing constructor","type":"posts"},{"content":" 这几天做游戏的时候要用到GUI。但是SDL2本身是没有GUI组件，所以得自己做一个。\n上面的视频就是目前完成的东西，有窗口，滚动条，按钮，Label和输入框。因为在输入框这里卡了很久，所以打算写一篇博客记录一下如何实现。\n封装UTF8 String # 首先要注意的是，SDL2中用户输入的字符是UTF8类型，所以我们需要首先封装一份针对UTF8编码的string。\n关于UTF8编码的说明可以参考这篇文章，这里直接把实现的代码给出来了encoding.hpp,encoding.cpp。\n简单介绍一下utf8string，它的接口仿照std::string，可以将std::string，char*中的每个UTF8编码抽取出来独立存储。这样utf8string的基本字符就是UTF8字符，方便我们对每一个UTF8字符进行操作。\n为什么不使用Unicode # Unicode以16位为一个字符，会涉及到大小端问题（高8位和低8位的顺序）。而UTF8则和大小端无关，转换起来较为方便。\n为什么不使用wstring # wstring中的wchar_t的位数最低是16位，具体由编译器决定，这意味着没有办法保证wchar_t能够装下最长能到达4Byte的UTF-8编码。而且如果你用\nstd::wstring s = L\u0026#34;中文\u0026#34;; 这里的s内装载的是Unicode字符，而不是UTF8。\n当然你可以不使用UTF8编码而使用Unicode，这样你就不需要封装一个utf8string而是使用wstring。至于将UTF8转换成Unicode，你可以借助libiconv库转换。\n我这里因为已经实现了utf8string了，所以我就用UTF8编码了。\n实现InputBox # 注：以下所有代码都是将我工程中的代码进行简化的结果（去除了不相干的类和函数），所以可能无法直接运行，这里主要是要有代码才好说明。\n首先来看看InputBox类的声明：\nclass InputBox { public: InputBox(int id, int lenPixel); ~InputBox(); void EventHandle(const SDL_Event\u0026amp;); void Render() override; private: utf8string text_; // encoding.hpp 中的对UTF8操作的string SDL_Texture* textTexture_ = nullptr; SDL_Rect rect_; // InputBox所在的矩形 /* Black和White是预先定义的`constexpr SDL_Color`， 分别是黑色(0, 0, 0, 255)，和白色(255, 255, 255, 255) */ SDL_Color borderColor_ = Black; SDL_Color bgColor_ = White; SDL_Color textColor_ = Black; int cursor_ = 0; // 当前光标在哪个字符上 int lineX_ = 0; // 当前光标应该绘制的x坐标（相对于InputBox） // 这三个函数后面会解释 void tryGenTextures(); void resetTextTexture(); int getUTF8FontWidth(TTF_Font* font, const utf8string::OneUTF8\u0026amp; c); }; 整个的难点和解决方法在于：\nSDL2是如何接收输入的。这一部分很多人搞不清，官方的API文档也没怎么说。 解决方法：这里附上官方教程，后面也会提到。 SDL2接收输入的是UTF8编码，存在char*中，这样当我们按下退格键或左右移动光标时，必须得判断当前光标所在的文字所占char的个数。 解决方法：使用utf8string 判断当前是否在使用输入法条。如果在使用输入法条，那么退格键和左右键将不能删除输入框中的字符和移动光标。 输入法框的位置问题。如不调用SDL_SetTextInputRect()函数，输入法的提示框会在屏幕左上角。 解决方法：调用SDL_SetTextInputRect() SDL2的输入问题 # 想要开启SDL的文字输入，必须调用SDL_StartTextInput()函数。并且需要配合SDL_StopTextInput()进行关闭。\n开启输入后，每次你输入时，SDL根据情况都会发送两个事件：\nSDL_TEXTINPUT:成功输入文字，比如输入法在英文模式下输入英文，或者在中文模式下按下空格确认文字时。简单来说，就是当你的输入法条出现时，不会发送这个事件。只有当你在输入法条中完成了每个词的拼写，按下空格/回车时（输入法条会消失）才会触发这个事件 SDL_TEXTEDITING:编辑文字时。在输入法条中选词/编辑词时。这个时候每按下按键都会触发这个事件。 SDL_TEXTINPUT事件发生时：\nevent.text.text中将会记录下输入的字符串(UTF8编码)。 SDL_TEXTEDITING事件发生时：\nevent.edit.text中会记录你现在正在编辑的字符串 event.edit.start中会记录你输入法条的中的光标在第几个字符处。 这里我不打算绘制正在编辑的字符串，那样会将事情搞得更复杂（你可以自己尝试，不难，就是有点烦）。而且输入法条中本身就会显示正在编辑的字符串：\n基于此，就可以实现一部分EventHandle()函数了：\nvoid InputBox::EventHandle(const SDL_Event\u0026amp; e) { // 当鼠标在InputBox内并且按下了按键，我们就打开输入，否则关闭 if (e.type == SDL_MOUSEBUTTONDOWN) { SDL_Point mousePoint = {e.motion.x, e.motion.y}; if (SDL_PointInRect(\u0026amp;rect_, \u0026amp;mousePoint)) { SDL_StartTextInput(); } else { SDL_StopTextInput(); } } if (e.type == SDL_TEXTINPUT) { text_ += e.text.text; // 得益于utf8string，它会自动将std::string的UTF8转换。 } } 记录光标所在字符序号 # 当用户按下左右键时，可以移动光标。\n光标所在的字符用cursor_变量表示。cursor_ = 0表示没有字符，cursor_ = 1表示输入框内的第一个字符。\n输入字符的实现如下：\nvoid InputBox::EventHandle(const SDL_Event\u0026amp; e) { ... if (e.type == SDL_TEXTINPUT) { char* inputText = e.text.text; text_ += inputText; // 得益于utf8string，它会自动将std::string的UTF8转换。 // 在cursor_处插入字符 utf8string str(inputText); text_.insert(text_.begin() + cursor_, str); cursor_ += str.size(); } } 判断当前是否在使用输入法条 # 接下来需要实现按下左右键进行移动光标。但是这里有个问题。如果现在用户在编辑输入法条中的问题，按下左右键其实是移动了输入法条中的光标，输入框本身的光标不应该移动。\n这就导致我们必须判断用户现在是否在使用输入法框。\n方法是使用isEditing的全局变量记录是否在使用输入法框，具体的代码如下：\nif (e.type == SDL_TEXTEDITING) { if (strlen(e.edit.text) == 0) { context.isEditing = false; } else { context.isEditing = true; } } if (e.type == SDL_TEXTINPUT) { context.isEditing = false; } 当接收到SDL_TEXTEDITING时用户是在使用输入法条。接收到SDL_TEXTINPUT就是不在使用输入法条。但是注意当你将输入法条内的所有字符删掉之后，他仍然会给你发一个SDL_TEXTEDITING，这个时候其text的长度为0，所以还要对这一点进行判断。\n进行光标的移动和退格键删除字符 # 这一点就比较容易了：\nvoid InputBox::EventHandle(const SDL_Event\u0026amp; e) { ... if (type == KEYDOWN) { const auto\u0026amp; key = e.key.keysym.sym; if (key == SDLK_BACKSPACE \u0026amp;\u0026amp; !isEditing) { // 按下退格删除字符 if (cursor_ != 0) { text_.erase(text_.begin() + (-- cursor_)); } } if (!isEditing) { if (key == SDLK_LEFT) { // 按下左键左移光标 if (cursor_ \u0026gt; 0) { cursor_ --; } } if (key == SDLK_RIGHT) { // 按下右键右移光标 if (cursor_ \u0026lt; text_.size()) { cursor_ ++; } } } } } 绘制光标的注意事项 # 需要注意的是，如果你绘制文字的字体不是等宽字体的话，中文和英文的宽度是不一样的，这意味着不能简单地通过如下代码得到光标的位置：\nint cursorLineX = rect.x + cursor_ * FONT_PT; 一般而言，中文的宽度是英文的两倍。但是这里我提出一种通用的解决方法，就是使用lineX_变量记录当光标的X坐标，每次移动光标，删除/增加字符的时候都判断一下修改的字符所占的Pixel，然后修改lineX_。\n这里可以通过TTF_SizeUTF8()来判断字符串生成图像的大小，而不需要真正的生成图像。\nvoid InputBox::EventHandle(const SDL_Event\u0026amp; e) { ... if (e.type == SDL_TEXTINPUT) { char* inputText = e.text.text; // 得到字符的宽度 int w; TTF_SizeUTF8(font, inputText, \u0026amp;w, nullptr); lineX_ += w; text_ += inputText; // 得益于utf8string，它会自动将std::string的UTF8转换。 // 在cursor_处插入字符 utf8string str(inputText); text_.insert(text_.begin() + cursor_, str); cursor_ += str.size(); } if (type == KEYDOWN) { const auto\u0026amp; key = e.key.keysym.sym; if (key == SDLK_BACKSPACE \u0026amp;\u0026amp; !isEditing) { // 按下退格删除字符 if (cursor_ != 0) { // 减去删除字符的长度 lineX_ -= getUTF8FontWidth(GuiContext.font, text_[-- cursor_]); text_.erase(text_.begin() + cursor_); } } if (!isEditing) { if (key == SDLK_LEFT) { // 按下左键左移光标 if (cursor_ \u0026gt; 0) { // 减去左边字符的长度 lineX_ -= getUTF8FontWidth(GuiContext.font, text_[-- cursor_]); } } if (key == SDLK_RIGHT) { // 按下右键右移光标 if (cursor_ \u0026lt; text_.size()) { // 增加右边字符的长度 lineX_ += getUTF8FontWidth(GuiContext.font, text_[cursor_ ++]); } } } } } 而这里的getUTF8FontWidth()的实现如下：\n// OneUTF8是std::array\u0026lt;char, 4\u0026gt;，是utf8string的基本单位。 int InputBox::getUTF8FontWidth(TTF_Font* font, const utf8string::OneUTF8\u0026amp; c) { auto str = UTF8ToString(c); // 将UTF8转化为std::string，在`encoding.hpp`中有声明 int w; TTF_SizeUTF8(font, str.c_str(), \u0026amp;w, nullptr); // 得到宽度 return w; } 让输入法条在输入框的下方 # 如果不使用SDL_SetTextInputRect()函数，输入法条将会出现在左上方。这里只要将输入框的rect_传给他就行了。\n","date":"21 September 2021","externalUrl":null,"permalink":"/posts/%E5%9C%A8sdl%E4%B8%AD%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E8%BE%93%E5%85%A5%E6%A1%86/","section":"Posts","summary":"","title":"在SDL中实现一个输入框","type":"posts"},{"content":"今天看《C++ Templates》中遇到的，一开始没看懂，就探究一下。\n参考cppreference pointer\n指向非静态的成员变量的指针 # C++中有专门对指向非静态成员变量指针的类型定义：S C::*，其中S是成员变量的类型，C是类名称。\n比如如下代码：\nstruct Node { int value; Node* left; Node* right; Node(int value): value(value), left(nullptr), right(nullptr) {} }; 那么int Node::*就是可以指向value的指针，而Node* Node::*就是可以指向left和right的指针。\n有了指针，我们可以通过成员指针访问运算符 .*和-\u0026gt;*来访问：\nint Node::* value = \u0026amp;Node::value; Node* Node::* left = \u0026amp;Node::left; Node* Node::* right = \u0026amp;Node::right; Node* root = new Node(1); root-\u0026gt;left = new Node(2); root-\u0026gt;right = new Node(3); cout \u0026lt;\u0026lt; root-\u0026gt;*left-\u0026gt;*value \u0026lt;\u0026lt; endl; // 输出root的左节点的值，是2 这也能在继承中生效，指向父类的成员指针，如果用子类去调用，会隐式转换到子类指针：\nclass Parent { int value; }; class Child: public Parent {}; int Parent::* valuePtr = \u0026amp;Parent::value; Child child; child.value = 1; cout \u0026lt;\u0026lt; child.*valuePtr \u0026lt;\u0026lt; endl; // 输出1 从子类到父类的转换可以通过static_cast或者显示转换，转换总能成功。如果父类不存在对应的成员，调用时会产生未定义行为。\n最玄幻的是，你还能自己包含指向自己类成员的指针：\nstruct A { int m; int A::* p; }; // 这样声明p的指针: int A::* A::* ptr = \u0026amp;A::p; // 即(int A::*) A::* ptr 需要注意的是，\u0026amp;(S::C*)这种格式不是上述这种指针\n指向静态成员的指针 # 就和普通的指针一样，无论是指向成员变量还是函数：\nstruct A { static int value; static void Func(); }; int* p = \u0026amp;A::value; using F = void(*)(void) F p = \u0026amp;A::Func; 指向成员函数的指针 # 格式上和指向成员的变量一样：\nstruct A { void Func(); }; using F = void(A::*)(void); F f = \u0026amp;A::Func; 用法也是用.*或-\u0026gt;*:\nA a; (a.*f)(); // 调用 ","date":"12 September 2021","externalUrl":null,"permalink":"/posts/c++%E6%8C%87%E5%90%91%E6%88%90%E5%91%98%E5%8F%98%E9%87%8F%E7%9A%84%E6%8C%87%E9%92%88/","section":"Posts","summary":"今天看《C++ Templates》中遇到的，一开始没看懂，就探究一下。\n参考cppreference pointer\n","title":"C++指向成员的指针","type":"posts"},{"content":"这里介绍了传统Roguelike游戏开发中的几种处理时间消耗的算法。\n参考网站是RogueBasin\n时间消耗系统和算法 # 像Roguelike这种回合制游戏，生物每次行动都需要消耗一定量的时间，这样就必须有一个通过消耗时间确定生物行动顺序的系统。\n简单的时间管理系统(elegant time management system) # 这个系统很简单，需要一个循环链表：\nstruct Entity { int Update() { /*.. your update ..*/ } // 返回物体行动消耗的时间 int speed; int action_points; }; struct ETMSystem { std::list\u0026lt;Entity*\u0026gt; entities; // 含有物体链表 }; 因为是循环链表，我们得定义一个头节点，这里默认就在下标0处吧。\n然后这个算法的核心就是：每次看头结点的物体action_points是否大于0，如果大于0，就执行其Update()函数进行更新，并且将action_points减去消耗的时间。如果没有，就加上物体的speed，然后继续看下一个物体。 整个流程的代码大致如下：\nstruct ETMSystem { std::list\u0026lt;Entity*\u0026gt; entities; // 含有物体链表 void RegistEntity(Entity* entity) { entities.push_front(entity); // 新增加的放在头部 } void Tick() { // 这个函数在游戏主循环中被调用，每帧调用过一次 if (!entities.empty()) { auto\u0026amp; entity = entities.front(); std::rotate(entities.begin(), ++entities.begin(), entities.end()); // 这里为了方便用rotate将所有元素循环左移了一位 entity-\u0026gt;action_points += entity-\u0026gt;speed; if (entity-\u0026gt;action_points \u0026gt; 0) { entity-\u0026gt;action_points -= entity-\u0026gt;Update(); } } } }; 如果你想要给你的游戏加上动画，这里有一个锁机制可以帮助你：\nstruct ETMSystem { void RegistEntity(); void Tick(); void Lock() { lockNum++; } void Unlock() { lockNum--; } int lockNum = 0; }; // 然后在调用Tick的时候判断，如果锁上了就不掉用Tick if (etm.lockNum == 0) { etm.Tick(); } /* 每一次动画开始时都需要Lock，然后动画播放完成就Unlock */ 这种算法适合将Update函数写死在物体内的游戏。\n基于优先队列的回合管理系统(a priority queue based turn scheduling system) # 这个很简单，就是将事件推入到优先队列中，然后每次从队列中取出需要耗费时间的最短的事件去处理，并且将余下的所有事件的剩余时间全部减掉。\nstruct Event { int costTime; int id; // 为了辨别Event // 定义operator\u0026lt;，优先队列需要 bool operator\u0026lt;(const Event\u0026amp; e) { return costTime \u0026lt; e.costTime; } }; struct PriorityQueue { std::vector\u0026lt;Event\u0026gt; events; void Push(const Event\u0026amp; event) { int i = 0; while (i \u0026lt; events.size() \u0026amp;\u0026amp; events[i + 1] \u0026gt; event) { i++; } events.insert(event, events.begin() + i); } Event Pop() { Event e = events[0]; events.erase(events.begin()); return e; } void DecCostTime(int time) { for (auto\u0026amp; e : events) e.coseTime -= time; } }; struct TurnSchedule { PriorityQueue q; Event NextEvent() { Event e = q.Pop(); q.DecCostTime(e.time); return event; } }; 这里STL的优先队列不能满足我们的需求，我就写了个简单的。在每次的游戏循环中，都需要调用NextEvent()函数获得Event并处理。\n这种算法适合基于事件或者命令机制的游戏。\n","date":"14 August 2021","externalUrl":null,"permalink":"/posts/roguelike%E9%87%8C%E7%9A%84%E5%87%A0%E7%A7%8D%E6%97%B6%E9%97%B4%E7%B3%BB%E7%BB%9F/","section":"Posts","summary":"","title":"Roguelike里的几种时间系统","type":"posts"},{"content":"这里是我对何时使用智能指针的思考🤔。\n何时使用智能指针 # 智能指针一共有三个unique_ptr,shared_ptr和weak_ptr。其中weak_ptr是为了弥补shared_ptr所造成的循环指向而诞生的，使用的场景很明确，所以不予讨论。\nunique_ptr的意义是一个人独占的所有权，不可分享的所有权。而与之对立的shared_ptr则是可以共享的所有权。\n但是很多时候所有权这个东西的定义很模糊，比如说给函数式编程中的函数（不改变参数，也没有副作用）传递参数时，比如下面的sum函数：\ntemplate \u0026lt;typename T\u0026gt; T Sum(const T\u0026amp; t1, const T\u0026amp; t2); 你说我这是将t1,t2的所有权给Sum函数了，这显然说不过去。但是你说我是和Sum函数共享这两个变量，好像也不太对。这里函数对于这两个变量的使用情况应该属于借用，借一下，用完就还给你的这种。\nRust将借用这种语义放在了语言里（通过编译器来进行安全性的保证）。而函数式编程则可以通过返回值返回处理后的结果（某种意义上也是借用，先获得所有权，然后创造个新的同类型东西给你，相当于给你原来的东西进行加工）。但是在OO思想中好像没有什么方法，这个时候使用unique_ptr和shared_ptr就会显得很尴尬，所以我认为在这种所有权模糊不清的情况下应该直接使用裸指针。\n有人说啊这里直接传递智能指针的引用不就行了，那如果是这样的情况呢：\nclass Entity { public: // 这里的T应该是什么类型呢？ T GetAttackComp() const { return attackable_; } T GetDefenceComp() const { return defencable_; } T GetHealComp() const { return healable_; } private: unique_ptr\u0026lt;Component\u0026gt; attackable_; unique_ptr\u0026lt;Component\u0026gt; defencable_; unique_ptr\u0026lt;Component\u0026gt; healable_; }; // 这里的T应该是什么类型呢？ void DoAttack(T attackComp) { // do your attack } 对于ECS系统，Component应当被其Entity所拥有。这个时候我需要获得某个组件，显然我不能够返回unique_ptr\u0026lt;Component\u0026gt;，这样所有权就不再在Entity身上了。但是返回shared_ptr也是很荒唐的，因为Entity想要独占Component的所有权。\n在调用DoAttack函数时同样是面临着借用的情况。而处理这种借用的情况我总结出了以下解决方法：\n使用裸指针。因为所谓借用，就是借你的东西用一会，这个使用时间很短，使用的时间一定在变量生命周期之内的（即不会出现智能指针已经释放了而借用仍然存在的情况，如果出现这种情况，应当使用shared_ptr，因为shared_ptr就是在不知道多个操作中哪个操作何时结束的情况下用来延后指针释放的时间的）。 对于函数的借用，可以学习函数式编程，参数传递智能指针，返回值返回同样的类型。 ","date":"10 August 2021","externalUrl":null,"permalink":"/posts/%E4%BD%95%E6%97%B6%E4%BD%BF%E7%94%A8%E6%99%BA%E8%83%BD%E6%8C%87%E9%92%88/","section":"Posts","summary":"","title":"何时使用智能指针","type":"posts"},{"content":"本文介绍了如何使用emscripten来将SDL程序编译成网页。\n准备工作 # 什么是emscripten？ # emscripten是一个用于WebAssembly的一套编译工具，基于LLVM。\nWebAssembly(wasm)，即网页汇编，说白了，就是用在Web上的汇编语言。WebAssembly的优点是速度快，他可以将C/C++代码转换成wasm代码，所以能够提升不少效率。\n不过我这里主要是为了将C++代码变成网页啦，不管效率。\n安装emscripten # 安装方法见官网\n编写一个Demo # wasm的SDL和普通SDL的区别 # 编写wasm的SDL程序和平时的不太一样，我们需要在程序内部指定我们的游戏主循环，这样Web端才能帮我们更新游戏:\n// 首先包含emscripten的头文件 #include \u0026#34;emscripten.h\u0026#34; // 这是主循环的函数声明 void mainloop(); // 使用这个函数来设置主循环，这个循环是异步的 emscripten_set_main_loop(mainloop, -1, 1); 其中最主要的就是这两个函数：\nvoid emscripten_set_main_loop_arg(em_callback_func func, void* arg, int fps, int simulate_infinite_loop); void emscripten_set_main_loop(em_callback_func func, int fps, int simulate_infinite_loop); 看参数名字就知道他们是什么意思了。fps如果是负数，则使用浏览器的requestAnimationFrame机制来更新。simulate_infinite_loop总应当是1，以便于浏览器执行循环。\n如果你要给mainloop传参，就用第一个函数，别忘了改变mainloop的声明：void mainloop(void* args);\n每个程序只能有一个主循环。如果需要用其他的主循环，需要先将前面的关闭。\n有关这个函数的详细说明，请看这里\nDemo # Demo的文件在这里\n编译的方式有两种，第一种是用emscripten自带的SDL：\nem++ wasm_sdl_demo.cpp -s WASM=1 -s USE_SDL=2 -o index.html 注意输出的是html。USE_SDL=2代表要使用SDL2。\nemscripten会自己去下载对应的SDL，如果你觉得太慢的话也可以将SDL的源码自己clone下来，然后看他docs/README-emscripten.md文件来自己手动编译成wasm所需的SDL。编译的过程还挺快的。\n编译好的网页在这里Demo\n常用的编译选项 # 再看一个完整的Makefile：\nCXX = em++ SDL_DEP = -s WASM=1 -s USE_SDL=2 SDL_IMAGE_DEP = # -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS=\u0026#39;[\u0026#34;png\u0026#34;, \u0026#34;jpg\u0026#34;, \u0026#34;bmp\u0026#34;]\u0026#39; ASSET_PRELOAD = --preload-file assets SRC = $(wildcard ./*.cpp) index.html: $(SRC) $(CXX) $^ ${SDL_DEP} ${SDL_IMAGE_DEP} ${ASSET_PRELOAD} -o $@ -std=c++17 .PHONY:clean run clean: -rm index.html -rm *.wasm -rm *.js -rm *.o run: emrun --port 8080 . 想要使用其他SDL库，请使用-s USE_SDL_XXX=2。这里第三行设置了SDL_image依赖。\n第四行则是预加载资源文件，这里预加载assets资源文件夹。\n运行命令使用emrun --port 8080，它会开启一个本地服务器然后运行你的网页。如果不开启服务器直接运行网页，且你没有预加载资源，那么资源可能无法被读取。\n将网页放到Hexo上 # 这里有个坑，但不是所有的主题都是这样：在发布页面的时候请静止Hexo渲染含有wasm的页面，不然wasm会失效。\n另一个完整的小游戏 # 舞狮是一个完整的小游戏，你可以将此作为参考。源码下载\n","date":"2 August 2021","externalUrl":null,"permalink":"/posts/%E5%B0%86sdl%E7%A8%8B%E5%BA%8F%E5%8F%98%E4%B8%BA%E7%BD%91%E9%A1%B5%E4%BD%BF%E7%94%A8emscripten/","section":"Posts","summary":"","title":"将SDL程序变为网页（使用emscripten）","type":"posts"},{"content":"本文介绍了如何对UTF-8编码的字符串进行Warp。\nText Warp是游戏和GUI开发中不可或缺的一个技术，说白了就是自动换行：给定一个长度，当你渲染的字符串超过这个长度时需要自动从下一行开始渲染。\n对ASCII码组成的字符串进行Warp很简单，但是对于UTF-8的编码就会麻烦一点。\n首先要了解UTF-8编码的编码方式。它是不定长编码，编码方式如下：\n字符所在的十六进制区间 编码方式 0x0000 0000 - 0x0000 007F 0xxx xxxx 0x0000 0080 - 0x0000 07FF 110x xxxx 10xx xxxx 0x0000 0800 - 0x0000 FFFF 1110 xxxx 10xx xxxx 10xx xxxx 0x0001 0000 - 0x0010 FFFF 1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx 第一个区间就是普通的ASCII码，因为ASCII码总是小于127，即最高位是空着的，所以就使用最高位是否为1来表示编码是否为UTF-8.\n举个栗子，“中”字的UTF-8码是\nE4 B8 AD 转换成二进制就是\n1110 0100 1011 1000 1010 1101 这个长度是表格中的第三行（其实只要看最左边4位就可以判断），我们从中将所有的标志位去掉，就是：\n1110 0100 1011 1000 1010 1101 去掉标志位： 0100 11 1000 10 1101 = 0100 1110 0010 1101 = 0x8F2B 显然0x8F2B位于区间0x0800 - 0xFFFF中，这就验证成功了。\n有了这些基本知识，我们就可以从一串字符串中得到UTF-8编码了：\n// 这个函数从str的idx位置处解析一个UTF-8字并返回 std::string ParseOneUTF8(const std::string\u0026amp; str, int idx) { std::string result; if ((str[idx] \u0026amp; 0xF0) == 0xF0) { result.push_back(str[idx++]); result.push_back(str[idx++]); result.push_back(str[idx++]); result.push_back(str[idx++]); } else if ((str[idx] \u0026amp; 0xE0) == 0xE0) { result.push_back(str[idx++]); result.push_back(str[idx++]); result.push_back(str[idx++]); } else if ((str[idx] \u0026amp; 0xC0) == 0xC0) { result.push_back(str[idx++]); result.push_back(str[idx++]); } else { result.push_back(str[idx++]); } return result; } 这样Text Warp也就好做了:\n// 这里最后的参数是表示一行的最大字符数 std::vector\u0026lt;std::string\u0026gt; WarpUTF8(const std::string\u0026amp; str, int maxCharNum) { int size = 0; int idx = 0; std::vector\u0026lt;std::string\u0026gt; result; std::string line; while (idx \u0026lt; str.length()) { auto utf8Char = ParseOneUTF8(str, idx); line += utf8Char; idx += utf8Char.length(); size++; if (size \u0026gt;= maxCharNum) { result.push_back(line); line.clear(); size = 0; } } if (!line.empty()) result.push_back(line); return result; } Unicode也是一样，只要找到了其编码方式就可以解析。\n关于编码，推荐个B站视频：\n{% bilicard BV1gZ4y1x7p7 %}\n","date":"25 July 2021","externalUrl":null,"permalink":"/posts/text-warp-for-utf-8/","section":"Posts","summary":"","title":"Text Warp (for UTF-8)","type":"posts"},{"content":"这里是本博客安装的一些Hexo插件，主要是记录下来要怎么用和一些坑，防止后面忘了。\nhexo-github # 吧github仓库上面某一个提交的时间线拉出来：\n{% github VisualGMQ TinyRenderer3D 00fcea51034d52f2e8ffd7ad63949fba654a2039 %}\nhexo-filter-optimize # 可以加快打开博客页面的速度，确实，安了后秒开。就是每次开新页面会闪一下。。。\nhexo-admonition # !!! warning 这是警告 这是警告的内容\n用空行结束这个内容 这里是普通文字\nhexo-bilibili-card # 增加Bilibili卡片：\n{% bilicard BV1kX4y1K7Sa %}\nhexo-bilibili-bangumi # 可以给网站增加一个追番列表。当自己的追番列表变化了之后要用\nhexo bangumi -u 进行本地的数据更新\nhexo-filter-flowchart # 制作一些流程图:\nst=\u0026gt;start: 开始 inputA=\u0026gt;inputoutput: 输入用户名密码 opA=\u0026gt;operation: 数据库查询子类 conditionA=\u0026gt;condition: 是否有此用户 conditionB=\u0026gt;condition: 密码是否正确 opB=\u0026gt;operation: 读入用户信息 e=\u0026gt;end: 登录 st-\u0026gt;inputA-\u0026gt;opA-\u0026gt;conditionA conditionA(yes)-\u0026gt;conditionB conditionA(no)-\u0026gt;inputA conditionB(yes)-\u0026gt;opB-\u0026gt;e conditionB(no)-\u0026gt;inputA hexo-filter-sequence # 可以制作一些UML图：\nAlice-\u0026gt;Bob: Hello Bob, how are you? Note right of Bob: Bob thinks Bob--\u0026gt;Alice: I am good thanks! ","date":"14 July 2021","externalUrl":null,"permalink":"/posts/%E5%AE%89%E8%A3%85%E7%9A%84%E4%B8%80%E4%BA%9Bhexo%E6%8F%92%E4%BB%B6%E7%94%A8%E6%B3%95%E5%A4%87%E5%BF%98/","section":"Posts","summary":"","title":"安装的一些Hexo插件（用法备忘）","type":"posts"},{"content":"对于Bresenham算法的解释\nBresenham算法 # Bresenham算法是一种绘制直线和曲线的方法。这里主要是介绍如何绘制直线。\n带有浮点数的Bresenham算法 # 首先我们讨论直线斜率在$[0, 1]$之间的情况：\nBresenham的思想是这样：\n因为计算机是基于像素的，所以将每个像素视为一个格子，并且理想直线从起点到终点，穿过这些格子。\n这里由于格子的长度为1，所以黄色所标记的那一段是斜率$k$。\n然后定义直线和每个格子的竖直方向交点到下面格子的距离是$d$。\nBresenham的算法思想就是通过判断$d$的值来判断下一个点是取直线上方的点还是下方的点。\n如果$d \u0026gt;= 0.5$，那么我们要取直线上方的点。如果$d \u0026lt; 0.5$那么我们要取直线下方的点。\n而且显然，在起点处的$d_0 = 0$，然后我们每次迭代的时候令$d_{i+1} = d_i + k$，这样就可以获取到下一个交点到起点所在横线的距离。但是$d$是可能大于1的，所以在$d$大于等于1的时候我们要减去一，这样我们就有了如下的递推式：\n$$ \\begin{matrix} d_0 = 0 \\\\ d_{i+1} = d_i + k \\\\ 如果 d_i \u003e= 1 那么 d_i = d_i - 1 \\end{matrix} $$y的变化情况就是这样：\n$$ y = \\begin{cases} y + 1, d_i \u003e= 0.5 \\\\ y, d_i \u003c 0.5 \\end{cases} $$这样我们就可以写出Bresenham的伪代码表示：\n// k在[0, 1]的Bresenham算法 void Bresenham(int x1, int y1, int x2, int y2) { float d = 0; float k = (y2 - y1) / (x2 - x1); while (x1 != x2) { plot(x1, y1); // 绘制点 x1 += 1;\t// x1向后移动一个像素点 d += k; if (d \u0026gt;= 1) d -= 1; // 计算y的下一个值 if(d \u0026gt;= 0.5) y1 += 1; } } 将所有的浮点数转换为整数 # Bresenham之所以比中点画线和DDA算法要好，就是因为它的算法内没有任何的浮点数运算。显然我们还需要改进。\n第一个改进的点就是令$e = d - 0.5$，这样的话我们只需要判断$e$是否大于等于0，如果是，则y+1，否则y不动：\n$$ y = \\begin{cases} y + 1, e \u003e= 0 \\\\ y, e \u003c 0 \\end{cases} $$初值为\n$$ e_0 = -0.5 $$递推式变成:\n$$ e_{i+1} = d_{i+1} - 0.5 = d_i - 0.5 + k = e_i + k = e_i + \\frac{y2 - y1}{x2 - x1} = e_i + \\frac{\\Delta y}{\\Delta x} $$e的更新式子:\n$$ 如果e \u003e= 0 那么 e = e-1 $$然后这里的$e_0$是-0.5这点不行，并且在迭代式中还有个可能是浮点数的k，所以我们令\n$$ e' = 2e\\Delta x $$这样就有了新的初值和递推式：\n$$ \\begin{matrix} e'_0 = 2e_0\\Delta x = 2\\times -0.5 \\times \\Delta x = -\\Delta x \\\\ e'_{i+1} = 2e_{i+1}\\Delta x = 2(e_i + k)\\Delta x = 2e_i\\Delta x + 2k\\Delta x = e_i + 2\\Delta y \\end{matrix} $$即\n$$ \\begin{matrix} e'_0 = -\\Delta x \\\\ e'_{i+1} = e_i + 2 \\Delta y \\end{matrix} $$$e\u0026rsquo;$的更新式子:\n$$ 如果e' \u003e= 0那么e' = e' - 2\\Delta x $$新的y改变规则就出来了：\n$$ y = \\begin{cases} y + 1, e' \u003e= 0 \\\\ y , e' \u003c 0 \\end{cases} $$这下所有的变量都是整数，没有任何的浮点数计算了。\n那么代码表示如下：\n// k在[0, 1]的Bresenham算法 void Bresenham(int x1, int y1, int x2, int y2) { int dx = abs(x1 - x2), dy = abs(y1 - y2); // 这里由于dx,dy只是用于e中，所以我们可以简单取正 int e = -dx; while (x1 != x2) { plot(x1, y1); x1 += 1; e += 2* dy; if (e \u0026gt;= 0) { e -= 2 * dx; y++; } } } 这样就完成了。\n推广到所有象限 # 首先，如果$\\Delta y \u0026gt; \\Delta x$，那么我们需要将式子中的$\\Delta x$和$\\Delta y$互换。\n其次是x和y的步进问题，这里我们这样：\nint sx; if (x1 \u0026gt; x2) sx = 1; else if (x1 == x2) sx = 0; else sx = -1 y也是同理，这样完整的程序就是：\nvoid Bresenham(int x1, int y1, int x2, int y2) { int dx = abs(x1 - x2), dy = abs(y1 - y2), sx = x1 \u0026gt; x2 ? 1 : x1 == x2 ? 0 : -1, sy = y1 \u0026gt; y2 ? 1 : y1 == y2 ? 0 : -1 int e; if (dx \u0026gt;= dy) e = -dx; else e = -dy; while (x1 != x2 \u0026amp;\u0026amp; y1 != y2) { plot(x1, y1) if (dx \u0026gt;= dy) { x1 += sx; e += 2 * dy; if (e \u0026gt;= 0) { e -= 2 * dx; y += sy; } } else { y1 += sy; e += 2 * dx; if (e \u0026gt;= 0) { e -= 2 * dy; x += sx;\t} } } } 完整的Nim实现在这里\n","date":"12 July 2021","externalUrl":null,"permalink":"/posts/bresenham%E7%AE%97%E6%B3%95/","section":"Posts","summary":"","title":"Bresenham算法","type":"posts"},{"content":"","date":"22 May 2021","externalUrl":null,"permalink":"/tags/game-engine/","section":"Tags","summary":"","title":"Game Engine","type":"tags"},{"content":" XenEngine是我的本科毕业设计，是一个基于OpenGL的游戏引擎，仓库在这里\n这次就来说一说我制作这个引擎的心得\n心得体会 # 这个引擎是我花费了将近20天制作的，其实也算是一个速成引擎，里面还有不少的bug。\n以前写过渲染器，但是写的比较垃圾。这次本来想把PBR和曲线绘制加进来的，但是时间实在是不够了，就写了个小的。总的来说不是很满意。\n因为之前没有写过游戏引擎，这次一开始是跟着Cherno的视频一步一步做的，前期基本就是跟着视频一步一步抄代码，毕竟我也不知道他后面会讲什么，所以不敢擅自改动，怕后面代码结构不一样自己变来变去还麻烦。但是他只讲到了2D部分，后面的3D部分是我自己独立完成的。\n不得不说大佬就是不一样，代码的结构非常的清晰，从中学到了不少。\n技术细节 # 如何跨平台和允许用户指定渲染API # 首先是关于跨平台的事情，Cherno一开始就打算让引擎跨平台，需要支持多种渲染API，并且还提供给用户自己选择API的权利。也就是说，你可以通过如下代码来指定引擎使用的API：\nRenderer::Init(RendererAPI::API::OpenGL); // 选择OpenGL Renderer::Init(RendererAPI::API::Vulkan); // 选择Vulkan Renderer::Init(RendererAPI::API::DX11); // 选择DirectX11 具体的做法是这样，首先对API进行抽象，比如顶点缓冲，索引缓冲和纹理等都做一个高层次的抽象:\nclass VertexBuffer; class IndexBuffer; class Texture; 然后让具体的API去实现这些抽象类：\nclass OpenGLVertexBuffer: public VertexBuffer {} class OpenGLIndexBuffer: public IndexBuffer {} class OpenGLTexture: public Texture {} 然后在Renderer::Init()方法中让用户指定API：\nenum class RendererAPI { None, OpenGL, Vulkan, DX11 }; class Renderer { public: static void Init(RendererAPI api) { api_type_ = api; } inline static RendererAPI GetAPI() { return api_type_; } ... private: static RendererAPI api_type_; }; 最后在抽象类中使用工厂方法，根据API的不同来创建不同的子类：\nstd::static_cast\u0026lt;VertexBuffer\u0026gt; VertexBuffer::Create() { switch (Renderer::GetAPI()) { case OpenGL: return std::make_shared\u0026lt;OpenGLVerteBuffer\u0026gt;(); case Vulkan: return std::make_shared\u0026lt;VulkanVerteBuffer\u0026gt;(); case DX11: return std::make_shared\u0026lt;DirectX11VerteBuffer\u0026gt;(); } return nullptr; } 这样就达到动态指定API的方法了。\n我在其上做了点改进，因为不是所有的平台都有OpenGL或者Vulkan的，我们需要检测平台有没有，如果没有的话就不应该让用户指定对应的API。这一点可以让CMake帮我们搞定：\nfind_package(OpenGL QUIET) # 先让CMake寻找库 add_library(XenEngine) # 添加target if (OpenGL_FOUND) # 如果找到了，我们就给一个宏定义HAS_OPENGL target_compile_options( XenEngine PUBLIC HAS_OPENGL ) endif() 有了这个宏定义我们就可以在工程中指定了：\nenum class RendererAPI { None, #ifdef HAS_OPENGL OpenGL, #endif #ifdef HAS_VULKAN Vulkan, #endif ... }; 这样就可以做到避免不存在的API指定。\n智能指针的广泛使用 # 其次让我感到新奇的是它对智能指针的广泛使用。在他的工程里面几乎没有使用过new和delete操作符，都是使用智能指针代替。\n其实我也很熟悉智能指针，只是没有这么广泛地用过，因为很难确定unique_ptr和shared_ptr的使用场景，经常要在两者之间来回切换。这一次我是见识到了智能指针的威力，以及何为“RAII”。这种不用操心内存释放的事情简直太美妙了。\n其实总的来说，shared_ptr的使用比unique_ptr广得多，毕竟有很多变量都是共享的，需要当做参数传来传去。我好像就没有用unique_ptr。\n但是智能指针也有不是很管用的情况，就是在使用ODE的时候，ODE需要在程序结束的时候调用dCloseODE()，而在调用这个函数前必须将所有的ODE引擎对象全部使用Destroy函数删掉。这就造成了一个问题：因为ODE是纯C的库，我在上面又封装了一层C++，并且仿照了Cherno的做法使用工厂函数返回shared_ptr。一旦我返回了shared_ptr，这意味着我将变量的析构交给了系统，按道理来说我是完全不需要操心的。但是由于ODE的这个特性，我不得不将每一个产生的shared_ptr记录下来，最后再ODE关闭前全部手动清除。讲真这样我还不如不用智能指针，我自己写一个管理类都好一些。\n这里顺便吐槽一下ODE：你居然自己不在关闭的时候自动释放。我怀疑他就没有追踪他分配的这些内存。。。\n着色器方面的细节 # 我之前一直为着色器的uniform变量困扰过，以冯氏光照为例，有些模型是有镜面贴图的，有些没有。我以前的做法是直接写两个着色器，一个有uniform Sampler2D specular_map，一个没有。因为不适用的uniform着色器会自动移除，所以如果你写了不用的话会在光照计算方面带来错误。\n但是显然这样非常麻烦。后面还有视差贴图和法线贴图等，不能总是每多一类贴图就多写一个着色器。\nCherno给了个很好的解决方案：如果你不适用镜面贴图，直接给一个黑色的1x1的贴图就行了。简直天才。同理，不适用的法线贴图可以给一个纯蓝色的1x1纹理。\n而且这些纹理不需要从文件读取，直接glTexImage()传给GPU就行了，美滋滋。\n用这个方法只需要写一个着色器就行了。\n对未来的展望 # 通过这次毕设，我学到了很多，也充分认识到了自己的不足。虽然是因为时间关系没有加一些新东西，但是说真话，就算加了PBR和曲线我也依然没有办法想出这么好的代码设计和处理一些疑难杂症的方法。而且我也看了别人写的引擎，比我的牛逼多了。我感觉我在计算机图形学方面还是刚刚入门。现在我越来越坚信我二战是正确的选择，我希望我能成功上岸图形学的研究生，花个两三年时间好好研究研究这方面。\n","date":"22 May 2021","externalUrl":null,"permalink":"/posts/xenengine%E5%88%B6%E4%BD%9C%E5%BF%83%E5%BE%97/","section":"Posts","summary":"","title":"XenEngine制作心得","type":"posts"},{"content":"本文介绍了如何使用宏自动创建序列化和反序列化函数的方法。\n吐槽 # 在C/C++中进行序列化向来是个令人头痛的事情。虽然有ProtoBuf的支持，但在编写小软件的时候还是不想使用庞大的ProtoBuf啊。语言本身不支持序列化的原因主要还是没有反射。虽然说可以手写编译期反射的代码，但是网上哪些花里胡哨的模板元编程代码我压根看不懂。。。\n不过我这两天找到了使用宏自动创建序列化函数的方法，使用被称为xmacro的技术。\n正文 # 什么是xmacro # X-MACRO是一种可靠维护代码或数据的并行列表的技术，其相应项必须以相同的顺序出现。它们在至少某些列表无法通过索引组成的地方（例如编译时）最有用。此类列表的示例尤其包括数组的初始化，枚举常量和函数原型的声明，语句序列和切换臂的生成等。X-MACRO的使用可以追溯到1960年代。它在现代C和C ++编程语言中仍然有用。\nX-MACRO应用程序包括两部分：\n列表元素的定义。 扩展列表以生成声明或语句的片段。 该列表由一个宏或头文件（名为LIST）定义，该文件本身不生成任何代码，而仅由一系列调用宏（通常称为“ X”）与元素的数据组成。LIST的每个扩展都在X定义之前加上一个list元素的语法。LIST的调用会为列表中的每个元素扩展X。\n这些定义可能较为难懂，没得事，看下面实现序列化代码的方法自然就懂了。\n实现序列化 # 目标 # 我们的目标是，通过编写这一段宏定义：\n#define serialize_name Student #define serialize_body \\ field_string(name, \u0026#34;VisualGMQ\u0026#34;) \\ field_int(age, 22) \\ field_double(height, 170.0) 编译器可以自动帮我们产生\n// 结构体的定义 struct Student { char* name = \u0026#34;VisualGMQ\u0026#34;; int age = 22; double height = 170.0; // 以及序列化函数（这里就先序列化成一个描述结构体的字符串，要想序列化为二进制原理是一样的） string Serialize(); }; serialize_tool.hpp # 首先我们需要定义一些工具宏，用于帮助我们定义结构体内的成员:\n#define field_int(name, ...) #define field_double(name, ...) #define field_string(name, ...) 这些宏是空宏，因为下文中我们还需要覆盖这些宏的功能。\nserialize_achieve.hpp # 在这个文件中我们来完成代码的生成部分。\n首先要想生成代码，用户必须提前定义表示结构体名称的serialize_name宏，以及结构体内部字段的serialize_body宏:\n#if !defined(serialize_name) || !defined(serialize_body) #error \u0026#34;please include serialize_tools.hpp,then define your serialize_name, serialize_body,then include this file\u0026#34; #endif 如果用户没有定义，直接报编译时错误。\n接下来就是xmacro发挥用途的时候了。xmacro其实就是通过不断地#undef和#define宏，让相同的宏在不同的地方展开成不一样的代码。\n首先我们需要对field_int(name, ...)类宏展开成int name = {__VA_ARGS__}，我们可以\n#undef field_int\t// 首先nudef掉serialize_tool.hpp中的定义 #define field_int(name, ...) int name = {__VA_ARGS__};\t// 给出新的定义 我们这边的确更改了field_int宏的含义，但是我们需要再一次使用serialize_body才能展开：\n#undef field_int #define field_int(name, ...) int name = {__VA_ARGS__}; serialize_body\t// 现在会将serialize_body宏内的所有field_int展开为变量声明。 其他的field_xxx同理。\n接下来我们需要将field_int(name, ...)展开成ss \u0026lt;\u0026lt; name 的情况，这里ss是stringstream的实例。\nstring Serialize() { // 在函数内展开 stringstream ss;\t// 创建ss #undef field_int #define field_int(name, ...) ss \u0026lt;\u0026lt; #name \u0026#34;=\u0026#34; \u0026lt;\u0026lt; name \u0026lt;\u0026lt; endl;\t// 定义展开，这里#name会将name变为字符串。 serialize_body\t// 展开serialize_body内的所有field_int } 这样序列化函数就完成了，反序列化也是同样的道理。\n最后不要忘记#undef serialize_body和#undef serialize_name以及#undef field_int，以免下一次用户定义失败。\n完整的代码 # serialize_tool.hpp:\n#define field_int(name, ...) #define field_double(name, ...) #define field_string(name, ...) serialize_achieve.hpp\n#include \u0026lt;sstream\u0026gt; #include \u0026lt;string\u0026gt; using std::stringstream; using std::string; using std::endl; // 如果用户没有定义基本的描述宏，报错 #if !defined(serialize_name) || !defined(serialize_body) #error \u0026#34;please include serialize_tools.hpp,then define your serialize_struct,then include this file\u0026#34; #endif // 开始生成 struct serialize_name { // 生成变量声明 #undef field_int #define field_int(name, ...) int name = {__VA_ARGS__}; #undef field_double #define field_double(name, ...) double name = {__VA_ARGS__}; #undef field_string #define field_string(name, ...) double name = {__VA_ARGS__}; serialize_body // 生成Serialize函数 string Serialize() { stringstream ss; #undef field_int #define field_int(name, ...) ss \u0026lt;\u0026lt; #name \u0026#34;=\u0026#34; \u0026lt;\u0026lt; name \u0026lt;\u0026lt; endl; #undef field_double #define field_double(name, ...) ss \u0026lt;\u0026lt; #name \u0026#34;=\u0026#34; \u0026lt;\u0026lt; name \u0026lt;\u0026lt; endl; #undef field_string #define field_string(name, ...) ss \u0026lt;\u0026lt; #name \u0026#34;=\u0026#34; \u0026lt;\u0026lt; name \u0026lt;\u0026lt; endl; serialize_body return ss.str(); } }; // undef 所有的宏 #undef field_int #undef field_double #undef field_string #undef serialize_name #undef serialize_body 注意这两个文件都不要加#ifndef XX,#define XX这种防止重复包含的宏。因为这个文件就是要重复包含的。\n然后是使用：\n// 产生第一个结构体 #include \u0026#34;serialize_tools.hpp\u0026#34; #define serialize_name Student #define serialize_body \\ field_string(name, \u0026#34;VisualGMQ\u0026#34;) \\ field_int(age, 22) \\ field_double(height, 170.0) \\ #include \u0026#34;serialize_achieve.hpp\u0026#34; // 产生第二个结构体 #include \u0026#34;serialize_tools.hpp\u0026#34; #define serialize_name Foo #define serialize_body \\ field_double(value, 32.0) #include \u0026#34;serialize_achieve.hpp\u0026#34; int main(int argc, char** argv) { Student student; std::cout \u0026lt;\u0026lt; student.Serialize(); Foo foo; std::cout \u0026lt;\u0026lt; foo.Serialize(); return 0; } 运行结果如下：\nname=VisualGMQ age=22 height=170 value=32 参考 # 文章-宏的高级用法：X-MACRO\n视频-C++编译期反射\n","date":"21 January 2021","externalUrl":null,"permalink":"/posts/%E5%88%A9%E7%94%A8xmacro%E7%9A%84%E5%BA%8F%E5%88%97%E5%8C%96%E5%92%8C%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/","section":"Posts","summary":"本文介绍了如何使用宏自动创建序列化和反序列化函数的方法。\n","title":"利用xmacro的序列化和反序列化","type":"posts"},{"content":"关于脚本 # 对于游戏脚本有两种解释，一般的解释是“外挂”，也就是自动刷怪等等的那种挂。一种是让脚本和游戏主体沟通，将逻辑单独拎出来的编程技术。我们这里说的不是外挂，说的是如何使用Lua语言做脚本，并且辅助我们自己的游戏主体的编程手段。\n预备知识 # 这个例子使用C++和SDL2制作游戏的主体，Lua作为游戏的脚本。另外需要知道Lua和C/C++互相通信的方法，见这篇博客\n注意事项 # 这个例子是在MacOS系统下编写的。由于用到了动态链接库的创建，如果你是Windows系统的话，需要修改动态链接库部分的代码和编译过程。\n例子的目标 # 我们的例子很简单，场上有两个方块，红色的是敌人，绿色的是玩家，玩家要保持移动不让敌人追上即可。如果追上了就会弹出游戏结束的对话框，并且关闭游戏：\n让我们开始吧！ # 首先让我们把主体的代码框架搭出来，下面的main.cpp可以创建一个800x800大小的窗体：\n//main.cpp #include \u0026lt;string\u0026gt; #include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; SDL_Window* window = nullptr; SDL_Renderer* render = nullptr; int main(int argc, char** argv){ SDL_Init(SDL_INIT_EVERYTHING); window = SDL_CreateWindow(\u0026#34;lua script\u0026#34;, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 800, SDL_WINDOW_SHOWN); render = SDL_CreateRenderer(window, -1, 0); SDL_Event event; bool isquit = false; //flag1 while(!isquit){ SDL_SetRenderDrawColor(render, 100, 100, 100, 255); SDL_RenderClear(render); while(SDL_PollEvent(\u0026amp;event)){ switch(event.type){ case SDL_QUIT: isquit = true; break; } //flag2 } //flag3 SDL_RenderPresent(render); SDL_Delay(30); } SDL_Quit(); return 0; } 这里的几个flag是我们后面要添加代码的地方。\n接下来我们要编写关于人物的类：\n//role.hpp #ifndef __ROLE_HPP__ #define __ROLE_HPP__ #include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;lua.hpp\u0026gt; //导入lua文件 #include \u0026lt;iostream\u0026gt; #include \u0026lt;string\u0026gt; #define WIDTH 50 //方块的宽度 #define HEIGHT 50 //方块的高度 using namespace std; //敌人和玩家的基类 class Role{ public: Role(int nx, int ny); //初始化和Lua交互的lua_State栈 virtual void initLua(string luafile, SDL_Renderer* render); //绘制 virtual void draw(SDL_Renderer* render); //获得X和Y坐标 int getX(); int getY(); //析构函数，我们要在这里关闭lua栈 ~Role(); protected: int x; int y; SDL_Color color; int speed; lua_State* S; }; class Player:public Role{ public: Player(int nx, int ny); //玩家需要接受按键控制，这个是处理按键的函数 void dealEvent(SDL_Event\u0026amp; event); }; class Enemy:public Role{ public: Enemy(int nx, int ny); //这个函数让敌人跟踪玩家 void follow(Role\u0026amp; role); }; #endif 接下来是实现这些类：\n#include \u0026#34;role.hpp\u0026#34; Role::Role(int nx, int ny):x(nx), y(ny){ speed = 5; S = nullptr; } int Role::getX(){ return x; } int Role::getY(){ return y; } Role::~Role(){ lua_close(S); } void Role::initLua(string luafile, SDL_Renderer* render){ //首先创建栈并且打开Lua标准库 S = luaL_newstate(); luaL_openlibs(S); //加载Lua脚本文件 int error = luaL_loadfile(S, luafile.c_str()); //判断脚本是否有效 if(error != LUA_OK){ cerr\u0026lt;\u0026lt;luafile\u0026lt;\u0026lt;\u0026#34; not found\u0026#34;\u0026lt;\u0026lt;endl; lua_close(S); return; } //调用一下初始化函数来初始化Lua栈 lua_pcall(S, 0, 0, 0); //我们的库名称叫做extlib，这里获得库 error = lua_getglobal(S, \u0026#34;extlib\u0026#34;); if(error == LUA_TNIL){ cerr\u0026lt;\u0026lt;\u0026#34;init render in lua failed\u0026#34;\u0026lt;\u0026lt;endl; return ; } //将我们用于绘图的渲染器放到extlib.render变量中 lua_pushlightuserdata(S, (void*)render); lua_setfield(S, -2, \u0026#34;render\u0026#34;); //将栈清空 lua_settop(S, 1); } void Role::draw(SDL_Renderer* render){ //找到脚本中的draw函数 int error = lua_getglobal(S, \u0026#34;draw\u0026#34;); if(error == LUA_TNIL){ cerr\u0026lt;\u0026lt;\u0026#34;not found draw() function\u0026#34;\u0026lt;\u0026lt;endl; return ; } //将参数压入栈 lua_pushinteger(S, x); lua_pushinteger(S, y); lua_pushinteger(S, WIDTH); lua_pushinteger(S, HEIGHT); lua_pushinteger(S, color.r); lua_pushinteger(S, color.g); lua_pushinteger(S, color.b); //调用draw函数 lua_pcall(S, 7, 0, 0); //清空栈 lua_settop(S, 1); } Player::Player(int nx, int ny):Role(nx, ny){ color.r = 0; color.g = 255; color.b = 0; color.a = 255; speed = 10; } void Player::dealEvent(SDL_Event\u0026amp; event){ if(event.type == SDL_KEYDOWN) switch(event.key.keysym.sym){ case SDLK_a: x -= speed; break; case SDLK_d: x += speed; break; case SDLK_w: y -= speed; break; case SDLK_s: y += speed; break; } } Enemy::Enemy(int nx, int ny):Role(nx, ny){ color.r = 255; color.g = 0; color.b = 0; color.a = 255; speed = 2; } void Enemy::follow(Role\u0026amp; role){ int dx = role.getX(); int dy = role.getY(); //找到脚本中的follow函数 int error = lua_getglobal(S, \u0026#34;follow\u0026#34;); if(error == LUA_TNIL){ cerr\u0026lt;\u0026lt;\u0026#34;follow function not in lua file\u0026#34;\u0026lt;\u0026lt;endl; return; } //将参数压入栈 lua_pushinteger(S, x); lua_pushinteger(S, y); lua_pushinteger(S, dx); lua_pushinteger(S, dy); lua_pushinteger(S, speed); //调用follow函数 lua_pcall(S, 5, 2, 0); //获得follow函数的返回值，并更新敌人的坐标 x = lua_tonumber(S, -2); y = lua_tonumber(S, -1); //清空栈 lua_settop(S, 1); } 其实调用脚本的方法很简单，就是先找到对应函数，将参数压入栈之后调用这个函数，然后再获得函数的返回值即可（如果有的话）。最后不要忘记将栈清空（如果函数没有返回值的话就不用清空，因为lua_pcall会将函数的参数和函数一起弹出栈，如果有返回值的话就要清空，因为lua栈最大只能容纳20个元素（不同机器不一样，但是都不是很大），如果返回值一直驻留在栈里面，会导致后期参数无法入栈）。\n接下来我们需要为Lua脚本编写相应的接口，让脚本文件可以调用一些绘图函数：\n//extlib.cpp #include \u0026lt;lua.hpp\u0026gt; #include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;SDL2/SDL_image.h\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; /** * @brief 在屏幕上绘制矩形 * * Lua的参数： * SDL_Renderer渲染器, x, y, w, h, r, g, b * * warning: * 我们假设所有的绘制函数里面都有渲染器render */ extern \u0026#34;C\u0026#34; int drawRect(lua_State* L){ //函数的第一个参数是渲染器，取出来 SDL_Renderer* render = (SDL_Renderer*)lua_touserdata(L, 1); //取出其他的参数 const int x = lua_tointeger(L, 2), y = lua_tointeger(L, 3), w = lua_tointeger(L, 4), h = lua_tointeger(L, 5), r = lua_tointeger(L, 6), g = lua_tointeger(L, 7), b = lua_tointeger(L, 8); //绘制矩形 SDL_Rect rect = {x, y, w, h}; SDL_SetRenderDrawColor(render, r, g, b, 255); SDL_RenderDrawRect(render, \u0026amp;rect); return 1; } //绘制实心矩形 //Lua的参数 // SDL_Renderer渲染器, x, y, w, h, r, g, b extern \u0026#34;C\u0026#34; int drawFillRect(lua_State* L){ SDL_Renderer* render = (SDL_Renderer*)lua_touserdata(L, 1); const int x = lua_tointeger(L, 2), y = lua_tointeger(L, 3), w = lua_tointeger(L, 4), h = lua_tointeger(L, 5), r = lua_tointeger(L, 6), g = lua_tointeger(L, 7), b = lua_tointeger(L, 8); SDL_Rect rect = {x, y, w, h}; SDL_SetRenderDrawColor(render, r, g, b, 255); SDL_RenderFillRect(render, \u0026amp;rect); return 1; } //注册函数到函数表 const struct luaL_Reg l[]={ {\u0026#34;drawRect\u0026#34;, drawRect}, {\u0026#34;drawFillRect\u0026#34;, drawFillRect}, {NULL, NULL} }; extern \u0026#34;C\u0026#34; int luaopen_extlib(lua_State* L){ //设置库的名称 const char* libName = \u0026#34;extlib\u0026#34;; luaL_newlib(L, l); lua_setglobal(L, libName); return 1; } 这样的话接口就定义好了。 我们需要将接口编译成动态链接库供脚本使用： g++ extlib.cpp -fPIC -shared -o extlib.so ${LUA_CONFIG} ${SDL_CONFIG} -std=c++11\n然后我们就可以编写脚本了：\n--player.lua require(\u0026#34;extlib\u0026#34;) function draw(x, y, w, h, r, g, b) extlib.drawRect(extlib.render, x, y, w, h, r, g, b); end 这里玩家的脚本，里面只有一个绘制函数。我们调用了extlib.drawRect函数来实现绘制。\n--enemy.lua require(\u0026#34;extlib\u0026#34;) require(\u0026#34;math\u0026#34;) --辅助函数，用于获得向量的大小 function getlen(x, y) return math.sqrt(x*x+y*y) end --绘制函数 function draw(x, y, w, h, r, g, b) extlib.drawFillRect(extlib.render, x, y, w, h, r, g, b) end --跟随玩家的函数。返回更新后的坐标 function follow(mx, my, dx, dy, speed) deltax = dx-mx deltay = dy-my len = getlen(deltax, deltay) vec = {x=deltax/len, y=deltay/len} return mx+vec.x*speed, my+vec.y*speed end 这样整个脚本和人物都做好了。接下来我们将这些东西整合到main函数中：\n//使用Lua作为绘图脚本的一个小例子，绘图引擎是SDL2 #include \u0026lt;lua.hpp\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;iostream\u0026gt; //导入头文件 #include \u0026#34;role.hpp\u0026#34; using namespace std; SDL_Window* window = nullptr; SDL_Renderer* render = nullptr; //声明的碰撞检函数 bool Collision(Player\u0026amp; p, Enemy\u0026amp; e){ SDL_Rect rect = {p.getX(), p.getY(), WIDTH, HEIGHT}; SDL_Point point1 = {e.getX(), e.getY()}, point2 = {e.getX()+WIDTH, e.getY()}, point3 = {e.getX()+WIDTH, e.getY()+HEIGHT}, point4 = {e.getX(), e.getY()+HEIGHT}; return SDL_PointInRect(\u0026amp;point1, \u0026amp;rect) || SDL_PointInRect(\u0026amp;point2, \u0026amp;rect) || SDL_PointInRect(\u0026amp;point3, \u0026amp;rect) || SDL_PointInRect(\u0026amp;point4, \u0026amp;rect); } int main(int argc, char** argv){ SDL_Init(SDL_INIT_EVERYTHING); window = SDL_CreateWindow(\u0026#34;lua script\u0026#34;, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 800, 800, SDL_WINDOW_SHOWN); render = SDL_CreateRenderer(window, -1, 0); SDL_Event event; bool isquit = false; //flag1 创建敌人和玩家对象，并且读入相应的脚本文件 Enemy enemy(400, 400); enemy.initLua(\u0026#34;enemy.lua\u0026#34;, render); Player player(200, 200); player.initLua(\u0026#34;player.lua\u0026#34;, render); while(!isquit){ SDL_SetRenderDrawColor(render, 100, 100, 100, 255); SDL_RenderClear(render); while(SDL_PollEvent(\u0026amp;event)){ switch(event.type){ case SDL_QUIT: isquit = true; break; } //flag2 玩家的按键监控 player.dealEvent(event); } //flag3 敌人的行动策略，以及绘制敌人和玩家 enemy.follow(player); player.draw(render); enemy.draw(render); //碰撞检测 if(Collision(player, enemy)){ SDL_ShowSimpleMessageBox(SDL_MESSAGEBOX_INFORMATION, \u0026#34;GamOver\u0026#34;, \u0026#34;你被追上了\u0026#34;, nullptr); isquit = true; } SDL_RenderPresent(render); SDL_Delay(30); } SDL_Quit(); return 0; } 这样，整个例子就写完了。\n总结 # 其实使用lua作为脚本语言很简单，只需要定义好给Lua的接口，然后在文件里调用脚本中相关的函数即可。\n","date":"14 November 2019","externalUrl":null,"permalink":"/posts/%E5%88%A9%E7%94%A8lua%E5%B5%8C%E5%85%A5%E5%BC%95%E6%93%8E%E5%81%9A%E6%B8%B8%E6%88%8F%E8%84%9A%E6%9C%AC/","section":"Posts","summary":"关于脚本 # 对于游戏脚本有两种解释，一般的解释是“外挂”，也就是自动刷怪等等的那种挂。一种是让脚本和游戏主体沟通，将逻辑单独拎出来的编程技术。我们这里说的不是外挂，说的是如何使用Lua语言做脚本，并且辅助我们自己的游戏主体的编程手段。\n预备知识 # 这个例子使用C++和SDL2制作游戏的主体，Lua作为游戏的脚本。另外需要知道Lua和C/C++互相通信的方法，见这篇博客\n","title":"利用Lua做游戏脚本","type":"posts"},{"content":"这里记录了各种好用的Linux命令\nconvert # convert工具可以转换图像的格式，包括很多的图像格式（PG, BMP, PCX, GIF, PNG, TIFF, XPM和XWD等）\n使用的方法很简单：\nconvert src.xxx dst.xxx 就可以将src.xxx转化为dst.xxx了，比如convert image.jpg image.png将image图像转化从jpg转化为png格式。\n在你使用libpng等库的时候，可能会出现这个警告：\nlibpng warning: iCCP: known incorrect sRGB profile 这个是因为你的图片里面有一些格式不对，这个时候你可以convert image.png image.png来将格式整理为最规范的。就不会报警告啦。\n这个命令甚至还可以旋转，缩放图像：\nconvert -resize 1024x768 xxx.jpg xxx1.jpg //改变成1024x768 convert -sample 50%x50% xxx.jpg xxx1.jpg //缩小为原来的一半 convert -rotate 270 sky.jpg sky-final.jpg //旋转270度 有了这个再也不用打开软件去转换图片了，直接一个命令搞定全部。\n参考 # Linux之convert命令\ntmux # 这个工具可以让一个窗口开启多个会话。\n简单来说就是，当你使用终端的时候，如果有程序在执行的时候关闭终端，那么这个程序也会被关闭。但是使用tmux可以讲程序和会话分离，即使终端窗口关闭了也可以保证程序不关闭。\n参考 # 阮一峰大神的介绍\nmusikbox # 使用ncursors库做界面的，CUI的音乐播放器。在没有GUI界面的Linux系统上十分好用，得劲的一批:\ngithub地址：github地址\n","date":"10 October 2019","externalUrl":null,"permalink":"/posts/%E5%A5%BD%E7%94%A8%E7%9A%84unix%E5%91%BD%E4%BB%A4%E8%A1%8C%E5%B7%A5%E5%85%B7/","section":"Posts","summary":"这里记录了各种好用的Linux命令\n","title":"好用的Unix命令行工具(持续更新)","type":"posts"},{"content":"在Learning-OpenGL的教程中，作者是使用stbi_image这个单头文件库来加载纹理的。当时我就在想：SDL2这么强大，自己应该也可以加载纹理数据的吧。所以就做了一些尝试。\n其实stbi_image从图片文件获得了下面的信息：\n图片的数据（纯像素数据，不包括头数据） 图片的大小 图片的通道 那我想我们也可以使用SDL2_image库来实现。\n首先把图片加载进来：\nSDL_Surface* surface1 = IMG_Load(\u0026#34;resource/Block.png\u0026#34;); 然后把数据，大小拿到手：\nint width, height, channel; width = surface1-\u0026gt;w; height = surface1-\u0026gt;h; unsigned char* data = (unsigned char*)surface1-\u0026gt;pixels; 关于通道的话，surface本身并没有存储通道，但是我们可以计算出来：\nchannel = surface1-\u0026gt;pitch/surface1-\u0026gt;w; 这个地方不清楚的请参考我的“SDL-Surface结构体详解”。\n这样所有的东西都获得了，就可以绑定纹理了。\n需要注意的是：和stbi_image库一样，载入的图片是反过来的，你需要通过一些手段把它翻转。\n","date":"7 October 2019","externalUrl":null,"permalink":"/posts/%E4%BD%BF%E7%94%A8%E7%BA%AFsdl2%E5%BA%93%E7%BB%99opengl%E7%BA%B9%E7%90%86/","section":"Posts","summary":"在Learning-OpenGL的教程中，作者是使用stbi_image这个单头文件库来加载纹理的。当时我就在想：SDL2这么强大，自己应该也可以加载纹理数据的吧。所以就做了一些尝试。\n","title":"使用纯SDL2库给OpenGL纹理","type":"posts"},{"content":"这里我们来使用SDL2从零开始制作一个基础的粒子系统。\n最后的成果像下面这样：\n基础理论 # 首先我们来看一下实现粒子系统需要哪些基础理论。 粒子系统中最基本需要三个东西：\n世界：用于对发射出来的粒子操控，产生物理运动 粒子 发射器：用于发射粒子 我们在世界中会维护一个粒子池。每次发射器需要从粒子池里面将没有发射出去的粒子拿出来发射，世界会自动计算已经发射的粒子的物理运动，并且在他们死亡的时候在此放回粒子池里面。\n每一个粒子，最基本需要一个生命值，这个生命值随着时间而减少。当减少到0的时候就是粒子死亡的时候，这个时候粒子需要回到粒子池里面。\n这里让世界控制粒子而不是发射器控制粒子，首先方便了管理：所有的粒子都在粒子池里面，而不是零散的分散在发射器中。其次如果发射器被销毁了，其发射过的粒子仍然可以继续运动，不会出现粒子突然消失的情况。\n实现 # 这里我们采用SDL2来实现粒子系统。 首先我们把所有的结构体全部给出来：\ntypedef struct{ int hp; /**\u0026lt; 粒子的生命值*/ SDL_Vector direct; /**\u0026lt; 粒子的发生方向*/ bool isdead; /**\u0026lt; 粒子是否死亡*/ SDL_Color color; /**\u0026lt; 粒子的颜色*/ SDL_Pointf position; /**\u0026lt; 粒子的位置*/ }_PS_Partical; typedef struct{ SDL_Vector gravity; /**\u0026lt; 重力*/ int partical_num; /**\u0026lt; 粒子池中的粒子个数*/ _PS_Partical* particals; /**\u0026lt; 粒子池*/ SDL_Renderer* render; /**\u0026lt; SDL2要求的渲染器*/ }PS_World; typedef struct{ SDL_Vector shoot_dir; /**\u0026lt; 粒子将要发射出去的方向*/ int partical_hp; /**\u0026lt; 每个粒子的生命值*/ float half_degree; /**\u0026lt; 发射口里发射中心的最大角度*/ SDL_Color color; /**\u0026lt; 粒子的颜色*/ PS_World* world; /**\u0026lt; 发射器所在的世界*/ int shoot_num; /**\u0026lt; 一次性发射出去的粒子个数*/ SDL_Point position; /**\u0026lt; 粒子发射器的位置*/ }PS_ParticalLauncher; 这里关于粒子发射器的各个参数，其实就是下图：\n这里我们不希望将粒子暴露给其他程序员，所以这里加上_表示私有的，不想要被访问。\n这里的思路是这样的：首先我们需要创造一个世界，然后需要创造一个粒子发射器。粒子发射器会从世界的粒子池里面找到isdead=true的粒子，设置它的属性，并且将其唤醒(isdead=false)。然后在每一帧的时候世界会遍历粒子池里面的每一个粒子，对已经被唤醒的粒子计算物理运动。\n这里有一些宏定义，先给出来：\n#define WORLD_PARTICAL_INIT_NUM 100 //当世界创建的时候粒子池里面粒子的个数 #define PARTICAL_SINK_INC 50 //每次粒子池里面粒子不够用的时候，新增加的粒子数 #define PARTICAL_R 5 //粒子的半径 #define PARTICALS_PER_DEGREE 0.15 //每1度内包含的粒子数目(你也可以改成粒子密度，但是我这里为了简单就以每度的方式定义了) 首先我们把所有的创建函数给出来：\nPS_World PS_CreateWorld(SDL_Vector gravity, SDL_Renderer* render){ //初始化随机数生成器 srand((unsigned)time(NULL)); PS_World world; //赋值属性 world.gravity = gravity; world.render = render; world.partical_num = WORLD_PARTICAL_INIT_NUM; world.particals = (_PS_Partical*)malloc(sizeof(_PS_Partical)*WORLD_PARTICAL_INIT_NUM); //malloc粒子池 //如果malloc失败报错 if(world.particals == NULL) SDL_LogError(SDL_LOG_CATEGORY_ERROR, \u0026#34;memory not enough, world partical malloc failed!!\u0026#34;); //将粒子池里面的所有粒子设为死亡状态 for(int i=0;i\u0026lt;world.partical_num;i++) world.particals[i].isdead = true; //false和true是C99标准新增的，在头文件\u0026lt;stdbool.h\u0026gt;中 return world; } PS_ParticalLauncher PS_CreateLauncher(SDL_Point position, SDL_Vector shoot_dir, int partical_hp, float half_degree, SDL_Color color, PS_World* world, int shoot_num){ PS_ParticalLauncher launcher; //赋值属性 launcher.color = color; launcher.half_degree = half_degree; launcher.partical_hp = partical_hp; launcher.shoot_dir = shoot_dir; launcher.world = world; //根据角度计算一次性发射的粒子总数 launcher.shoot_num = (int)ceil(half_degree*2*PARTICALS_PER_DEGREE); launcher.position = position; return launcher; } 然后是一些辅助函数：\n//这个函数在粒子池不够用的时候给粒子池扩容 void _PS_IncreaseParticalSink(PS_World* world){ world-\u0026gt;particals = (_PS_Partical*)realloc(world-\u0026gt;particals, sizeof(_PS_Partical)*(world-\u0026gt;partical_num+PARTICAL_SINK_INC)); if(world-\u0026gt;particals == NULL) SDL_LogError(SDL_LOG_CATEGORY_ERROR, \u0026#34;memory not enough, partical sink realloc failed!!\u0026#34;); for(int i=world-\u0026gt;partical_num-1;i\u0026lt;world-\u0026gt;partical_num+PARTICAL_SINK_INC;i++) world-\u0026gt;particals[i].isdead = true; world-\u0026gt;partical_num += PARTICAL_SINK_INC; } //这个函数在粒子池中从idx开始寻找下一个死亡的粒子，并且返回这个粒子，将这个粒子的下标赋值给idx（idx相当于迭代器） _PS_Partical* _PS_GetNextDeadPartical(PS_World* world, int* idx){ int sum = 0; (*idx)++; if(*idx \u0026gt;= world-\u0026gt;partical_num) *idx = 0; while(world-\u0026gt;particals[*idx].isdead != true){ (*idx)++; if(*idx \u0026gt;= world-\u0026gt;partical_num) (*idx) = 0; sum++; if(sum \u0026gt;= world-\u0026gt;partical_num) break; } if(sum \u0026gt;= world-\u0026gt;partical_num) return NULL; return \u0026amp;world-\u0026gt;particals[*idx]; } //这个函数和上面的一样，只不过是找到下一个没有死亡的粒子 _PS_Partical* _PS_GetNextUndeadPartical(PS_World* world, int* idx){ int sum = 0; (*idx)++; if(*idx \u0026gt;= world-\u0026gt;partical_num) *idx = 0; while(world-\u0026gt;particals[*idx].isdead == true){ (*idx)++; if(*idx \u0026gt;= world-\u0026gt;partical_num) (*idx) = 0; sum++; if(sum \u0026gt;= world-\u0026gt;partical_num) return NULL; } return \u0026amp;world-\u0026gt;particals[*idx]; } //这个函数绘制粒子 void _PS_DrawPartical(SDL_Renderer* render, _PS_Partical* partical){ SDL_Color* color = \u0026amp;partical-\u0026gt;color; SDL_SetRenderDrawColor(render, color-\u0026gt;r, color-\u0026gt;g, color-\u0026gt;b, color-\u0026gt;a); SDL_RenderDrawCircle(render, partical-\u0026gt;position.x, partical-\u0026gt;position.y, PARTICAL_R); //这个函数是我自己封装的，SDL2本身是不带有的。绘制圆的函数。 } //绘制圆函数的实现 void SDL_RenderDrawCircle(SDL_Renderer* render, int x, int y, int r){ float angle = 0; const float delta = 5; for(int i=0;i\u0026lt;360/delta;i++){ float prevradian = Degree2Radian(angle), nextradian = Degree2Radian(angle+delta); SDL_RenderDrawLine(render, x+r*cosf(prevradian), y+r*sinf(prevradian), x+r*cosf(nextradian), y+r*sinf(nextradian)); angle += delta; } } 然后就是发射粒子和对更新世界的函数了\n//发射粒子,其实就是给粒子的各个属性赋值，然后设置isdead为false void PS_ShootPartical(PS_ParticalLauncher* launcher){ PS_World* world = launcher-\u0026gt;world; int idx = 0; //这里需要发射shoot_num个粒子 for(int i=0;i\u0026lt;launcher-\u0026gt;shoot_num;i++){ _PS_Partical* partical; //这里循环获得下一个死亡的粒子。如果返回NULL表示粒子池里面的粒子都在活动，这个时候就要扩充粒子池。 while((partical=_PS_GetNextDeadPartical(world, \u0026amp;idx))==NULL){ _PS_IncreaseParticalSink(world); } //这里对其发射的角度进行随机(在half_degree里) int randnum = rand()%(int)(2*launcher-\u0026gt;half_degree*1000+1)-(int)launcher-\u0026gt;half_degree*1000; float randdegree = randnum/1000.0f; //TODO 这个地方的赋值要不要使用指针呢？放在最后的时候优化吧 partical-\u0026gt;color = launcher-\u0026gt;color; SDL_Vector direct = Vec_Rotate(\u0026amp;launcher-\u0026gt;shoot_dir, randdegree); //旋转发射向量 partical-\u0026gt;direct = direct; partical-\u0026gt;hp = launcher-\u0026gt;partical_hp + rand()%(10+1)-5; partical-\u0026gt;isdead = false; partical-\u0026gt;position.x = launcher-\u0026gt;position.x; partical-\u0026gt;position.y = launcher-\u0026gt;position.y; } } //旋转向量的代码在这里（如果看不懂可以参考我的“游戏编程中的旋转”一文） typedef struct{ float x; float y; }SDL_Pointf; typedef SDL_Pointf SDL_Vector; inline float Degree2Radian(float degree){ return degree*M_PI/180.0f; } SDL_Vector Vec_Rotate(SDL_Vector* v, float degree){ float radian = Degree2Radian(degree); SDL_Vector ret = {cosf(radian)*v-\u0026gt;x-sinf(radian)*v-\u0026gt;y, sinf(radian)*v-\u0026gt;x+cosf(radian)*v-\u0026gt;y}; return ret; } 然后就是最重要的世界更新函数了：\nvoid PS_WorldUpdate(PS_World* world){ _PS_Partical* partical; //遍历粒子池里面每一个粒子 for(int i=0;i\u0026lt;world-\u0026gt;partical_num;i++){ partical = \u0026amp;world-\u0026gt;particals[i]; //如果是活的，就计算其下一帧的位置 if(partical-\u0026gt;isdead == false){ if(partical-\u0026gt;hp \u0026gt; 0){ partical-\u0026gt;position.x += partical-\u0026gt;direct.x+world-\u0026gt;gravity.x/2.0; partical-\u0026gt;position.y += partical-\u0026gt;direct.y+world-\u0026gt;gravity.y/2.0; _PS_DrawPartical(world-\u0026gt;render, partical); } partical-\u0026gt;hp--; } if(partical-\u0026gt;hp \u0026lt;= 0) partical-\u0026gt;isdead = true; } } 使用 # 最后给出我们的使用方式：\n#include \u0026#34;SDL.h\u0026#34; #include \u0026#34;particalSystem.h\u0026#34; #include \u0026#34;log.h\u0026#34; #define TEST_ALL int main(int argc, char** argv){ SDL_Init(SDL_INIT_EVERYTHING); SDL_Window* window; SDL_Renderer* render; SDL_CreateWindowAndRenderer(800, 800, SDL_WINDOW_SHOWN, \u0026amp;window, \u0026amp;render); SDL_Event event; bool isquit = false; SDL_Vector gravity = {0, 0}; SDL_Color color = {0, 255, 0, 255}; SDL_Color explodecolor = {255, 0, 0, 255}; SDL_Vector direct = {5, -5}; SDL_Point position = {400, 400}; SDL_Point explodePositon = {300, 300}; int partical_hp = 50; PS_World world; world = PS_CreateWorld(gravity, render); PS_ParticalLauncher launcher = PS_CreateLauncher(position, direct, partical_hp, 30, color, \u0026amp;world, 10); while(!isquit){ SDL_SetRenderDrawColor(render, 100, 100, 100, 255); SDL_RenderClear(render); while(SDL_PollEvent(\u0026amp;event)){ if(event.type == SDL_QUIT) isquit = true; if(event.type == SDL_KEYDOWN){ switch(event.key.keysym.sym){ case SDLK_SPACE: PS_Explode(\u0026amp;world, explodecolor, explodePositon, 100); break; case SDLK_d: launcher.shoot_dir = Vec_Rotate(\u0026amp;launcher.shoot_dir, 5); break; case SDLK_a: launcher.shoot_dir = Vec_Rotate(\u0026amp;launcher.shoot_dir, -5); break; case SDLK_w: launcher.partical_hp+=2; break; case SDLK_s: if(launcher.partical_hp \u0026gt; 0) launcher.partical_hp-=2; break; } } } PS_ShootPartical(\u0026amp;launcher); //发射粒子 PS_WorldUpdate(\u0026amp;world); //世界更新 SDL_SetRenderDrawColor(render, 255, 0, 0, 255); SDL_RenderDrawLine(render, launcher.position.x, launcher.position.y, launcher.position.x+launcher.shoot_dir.x*50, launcher.position.y+launcher.shoot_dir.y*50); SDL_RenderPresent(render); SDL_Delay(30); } PS_DestroyLauncher(\u0026amp;launcher); PS_DestroyWorld(\u0026amp;world); SDL_DestroyRenderer(render); SDL_DestroyWindow(window); SDL_Quit(); return 0; }","date":"3 October 2019","externalUrl":null,"permalink":"/posts/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E5%88%B6%E4%BD%9C%E4%B8%80%E4%B8%AA%E5%9F%BA%E7%A1%80%E7%B2%92%E5%AD%90%E7%B3%BB%E7%BB%9F/","section":"Posts","summary":"这里我们来使用SDL2从零开始制作一个基础的粒子系统。\n","title":"从零开始制作一个基础的粒子系统","type":"posts"},{"content":"这个库是非官方的第三方库，官网在这里。 在线API文档在这里\ngfx库简介 # SDL2_gfx库用主要用于以下三件事：\n绘制图形（包括原本SDL不直接支持的圆，椭圆，圆角矩形（实心的和空心的）等等等）和文字（很简单的英文文字） 对SDL_Surface进行变换，包括旋转，缩放 控制帧率 其实我觉得主要还是他对绘制图形方面的支持（因为SDL_Surface不是很常用了，帧率的控制我们自己几行代码也能控制）比较好。\n绘制图形 # 你可以直接跳到其API文档里面的SDL2_gfxPrimitives.h文件部分（直接跳转），会看到各种各样的绘制图形的函数。函数都是直接使用render绘制，直接指定颜色和坐标。函数太多了，而且API很详细，所以大家自己去看吧。\n需要注意的是：绘制之后，render的颜色会一直被改变。所以在使用gfx库的时候要在每次的循环前加上SDL_SetRenderDrawColor()设置清屏颜色。而且绘制字符的函数很垃圾，有必要的时候还是用SDL_ttf和SDL_FontCache库吧。\n对SDL_Surface进行变换 # 这一部分直接API文档吧:直接跳转\n控制帧率 # 首先你需要声明一个FPSmanager结构体，这个结构体里面会存储有关帧率的信息：\nFPSmanager manager; 然后你需要初始化这个结构体：\nSDL_initFramerate(\u0026amp;manager); 然后，如果有必要，你需要指定帧率（不指定默认30FPS)：\nSDL_setFramerate(\u0026amp;manager); 最后，在主循环中，不要使用SDL_Delay()函数延时，使用它给的SDL_framerateDelay()函数延时。这个函数会帮助你控制帧率：\nSDL_framerateDelay(\u0026amp;manager); 小声BB # 其实在SDL1的时候有一个GUIlib可以绘制GUI控件的，但是SDL2的时候没了（难道是还没有开发出来？？？），很遗憾啊，现在GUI要自己写了。\n","date":"22 September 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%AC%AC%E4%B8%89%E6%96%B9%E5%BA%93_sdl2_gfx/","section":"Posts","summary":"这个库是非官方的第三方库，官网在这里。 在线API文档在这里\n","title":"SDL2第三方库：SDL2_gfx","type":"posts"},{"content":"SDL2除了自己本身的SDL2库，官方和其他开发者为了补充相应的内容，给出了很多有用的库。这里就来看一下适用范围最广的SDL2_image库。\n所有的官方拓展库可以在这里找到。\nSDL2_image库 # 由于SDL2本身只有SDL_LoadBMP()函数，只能够载入位图，所以官方给出了SDL_image库，允许SDL2加载其他各种格式的图片资源。可以加载BMP, GIF, JPEG, LBM, PCX, PNG, PNM, SVG, TGA, TIFF, WEBP, XCF, XPM, XV。 其文档在这里\n使用流程 # 使用的流程十分简单：\n首先使用IMG_Init()函数初始化SDL_Image库。这里也需要像SDL_Init()函数一样给入flag。flag有IMG_INIT_JPG, IMG_INIT_PNG, IMG_INIT_WEBP, IMG_INIT_GIF。可以使用按位或来传入多个。默认可以载入位图。 使用IMG_Load()函数载入图像。 关闭SDL_image库IMG_Quit() 主要函数 # 主要的函数分为两种：载入图片的函数，判断图片函数，存储图片函数和错误处理函数。\n载入图片 # 一般最通用的函数是IMG_Load()，可以载入你在IMG_Init()里面立的flag相关的图片。 或者使用IMG_Load_RW()来从raw资源载入图片。raw资源可以通过SDL_RWFromFile()函数获得。但是一般不这样做（直接IMG_Load不就行了）。\n而且还有各种IMG_LoadXXX_RW()函数用于读取特定格式的raw资源，比如IMG_LoadPNG_RW()函数。这些函数基本上没什么用。\n判断图片资源 # 所有的判断函数都是IMG_isXXX()形式，其中XXX是你的图片格式的大写。比如IMG_isCUR(),IMG_isJPG()。所有的判断函数的参数都是SDL_RWops*，可以使用SDL_RWFromFile()获得。\n这些图片判断函数的作用在于，如果你的图片的后缀名不是平常的后缀名（比如.png被改成.xxx了），你可以使用这些函数判断你的图片的类型。\n存储图片 # 你可以使用IMG_SaveJPG(), IMG_SavePNG()函数来保存图片。对于jpg图片会有一个额外的“质量”参数。 需要注意的是存储图片的函数在文档里没有被提及，但是确实是存在的。\n错误处理 # 通过IMG_SetError(), IMG_GetError()函数获得和设置错误。\n","date":"22 September 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%AC%AC%E4%B8%89%E6%96%B9%E5%BA%93_sdl2_image/","section":"Posts","summary":"SDL2除了自己本身的SDL2库，官方和其他开发者为了补充相应的内容，给出了很多有用的库。这里就来看一下适用范围最广的SDL2_image库。\n所有的官方拓展库可以在这里找到。\n","title":"SDL2第三方库：SDL2_image库","type":"posts"},{"content":"TTF库的文档在这里。 FontCache库的github地址在这里。\nSDL2_ttf库介绍 # SDL本身是没有绘制文字的功能的。但是官方给出了SDL2_ttf拓展库，让程序员可以绘制文字。注意SDL2_ttf只能够处理ttf文件。\nSDL2_ttf库的使用流程 # 初始化库TTF_Init()，不需要参数 使用库： 使用TTF_OpenFont()函数打开一个字体文件，会返回一个TTF_Font*。 使用绘制函数TTF_RenderText_Solid()将字体转换为SDL_Surface*。 将SDL_Surface*转化为SDL_Texture*绘制到指定位置。 关闭字体描述符TTF_CloseFont()。 关闭库TTF_Close() SDL2_ttf库的使用 # 基本上可以分为三大函数：\n打开字体函数 设置/获得字体函数 将文字转换为SDL_Surface* 打开子图和设置/获得字体属性的函数就不说了，手册里面说的很清楚\n将文字转换为SDL_Surface* # ttf库是不能直接绘制的，必须将字体转换为surface。这里的转换函数大体分为：\nTTF_RenderXXX_Solid():直接转换 TTF_RenderXXX_Shaded():转换为有阴影的（质量高，效率低） TTF_RenderXXX_Blended()：使用混合转换（质量最高，效率最低） 其中XXX可以是Text, UTF8, UNICODE, Glyph，分别代表从文本，UTF8字体，Unicode字体和轮廓转换。\nSDL_FontCache库 # SDL_ttf库虽然可以绘制文字，但是非得先转化为Surface，然后再绘制。而且对字体的控制也不是很容易。这里强力推荐一个github上的库SDL_FontCache()库，十分好用。\nSDL_FontCache库的使用 # 首先使用FC_CreateFont()创建一个字体描述符FC_Font* 然后使用FC_LoadFont()载入一个字体 然后使用FC_Draw()就可以绘制了，直接绘制到目标地点哦。 使用完之后使用FC_FreeFont()释放描述符就OK了 这个库是不是相比来说很快很简单呢？而且官方说这个库比SDL2_ttf更快哦。\n","date":"22 September 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%AC%AC%E4%B8%89%E6%96%B9%E5%BA%93_sdl2-ttf%E5%92%8Csdl_fontcache/","section":"Posts","summary":"TTF库的文档在这里。 FontCache库的github地址在这里。\n","title":"SDL2第三方库：SDL2_ttf库和SDL_FontCache","type":"posts"},{"content":"Lua这个语言常常作为游戏引擎的游戏脚本，用于控制人物逻辑。一般Lua在游戏引擎中需要和其他语言进行沟通，而由于Lua是使用纯C写的语言，所以Lua当然也可以和C/C++沟通。\nC++，Lua沟通的方式 # C++和Lua沟通的方式其实是在内存中维护一个栈。需要注意的是：栈顶的index是-1，栈底的index是1：\n也就是说，在这个图里你可以通过-3和5来指定同一个元素。\n然后通过一些lua提供的API函数，Lua会将其变量，函数，表等放入栈中，然后C/C++再通过lua的API从栈中提取出来。同样C/C++也可以使用Lua的API将元素放入栈中供Lua使用。也就是说这个栈其实就是一个暂存，用于Lua和C/C++交流的通道。\nC++调用Lua # 头文件 # 首先需要知道要包含的头文件：\nlua.hpp\n这个头文件需要注意一下，如果你打开头文件，会发现里面是：\nextern \u0026#34;C\u0026#34; { #include \u0026#34;lua.h\u0026#34; #include \u0026#34;lualib.h\u0026#34; #include \u0026#34;lauxlib.h\u0026#34; } 其实是包含了三个文件。那么如果你使用的是C语言，就直接包含这三个文件就可以了。\n预备步骤 # 首先我们有如下的Lua文件：\nstr = \u0026#34;I am so cool\u0026#34; tbl = {name = \u0026#34;shun\u0026#34;, id = 20114442} function add(a,b) return a + b end 首先我们需要初始化一个内存栈来作为交流中介：\nlua_State* state = luaL_newstate(); 使用luaL_newstate()函数创建一个栈，栈的结构体就是lua_State*。\n然后需要将Lua文件和这个栈关联起来，不然怎么知道是和哪个Lua文件通信呢：\nint luafile = luaL_loadfile(state, \u0026#34;hello.lua\u0026#34;); 然后使用lua_pcall()初始化栈（或者说将luafile中的信息记录出来）：\nluafile = lua_pcall(state, 0, 0, 0); 以上就是预备步骤了。\n获得数据 # 接下来需要获得数据。获得数据的方法是使用API让Lua把数据（变量）放入栈中，然后C/C++再获得：\nlua_getglobal(state, \u0026#34;str\u0026#34;); if(lua_isstring(state, -1)) cout\u0026lt;\u0026lt;lua_tostring(state, -1)\u0026lt;\u0026lt;endl; 首先使用lua_getglobal()获得全局变量str。这个时候Lua就会把变量str的值放入栈中了。接下来使用lua_isstring()函数来判断栈顶元素是不是字符串（第二个参数是栈中元素的index，这里只有一个元素str所以直接在栈顶），如果是，则使用转换函数lua_tostring()来返回这个变量（同样第二个参数也是index）。\n需要注意的是：使用转换函数获得元素值之后是不能自动将这个元素从栈中删除的，想要删除需要使用void lua_remove (lua_State *L, int idx);来移除idx上的值\n也有其他的函数：\nint lua_gettop (lua_State *L); //返回栈顶索引（即栈长度） void lua_settop (lua_State *L, int idx); //设置栈索引 void lua_pushvalue (lua_State *L, int idx);//将idx索引上的值的副本压入栈顶 void lua_insert (lua_State *L, int idx); //弹出栈顶元素，并插入索引idx位置 void lua_replace (lua_State *L, int idx); //弹出栈顶元素，并替换索引idx位置的值 获得表的操作有点麻烦，你需要首先使用lua_getglobal()函数将表放到栈中，然后使用lua_getfield()来将里面的字段放到栈中，然后再从栈里面获得字段值：\nlua_getglobal(state, \u0026#34;tbl\u0026#34;); lua_getfield(state, -1, \u0026#34;name\u0026#34;); lua_getfield(state, -2, \u0026#34;id\u0026#34;); cout\u0026lt;\u0026lt;lua_tostring(state, -2)\u0026lt;\u0026lt;endl; cout\u0026lt;\u0026lt;lua_tonumber(state, -1)\u0026lt;\u0026lt;endl; 这里首先使用lua_getglobal()将tbl放在栈顶，然后使用lua_getfield()将tbl中的name属性入栈，再将id入栈，这样栈里面按顺序就是id, name, tbl三个值。所以这里的lua_tostring()作用在-2上。\n调用函数 # 函数不能够直接通过转换函数，只能直接调用。 首先一样先让Lua将函数放到栈顶：\nlua_getglobal(state, \u0026#34;add\u0026#34;); 然后你需要将函数要用到的参数压入栈中：\nlua_pushinteger(state, 2); lua_pushinteger(state, 7); 然后使用函数lua_call();来调用函数：\nlua_call(state, 2, 1); 这个函数第二个参数是表示这个函数需要多少个参数，第三个参数是这个函数会返回多少个参数（Lua函数可以返回多参数）。然后Lua就会从栈里面获得参数和函数，然后调用，并且将返回值压入栈：\nif(lua_isnumber(state, -1)) cout\u0026lt;\u0026lt;\u0026#34;2+7=\u0026#34;\u0026lt;\u0026lt;lua_tonumber(state, -1)\u0026lt;\u0026lt;endl; 输出当然是2+7=9。\nLua调用C++函数 # 想要在Lua中调用C/C++函数，我们可以首先将C/C++编译为链接库，然后在Lua中调用。（其实还有一种在C++代码中调用Lua，使用Lua调用C++代码的方法，但是好像没什么用，就不说了）\n首先我们需要编写一个库：\n#include \u0026lt;lua.hpp\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; //由于是C++文件，但是Lua是纯C写的，所以我们需要使用extern \u0026#34;C\u0026#34;导出成纯C的编译方式。 extern \u0026#34;C\u0026#34; int say(lua_State* L){ const char* str = luaL_checkstring(L, 1); //使用luaL_check\u0026lt;type\u0026gt;()函数从栈中获得参数 cout\u0026lt;\u0026lt;str\u0026lt;\u0026lt;endl; return 1; } extern \u0026#34;C\u0026#34; int addNum(lua_State* L){ int num1 = luaL_checknumber(L,1); int num2 = luaL_checknumber(L,2); lua_pushnumber(L, num1+num2); //这里将计算的结果作为返回值压入栈中 return 1; } //这个luaL_Reg是用来注册C/C++函数的 const struct luaL_Reg mylibs[]={ //{函数在Lua中的名称， 函数在C/C++中的名称} {\u0026#34;say\u0026#34;, say}, {\u0026#34;addNum\u0026#34;, addNum}, {NULL, NULL} //最后一对函数注册是NULL,说明已经到底了没有函数了 }; //这是Lua唯一的库函数获取函数，以luaopen_xxx来命名，在Lua中也要写require xxx extern \u0026#34;C\u0026#34; int luaopen_mylib(lua_State* L){ //下面是一系列常规步骤，将函数名称和函数压入栈中 const char* libName = \u0026#34;mylib\u0026#34;; luaL_newlib(L,mylibs); lua_pushvalue(L,-1); lua_setglobal(L,libName); return 1; } 然后要生成动态链接库mylib.so：\ng++ -bundle -undefined dynamic_lookup -o mylib.so main.cpp `pkg-config --libs --cflags lua ` -std=c++11 然后你就可以在Lua中使用这库了：\nrequire \u0026#34;mylib\u0026#34; mylib.say(\u0026#34;hello c++\u0026#34;) mylib.addNum(2,3) --输出: hello c++ 5.0 参考 # Lua与C++交互详解 Mac下Lua调用C生成的so文件\n","date":"31 August 2019","externalUrl":null,"permalink":"/posts/c++%E5%92%8Clua%E7%9A%84%E4%BA%A4%E4%BA%92/","section":"Posts","summary":"Lua这个语言常常作为游戏引擎的游戏脚本，用于控制人物逻辑。一般Lua在游戏引擎中需要和其他语言进行沟通，而由于Lua是使用纯C写的语言，所以Lua当然也可以和C/C++沟通。\n","title":"C++和Lua的交互","type":"posts"},{"content":"","date":"31 August 2019","externalUrl":null,"permalink":"/tags/lua/","section":"Tags","summary":"","title":"Lua","type":"tags"},{"content":"SDL2以来的，相对于SDL1的一大改变就是添加了SDL_Renderer和SDL_Texture，这两个结构体和对应的函数。这两个结构体用于进行软件加速以便于更快更好地绘制出图形，其使用方法也比SDL_Surface要简单（至少不用底层修改像素来绘制几何图形了）。\n其实SDL_Texture是基于SDL_Surface的，有很多东西都很相似。SDL2较SDL1的优点就是，其SDL_Texture是运行在GPU上的，SDL会视情况使用D3D或OpenGL，效率更高。\n创建 # 创建SDL_Texture有两种方法：\nSDL_CreateTexture()：直接创建一个Texture SDL_CreateTextureFromSurface()：从一个Surface上创建一个Texture 其实第二个函数底层包含了第一个函数的调用。那么我们就先从第一个函数入手看看如何创建：\nSDL_Texture* SDL_CreateTexture(SDL_Renderer* renderer, Uint32 format, int access, int w, int h) 首先需要一个SDL_Renderer*，然后需要指定format和access，format就和SDL_Surface的format-\u0026gt;format成员一样（就是RGB8A888那种），access是Texture新增加的：\nSDL_TEXTUREACCESS_STATIC:很少改变的texture SDL_TEXTUREACCESS_STREAMING:常改变的 SDL_TEXTUREACCESS_TARGET:可以被设置为render的目标的 一般都会使用第三个。 然后就是宽和高了。\n这样就可以得到一个Texture了。\n接下来你可以通过SDL_RenderDrawxxx系列函数在Texture上绘制图形（要先设定为render的目标）。如果你想要从底层走的话有这个函数：\nint SDL_UpdateTexture(SDL_Texture* texture, const SDL_Rect* rect, const void* pixels, int pitch) 这里的rect表示需要更新的大小（为NULL更新全部），然后是更新的像素信息pixels和一行的像素个数pitch（以字节计）。 通过上面这个函数，我们也可以将Surface的内容放到Texture上：\nSDL_Surface* surface = SDL_LoadBMP(\u0026#34;image.bmp\u0026#34;); SDL_Texture* texture = SDL_CreateTexture(render, surface-\u0026gt;format-\u0026gt;format, SDL_TEXTUREACCESS_TARGET|SDL_TEXTUREACCESS_STATIC, surface-\u0026gt;w, surface-\u0026gt;h); SDL_UpdateTexture(texture, nullptr, surface-\u0026gt;pixels, surface-\u0026gt;pitch); SDL_FreeSurface(surface); 上面的过程其实和函数SDL_CreateTextureFromSurface()的作用是一样的：\nSDL_Texture* SDL_CreateTextureFromSurface(SDL_Renderer* renderer, SDL_Surface* surface) 这样就从Surface到Texture了。\n其他函数操作 # 关于Texture的操作都是很简单的。\n获得Texture信息 # 首先Texture不能像Surface一样直接获得成员，所以我们有函数来获得信息：\nint SDL_QueryTexture(SDL_Texture* texture, Uint32* format, int* access, int* w, int* h) format,access,w,h都会以参数形式返回。\n设置渲染方式 # 如果不将渲染方式设置为Blender方式的话，透明色将不起作用：\nint SDL_SetRenderDrawBlendMode(SDL_Renderer* renderer, SDL_BlendMode blendMode) 其中blendMode:\nSDL_BLENDMODE_NONE:dstRGBA = srcRGBA SDL_BLENDMODE_BLEND:dstRGB = (srcRGB * srcA) + (dstRGB * (1-srcA));dstA = srcA + (dstA * (1-srcA)) SDL_BLENDMODE_ADD:dstRGB = (srcRGB * srcA) + dstRGB;dstA = dstA SDL_BLENDMODE_MOD:dstRGB = srcRGB * dstRGB;dstA = dstA 同样你可以通过SDL_GetRenderDrawBlendMode()函数来获得渲染方式。\n改变颜色和透明度 # 可以使用\nint SDL_SetTextureAlphaMod(SDL_Texture* texture, Uint8 alpha) 来设置透明度，当Texture被渲染的时候，会执行这个公式：srcA = srcA * (alpha / 255)。 这个函数可以来让透明度更加显眼。\n同理对RGB也有：\nint SDL_SetTextureColorMod(SDL_Texture* texture, Uint8 r, Uint8 g, Uint8 b) 计算公式如出一辙：srcC = srcC * (color / 255)\n这个函数可以帮助你凸显RGB三分量中的一种，比如我可以将r设置为255，将g和b设置为0: ","date":"26 August 2019","externalUrl":null,"permalink":"/posts/sdl-texture/","section":"Posts","summary":"SDL2以来的，相对于SDL1的一大改变就是添加了SDL_Renderer和SDL_Texture，这两个结构体和对应的函数。这两个结构体用于进行软件加速以便于更快更好地绘制出图形，其使用方法也比SDL_Surface要简单（至少不用底层修改像素来绘制几何图形了）。\n其实SDL_Texture是基于SDL_Surface的，有很多东西都很相似。SDL2较SDL1的优点就是，其SDL_Texture是运行在GPU上的，SDL会视情况使用D3D或OpenGL，效率更高。\n","title":"SDL2-Texture","type":"posts"},{"content":"SDL_Surface是用于存储图像，可以用于图像绘制的结构体。 这里我们来看一下SDL2中的SDL_Surface结构体和与其有关的函数操作。\nSDL_Surface结构体 # 其实操作SDL_Surface的函数有很多，而且都很简单。但是如果不先认识SDL_Surface这个结构体的话，函数上的学习会比较困难。\ntypedef struct{ SDL_PixelFormat* format; //存储着和像素有关的格式 read-only int w; //图像宽度 read-only int h; //图像高度 read-only int pitch; //pixels中一行有多少个像素（以Bytes计） read-only void* pixels; //实际的像素数据 read-write void* userdata; //用户数据，用户可以自己随意存储，读取 read-write SDL_Rect clip_rect; //裁切矩形 read-only int refcount; //引用计数，一般由SDL函数自己改变 }SDL_Surface; 本来还有Uint32 flags,int locked, void* locked_data, SDL_BlitMap* map四个属性的，但是这四个属性是SDL内部使用的，所以我们就不去在意了。\n接下来对上面列出的每一个属性进行详细的解释。\nformat # format属性存储着像素有关的格式，是一个很重要的结构体：\ntypedef struct{ Uint32 format; //这个说明了像素在内存中存储的方式 SDL_Palette* palette; //调色板（如果没有是NULL） Uint8 BitsPerPixel; //每一个像素使用多少个Bit存储 Uint8 BytesPerPixel; //每个像素使用多少个Byte存储 Uint32 Rmask; //R分量的掩码 Uint32 Gmask; //G分量的掩码 Uint32 Bmask; //A分量的掩码 Uint32 Amask; //B分量的掩码 //下面都是内部使用属性 Uint8 Rloss; Uint8 Gloss; Uint8 Bloss; Uint8 Aloss; Uint8 Rshift; Uint8 Gshift; Uint8 Bshift; Uint8 Ashift; int Refcount; SDL_PixelFormat* next; }SDL_PixelFormat; 有一些内部属性还是有用的，如果会用到的话我会说。\nformat # 首先是format属性，他的值是SDL_PixelFormatEnum枚举类型里面的一个。看了这个枚举类型相信你就知道这个属性是做什么的了，完整的在这里：\nSDL_PIXELFORMAT_RGB332 SDL_PIXELFORMAT_ARGB4444 SDL_PIXELFORMAT_RGBA8888 SDL_PIXELFORMAT_UYVY 没错这一个个枚举常量就是表示像素点在内存中的存储方式，比如我们最熟悉的SDL_PIXELFORMAT_RGBA8888就是表示本像素有四个分量R,G,B,A，并且这四个分量的存储顺序是RGBA，每个分量占8Bits。那么以此类推，SDL_PIXELFORMAT_ABGR8888就是A分量在最前面存储，R分量在最后存储。SDL_PIXELFORMAT_RGB332就是只有三个分量R，G，B，并且R占3Bits，G占3Bits，B占2Bits。 YUV颜色空间同理。\n这里还有比较特殊的SDL_PIXELFORMAT_INDEX8，其实这个是指只有RGB三分量，每个分量8位的存储方式，如果有对应的枚举类型的话应该命名为SDL_PIXELFORMAT_RGB888。\n如果你想要以人类可读的方式查看自己的surface是怎么存储图像的话，这里有一个const char* SDL_GetPixelFormatName(Uint32 format)函数，你可以把format放进去，他会返回给你一个和枚举类型一模一样名称的字符串。\npalette # 这个是调色板，也是一个枚举类型：\ntypedef struct{ int ncolors; //指出调色板里面有多少颜色 SDL_Color* colors; //颜色数组 }SDL_Palette; 调色板有说明用呢？这里还得说一下位图的存储方式。需要注意的是：SDL_Surface本身只能存储位图数据（因为其官方只给了SDL_LoadBMP()函数来加载位图，而没有函数去加载其他格式的图片），虽然有SDL_Image库，但那是第三方的不算在讨论范围内，所以我们首先得搞清楚调色板在位图中的用途。\n调色板在位图中的用途 # 位图里面也有一个称为调色板的东西，和SDL_Surface里面的很像，具体作用是这样的： 首先位图中每个像素可以由8Bits, 16Bits, 32Bits, 64Bits等长度的位存储。其中小于24位的位图需要调色板，大于等于24位的位图没有调色板（所以如果你的Surface-\u0026gt;BitsPerPixel\u0026gt;=24的话palette属性就是NULL）。之所以有调色板是为了减少存储空间设计的。\n在我们的印象中，图片应该是这样存储的（假设以RGB888格式存储）：\n这也是OpenCV这种库存储的方式：以R，G，B三种分量值的循环来存储。\n对于BitsPerPixels\u0026gt;=24的位图的确是这样存储的，但是这样存储的话每个像素点就要花费$24(或者更多)*3=72bits$。那么计算机科学家就想出，能不能有一种方法在不减少像素的情况下存储少量的数据。这个方法就是调色板。\n调色板中记录了这个图片中所有要用到的颜色（对于256色位图，就会记录256色），这也就是ncolors属性的作用。然后会将所有属性以RGBxxx(例子里面是RGB888)的方式存储在colors属性中。 然后原本的像素就不再以RGB888方式存储了，其会存储一个索引，这个索引指向colors属性中的颜色：\n这样本来一个像素点需要3*8=24位的，现在只要8位就OK了，虽然加了调色板，但是每个像素减少了3倍大小。\n所以：如果像素以24位一下存储，像素存储的是其值在调色板中的索引，如果以24位以上（包括24位）存储那么直接存储颜色数据，没有调色板\nBitsPerPixel,BytesPerPixel # 这两个属性就是表示像素点以多大的内存空间存储。\n掩码 # 掩码的话是这样的：\n如果是带有调色板的位图（24位以下），那么掩码默认为0（因为颜色值都存储在调色板中了） 如果是没有调色板的位图，那么如果想要得到其像素点存储的颜色值，需要先和掩码做逻辑和运算。以RGBA8888格式为例：假设一个像素点的值是0xEA124256，想要取出来R分量，首先和Rmask（这里是0xFF000000）做逻辑和，得到0xEA000000，然后就需要用到一些内部属性了：再将结果右移Rshift，再左移Rloss长度即可得到最后的值。也就是说整个过程可以这样写： temp = pixel \u0026amp; fmt-\u0026gt;Rmask; temp = temp \u0026raquo; fmt-\u0026gt;Rshift; temp = temp \u0026laquo; fmt-\u0026gt;Rloss; red = (Uint8)temp; ```\n宽度和高度 # 这个没什么好说的，就是图像的宽度和高度。\npitch # 这个值保存着一行有多少个像素（以Bytes计），比如你的图像是RGB888存储的，假设pitch=24，由于是24位以下的位图，所以每个像素用8位保存，也就是每个像素用1Byte保存，那么一行就是24个像素。\n因为图像虽然显示是二维的，但是在内存中保存是一维的，所以必须知道一行存储了多少像素我们才能遍历整个点。\npixels # 这个就是实际存储像素值的数组，24位以下存储调色板索引，24位以上直接存值\nuserdata # 这个用户可以自己随意放入数据或者读取数据，默认为NULL。\nclip_rect # 这个是在你使用SDL_BlitSurface()这样的绘图函数时，指定实际会绘制到目的地的图像范围。 可以使用SDL_SetClipRect()来改变，默认为全部图片.\nrefcount # 这个是引用计数，是SDL函数在对Surface操作的时候会设置，我们不用关心。\n对SDL_Surface结构体的操作 # 接下来要展示以下如何对RGB888存储的SDL_Surface进行操作。我们会在上面绘制一条线，并且将绘制了线的图像保存下来。\n首先我们要有一个符合格式的位图：\n然后我们开始操作：\n#include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; int main(int argc, char** argv){ SDL_Init(SDL_INIT_EVERYTHING); SDL_Surface* img = SDL_LoadBMP(\u0026#34;./1.bmp\u0026#34;); //这里判断图片是不是加载成功，以及是不是8位的 if(!img || img-\u0026gt;format-\u0026gt;BitsPerPixel!=8){ cerr\u0026lt;\u0026lt;\u0026#34;load 8 bits image failed\u0026#34;\u0026lt;\u0026lt;endl; return -1; } //这里创造一个和原图像一样格式的surface SDL_Surface* surface = SDL_CreateRGBSurface(img-\u0026gt;format-\u0026gt;format, img-\u0026gt;w, img-\u0026gt;h, 8, 0, 0, 0, 0); if(!surface){ cerr\u0026lt;\u0026lt;\u0026#34;create surface failed\u0026#34;\u0026lt;\u0026lt;endl; return -1; } //这里将原图像的调色板赋值给surface，不然我们的surface没有调色板没办法绘图 SDL_SetSurfacePalette(surface, img-\u0026gt;format-\u0026gt;palette); //接下来的循环遍历img中的所有点，一一赋值给surface，并且在行列相等的时候改变颜色索引 for(int i=0;i\u0026lt;img-\u0026gt;w;i++){ for(int j=0;j\u0026lt;img-\u0026gt;h;j++){ Uint8 index = *((Uint8*)img-\u0026gt;pixels + j*img-\u0026gt;pitch + i); //这里由于是8位存储，所以要转换为Uint8 if(i!=j) *((Uint8*)surface-\u0026gt;pixels + j*surface-\u0026gt;pitch + i) = index; else *((Uint8*)surface-\u0026gt;pixels + j*surface-\u0026gt;pitch +i) = 20; //改变颜色索引 } } //调用函数保存图像 SDL_SaveBMP(surface, \u0026#34;result.bmp\u0026#34;); //释放图像 SDL_FreeSurface(img); SDL_FreeSurface(surface); SDL_Quit(); return 0; } 最后保存的图像是：\n","date":"23 August 2019","externalUrl":null,"permalink":"/posts/sdl-surface%E7%BB%93%E6%9E%84%E4%BD%93%E8%AF%A6%E8%A7%A3/","section":"Posts","summary":"SDL_Surface是用于存储图像，可以用于图像绘制的结构体。 这里我们来看一下SDL2中的SDL_Surface结构体和与其有关的函数操作。\n","title":"SDL2-Surface结构体详解","type":"posts"},{"content":"SDL2中想要展示对话框有两种办法：使用SDL_ShowSimpleMessageBox()来显示一个简单的对话框，或者使用SDL_ShowMessageBox()来显示一个自定义对话框。 SDL2中只能展示模态对话框（就是如果你不点掉它你原本的窗体就不会有响应的对话框）。\nSDL_ShowSimpleMessageBox() # int SDL_ShowSimpleMessageBox(Uint32 flags, const char* title, const char* message, SDL_Window* window) flag：是一个SDL_MessageBoxFlags枚举类型，可以是SDL_MESSAGEBOX_ERROR, SDL_MESSAGEBOX_WARNING, SDL_MESSAGEBOX_INFORMATION其中的一个，用于表示错误信息，警告信息和普通信息（相应的对话框icon和按钮也会改变） title：UTF-8的对话框标题 message：UTF-8的对话框内容 window：对话框所属的父窗体 这个函数很简单，没什么其他可说的。也是为了简单产生一个对话框。\n需要注意的是：这个函数在使用SDL_Init()函数之前就可以使用了，也就是说你可以使用这个函数来显示初始化SDL或者OpenGL是否成功\nSDL_ShowMessageBox() # int SDL_ShowMessageBox(const SDL_MessageBoxData* messageboxdata, int* buttonid) 这个函数可以让你自己定义对话框，其中buttonid参数会存储最后按下的按钮的id。 这里主要需要介绍的结构体就是SDL_MessageBoxData:\ntypedef struct{ Uint32 flags; //就是上面说的SDL_MessageBoxFlags枚举类型 SDL_Window* window; //父窗口，可以是NULL const char* title; //标题 const char* message; //内容 int numbuttons; //按钮的个数 const SDL_MessageBoxButtonData* buttons; //按钮的信息 const SDL_MessageBoxColorScheme* colorScheme;//对话框颜色的信息 }SDL_MessageBoxData; 这里其他的都比较好理解，想要对对话框进行自定义主要还是SDL_MessageBoxButtonData和SDL_MessageBoxColorScheme两个结构体：\nSDL_MessageBoxButtonData typedef struct{ Uint32 flag; //是0(没有特殊含义)，SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT(按下回车等于按 下这个按钮),SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT(按下ESC键等于按下这个按钮) int buttonid; //此按钮的ID，调用函数之后会返回按下按钮的id const char* text; //按钮上的文字 }SDL_MessageBoxButtonData; typedef SDL_MessageBoxColorScheme SDL_MessageBoxColor[5]; //没错它是另一个结构体数组的别名。。。 //这个结构体就是存储颜色的结构体，只不过专门用在对话框函数上 typedef struct{ Uint8 r; Uint8 g; Uint8 b; }SDL_MessageBoxColor; 这里需要说明一下的是SDL_MessageBoxColorScheme类型，这个类型其实就是一个包含5个SDL_MessageBoxColor元素的数组。而每一个SDL_MessageBoxColor又是存储颜色的结构体。那么这5个SDL_MessageBoxColor分别代表什么呢？看下方：\n[0]:SDL_MESSAGEBOX_COLOR_BACKGROUND [1]:SDL_MESSAGEBOX_COLOR_TEXT [2]:SDL_MESSAGEBOX_COLOR_BUTTON_BORDER [3]:SDL_MESSAGEBOX_COLOR_BUTTON_BACKGROUND [4]:SDL_MESSAGEBOX_COLOR_BUTTON_SELECTED 没错分别是背景颜色，文本颜色，按钮框颜色，按钮背景颜色，按钮按下颜色五个颜色。 所以你到时候自定义就需要按照这五个值的顺序来定义颜色了。\n需要注意的是：你也可以给SDL_MessageBoxColorScheme*成员赋值NULL，这样系统会采用默认配色。而且有些系统是不能够改变配色的（比如说我的Mac。。。）\n这里就给一个官方的例子吧，比较清晰明了：\n#include \u0026#34;SDL.h\u0026#34; int main(int argc, char *argv[]) { //设置按钮 const SDL_MessageBoxButtonData buttons[] = { { /* .flags, .buttonid, .text */ 0, 0, \u0026#34;no\u0026#34; }, { SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, 1, \u0026#34;yes\u0026#34; }, { SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT, 2, \u0026#34;cancel\u0026#34; }, }; //设置对话框颜色 const SDL_MessageBoxColorScheme colorScheme = { { /* .colors (.r, .g, .b) */ /* [SDL_MESSAGEBOX_COLOR_BACKGROUND] */ { 255, 0, 0 }, /* [SDL_MESSAGEBOX_COLOR_TEXT] */ { 0, 255, 0 }, /* [SDL_MESSAGEBOX_COLOR_BUTTON_BORDER] */ { 255, 255, 0 }, /* [SDL_MESSAGEBOX_COLOR_BUTTON_BACKGROUND] */ { 0, 0, 255 }, /* [SDL_MESSAGEBOX_COLOR_BUTTON_SELECTED] */ { 255, 0, 255 } } }; //填充SDL_MessageBoxData结构体 const SDL_MessageBoxData messageboxdata = { SDL_MESSAGEBOX_INFORMATION, /* .flags */ NULL, /* .window */ \u0026#34;example message box\u0026#34;, /* .title */ \u0026#34;select a button\u0026#34;, /* .message */ SDL_arraysize(buttons), /* .numbuttons */ buttons, /* .buttons */ \u0026amp;colorScheme /* .colorScheme */ }; int buttonid; //调用函数 if (SDL_ShowMessageBox(\u0026amp;messageboxdata, \u0026amp;buttonid) \u0026lt; 0) { SDL_Log(\u0026#34;error displaying message box\u0026#34;); return 1; } if (buttonid == -1) { SDL_Log(\u0026#34;no selection\u0026#34;); } else { SDL_Log(\u0026#34;selection was %s\u0026#34;, buttons[buttonid].text); } return 0; }","date":"22 August 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%AF%B9%E8%AF%9D%E6%A1%86/","section":"Posts","summary":"SDL2中想要展示对话框有两种办法：使用SDL_ShowSimpleMessageBox()来显示一个简单的对话框，或者使用SDL_ShowMessageBox()来显示一个自定义对话框。 SDL2中只能展示模态对话框（就是如果你不点掉它你原本的窗体就不会有响应的对话框）。\n","title":"SDL2-模态对话框","type":"posts"},{"content":"在学习OpenGL的时候，或者在使用其他的3D引擎的时候总会遇到投影矩阵。这里就对投影矩阵进行推导 这里只推导透视投影，如果会了透视投影矩阵的推导平行投影矩阵其实也是一样的。\n坐标系 # 首先是坐标系。坐标系分为左手坐标系，右手坐标系： 记忆的话很简单： 首先要记住每个手指👋对应的坐标轴：拇指——x，食指——y，中指——z。 然后将三个手指互相垂直形成三个坐标轴互相垂直的情况。 然后拇指指向右边。如果是左手坐标系就用左手，右手坐标系就用右手。这样就可以得到左右手坐标系了。\n投影矩阵概念 # 之所以需要投影矩阵，是因为屏幕本身是2D的，我们需要将3D中的点投影到屏幕中，也就是将3D投影到2D中。\n透视投影是根据人的视觉成像来设计的： 而平行投影（或者称为正投影）的投影线是直的： 在游戏编程中为了模拟人眼看到的，需要用到透视投影。\n透视投影推导 # 首先来看一下透视投影的概念图： 这里后面的那个锥体就是视锥体，紫色的被称为近平面，蓝色的被称为远平面。在视锥体中的所有点都会被投影到近平面中，也就是会产生这样的映射: 这里保留了z轴的映射，为了OpenGL或者我们自己进行深度测试。\n那么要如何做到呢？推导如下： 首先定义一些值：\n近平面到原点的距离是n 远平面到原点的距离是f 然后要定义近平面的各个点的坐标： 那么这样就OK了，需要注意的是这里的$l,r,t,b$都是距离，也就是说都是正的（所以坐标上面有负数）\n然后就是公式推导了：\n首先我们需要计算在视锥体内的$p$点在近平面上的投影$p^{\u0026rsquo;}$的坐标。这很简单： 首先已经确定了$p^{\u0026rsquo;}$的$z$坐标是$n$，然后使用相似三角形可以得到：\n$$ \\frac{n}{z}=\\frac{x^{'}}{x}=\\frac{y^{'}}{y} $$ $$ \\begin{cases} x^{'}=\\frac{n}{z}x \\\\\\\\ y^{'}=\\frac{n}{z}y \\end{cases} $$然后我们需要将$x^{\u0026rsquo;},y^{\u0026rsquo;}$的值映射到[-1,1]上，由于$p^{\u0026rsquo;}$在近平面上，所以有这样的不等式：\n$$ \\begin{cases} l\\leq x^{'}\\leq t \\\\\\\\ b\\leq y^{'}\\leq t \\end{cases} $$然后变换一下：\n$$ \\begin{cases} l\\leq x^{'} \\leq r \\\\\\\\ b\\leq y^{'} \\leq t \\end{cases} \\Rightarrow \\begin{cases} 0\\leq x^{'}-l \\leq r-l \\\\\\\\ 0\\leq y^{'}-b \\leq t-b \\end{cases} \\Rightarrow \\begin{cases} 0\\leq \\frac{1}{r-l}x^{'} \\leq 1 \\\\\\\\ 0\\leq \\frac{1}{t-b}y^{'} \\leq 1 \\end{cases} \\Rightarrow \\begin{cases} 0\\leq 2\\frac{1}{r-l}x^{'} \\leq 2 \\\\\\\\ 0\\leq 2\\frac{1}{t-b}y^{1} \\leq 2 \\end{cases} \\Rightarrow \\begin{cases} -1\\leq 2\\frac{1}{r-l}-1x^{'} \\leq 1 \\\\\\\\ -1\\leq 2\\frac{1}{t-b}-1y^{'} \\leq 1 \\end{cases} \\Rightarrow $$ $$ \\begin{cases} -1\\leq \\frac{2nx}{z(r-l)}-\\frac{l+r}{r-l} \\leq 1 \\\\\\\\ -1\\leq \\frac{2ny}{z(t-b)}-\\frac{t+b}{t-b} \\leq 1 \\end{cases} \\Rightarrow \\begin{cases} -1\\leq [\\frac{2n}{r-l}x - \\frac{l+r}{r-l}z]/z \\leq 1 \\\\\\\\ -1\\leq [\\frac{2n}{t-b}y - \\frac{t+b}{t-b}z]/z \\leq 1 \\end{cases} $$这里最后两步的变换是将上面使用相似三角形得到的等式放入得到的。 最后上面的公式可以得到：\n$$ \\begin{cases} x^{''}=(\\frac{2n}{r-l}x - \\frac{l+r}{r-l}z)/z \\\\\\\\ y^{''}=(\\frac{2n}{t-b}y - \\frac{t+b}{t-b}z)/z \\end{cases} (result1) \\Rightarrow \\begin{cases} zx^{''}=\\frac{2n}{r-l}x - \\frac{l+r}{r-l}z \\\\\\\\ zy^{''}=\\frac{2n}{t-b}y - \\frac{t+b}{t-b}z \\end{cases} (result2) $$这里$x^{\u0026rsquo;\u0026rsquo;},y^{\u0026rsquo;\u0026rsquo;}$是最后映射到立方体上的点的坐标。\n现在我们得到了最后映射点的$x^{\u0026rsquo;\u0026rsquo;},y^{\u0026rsquo;\u0026rsquo;}$坐标，还差$z^{\u0026rsquo;\u0026rsquo;}$坐标。因为原本的$z$坐标和$x,y$是无关的（废话，$z$的值本身等于$n$啊），所以我们认为最后映射点的$z^{\u0026rsquo;\u0026rsquo;}$坐标也和$x^{\u0026rsquo;\u0026rsquo;},y^{\u0026rsquo;\u0026rsquo;}$无关，那么通过$x^{\u0026rsquo;\u0026rsquo;},y^{\u0026rsquo;\u0026rsquo;}$的式子，我们推测出应该有如下等式：\n$$ z^{''}z=pz+q ^* $$如何解这个式子中的$z^{\u0026rsquo;\u0026rsquo;}$呢？我们可以将近平面的距离$n$和远平面距离$f$代入求的，因为我们知道最后是将视锥体映射到单位正方体上，所以很显然$n$最后会映射到$1$处，$f$会映射到$-1$处，那么我们就有条件：\n$$ \\begin{cases} z = n \\\\\\\\ z^{''} = 1 \\end{cases} , \\begin{cases} z = f \\\\\\\\ z^{''} = -1 \\end{cases} $$那么代入上述式子$(*)$可以解得方程的结果为：\n$$ \\begin{cases} p = \\frac{n+f}{n-f} \\\\\\\\ q = -2\\frac{2nf}{n-f} \\end{cases} $$那么我们的$z^{\u0026rsquo;\u0026rsquo;}$也就知道了：\n$$ \\begin{cases} z^{''} = \\frac{n+f}{n-f}z - \\frac{2nf}{n-f} \\end{cases} $$需要注意的是：这里的n,f也是距离，所以这里n,f前面要加负号（他俩在z轴负方向上）：\n$$ zz^{''} = -\\frac{f+n}{f-n}+\\frac{2nf}{n-f} $$那么综上：\n$$ \\begin{cases} zx^{''}=\\frac{2n}{r-l}x - \\frac{l+r}{r-l}z \\\\\\\\ zy^{''}=\\frac{2n}{t-b}y - \\frac{t+b}{t-b}z \\\\\\\\ zz^{''} = -\\frac{f+n}{f-n}+\\frac{2nf}{n-f} \\end{cases} $$这是一个线性方程组，完全可以放入矩阵中：\n$$ \\begin{bmatrix} \\frac{2n}{r-l} \u0026 0 \u0026 \\frac{l+r}{l-r} \u0026 0 \\\\\\\\ \\frac{2n}{t-b} \u0026 0 \u0026 \\frac{t+b}{t-b} \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 \\frac{f+n}{n-f} \u0026 \\frac{2nf}{n-f} \\\\\\\\ 0 \u0026 0 \u0026 1 \u0026 0 \\end{bmatrix} $$这里可能有人会问为什么最后一行第三个元素是1，因为你前面的线性方程组中左边的未知数不是$x^{\u0026rsquo;\u0026rsquo;},y^{\u0026rsquo;\u0026rsquo;},z^{\u0026rsquo;\u0026rsquo;}$而是$zx^{\u0026rsquo;\u0026rsquo;},zy^{\u0026rsquo;\u0026rsquo;},zz^{\u0026rsquo;\u0026rsquo;}$，所以要多乘上一个$z$来保持原式，这个1就是这个作用。\n这里还有一个问题：不是说要将三个坐标映射到[-1,1]上吗？你上面那个结果等式左边可是$zx^{\u0026rsquo;\u0026rsquo;},zy^{\u0026rsquo;\u0026rsquo;},zz^{\u0026rsquo;\u0026rsquo;}$，这怎么映射到[-1,1]上？。其实这一步是为了OpenGL准备的。OpenGL在将点左乘完透视投影矩阵之后，会自己再将生成点的坐标除以$z$，这一步叫做透视除法。当然如果你是自己写游戏引擎，不想要透视除法的话完全可以使用result1公式，将里面的$z$变为$n$，然后继续推导。\n这里还需要注意一次**$b,l$是距离，前面要加上负号**，所以最后的矩阵为：\n$$ \\begin{bmatrix} \\frac{n}{r} \u0026 0 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 \\frac{n}{t} \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 \\frac{f+n}{n-f} \u0026 \\frac{2nf}{n-f} \\\\\\\\ 0 \u0026 0 \u0026 -1 \u0026 0 \\end{bmatrix} $$（这里最后一行变为-1是因为整个视锥体在z轴的负半轴上，除的z值是负数(也即是除以-n))\n透视矩阵的其他表示形式 # 除了使用$l,r,b,t,n,f$参数表示透视矩阵以外，一般的游戏引擎还会使用俯仰角fov，近平面距离n，远平面距离f，宽高比pro四个参数来计算投影矩阵。这也很简单： $$ \\frac{h}{2} = n\\tan{\\frac{fov}{2}} , w=pro\\*h=2npro\\tan{\\frac{fov}{2}} \\Rightarrow t = \\frac{h}{2}=n\\tan{\\frac{fov}{2}} , r = \\frac{w}{2}=n\\*pro\\*\\tan{\\frac{fov}{2}} $$代入式子中就可以得到矩阵为：\n$$ \\begin{bmatrix} \\frac{\\cot{\\frac{fov}{2}}}{pro} \u0026 0 \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 \\cot{\\frac{fov}{2}} \u0026 0 \u0026 0 \\\\\\\\ 0 \u0026 0 \u0026 \\frac{f+n}{n-f} \u0026 \\frac{2nf}{n-f} \\\\\\\\ 0 \u0026 0 \u0026 -1 \u0026 0 \\end{bmatrix} $$其实投影矩阵的表示形式很多，主要还是要学会推导\n参考 # 变换先森的博客\n","date":"10 August 2019","externalUrl":null,"permalink":"/posts/%E9%80%8F%E8%A7%86%E6%8A%95%E5%BD%B1%E7%9F%A9%E9%98%B5%E7%9A%84%E6%8E%A8%E5%AF%BC/","section":"Posts","summary":"在学习OpenGL的时候，或者在使用其他的3D引擎的时候总会遇到投影矩阵。这里就对投影矩阵进行推导 这里只推导透视投影，如果会了透视投影矩阵的推导平行投影矩阵其实也是一样的。\n","title":"透视投影矩阵的推导","type":"posts"},{"content":"OpenAL的学习笔记\nOpenAL很好的入门文章：https://blog.csdn.net/zouxy09/article/details/7893162\nOpenAL官方的API文档：http://www.openal.org/documentation/OpenAL_Programmers_Guide.pdf\nOpenAL的API很少，也很容易懂，学起来也很简单\n","date":"30 July 2019","externalUrl":null,"permalink":"/posts/openal%E7%AC%94%E8%AE%B0/","section":"Posts","summary":"","title":"OpenAL笔记","type":"posts"},{"content":"跨平台的库一直被视为减轻程序员工作负担的一大利器。那么这些程序是怎么样做到跨平台的呢。这里我分析了SDL2和Catch2的源码，发现了编写的方法。\n首先需要注意的是：以下说的方法都只在GUNC编译器下编译，其他编译器不知道能不能通过。\n识别当前的操作系统 # 首先需要知道如何识别当前的操作系统。GNUC编译器会在编译程序的时候定义平台相关的宏。你可以在SDL2和Catch2中看到如下代码：\n#if (defined(linux) || defined(__linux) || defined(__linux__)) #undef __LINUX__ #define __LINUX__ 1 #endif #if defined(ANDROID) || defined(__ANDROID__) #undef __ANDROID__ #define __ANDROID__ 1 #endif #if defined(__FreeBSD__) || defined(__FreeBSD_kernel__) || defined(__DragonFly__) #undef __FREEBSD__ #define __FREEBSD__ 1 #endif 如果你是Linux系统，那么GNUC会给你宏linux,__linux,__linux__三个宏中的一个。如果你是安卓系统，那么就会给ANDROID,__ANDROID__宏中的一个。 所以跨平台就是根据这些GNUC给出的宏来判断的。根据源码，我们可以知道GUNC会给出如下的宏：\nlinux系统:linux, __linux, __linux__ 苹果系统:__APPLE__ 苹果电脑:__MACOSX__ 苹果手机:__IPHONEOS__ 苹果电视:__TVOS__ 安卓系统:__ANDROID__ windows系统:WIN32, _WIN32, __CYGWIN__, __MINGW32__,__WINDOWS__ PSP系统:__PSP__ 需要注意的是，苹果系统GNUC只会提供__APPLE__宏。你在判断这个宏之后你还需要包含苹果的头文件AvailabilityMacros.h,TargetConditionals.h，然后继续通过__MACOSX__等宏来判断。 还有很多很多的平台识别宏。这里就不列举了。\n声明自己的宏用于方便识别当前系统 # 当你能够识别当前的操作系统时，你需要定义自己的宏来便于以后自己识别操作系统，和根据系统来进行不同的操作。比如SDL2就是这样做的。在判断是linux系统后，他会定义__LINUX__宏；判断为FreeBSD操作系统之后，会定义__FREEBSD__宏。\n通过系统宏来定义系统特定操作 # SDL2在这一点上做的很好。它定义了平台宏之后，为每一个平台写了一个config文件(SDL_config_os2.h,SDL_config_android.h等)。然后将这些头文件按照平台包含到一个总的config文件中(SDL_config.h)，之后想要利用平台相关特性就可以只包含这个总的config文件了：\n#if defined(__WIN32__) #include \u0026#34;SDL_config_windows.h\u0026#34; #elif defined(__WINRT__) #include \u0026#34;SDL_config_winrt.h\u0026#34; #elif defined(__MACOSX__) #include \u0026#34;SDL_config_macosx.h\u0026#34; #elif defined(__IPHONEOS__) #include \u0026#34;SDL_config_iphoneos.h\u0026#34; #elif defined(__ANDROID__) #include \u0026#34;SDL_config_android.h\u0026#34; #elif defined(__PSP__) #include \u0026#34;SDL_config_psp.h\u0026#34; #elif defined(__OS2__) #include \u0026#34;SDL_config_os2.h\u0026#34; #else /* This is a minimal configuration just to get SDL running on new platforms. */ #include \u0026#34;SDL_config_minimal.h\u0026#34; #endif /* platform config */ #ifdef USING_GENERATED_CONFIG_H #error Wrong SDL_config.h, check your include path? #endif 在每个系统独立config文件中，通过宏来表示“这个系统能做到什么”：\n//SDL_config_osx.h #define HAVE_STDINT_H 1 #define HAVE_LIMITS_H 1 #define HAVE_CTYPE_H 1 #define HAVE_MATH_H 1 #define HAVE_FLOAT_H 1 #define HAVE_SIGNAL_H 1 //SDL_config_windows.h #define HAVE_CTYPE_H 1 #define HAVE_FLOAT_H 1 #define HAVE_LIMITS_H 1 #define HAVE_MATH_H 1 #define HAVE_SIGNAL_H 1 #define HAVE_STDIO_H 1 #define HAVE_STRING_H 1 #define HAVE_ATOI 1 这里在Macosx中就没有ATOI函数，所以就没有定义HAVE_ATOI宏。而windows里面有了，就可以定义这个宏。 一般都会定义如下的宏：\n是否有某个头文件的，比如HAVE_MATH_H 是否有需要用到的函数，比如HAVE_MALLOC 为平台特定函数声明宏，比如HAVE_ATOI 然后你就可以通过这些宏来控制平台头文件和函数了。\n同一模块不同系统编写不同的文件 # 有了以上的准备，当我们编写平台之间差别较大的函数的时候（比如GUI界面需要用到不同系统的API），就可以为不同的平台编写不同的文件。比如SDL的文件组织如下： 这里为线程方面编写了不同系统的文件。为计时器方面编写了不同系统的文件。\n总结 # 跨平台库需要设计者知道各个系统的API，并且有很大的代码量。开发跨平台库实属不易。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/%E5%90%84%E7%A7%8D%E5%BA%93%E6%98%AF%E5%A6%82%E4%BD%95%E5%81%9A%E5%88%B0%E8%B7%A8%E5%B9%B3%E5%8F%B0%E7%9A%84/","section":"Posts","summary":"跨平台的库一直被视为减轻程序员工作负担的一大利器。那么这些程序是怎么样做到跨平台的呢。这里我分析了SDL2和Catch2的源码，发现了编写的方法。\n首先需要注意的是：以下说的方法都只在GUNC编译器下编译，其他编译器不知道能不能通过。\n","title":"第三方库是如何做到跨平台的","type":"posts"},{"content":"鼠标和光标操作 # SDL2里面专门有SDL_mouse.h头文件给出了鼠标和光标的操作。\n关于光标的操作 SDL2里面提供了很多关于光标的操作，包括创建光标，显示光标等。\n创建光标\nSDL_Cursor* SDL_CreateColorCursor(SDL_Surface* surface, int hot_x, int hot_y) 这个函数通过SDL_Surface指定一个图片，hot_x,hot_y来指定光标的操纵点来创建一个光标。\nSDL_Cursor* SDL_CreateCursor(const Uint8* data, const Uint8* mask, int w, int h, int hot_x, int hot_y) 这个函数创建一个黑白的光标，通过data,mask数组给出光标的像素信息，w,h给出光标大小,hot_x,hot_y给出光标操纵点来创建光标，这里有一个例子\nSDL_Cursor* SDL_CreateSystemCursor(SDL_SystemCursor id) 这个函数创建一个系统自带的光标，其中id有如下取值: 设置光标 创建了光标当然要使用了\nvoid SDL_SetCursor(SDL_Cursor* cursor) 释放光标 创建了光标当然要释放了\nvoid SDL_FreeCursor(SDL_Cursor* cursor) 获得当前光标\nSDL_Cursor* SDL_GetCursor(void) 获得SDL2默认光标\nSDL_Cursor* SDL_GetDefaultCursor(void) 显示光标\nint SDL_ShowCursor(int toggle) 给入SDL_ENABLE显示光标，SDL_DISABLE隐藏光标，SDL_QUERY 返回当前光标显示状态。\n关于鼠标的操作\n鼠标事件 鼠标事件有如下几个：\nSDL_MOUSEMOTION 鼠标移动 SDL_MOUSEBUTTONDOWN 鼠标按下 SDL_MOUSEBUTTONUP 鼠标按下 SDL_MOUSEWHEEL 鼠标滚轮 鼠标的事件处理中，event.button会存储所有的信息（滚轮事件中成员变量意义可能不一样，请看wiki），包括：\nbutton:按下的按键，可以是 SDL_BUTTON_RIGHT SDL_BUTTON_LEFT SDL_BUTTON_MIDDLE x:鼠标的x位置 y:鼠标的y坐标 xrel, yrel:鼠标的移动距离（仅MOUSEMOTION事件） clicks:鼠标点击次数。1是单击，2是双击（仅MOUSEDOWN,MOUSEUP事件） direction：鼠标滚轮的方向（仅MOUSEWHEEL事件） 是否全局追踪鼠标\nint SDL_CaptureMouse(SDL_bool enabled) 这个函数可以让你定义是否全局追踪鼠标。如果你给入了SDL_TRUE，表示全局追踪鼠标。这样即使鼠标事件发生在程序窗体外面也可以监测到。 如果为SDL_FALSE，那么就只能检测到这个程序的鼠标事件。\n获得鼠标状态（按键，位置等）\nUint32 SDL_GetMouseState(int* x, int* y) 这个函数获得鼠标的局部坐标位置\nUint32 SDL_GetGlobalMouseState(int* x, int* y) 这个函数获得全局鼠标位置（相对于整个屏幕的）\nUint32 SDL_GetRelativeMouseState(int* x, int* y) 这个函数用来获得上次调用这个函数到这次调用函数鼠标的x,y坐标的偏移量。\n如果想要使用这个函数，你需要通过这个函数来指定是否开启Relative模式：\nint SDL_SetRelativeMouseMode(SDL_bool enabled) 传入SDL_TRUE表示开启 或者通过这个函数得到Relative模式的状态:\nSDL_bool SDL_GetRelativeMouseMode(void) 这三个函数的返回值都是鼠标的按键状态。你可以使用 SDL_BUTTON(X) 宏来获得状态： SDL_BUTTON(X返回：\n1：左键按下 2：中键按下 3: 右键按下 鼠标的坐标转换\nint SDL_WarpMouseGlobal(int x, int y) 从局部坐标到全局坐标\nvoid SDL_WarpMouseInWindow(SDL_Window* window, int x, int y) 从全局坐标到局部坐标\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E9%BC%A0%E6%A0%87%E6%93%8D%E4%BD%9C%E5%92%8C%E5%85%89%E6%A0%87%E7%94%9F%E6%88%90/","section":"Posts","summary":"鼠标和光标操作 # SDL2里面专门有SDL_mouse.h头文件给出了鼠标和光标的操作。\n关于光标的操作 SDL2里面提供了很多关于光标的操作，包括创建光标，显示光标等。\n","title":"SDL2-鼠标操作和光标生成","type":"posts"},{"content":"窗体是通过事件驱动的。掌握了事件就可以掌握窗体等的行动。SDL2的事件结构体为SDL_Event.\nSDL2的事件处理过程是这样的:\n首先定义SDL_Event结构体，所有关于事件的信息都存储在这个结构体里面 在循环中使用PollEvent或者PeekEvent()函数来获得事件 判断结构体的type成员，看他是什么事件，并作出相应判断 大体的代码如下: SDL_Event event while(true){ //game loop while(Poll_Event(\u0026amp;event)){ //event loop if(event.type==/*event type*/) //TODO } //TODO game processing } 事件结构体SDL_Event 事件结构体里面首先要掌握的是type成员。这个成员会给出接收到的事件的类型。所有的事件类型看这里 其次他有很多的其他成员，具体的成员有什么用处，是根据事件的不同而不同的。比如按下鼠标的话就需要用到button属性，按下按键的话就需要key属性。每个属性又是结构体，含有关于那个事件的信息。 除了type之外，每一个成员都有如下的属性：\ntype 标志着这个成员所对应的事件 timestamp 事件的时间戳 如果不是系统事件的话还会有 windowID 发生事件的window的ID 事件获取函数 事件获取函数有两个SDL_PollEvent(),SDL_WaitEvent()。 SDL_PollEvent()函数最常用，他会在有事件的时候给事件结构体填充事件信息。而SDL_WaitEvent()会一直等待事件到来，到来之后会填充结构体。所以一般不会用到SDL_WaitEvent(). 事件获取函数的用法都是传入事件结构体就可以了，比如下面这样：\nSDL_Event event; while(PollEvent(\u0026amp;event)){ //TODO about event } 判断事件类型 获得事件之后，需要通过事件结构体的type属性来判断是什么事件，比如用户按下了退出按钮（窗口左上角的❌）会触发退出事件，可以这样写：\nbool isquit=false; int main(int argc,char** argv){ //Init SDL and Create Window and Renderer SDL_Event event; while(!isquit){ while(PollEvent(\u0026amp;event)){ if(event.type==SDL_QUIT) isquit=true; } } SDL_Quit(); } 接下来列举一些典型的事件\n窗体事件 有关于窗体的事件，比如窗体的缩放，是否有焦点什么的都是这个事件:\nSDL_WINDOWEVENT\n你监测到这个事件之后，可以通过SDL_Event的window成员获得更近一步的信息，比如:\ntype，这个固定的为SDL_WINDOWEVENT，因为你监测到的是这个啊。 windowID，哪个window触发的 event，SDL_WindowEventID类型，你需要通过这个成员知道到底窗体的什么地方发生事件了，比如是窗体大小改变了还是失去焦点了。 data1,data2附加的信息。 其中你需要通过event来更进一步知道发生了什么，比如这样: while(SDL_PollEvent(\u0026amp;event)){ if(event.type==SDL_WINDOWEVENT) if(event.window.event==SDL_WINDOWEVENT_SIZE_CHANGED) SDL_Log(\u0026#34;new width:%d,new height:%d\u0026#34;,event.window.data1,event.window.data2); } 不是所有的event.window.event都需要用到data1,data2的。比如窗体的关闭事件SDL_WINDOWEVENT_CLOSE就不需要。\n关于窗体的事件，wiki上给出了所有的event.window.event取值，并且给出了一个综合实例说明了所有的窗体事件的用法。\n程序生命周期事件 这些事件说明了程序的生命周期，并且这些事件不和事件结构体里面的成员相对应：\nSDL_APP_TERMINATING SDL_APP_LOWMEMORY 内存过低的时候 SDL_APP_WILLENTERBACKGROUND 将要进入背景的时候（失去焦点） SDL_APP_DIDENTERBACKGROUND 已经进入背景的时候 SDL_APP_WILLENTERFOREGROUND 将要到前景的时候（获得焦点） SDL_APP_DIDENTERFOREGROUND 已经到前景的时候 SDL_QUIT 程序退出 比如你可以这么用： if(event.type==SDL_APP_WILLENTERBACKGROUND) SDL_Log(\u0026#34;Will Enter Background!); 键盘事件 键盘事件有两个:\nSDL_KEYDOWN SDL_KEYUP 如果你获得键盘事件，可以通过事件结构体的key成员来获得详细信息。key结构体如下:\ntype timestamp windowID state 取值SDL_PRESSED，SDL_RELEASED repeat 如果一个按键被重复按下，不为0.否则为0. keysym 按下或者弹起的键的编号，这是一个结构体。keysym结构体如下； scancode,SDL_Scancode 结构体，定义了按键码 sym，SDL_Keycode结构体，定义了按键码 mod，有没有其他的，比如Shift,Ctrl键和这个键一起按下/弹起的。SDL_Keymod结构体 你可以随便用scancode或者sym来确定按下了哪个键，比如这样：\nif(event.type==SDL_KEYDOWN) if(event.key.scancode==SDL_SCANCODE_A) //TODO when \u0026#39;A\u0026#39; pressed SDL2给出了专门操作按键的头文件，请看“按键操作”笔记\n鼠标事件 鼠标事件有四个：\nSDL_MOUSEMOTION 鼠标移动 SDL_MOUSEBUTTONDOWN 鼠标按下按钮 SDL_MOUSEBUTTONUP 鼠标按钮弹起 SDL_MOUSEWHEEL 鼠标滚轮 这里每一个事件都对应不同的事件结构体成员，详细的可以点进去看看。 因为SDL2有专门针对鼠标的操控，所以我们在这里就不详细讲解了。详细请看“鼠标操作”笔记\n文本输入 文本输入的事件有两个：\nSDL_TEXTEDITING SDL_TEXTINPUT 其中SDL_TEXTEDITING对应事件结构体中的edit属性。这个属性有如下三个属性：\ntext用户输入的文本 start当前光标离开始输入处的距离 length当前光标到输入处的字符长度 SDL_TEXTINPUT事件对应text属性，这个属性只有一个重要的成员变量text，为用户输入的文本。\n需要注意的是，文本输入事件默认是不接收的。用户必须调用SDL_StartTextInput()函数来开启接收这个事件。SDL_StopTextInput()来关闭对这个事件的接收\n其他的事件还有游戏杆🕹️事件和游戏手柄🎮事件什么的，那些都不太重要。如果想要了解全部事件看这里\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E4%BA%8B%E4%BB%B6/","section":"Posts","summary":"窗体是通过事件驱动的。掌握了事件就可以掌握窗体等的行动。SDL2的事件结构体为SDL_Event.\n","title":"SDL2-事件","type":"posts"},{"content":"SDL_Rect和SDL_Point都是SDL中用于表示几何图像的基本元素。让我们来深入了解一下这两个结构体吧。\nSDL_Rect\nRect的内容 SDL_Rect用于表示矩形，其结构很简单，只有四个属性：\nint x int y int w int h 这四个属性表示了左上角点的坐标和矩形的大小。\nRect相关的函数 关于Rect的函数很多，主要用于碰撞检测的有：\nSDL_HasIntersection用于判断两个矩形是否相交 SDL_IntersectRect这个函数不仅会判断是否相交，其第三个参数还可以获得相交之后的矩形 SDL_IntersectRectAndLine这个函数判断矩形和线段是否相交，由于SDL中没有线段的结构体，所以都是将端点坐标作为参数传入 SDL_PointInRect这个函数判断点是否在矩形内 还有一些计算几何的函数：\nSDL_EnclosePoints找到能够包含一系列点的最小矩形 SDL_RectEmpty判断矩形是否有面积 SDL_RectEquals判断两个矩形是否一样（判断四个属性是否相等） SDL_UnionRect获得两个矩形组合之后的大矩形 SDL_Point SDL_Point用于表示点。\nPoint的内容 很简单，两个属性：\nint x int y Point的函数\nSDL_EnclosePoints ","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-rect%E5%92%8Cpoint/","section":"Posts","summary":"SDL_Rect和SDL_Point都是SDL中用于表示几何图像的基本元素。让我们来深入了解一下这两个结构体吧。\n","title":"SDL2-Rect和Point","type":"posts"},{"content":"SDL不仅是用来写游戏的，其实他的官方网站给他的定义是游戏和GUI界面，也就是说它其实就是一个实现图形用户界面的库。那么肯定可以有多窗口啦。 其实多窗口实现的方式很简单：你只要定义多个窗口，然后每个窗口再给一个Renderer就可以了。然后在循环的时候再调用各自的Renderer函数就可以了。\n这里有一个例子给你看一下：\n#include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;thread\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; void runWindow(int argc,char** argv){ SDL_Window* win = SDL_CreateWindow(\u0026#34;MultiWindow\u0026#34;, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 700, 700, SDL_WINDOW_SHOWN); SDL_Renderer* render = SDL_CreateRenderer(win, -1, 0); SDL_Event event; bool isQuit = false; SDL_SetRenderDrawColor(render ,255,255,255,255); while(!isQuit){ SDL_RenderClear(render); while(SDL_PollEvent(\u0026amp;event)){ if(event.type == SDL_QUIT) isQuit = true; } SDL_RenderPresent(render); } } int main(int argc, char** args){ SDL_Init(SDL_INIT_EVERYTHING); SDL_Window* win1 = SDL_CreateWindow(\u0026#34;MultiWindow1\u0026#34;, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 700, 700, SDL_WINDOW_SHOWN); SDL_Window* win2 = SDL_CreateWindow(\u0026#34;MultiWindow2\u0026#34;, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 700, 700, SDL_WINDOW_SHOWN); SDL_Renderer* render1 = SDL_CreateRenderer(win1, -1, 0); SDL_Renderer* render2 = SDL_CreateRenderer(win2, -1, 0); SDL_Event event; bool isQuit = false; SDL_SetRenderDrawColor(render2 ,255,255,255,255); SDL_SetRenderDrawColor(render1 ,0,255,0,255); while(!isQuit){ SDL_RenderClear(render1); SDL_RenderClear(render2); while(SDL_PollEvent(\u0026amp;event)){ if(event.window.event == SDL_WINDOWEVENT_CLOSE) isQuit = true; } SDL_RenderPresent(render1); SDL_RenderPresent(render2); SDL_Delay(30); } SDL_Quit(); return 0; } 最后的结果是这样： 需要注意的是这里的事件处理，我们使用的是event.window。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%A4%9A%E7%AA%97%E5%8F%A3/","section":"Posts","summary":"SDL不仅是用来写游戏的，其实他的官方网站给他的定义是游戏和GUI界面，也就是说它其实就是一个实现图形用户界面的库。那么肯定可以有多窗口啦。 其实多窗口实现的方式很简单：你只要定义多个窗口，然后每个窗口再给一个Renderer就可以了。然后在循环的时候再调用各自的Renderer函数就可以了。\n","title":"SDL2-多窗口","type":"posts"},{"content":"时间控制函数在SDL_timer.h中\n延时\nvoid SDL_Delay(Uint32 ms) 延时ms个毫秒。这个函数广泛用在游戏编程中用于延时\n时钟控制 添加时钟\nSDL_TimerID SDL_AddTimer(Uint32 interval, SDL_TimerCallback callback, void* param) interval：每次触发回调函数的时间间隔 callback：回调函数，必须以Uint32 my_callbackfunc(Uint32 interval, void *param)的形式编写并且返回值为interval。 param：用户自定义数据。 这个函数返回添加的时钟ID\n移除时钟\nSDL_bool SDL_RemoveTimer(SDL_TimerID id) 和Windows编程一样，时钟不提供暂停机制。如果想要暂停必须通过删除来达到目标。\n精确时间\nUint32 SDL_GetTicks(void) 这个函数获得SDL2库初始化以来的毫秒数。这个函数用于在游戏编程里面精确计算经过的时间长度。\n用于比较时间的宏\nSDL_TICKS_PASSED(A, B) 如果时间A已经经过时间B了，那么返回true。否则返回false\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E6%97%B6%E9%97%B4%E6%8E%A7%E5%88%B6/","section":"Posts","summary":"时间控制函数在SDL_timer.h中\n","title":"SDL2-时间控制","type":"posts"},{"content":"SDL2提供了平台系统检测和CPU信息检测\n平台检测 平台检测只有一个函数，包含在SDL_platform.h文件中:\nconst char* SDL_GetPlatform(void) 返回值为C字符串，取值如下：\nWindows Mac OS X Linux iOS Android CPU信息检测 有关CPU信息检测详见这里\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%B9%B3%E5%8F%B0%E6%A3%80%E6%B5%8B%E5%92%8Ccpu%E4%BF%A1%E6%81%AF/","section":"Posts","summary":"","title":"SDL2-平台检测和CPU信息","type":"posts"},{"content":"获取电量的函数只有一个：\nSDL_PowerState SDL_GetPowerInfo(int* secs, int* pct) SDL_PowerState是一个枚举常量，取值如下：\nSDL_POWERSTATE_UNKNOWN，无法得知信息 SDL_POWERSTATE_ON_BATTERY，靠电池运作，没插电 SDL_POWERSTATE_NO_BATTERY，插电，没有电池 SDL_POWERSTATE_CHARGING，充电中 SDL_POWERSTATE_CHARGED，插电，且电池充满了。 其中参数secs表示电量还可以运作多长时间，pct表示还有百分之多少的电量。函数通过这两个参数返回信息。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%94%B5%E9%87%8F%E4%BF%A1%E6%81%AF%E8%8E%B7%E5%8F%96/","section":"Posts","summary":"","title":"SDL2-电量信息获取","type":"posts"},{"content":"除了事件里面可以处理按键，SDL2还专门提供了SDL_keyboard.h, SDL_keycode.h, SDL_scancode.h三个文件来对按键进行操作。\n三个枚举类型 这三个枚举类型在事件处理里面已经见过了:\nSDL_Keycode：按键码 SDL_Keymod：附加按键（Shift,Alt,Ctrl之类的） SDL_Scancode：按键码 一个结构体 这个结构体也是记录按键键码的： SDL_Keysym 其实这个结构体里面只是封装了SDL_Keycode,SDL_Keymod,SDL_Scancode三个枚举类型。\nScancode,Keycode和按键名称之间的转化 其他两个到Scancode\nSDL_Scancode SDL_GetScancodeFromKey(SDL_Keycode key) SDL_Scancode SDL_GetScancodeFromName(const char* name) 其他两个到Keycode\nSDL_Keycode SDL_GetKeyFromName(const char* name) SDL_Keycode SDL_GetKeyFromScancode(SDL_Scancode scancode) 其他两个到按键名称\nconst char* SDL_GetKeyName(SDL_Keycode key) const char* SDL_GetScancodeName(SDL_Scancode scancode) 也就是说存在下面的关系:\ngraph TD A(Scancode) --\u0026gt;|SDL_GetKeyFromScancode| B(Keycode) B --\u0026gt;|SDL_GetScancodeFromKey| A B --\u0026gt;|SDL_GetKeyName| C(Key Name) A --\u0026gt; |SDL_GetScancodeName| C C --\u0026gt; |SDL_GetScancodeFromName| A C --\u0026gt; |SDL_GetKeyFromName| B 获得/设置Keymod\nSDL_Keymod SDL_GetModState(void) void SDL_SetModState(SDL_Keymod modstate) 获取键盘状态 获得焦点窗口\nSDL_Window* SDL_GetKeyboardFocus(void) 获得键盘按键\nconst Uint8* SDL_GetKeyboardState(int* numkeys) 这个函数返回一个Uint8的数组。这个数组里面装载着对应Scancode的按键。如果为1代表按下了，比如检测w是否按下：\nUint8* keyarr = SDL_GetKeyboardState(nullptr); if(keyarr[SDL_SCANCODE_W]==1) SDL_Log(\u0026#34;w is pressed!\u0026#34;); 传入的参数numkeys代表要获得多少个按键的状态。如果为NULL的话就是全部按键。\n其他函数 屏幕的虚拟键盘开启了吗\nSDL_bool SDL_IsScreenKeyboardShown(SDL_Window* window) 系统支持虚拟键盘吗\nSDL_bool SDL_HasScreenKeyboardSupport(void)","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E6%8C%89%E9%94%AE%E6%93%8D%E4%BD%9C/","section":"Posts","summary":"除了事件里面可以处理按键，SDL2还专门提供了SDL_keyboard.h, SDL_keycode.h, SDL_scancode.h三个文件来对按键进行操作。\n","title":"SDL2-按键操作","type":"posts"},{"content":"对于SDL的文本输入操作，其wiki还专门给了一个教程：见这里 我们按照这个教程来简单说明一下。\n打开输入功能 首先，如果你想要用户输入文本，你需要使用 SDL_StartTextInput()函数打开文本输入功能，不想要输入的话使用SDL_StopTextInput()来停止输入。 你也可以使用SDL_IsTextInputActive()来判断是否打开了文本输入功能。\n获得用户输入的字符 打开了输入功能之后，系统就可以接收到文本输入事件了，有如下：\nSDL_TEXTINPUT 用户输入文本的事件 SDL_TEXTEDITING 用户修改文本的事件 我们首先需要使用的是SDL_TEXTINPUT。这个事件的event有一个text成员，记录了用户输入的字符（UTF-8的）。 这里需要注意的是，如果你使用的是英文，那么就是一次一个字符。但是如果你使用的是中文或者日文这种“多个按键合成一个字符”的输入法，那么当你按下空格之后就会一次性输入很多字符。比如你用搜狗输入法打出huijia，那么搜狗输入法的输入条会显示回家，然后你按下空格确认输入回家，这个时候text就是\u0026quot;回家\u0026quot;而不是\u0026quot;h\u0026quot;或者\u0026quot;huijia\u0026quot;。 搜狗输入法条\n于是你就可以通过这个来获得字符（而且组合键它也可以自动监测到，比如输入大小写）。但是没有办法检测到退格键和回车键，你可以通过键盘事件来检测：\nif(event.type == SDL_KEYDOWN){ if(event.key.keysym.sym == SDLK_BACKSPACE) textinput.pop_back(); if(event.key.keysym.sym == SDLK_RETURN) textinput += \u0026#39;\\n\u0026#39;; } 这样你就可以得到用户的输入了。\n其他输入法的输入 需要注意的是如果你使用中文输入法，那么情况会不大一样，下面是将中文显示到屏幕上的一段程序的结果，你可以看到和一般文本编辑器不同的地方：\n没错，我们在输入的时候居然没有在屏幕上显示。只有我们按下空格键（或者选择你要输入的文本）之后才会显示在屏幕上。也就是说输入法对文本的转换过程是不会触发SDL_TEXTINPUT事件的，而是会触发SDL_TEXTEDITING。\n正常的文本编辑器\n我们将这种通过多个键生成一个字符的方式叫做组合(Composition)\n这个时候我就要介绍SDL_TEXTEDITING这个事件了。 这个事件的event有三个成员：\ntext:正在键入的文本，比如“回家的诱惑”对应的就是“hui jia de you huo”（空格也会被检测到哦） start:修改的开始处 length:修改的长度 通过以上的事件就可以制作一个文本输入框了（虽然说还是很有难度的）。\n设置输入法条的位置和大小 这里还有一个函数可以影响输入法条的位置和大小:SDL_SetTextInputRect(SDL_Rect* rect) 通过给出一个SDL_Rect来指定输入法条的位置和宽度。 比如我的rect的x,y为0的时候，就会这样：\n如果指定y=300，会这样：\n可以看到距离明显变长了。\n有时候w和h属性并不能影响输入法条，这个要看不同的输入法和不同系统而定了。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E6%96%87%E6%9C%AC%E8%BE%93%E5%85%A5%E6%93%8D%E4%BD%9C/","section":"Posts","summary":"对于SDL的文本输入操作，其wiki还专门给了一个教程：见这里 我们按照这个教程来简单说明一下。\n","title":"SDL2-文本输入操作","type":"posts"},{"content":"SDL本身是不支持文本的绘制的，如果你想要绘制文本的话你需要包含其第三方库SDL_ttf，下载网址在这里 顺带一提，SDL官方的所有拓展库都可以在这里找到，这个网址同时还有很多很多的学习资源（包括第一版DOOM的源代码）\n使用SDL_ttf 和SDL_image一样，首先得初始化，然后结束的时候需要释放：\nTTF_Init() TTF_Quit() 这两个函数都没有参数。\n接下来我们需要加载字体文件，使用TTF_OpenFont函数来打开。第一个参数是字体文件，第二个参数是加载过后字体的大小：\nTTF_Font* font = TTF_OpenFont(\u0026#34;font.ttc\u0026#34;, 24); 接下来你就可以通过SDL_Surface* surface = TTF_RenderUTF8_Blended();函数来得到一个有文字的SDL_Surface*了。这个函数第一个参数是TTF_Font*，第二个参数是文字，第三个参数是颜色。\n其实这里有很多的绘制文字的参数。但是由于我们使用的是中文，所以我们选择了RenderUTF8_Blended()。具体的函数名称有一下格式： TTF_Render_ 这里encoding为编码的方式，type为渲染的类型，分别可以是： encoding:\nGlyph Text 普通文字 UTF8 UNICODE type可以是\n没有， 代表用最普通的方式渲染 Blended，高级的渲染方式 Solid，实体的 Shaded，有阴影的，会有额外的参数来指定阴影 Blended_Wrapped:最高级的渲染方式，最清晰，最慢 其中各个选项的效率从大到小依次为： Blended_Wrapped Blended Shaded Solid 无 同样，效率越低，渲染出来的文字清晰度越高。\n然后你就可以通过SDL_BlitSurface或者转换成SDL_Texture来绘制到屏幕上了。\n最后你需要关闭已经打开的字体文件: TTF_CloseFont()。\n使用SDL_FontCache绘制文字 虽然SDL_ttf是SDL官方开发的库，但是效率不是很高，而且也不是很好用。主要有以下几个缺点：\n不能够解析字符串中的\\t和\\n \\b等字符，也就是说如果你想要换行的话还得重新给一个Surface 效率很低，如果你想要每一帧都渲染不同的文本的话，那么你需要下面这个循环： //循环中 SDL_Surface* surface = TTF_RenderUTF8_Blended(font, textinput.c_str(), color); if(surface != nullptr){ SDL_Texture* texture = SDL_CreateTextureFromSurface(render, surface); SDL_Rect dstrect; dstrect.x = 0; dstrect.y = 0; dstrect.w = surface-\u0026gt;w; dstrect.h = surface-\u0026gt;h; SDL_RenderCopy(render, texture, nullptr, \u0026amp;dstrect); SDL_DestroyTexture(texture); SDL_FreeSurface(surface); } 可以看到每一帧都要生成和释放surface和texture。效率很低。\n这里我们使用SDL_FontCache，这不是SDL官方开发的，但是可以解析特殊字符，以及可以直接使用SDL_Renderer*进行绘制，不需要生成繁琐的surface和texture，而且还支持一些特殊的字体样式（如粗体和斜体等），而且使用了缓存技术提升了效率。\nSDL_FontCache的github地址在这里 这个库的文件很少，只有一个SDL_FontCache.c和一个SDL_FontCache.h文件。你可以将.c文件编译成链接库，或者直接和源代码一起编译。 需要注意的是：\nSDL_FontCache使用的是纯C，其中有些代码不能直接和C++11代码在一起编译，你需要拆分开来编译，也就是说\ng++ textinput.cpp SDL_FontCache.c -o textinput `sdl2-config --libs --cflags` `pkg-config --libs --cflags SDL2_ttf` -std=c++11 是不行的，你必须将.c文件分开编译：\ngcc ./SDL_Cache/SDL_FontCache.c -c `sdl2-config --libs --cflags` `pkg-config --libs --cflags SDL2_ttf` g++ textinput.cpp SDL_FontCache.o -o textinput `sdl2-config --libs -std=c++11 SDL_FontCache.h文件需要SDL2和SDL_ttf文件，而且它是这样包含的:\n#include \u0026#34;SDL.h\u0026#34; #include \u0026#34;SDL_ttf.h\u0026#34; 所以如果你的SDL2和SDL_ttf的路径和他的不一致的话，你可以进.h文件改一下源码\n接下来就是使用SDL_FontCache库了。官方有一个例子，我们来看一下：\nFC_Font* font = FC_CreateFont(); FC_LoadFont(font, renderer, \u0026#34;fonts/FreeSans.ttf\u0026#34;, 20, FC_MakeColor(0,0,0,255), TTF_STYLE_NORMAL); ... FC_Draw(font, renderer, 0, 0, \u0026#34;This is %s.\\n It works.\u0026#34;, \u0026#34;example text\u0026#34;); ... FC_FreeFont(font); 首先通过FC_CreateFont()来创建一个文字句柄。 然后通过FC_LoadFont()函数来加载一个字符文件，其中第四个参数是SDL_Color用于指定颜色，但是它自己有FC_MakeColor()函数来直接生成一个颜色。第五个参数是字体的样式，可以是TTF_STYLE_ NORMAL,BOLD,ITALIC,OUTLINE,STRIKETHROUGH,UNDERLINE中的一个 然后直接通过FC_Draw()函数就可以绘制出来了，无需变为texture，而且FC_Draw()是类似printf的风格，可以用占位符的。 最后当你用完了可以使用FC_FreeFont()函数释放掉。\n这个库还有很多的函数，但是由于没有文档只有源代码，所以在这里我也就不分析了，有兴趣可以自己看看源码，.c文件也就两千多行。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%BB%98%E5%88%B6%E6%96%87%E6%9C%AC/","section":"Posts","summary":"SDL本身是不支持文本的绘制的，如果你想要绘制文本的话你需要包含其第三方库SDL_ttf，下载网址在这里 顺带一提，SDL官方的所有拓展库都可以在这里找到，这个网址同时还有很多很多的学习资源（包括第一版DOOM的源代码）\n","title":"SDL2-绘制文本","type":"posts"},{"content":"官方给了很多的版本获取宏和函数，但是我觉得根本没有这个必要，我们这里只介绍一个函数和一个宏，其他的请到官方文档去看一看。\n我们的函数就是SDL_GetVersion(SDL_version* version)，宏是SDL_VERSION(version)。这两个都需要一个SDL_version*，而SDL_version是一个结构体，只有三个成员major,minor,patch。所以你只需要这一个函数或者宏就可以得到SDL的版本了。\n区别在于：SDL_VERSION是获得链接库的版本，而SDL_GetVersion()是获得编译的版本。也就是说一个是链接库的版本，一个是头文件的版本。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E7%89%88%E6%9C%AC%E8%8E%B7%E5%8F%96/","section":"Posts","summary":"","title":"SDL2-版本获取","type":"posts"},{"content":"SDL的日志功能很简单. 首先要明确，SDL的每个日志都由两个条件决定：\ncategory：类型，可以是如下几个：\nSDL_LOG_CATEGORY_APPLICATION SDL_LOG_CATEGORY_ERROR SDL_LOG_CATEGORY_ASSERT SDL_LOG_CATEGORY_SYSTEM SDL_LOG_CATEGORY_AUDIO SDL_LOG_CATEGORY_VIDEO SDL_LOG_CATEGORY_RENDER SDL_LOG_CATEGORY_INPUT SDL_LOG_CATEGORY_TEST 测试用 SDL_LOG_CATEGORY_CUSTOM 用户自己定义的 SDL_LOG_CATEGORY_RESERVED 为了未来SDL版本的拓展使用，用户不应该占用。 可以看到这些都对应着SDL的模块，也就是按照模块信息分类的\npriority：日志级别，这个用过日志库的都很熟悉了，有如下几个：\nSDL_LOG_PRIORITY_VERBOSE SDL_LOG_PRIORITY_DEBUG SDL_LOG_PRIORITY_INFO SDL_LOG_PRIORITY_WARN SDL_LOG_PRIORITY_ERROR SDL_LOG_PRIORITY_CRITICAL 那么优先级就是CRITICAL\u0026gt;ERROR\u0026gt;WARN\u0026gt;INFO\u0026gt;DEBUG\u0026gt;VERBOSE\n接下来就是我们常见的日志库中的日志函数了。SDL的日志函数都是类printf格式的。有如下日志函数： SDL_Log SDL_Log Critical/Debug/Error/Info/Verbose/Warn/Message 其中SDL_Log会默认将日志记到SDL_LOG_CATEGORY_APPLICATION,SDL_LOG_PRIORITY_INFO类别中。其他的都需要指定。 SDL_LogMessage的第一个参数是category，第二个参数是priority，可以将日志记到任意类别和优先级中。其他的就是按照优先级命名的日志函数了，第一个参数都是category。\n接下来就是关于日志优先级的函数了：\nSDL_LogGetPriority(int category)获得优先级 SDL_LogResetPriorities()将所有category的优先级设为默认（INFO级） SDL_LogSetPriority(int category,SDL_LogPriority priority)改变优先级。 SDL_LogSetAllPriority(SDL_LogPriority priority)设置所有category的日志一个优先级。 最后SDL还允许你自定义日志的输出方式。你可以给一个函数给SDL，他会按照调用你的函数来输出：\nSDL_LogGetOutputFunction(SDL_LogOutputFunction* callback,void** userdata)：获得当前的日志格式函数和额外数据 SDL_LogSetOutputFunction(SDL_LogOutputFunction callback,void* userdata)：设置日志输出格式函数，函数需要有下面的格式 void SDL_LogOutputFunction(void* userdata,int category,SDL_LogPriority priority,const char* message) ","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E6%97%A5%E5%BF%97/","section":"Posts","summary":"","title":"SDL2-日志","type":"posts"},{"content":"SDL错误检测和处理一共就三个函数：\nSDL_ClearError()：清除所有错误 const char* SDL_GetError():获得前一个出错函数产生的错误信息（英文） SDL_SetError(const char* fmt,...):设置自己的错误，格式类似printf。（这个函数总是返回-1） SDL的错误处理是这样的：SDL的函数首先会有一个int返回值表示函数是否出错了（一般0为成功，负数表示发生了错误），然后会向错误变量中记录这个错误的文字信息，这个时候你就可以通过SDL_GetError()来获得错误了。你也可以通过SDL_SetError()来向错误变量中记录自己的错误。SDL_ClearError()来清除错误变量记录的内容。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E9%94%99%E8%AF%AF%E6%A3%80%E6%B5%8B%E5%92%8C%E5%A4%84%E7%90%86/","section":"Posts","summary":"","title":"SDL2-错误检测和处理","type":"posts"},{"content":"SDL操作剪切版的函数很简单：\nchar* SDL_GetClipboardText(void)：获得剪切板内容 SDL_bool SDL_HasClipboardText(void)：判断剪切板内是否有内容 int SDL_SetClipboardText(const char* text)：将文字放入剪切板（返回0成功，返回负值失败） ","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%89%AA%E5%88%87%E6%9D%BF/","section":"Posts","summary":"","title":"SDL2-剪切板","type":"posts"},{"content":"由于SDL2是C语言库，所以提供了多线程的库。但是我们是用C++编写的，不如直接使用C++的线程库咯。这部分等我有时间会补上的\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%A4%9A%E7%BA%BF%E7%A8%8B/","section":"Posts","summary":"","title":"SDL2-多线程","type":"posts"},{"content":"SDL2渲染器 # 有了窗口之后，我们还需要有一个渲染器。渲染器用于将图像渲染到窗口上。\n创建渲染器 通过SDL_CreateRenderer()函数来创建渲染器：\nSDL_Renderer* SDL_CreateRenderer(SDL_Window* window, int index, Uint32 flags) window:需要为哪个窗口创建渲染器 index:渲染器驱动需要初始化的渲染器索引。-1为默认渲染器 flags:渲染器flag，为SDL_RendererFlags枚举类型。有以下四个值 SDL_RENDERER_SOFTWARE：软件渲染 SDL_RENDERER_ACCELERATED：用硬件加速渲染 SDL_RENDERER_PRESENTVSYNC：画面与刷新速率同步 SDL_RENDERER_TARGETTEXTURE：渲染器支持纹理渲染 一般index都是-1。如果你明确知道自己有其他的渲染器的话，可以用其他值。但是我们一般都是使用-1来用默认渲染器。\n与窗口一起创建渲染器 你也可以使用SDL_CreateWindowAndRenderer()来连同渲染器一起创建窗口：\nint SDL_CreateWindowAndRenderer(int width, int height, Uint32 window_flags, SDL_Window** window, SDL_Renderer** renderer) 但是这里就不能指定窗口的初始坐标和标题了。\nRenderer是否有Target\nSDL_bool SDL_RenderTargetSupported(SDL_Renderer* renderer) 返回SDL_bool有两个值：SDL_TRUE,SDL_FALSE，分别对应true和false.\n获得RenderDriver个数 通过SDL_GetNumRenderDrivers()来获得驱动个数。小于0表示失败。\nint SDL_GetNumRenderDrivers(void) 获得RenderDriver信息 通过SDL_GetRenderDriverInfo()来获得。返回负值表示失败.\nint SDL_GetRenderDriverInfo(int index, SDL_RendererInfo* info) 获得Renderer信息 通过SDL_GetRendererInfo()获得Renderer信息：\nint SDL_GetRendererInfo(SDL_Renderer* renderer, SDL_RendererInfo* info) 该函数将信息返回到info中，SDL_RendererInfo结构体见这里\n获得/设置Renderer渲染目标 通过SDL_Get/SetRenderTarget()函数\nSDL_Texture* SDL_GetRenderTarget(SDL_Renderer* renderer) int SDL_SetRenderTarget(SDL_Renderer* renderer, SDL_Texture* texture) 其中texture必须为指定了SDL_TEXTUREACCESS_TARGET这个Flag所创建出来的texture。或者给入NULL表示默认texture.\n获得Renderer的输出大小\nint SDL_GetRendererOutputSize(SDL_Renderer* renderer, int* w, int* h) 这个函数可以获得Renderer的绘制图像的大小。默认为生成的窗体大小。\n设置Renderer混合模式\nint SDL_SetRenderDrawBlendMode(SDL_Renderer* renderer, SDL_BlendMode blendMode) int SDL_GetRenderDrawBlendMode(SDL_Renderer* renderer, SDL_BlendMode* blendMode) 这里的belnMode为枚举类型：\nSDL_BLENDMODE_NONE:dstRGB = srcRGB SDL_BLENDMODE_BLEND:dstRGB = (srcRGB * srcA) + (dstRGB * (1-srcA));dstA = srcA + (dstA * (1-srcA)) SDL_BLENDMODE_ADD:dstRGB = (srcRGB*srcA) + dstRGB;dstA = dstA SDL_BLENDMODE_MOD:dstRGB = srcRGB * dstRGB;dstA = dstA 将当前的渲染数据绘制到窗体上面 通过SDL_RenderPresent()来会自豪当前图像数据到窗体上面:\nvoid SDL_RenderPresent(SDL_Renderer* renderer) 销毁Renderer 使用SDL_DestroyRenderer()销毁：\nvoid SDL_DestroyRenderer(SDL_Renderer* renderer)","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E6%B8%B2%E6%9F%93%E5%99%A8sdl_renderer/","section":"Posts","summary":"SDL2渲染器 # 有了窗口之后，我们还需要有一个渲染器。渲染器用于将图像渲染到窗口上。\n","title":"SDL2-渲染器SDL_Renderer","type":"posts"},{"content":"这里我们先来一个Hello World来看看什么是SDL。这一部分可能比较多。\nSDL2的配置 下载安装之后需要配置SDL。具体的配置方法在这里 SDL的教程比较好的是Lazy Foo的博客，地址在这里 或者你可以看SDL官方写的书《SDL Game Development》\nHello World 这里给出SDL2的Hello World。这个Hello World的功能是产生一个700x700的窗口，背景是绿色的，并且会绘制一幅图片在上面： 现在来看看这个Hello World：\n#include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;SDL2/SDL_image.h\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; const int WIDTH=700; const int HEIGHT=700; const int DELAYTIME=30; SDL_Window* mainwindow = nullptr; SDL_Renderer* render = nullptr; SDL_Surface* surface = nullptr; SDL_Texture* texture = nullptr; SDL_Event event; SDL_Rect imgRect,destRect; bool isquit=false; bool init(); void renderScreen(); void handleEvent(); void update(); void clean(); int main(int argc,char** argv){ if(init()==false){ SDL_Log(\u0026#34;Init faliled\u0026#34;); return -1; } SDL_Log(\u0026#34;Init success\u0026#34;); while(isquit==false){ renderScreen(); update(); SDL_Delay(DELAYTIME); } SDL_Log(\u0026#34;Window is quit\\n\u0026#34;); clean(); SDL_Log(\u0026#34;SDL quit\u0026#34;); return 0; } bool init(){ if(SDL_Init(SDL_INIT_EVERYTHING)\u0026lt;0){ cerr\u0026lt;\u0026lt;\u0026#34;SDL2 can\u0026#39;t be init!\u0026#34;; return false; } mainwindow = SDL_CreateWindow(\u0026#34;hello world\u0026#34;,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,WIDTH,HEIGHT,SDL_WINDOW_RESIZABLE); if(mainwindow==nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;can\u0026#39;t create window!\u0026#34;; return false; } render = SDL_CreateRenderer(mainwindow,-1,0); if(render == nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;can\u0026#39;t create renderer!\u0026#34;; return false; } surface = IMG_Load(\u0026#34;1.jpeg\u0026#34;); if(surface==nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;1.jpeg is not found\u0026#34;; SDL_Log(\u0026#34;img is not loaded\u0026#34;); }else{ texture = SDL_CreateTextureFromSurface(render,surface); SDL_QueryTexture(texture,nullptr,nullptr,\u0026amp;imgRect.w,\u0026amp;imgRect.h); destRect=imgRect; imgRect.x=imgRect.y=0; SDL_Log(\u0026#34;the image width and height is:%d,%d\u0026#34;,imgRect.w,imgRect.h); } return true; } void renderScreen(){ SDL_RenderClear(render); SDL_SetRenderDrawColor(render,0,200,0,255); } void handleEvent(){ while(SDL_PollEvent(\u0026amp;event)){ if(event.type == SDL_QUIT) isquit=true; } } void update(){ handleEvent(); destRect.x=destRect.y=100; SDL_RenderCopy(render,texture,\u0026amp;imgRect,\u0026amp;destRect); SDL_RenderPresent(render); } void clean(){ SDL_FreeSurface(surface); SDL_DestroyTexture(texture); SDL_DestroyWindow(mainwindow); SDL_Quit(); } 这里将不同的功能放入了不同的函数，以便代码的阅读。这个代码的结构大体如下： 初始化SDL2，创建窗体，创建渲染器，窗体事件循环，程序结束清理\n包含头文件 首先是两行的包含头文件的代码：\n#include \u0026lt;SDL2/SDL.h\u0026gt; #include \u0026lt;SDL2/SDL_image.h\u0026gt; #include \u0026lt;iostream\u0026gt; using namespace std; 包含了每次使用SDL都必须的头文件SDL.h。以及SDL2拓展库SDL_image.h。SDL_image.h不属于标准SDL里面。但是因为标准SDL的图像读取函数Load_Image只能读取bmp,jpeg。所以使用SDL_image.h的IMG_Load()来读取。还包含了C++的iostream，并且使用了命名空间std。\n变量的声明 声明了一些变量：\nconst int WIDTH=700; const int HEIGHT=700; const int DELAYTIME=30; SDL_Window* mainwindow = nullptr; SDL_Renderer* render = nullptr; SDL_Surface* surface = nullptr; SDL_Texture* texture = nullptr; SDL_Event event; SDL_Rect imgRect,destRect; bool isquit=false; WIDTH和HEIGHT是生产窗体的大小，DEKAYTIME表示游戏一帧的延时时间。接下来以SDL开头的就是SDL自己的结构体，分别代表窗口，渲染器，表面，纹理，事件和矩形结构。最后声明一个bool变量来判断游戏是否结束。\n接下来是一些函数的前置声明：\nbool init(); void renderScreen(); void handleEvent(); void update(); void clean(); init()函数\nbool init(){ if(SDL_Init(SDL_INIT_EVERYTHING)\u0026lt;0){ cerr\u0026lt;\u0026lt;\u0026#34;SDL2 can\u0026#39;t be init!\u0026#34;; return -1; } mainwindow=SDL_CreateWindow(\u0026#34;hello world\u0026#34;,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,WIDTH,HEIGHT,SDL_WINDOW_RESIZABLE); if(mainwindow==nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;can\u0026#39;t create window!\u0026#34;; return -1; } render = SDL_CreateRenderer(mainwindow,-1,0); if(render == nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;can\u0026#39;t create renderer!\u0026#34;; return -1; } surface = IMG_Load(\u0026#34;1.jpeg\u0026#34;); if(surface==nullptr){ cerr\u0026lt;\u0026lt;\u0026#34;1.jpeg is not found\u0026#34;; SDL_Log(\u0026#34;img is not loaded\u0026#34;); }else{ texture = SDL_CreateTextureFromSurface(render,surface); SDL_QueryTexture(texture,nullptr,nullptr,\u0026amp;imgRect.w,\u0026amp;imgRect.h); destRect=imgRect; imgRect.x=imgRect.y=0; SDL_Log(\u0026#34;the image width and height is:%d,%d\u0026#34;,imgRect.w,imgRect.h); } return 1; } 首先是初始化函数init。里面发生了以下的事情：\nSDL_Init()。每次使用SDL的时候都必须调用这个函数来进行初始化，这样才可以使用SDL引擎。这里传入SDL_INIT_EVERYTHING来表示初始化SDL的每个部分。如果初始化失败会返回小于0的数值。 SDL_CreateWindow()。用于创建以个窗体，返回一个SDL_Window*的指针。其中的参数分别表示窗体的标题，初始x，y坐标，初始大小（宽度和高度）以及窗体类型。这里入的参数说明是一个700x700，显示在屏幕中央的可变大小的窗体。 SDL_CreateRenderer()。创建一个渲染器，返回一个SDL_Renderer*。每个窗体都需要一个渲染器用于渲染图像。这个函数的第一个参数是为哪个窗体创建渲染器。后面的-1和0默认。 IMG_Load()。这个是SDL_image.h头文件里的函数，用于读入一个图像并且放在surface里面。这个函数可以读取几乎任何的图像格式，而不像SDL2自带的Load_Image()只能够读取bmp和jpg。 SDL_Log()。存在在if语句里面的日志函数，用法和printf()函数一样，用于在控制台输出格式化的日志。 SDL_CreateTextureFromSurface()。从surface创建一个texture。返回SDL_Texture*。参数分别是对应的渲染器和surface。 Surface和Texture的区别 Surface是表面，是SDL1的产物，用于绘制图像，但是其占用的是内存，完全由CPU绘制。Texture是纹理，是SDL2新增的结构体，放在显存里面，用GPU加速。SDL2推荐的是Texture。但是目前Texture只可以通过Surface来进行构造。 SDL_QueryTexture()。查询Texture。这个函数可以或者Texture的一些信息。这里获得了其大小。 renderScreen()函数 这个函数用于渲染窗体\nvoid renderScreen(){ SDL_RenderClear(render); SDL_SetRenderDrawColor(render,0,200,0,255); } SDL_RenderClear()。用Renderer的颜色来清空窗口。 SDL_SetRenderDrawColor()。设置Render的颜色。这里给了绿色。这个颜色带有第四个参数alpha值。 handleEvent()函数 用于事件循环\nvoid handleEvent(){ while(SDL_PollEvent(\u0026amp;event)){ if(event.type == SDL_QUIT) isquit=true; } } 使用了SDL_PollEvent()函数来获得事件。如果是SDL_QUIT（退出）事件，就置isquit=true来退出循环。\nupdate 用于更新游戏状态\nvoid update(){ handleEvent(); destRect.x=destRect.y=100; SDL_RenderCopy(render,texture,\u0026amp;imgRect,\u0026amp;destRect); SDL_RenderPresent(render); } 首先调用了handleEvent()函数来进行事件处理 83行置图像最终要绘制的x，y坐标为(100，100) SDL_RenderCopy()。用于将Texture绘制到窗体上，但是不可以旋转和翻转图像（如果想要旋转和翻转需要使用SDL_RenderCopyEx()）。参数分别为渲染器，纹理，要绘制的图像区域和目的地。 SDL_RenderPresent()。将当前窗体绘制出来。 clean() 游戏结束后的清除。\nvoid clean(){ SDL_FreeSurface(surface); SDL_DestroyTexture(texture); SDL_DestroyWindow(mainwindow); SDL_Quit(); } 这个函数里面清除了一些游戏资源，包括surface，texture，window。并且调用SDL_Quit()退出了SDL引擎。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-helloworld/","section":"Posts","summary":"这里我们先来一个Hello World来看看什么是SDL。这一部分可能比较多。\n","title":"SDL2-HelloWorld","type":"posts"},{"content":"初始化并非只有SDL_Init一个函数。让我们来看看还有其他什么函数\nSDL_Init SDL_Init函数很简单，其参数为要初始化的模块（SDL有很多模块，像是时间模块SDL_INIT_TIMER,声音模块SDL_INIT_AUDIO,显示模块SDL_INIT_VIDEO等）。你也可以通过SDL_INIT_EVERYTHING来初始化所有模块。具体模块如下：\nSDL_INIT_TIMER SDL_INIT_AUDIO SDL_INIT_VIDEO SDL_INIT_JOYSTICK 摇杆模块 SDL_INIT_HAPTIC 压力模块，用于移动端 SDL_INIT_GAMECONTROLLER 游戏手柄模块 SDL_INIT_EVENTS 事件模块 有了这些控制符，你可以在节省资源的情况下使用任意模块。 这个函数成功会返回0，失败返回一个小于0的数。\nSDL_InitSubSystem 这个函数和SDL_Init()函数差不多，可以在你使用SDL_Init()之后再次初始化你要的模块。\nSDL_QuitSubSystem 这个函数是对应SDL_InitSubSystem, SDL_Init函数的，用于关闭已经初始化的模块。\nSDL_WasInit 判断模块是否初始化。其参数为一个flag（从SDL_INIT_xx中选择）表示你想要知道哪个模块的信息。如果你传入0回返回所有初始化模块的按位或运算的结果，那么你可以这样得到模块初始化信息：\nUint32 subsystem_init = SDL_WasInit(SDL_INIT_EVERYTHING); if (subsystem_init \u0026amp; SDL_INIT_VIDEO) { printf(\u0026#34;Video is initialized.\\n\u0026#34;); } else { printf(\u0026#34;Video is not initialized.\\n\u0026#34;); } 或者直接传入模块，那样如果初始化回返回非0值：\nif (SDL_WasInit(SDL_INIT_VIDEO) != 0) { printf(\u0026#34;Video is initialized.\\n\u0026#34;); } else { printf(\u0026#34;Video is not initialized.\\n\u0026#34;); } SDL_Quit SDL_Quit会强制退出所有的模块。\n接下来要说的是一些模块独立的初始化函数。这些初始化函数初始化的模块不能够被SDL_Quit或者SDL_QuitSubSystem函数关闭，必须调用相应的函数来关闭：\nSDL_VideoInit(const char* driver_name)初始化Video模块，传入驱动名称，NULL为默认驱动。对应关闭函数SDL_VideoQuit int SDL_AudioInit(const char* driver_name)初始化Audio模块（用法同SDL_VideoInit），关闭函数SDL_AudioQuit ","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%88%9D%E5%A7%8B%E5%8C%96/","section":"Posts","summary":"初始化并非只有SDL_Init一个函数。让我们来看看还有其他什么函数\n","title":"SDL2-初始化","type":"posts"},{"content":"一个游戏，如果想要开始的话，必须得先有一个窗口。这里我们先来创造一个窗口。\nSDL_Window结构体 由于SDL2是C语言写的，所以他没有对象什么的，只有结构体。窗体的结构体是SDL_Window。如果你查阅的是SDL2的官方文档，那么如果他的结构体有提供给你操作的属性，他会直接给你。但是SDL_Window的结构体没有给出任何的属性，所以只能使用函数来操控了。 每一个SDL_Window都代表着一个窗口，如果被正确构造的话。\nSDL_CreateWindow 这个函数会创建一个窗口，他的用法如下：\nSDL_Window* SDL_CreateWindow(const char* title, int x, int y, int w, int h, Uint32 flags) 参数说明如下：\ntitle。一个字符串，为窗体的标题。 x，y。表示以整个屏幕为坐标系的窗体位置。 w，h。表示窗体的大小 flags。表示窗体的样式，其为SDL_WindowFlags。这个枚举的取值在这里 这个函数返回一个SDL_Window*的结构体指针。 于是我们就可以使用这个函数来创造一个窗口了：\nSDL_Window* win = SDL_CreateWindow(\u0026#34;Hello World\u0026#34;,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,700,700,SDL_WINDOW_SHOWN); 这里的flags我们给的是SDL_WINDOW_SHOWN，表示创建的之后直接显示窗口。你也可以使用按位或运算来组合多个值，比如SDL_WINOW_FULLSCREEN可以全屏窗口之类的。\n窗口动不了或者一闪而过？？？ 如果你的代码仅仅是：\nint main(int argc,char** argv){ SDL_Window* win = SDL_CreateWindow(\u0026#34;Hello World\u0026#34;,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,700,700,SDL_WINDOW_SHOWN); return 0; } 那么即使创建完成窗口了，由于程序结束了，所以也会一闪而过。 如果你加上了while循环，那么可以保证窗体显示出来，但是会移动不了：\nint main(int argc,char** argv){ SDL_Window* win = SDL_CreateWindow(\u0026#34;Hello World\u0026#34;,SDL_WINDOWPOS_CENTERED,SDL_WINDOWPOS_CENTERED,700,700,SDL_WINDOW_SHOWN); while(1); return 0; } 这是因为还没有事件处理，所以没有办法去移动或者操作。 我们这里仅仅只是创建了一个窗口而已，要怎么显示这个窗口还要到渲染器那一章再说。\n获得窗口的状态 这里还有一些函数来获得窗口的状态：\nSDL_GetWindowFlags()用于获得你设置的flags。你可以使用按位或运算来判断这个窗口的样式:Uint32 SDL_GetWindowFlags(SDL_Window* window) SDL_GetWindowSize()这个函数获得窗体的大小：void SDL_GetWindowSize(SDL_Window* window, int* w, int* h) SDL_GetRendererOutputSize()用于获得渲染器渲染的大小： int SDL_GetRendererOutputSize(SDL_Renderer* renderer, int* w, int* h) 想必你已经发现了，每个关于窗口的函数都需要SDL_Window*参数，这是因为这些函数需要知道是对哪个窗体进行操作。 销毁窗体 由于SDL_CreateWindow()返回的是一个指针，而参数又没有传入指针，所以很明显SDL_CreateWindow()再内部malloc了一个指针，然后返回他。既然是malloc的指针，就必须free掉。但是这里你不可以直接使用free()函数，而需要使用SDL_DestroyWindow()来销毁窗体（其实SDL_DestroyWindow()内部也是使用的free，但是你不要直接用free）：\nvoid SDL_DestroyWindow(SDL_Window* window) 这样这个窗口就被我们销毁了。\n由于SDL2是C语言编写的，所以会有很多的创造和销毁函数，这些函数最好成对出现（你也可以等着程序结束自动释放资源，但是我极度不建议你这样做），因为程序员产出的内存，程序员要对其负责。\n","date":"28 July 2019","externalUrl":null,"permalink":"/posts/sdl2-%E5%88%9B%E5%BB%BA%E7%AA%97%E5%8F%A3/","section":"Posts","summary":"一个游戏，如果想要开始的话，必须得先有一个窗口。这里我们先来创造一个窗口。\n","title":"SDL2-创建窗口","type":"posts"},{"content":"​\tSDL2(Simple Direct Media Player 2) 是一个用于多媒体技术，游戏编程，计算机辅助设计的，一个用于绘图和事件处理的库。大多数人将其看作游戏引擎，包括其官网也是在大力宣传其在游戏编程方面的成果。但是正如DirectX一样，SDL的也被用来制作多媒体或者数据展示等。 SDL2比起SDL1来说改变的幅度很大，所以我们这里介绍的是SDL2而不是SDL1.最新的SDL应该是2.0.9版本。你可以从这里下载。 SDL2是一个跨平台的(Windows, Mac OS X, Linux, iOS, and Android)，由C编写的程序库。也可以使用C++来编写，同时也给出了很多其他语言的支持，比如python的pygame和pygame-SDL。具体的支持见这里。 和OpenGL不一样，SDL2大部分用来处理2D游戏，但是其内置对OpenGL的原生支持，你也可以用它来编写3D游戏。SDL2是个底层的游戏引擎，这意味着它包含着绘图，时间与事件处理等基本操作，但是不提供帧动画，场景和几何变换等高级的功能，这些功能需要程序员自己利用SDL，数学知识和编程知识进行编写。SDL十分底层，可以用来在其上进行二次开发来制作新的游戏引擎（比如OGRE就是基于SDL2的）。 如果你想使用高级的游戏引擎，请参阅cocos2dx，Unity3D，Unreal Engine等知名的游戏引擎。这些引擎将大多数游戏编程的元素封装好了便于程序员使用。但是如果你想要从底层开发的话，SDL仍然是一个不错的选择。 SDL也提供了自身的API文档，你可以参阅这里\n","date":"27 July 2019","externalUrl":null,"permalink":"/posts/sdl2%E6%98%AF%E4%B8%AA%E5%95%A5/","section":"Posts","summary":"","title":"SDL2是个啥","type":"posts"},{"content":" HelloWorld # 本HelloWorld展示了执行一个JS文件。此JS文件会在屏幕上输出一行Hello World。\nGithub代码链接\n包含头文件 # 首先要做的是包含QuickJS头文件：\n#include \u0026#34;quickjs.h\u0026#34; #include \u0026#34;quickjs-libc.h\u0026#34; // optional quickjs.h包含了所有需要的API，是最基础头文件。而quickjs-libc.h则是拥有很多实用工具的文件（可选）\n创建Runtime # 首先创建JSRuntime：\nJSRuntime* runtime = JS_NewRuntime(); if (!runtime) { std::cerr \u0026lt;\u0026lt; \u0026#34;init runtime failed\u0026#34; \u0026lt;\u0026lt; std::endl; return 1; } JSRuntime的官方文档解释是：\nJSRuntime 表示与对象堆对应的 JavaScript 运行时。多个运行时可以同时存在，但它们不能交换对象。在给定的运行时中，不支持多线程。\nJSRuntime是整个QuickJS环境的基础。\n创建Context # 然后我们需要创建一个Context:\nJSContext* ctx = JS_NewContext(runtime); if (!ctx) { std::cerr \u0026lt;\u0026lt; \u0026#34;create context failed\u0026#34; \u0026lt;\u0026lt; std::endl; JS_FreeRuntime(runtime); return 2; } JSContext的官方文档解释是：\nJSContext 表示 JavaScript 上下文（或 Realm）。每个 JSContext 都有其自己的全局对象和系统对象。每个 JSRuntime 可以有多个 JSContext，并且它们可以共享对象，类似于在 Web 浏览器中共享 JavaScript 对象的同源框架。\n初始化std帮助库 # QuickJS环境初始化之后，环境内除几乎没有任何可用的帮助对象（比如在JS中常见的console.log）。这个时候我们需要从quickjs-libc.h中的函数\njs_std_add_helpers(ctx, 0, NULL); 来给JSContext注册一些辅助对象。这些对象包含：\nconsole.log(\u0026hellip;args)：用于控制台输出（注：没有其他的console函数，他只注册了log函数） print(...args)：也是用于控制台输出 scriptArgs：提供命令行参数。第一个参数是脚本名称 我们会在JS脚本中使用console.log函数输出HelloWorld\n读取JS文件 # 我们的JS文件如下：\n// main.js console.log(\u0026#34;Hello QuickJS!\u0026#34;); 首先将文件读入内存：\nstd::ifstream file(\u0026#34;demos/01-HelloWorld/main.js\u0026#34;, std::ios::in | std::ios::binary); if (!file) { std::cerr \u0026lt;\u0026lt; \u0026#34;open file main.js failed\u0026#34; \u0026lt;\u0026lt; std::endl; } std::stringstream ss; ss \u0026lt;\u0026lt; file.rdbuf(); std::string content = ss.str(); 注意需要以二进制形式读取\n然后使用JS_Eval函数执行：\nJSValue result = JS_Eval(ctx, content.c_str(), content.size(), nullptr, JS_EVAL_TYPE_GLOBAL); 参数分别是：\nJSContext：你的JS上下文 code：代码 code_len：代码长度 filename：代码所在文件名。可以给空指针或者随便给一个，只是调试使用 flags：以何种方式执行。这里是全局执行，也是默认方式 这个函数会执行整个脚本，并返回一个值。因为我们的脚本内没有返回任何值，所以这里的result是JS_UNDEFINED（即JS中的undefine）\n错误处理 # 有些时候脚本会执行出错（出现了语法错误,运行时错误或用户在JS代码中抛了异常等），这时JS_Eval会返回一个异常，可以进行处理：\nif (JS_IsException(result)) { // from quickjs-libc.hpp, to log exception js_std_dump_error(ctx); } 这里先判断返回值是否是异常。然后使用了quickjs-libc的辅助函数帮我们将异常信息输出到控制台上。\n释放内存 # 最后不要忘记释放内存：\nJS_FreeValue(ctx, result); JS_FreeContext(ctx); JS_FreeRuntime(runtime); ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/helloworld/","section":"books","summary":"","title":"","type":"books"},{"content":" 介绍 # 什么是QuickJS # QuickJS is a small and embeddable Javascript engine. It supports the ES2023 specification including modules, asynchronous generators, proxies and BigInt.\nMain Features:\nSmall and easily embeddable: just a few C files, no external dependency, 367 KiB of x86 code for a simple hello world program. Fast interpreter with very low startup time: runs the 78000 tests of the ECMAScript Test Suite in about 2 minutes on a single core of a desktop PC. The complete life cycle of a runtime instance completes in less than 300 microseconds. Almost complete ES2023 support including modules, asynchronous generators and full Annex B support (legacy web compatibility). Passes nearly 100% of the ECMAScript Test Suite tests when selecting the ES2023 features (warning: the report available at test262.fyi underestimates the QuickJS results because it does not use the right executable). Can compile Javascript sources to executables with no external dependency. Garbage collection using reference counting (to reduce memory usage and have deterministic behavior) with cycle removal. Command line interpreter with contextual colorization implemented in Javascript. Small built-in standard library with C library wrappers. QuickJS是由大神Bellard制作的一款小巧，快速的JavaScript运行时。目标是用于嵌入各种程序中。\n为何写这份教程 # QuickJS本身几乎没有任何的API文档。所有API基本上只能通过看例子进行学习。而且很多API还有很多坑。我在将QuickJS嵌入自己的游戏引擎的过程中遇到了很多坑，所以想做一份较为完善的教程。\n本教程有/没有的内容 # 本教程会介绍如何在C++代码中嵌入QuickJS，将C/C++的代码绑定给QuickJS并且在C++中调用js代码。\n本教程不会说如何从Js编译成独立可执行文件，并且调用C++链接库。本教程着眼于JS作为脚本语言嵌入，而非JS作为主语言的情况。\n代码实例 # 本教程附带一份代码实例，使用QuickJS-NG在C++20下编译。\n之所以在C++20下是因为在初始化时指定结构体成员的C相关语法只能在C++20下编译通过：\nstruct Person { float age; float height; }; int main() { // can compile under C, but can\u0026#39;t compile before C++20 Person p = {.age = 21.0f, .height = 190.0f}; return 0; } 注意事项 # 本教程的所有知识来源于QuickJS-NG而非Bellard的QuickJS。因为最初的QuickJS只能在Linux下编译，而QuickJS-NG做了跨平台处理。QuickJS-NG和最初的QuickJS API相差无几，本书的知识点应该可以同时用在两者之上。\n作者不是个熟练的JS使用者 # 我的工作是游戏引擎开发，并不是前后端开发，我也不太使用JavaScript，所以我并不十分了解JavaScript。如果本教程有疏漏还请指正（email: 2142587070@qq.com）\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/introduce/","section":"books","summary":"","title":"","type":"books"},{"content":" Summary # 介绍 基础知识 HelloWorld 异常处理 使用内部模块 代码执行方式 让我们开始绑定 绑定全局变量 绑定全局函数 绑定类 更多设置对象属性的方法 绑定模块 ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/summary/","section":"books","summary":"","title":"","type":"books"},{"content":" 代码执行方式 # 本节解释JS_Eval的最后一个参数flag的含义。以及如何使用编译后的JS二进制代码执行。\n二进制代码执行的例子在这里\n代码执行方式 # 有两种Type：\nJS_EVAL_TYPE_GLOBAL：全局模式执行（默认的），即所有代码都在全局模式下执行。某些代码中定义的对象可以在其他代码中使用 JS_EVAL_TYPE_MODULE：以模块模式执行。代码被视为模块（类似nodejs模块），并且可以使用模块导入语法import * from 'Module' 和四种Flag（可组合）：\nJS_EVAL_FLAG_STRICT：以严格模式执行（相当于JS代码中写use strict，但QuickJS会忽略你写的，所以你只能通过指定此Flag来执行严格模式） JS_EVAL_FLAG_COMPILE_ONLY：只是编译，不运行 JS_EVAL_FALG_BACKTRACE_BARRIER：不要在出错时的栈回溯中包含此代码执行之前的堆栈帧 JS_EVAL_FLAG_ASYNC：只能搭配JS_EVAL_TYPE_GLOBAL。代码以异步执行，JS_Eval返回一个Promise，需要等待。 以二进制方式执行代码 # QuickJS有个编译器qjsc可以将JS代码编译成二进制，这样减少了代码体积，在运行时速度也会更快。\n使用qjsc编译我们的代码：\nqjsc -b -n \u0026#34;main.js\u0026#34; -o output.qjs main.js -b代表编译成二进制而不是C代码（没错可以编译成C代码，直接嵌入C文件中执行） -n指定编译之后的脚本名称（用于调试，在抛出异常时包含在stack traces中） -o指定编译的结果文件 最后的参数是要编译的代码文件。\n然后在C++代码中执行：\nJSValue obj = JS_ReadObject(ctx, (uint8_t*)content.data(), content.size(), JS_READ_OBJ_BYTECODE); JSValue result = JS_EvalFunction(ctx, obj); 首先使用JS_ReadObject从二进制代码中读取JSValue，然后使用JS_EvalFunction执行。\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E4%BB%A3%E7%A0%81%E6%89%A7%E8%A1%8C%E6%96%B9%E5%BC%8F/","section":"books","summary":"","title":"","type":"books"},{"content":" 使用QuickJS标准库 # Github代码链接\n使用QuickJS标准库 # QuickJS有自己的标准库（输入输出，文件操作，json操作等），详见QuickJS-NG文档。但是要使用的话需要调用额外函数进行初始化。\n在HelloWorld章节已经看到了如何注册console.log和其他全局变量/函数了。其他的函数则需要通过js_init_module_xxx进行初始化：\njs_init_module_os(ctx, \u0026#34;os\u0026#34;); js_init_module_std(ctx, \u0026#34;std\u0026#34;); js_init_module_bjson(ctx, \u0026#34;json\u0026#34;); 第二个参数是库注册的名称。\nJS代码加载库 # 有至少三种方式：\n使用模块模式执行代码 # 如果想要在JS代码中使用\nimport * as std from \u0026#39;std\u0026#39; 这类代码，在执行代码时必须将其视为模块执行：\nJSValue result = JS_Eval(ctx, content.c_str(), content.size(), nullptr, // use this flag! JS_EVAL_TYPE_MODULE); 否则会说没有import语法。\n作为模块执行的话有一个很严重的缺点：如果出现某些语法错误/运行时错误，QuickJS会直接静默而非抛出异常。这意味着调试困难。一般如果模块找不到的话他会正常报错。但是在你使用模块内部的对象（比如对一个不存在的类进行new），QuickJS则会abort，并且不会抛出异常（检查JS_Eval的返回值不会是异常）\n使用异步加载方式加载 # 这种方式可以不以模块方式加载：\nimport(\u0026#39;std\u0026#39;).then(module =\u0026gt; { module.puts(\u0026#34;I am std module in non-module mode\\n\u0026#34;); }).catch(err =\u0026gt; { print(\u0026#34;Error loading module:\u0026#34;, err); }); 但这是一段异步代码。我们需要在执行完之后使用js_std_loop或js_std_await等待异步执行完毕：\nJSValue result = JS_Eval(ctx, content.c_str(), content.size(), nullptr, JS_EVAL_FLAG_GLOBAL); js_std_loop(ctx); js_std_loop会对整个JSContext中需要等待的脚本做等待。只指定某个脚本等待的话需调用js_std_await\n预加载模块，然后执行 # 我们也可以走两步：\n先通过模块模式import需要的模块入JSContext 执行我们自己的代码。这样可以直接使用已经导入的模块 这里首先以模块模式执行：\nstd::cout \u0026lt;\u0026lt; \u0026#34;execute in pre-module mode\u0026#34; \u0026lt;\u0026lt; std::endl; const char* module_preload_code = R\u0026#34;( import * as std from \u0026#39;std\u0026#39;; globalThis.std = std; )\u0026#34;; auto value = JS_Eval(ctx, module_preload_code, strlen(module_preload_code), nullptr, JS_EVAL_TYPE_MODULE); 注意这里需要将模块放入globalThis中。\n然后执行我们自己的代码：\nstd.puts(\u0026#34;I am std module when preload module\\n\u0026#34;) // call function in std module JSValue result = JS_Eval(ctx, content.c_str(), content.size(), nullptr, JS_EVAL_FLAG_GLOBAL); ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E4%BD%BF%E7%94%A8%E5%86%85%E9%83%A8api/","section":"books","summary":"","title":"","type":"books"},{"content":" 基础知识 # 本章会介绍一些QuickJS基础知识。包括一个HelloWorld，使用QuickJS内部标准库，码执行方式和遇到异常时如何检查和处理。。\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","section":"books","summary":"","title":"","type":"books"},{"content":" 异常处理 # 本节介绍如何处理QuickJS异常。\n何时发生异常 # 返回JSValue的函数，使用JS_IsException判断是否是异常 返回整数值的函数，如果小于0，则发生异常。 发生异常时得到异常 # 典型的方式如下：\nvoid LogException(JSContext* ctx) { JSValue exception_val = JS_GetException(ctx); bool is_error = JS_IsError(ctx, exception_val); // convert quickjs exception to string const char* str = JS_ToCString(ctx, exception_val); if (str) { std::cerr \u0026lt;\u0026lt; str \u0026lt;\u0026lt; std::endl; // don\u0026#39;t forget to free JS_FreeCString(ctx, str); } if (is_error) { // get stack info val = JS_GetPropertyStr(ctx, exception_val, \u0026#34;stack\u0026#34;); if (!JS_IsUndefined(val)) { const char* stack_info = JS_ToCString(ctx, val); std::cerr \u0026lt;\u0026lt; \u0026#34;stack: \u0026#34; \u0026lt;\u0026lt; stack_info \u0026lt;\u0026lt; std::endl; JS_FreeCString(ctx, stack_info); } JS_FreeValue(ctx, val); } JS_FreeValue(ctx, exception_val); } 当有异常抛出时，异常会被记录在JSContext中。使用JS_GetException拿出，转换成字符串输出即可。如果有堆栈信息也可以一并输出。\n在有异常时即可调用：\nJSValue result = JS_Eval(ctx, content.c_str(), content.size(), nullptr, JS_EVAL_FLAG_STRICT | flags); if (JS_IsException(result)) { LogException(ctx); } ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E5%BC%82%E5%B8%B8%E5%A4%84%E7%90%86/","section":"books","summary":"","title":"","type":"books"},{"content":" 绑定全局函数 # Github代码\n本节介绍了如何绑定全局函数\n例子 # 假设我们有一个简单的Add函数：\nint Add(int a, int b) { return a + b; } 我们和绑定变量一样绑定他到全局对象。只是使用JS_NewCFunction创建JSValue：\nJSValue global_this = JS_GetGlobalObject(ctx); constexpr int FnParamCount = 2; JSValue fn = JS_NewCFunction(ctx, AddFnBinding, \u0026#34;Add\u0026#34;, FnParamCount); JS_SetPropertyStr(ctx, global_this, \u0026#34;Add\u0026#34;, fn); JS_FreeValue(ctx, global_this); 注意JS_NewCFunction的第二个参数AddFnBinding。他的类型是JSCFunction，一个函数指针：\ntypedef JSValue JSCFunction(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv); QuickJS在看到JS调用C++函数时其实会调用我们给的这个函数指针。我们需要实现此函数指针并且在内部调用我们自己的C++函数：\nJSValue AddFnBinding(JSContext* ctx, JSValue self, int argc, JSValueConst* argv) { if (argc != 2) { return JS_ThrowPlainError(ctx, \u0026#34;Add function must has two parameters\u0026#34;); } JSValueConst param1 = argv[0]; JSValueConst param2 = argv[1]; if (!JS_IsNumber(param1) || !JS_IsNumber(param2)) { return JS_ThrowTypeError(ctx, \u0026#34;Add accept two integral\u0026#34;); } int32_t value1, value2; JS_ToInt32(ctx, \u0026amp;value1, param1); JS_ToInt32(ctx, \u0026amp;value2, param2); return JS_NewInt32(ctx, Add(value1, value2)); } 参数解释：\nctx：JS上下文\nself：如果函数是类对象函数，那这里会传入类对象。否则是JS_UNDEFINED\nargc：传给函数的参数格式\nargv：传给函数的参数。注意这里写的虽是JSValueConst但并不代表他们是常量。因为底层定义如下：\n#define JSValueConst JSValue 这里首先检查参数个数和参数类型是否是我们想要的，然后将两个参数使用JS_ToXXX转换成C++类型，然后调用我们的Add函数并将结果穿给JSValue。\n更多函数类型 # QuickJS内置了很多函数类型。JSCFunction只是最通用和最常见的类型。还有如下类型（定义在JSCFunctionEnum）：\nJS_CFUNC_generic：最通用的函数类型（就是我们使用的JSCFunction） JS_CFUNC_constructor：类的构造函数 专门给数学函数定义的类型： JS_CFUNC_f_f：形如double(*)(double)的函数指针 JS_CFUNC_f_f_f：形如double(*)(double, double)的函数指针 JS_CFUNC_getter和JS_CFUNC_setter：getter和setter JS_CFUNC_iterator_next：用于迭代器的类型 JS_CFUNC_constructor_or_func：\u0026lt;不清楚是什么，以后研究\u0026gt; 以及某些函数的magic版本（在类型后面加_magic例如JS_CFUNC_generic_magic） 这些函数的签名可以在JSCFunctionType看到：\ntypedef union JSCFunctionType { JSCFunction *generic; JSValue (*generic_magic)(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int magic); JSCFunction *constructor; JSValue (*constructor_magic)(JSContext *ctx, JSValueConst new_target, int argc, JSValueConst *argv, int magic); JSCFunction *constructor_or_func; double (*f_f)(double); double (*f_f_f)(double, double); JSValue (*getter)(JSContext *ctx, JSValueConst this_val); JSValue (*setter)(JSContext *ctx, JSValueConst this_val, JSValueConst val); JSValue (*getter_magic)(JSContext *ctx, JSValueConst this_val, int magic); JSValue (*setter_magic)(JSContext *ctx, JSValueConst this_val, JSValueConst val, int magic); JSValue (*iterator_next)(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv, int *pdone, int magic); } JSCFunctionType; 数学函数类型 # JS_CFUNC_f_f和JS_CFUNC_f_f_f。\n这两个函数都是接收double作为参数（f_f接收一个，f_f_f接收两个），返回一个double值。一般用于绑定sin,cos这种数学函数。优点是函数签名不含JSValue直接是double，不需要做额外的转换。\ngetter和setter # 用于变量的Getter和Setter。可以使用这种方式实现只读变量（只实现getter不实现setter)。函数签名分别是：\nGetter: JSValue (*getter)(JSContext *ctx, JSValueConst this_val)。传入要获得值的对象 Setter:JSValue (*setter)(JSContext *ctx, JSValueConst this_val, JSValueConst val)。传入值的来源val和放入值的变量this_val 迭代器类型 # \u0026lt;暂时没研究，之后补上\u0026gt;\n带有magic的类型 # 几乎所有类型的函数都有magic版本。magic版本的作用是通过额外的参数int magic来将多个函数聚合在一起：\n// binding function JSValue BindMagicFn(JSContext* ctx, JSValue, int argc, JSValueConst* argv, int magic) { if (magic == 0) { MagicFn1(); } else if (magic == 1) { MagicFn2(); } return JS_UNDEFINED; } // bind JSValue fn1 = JS_NewCFunctionMagic(ctx, BindMagicFn, \u0026#34;MagicFn1\u0026#34;, FnParamCount, JS_CFUNC_generic_magic, 0); JSValue fn2 = JS_NewCFunctionMagic(ctx, BindMagicFn, \u0026#34;MagicFn2\u0026#34;, FnParamCount, JS_CFUNC_generic_magic, 1); 使用JS_NewCFunctionMagic来绑定magic函数。通过最后的参数来区分内部到底是在使用哪个函数。\n如何绑定这些花里胡哨的函数类型 # 虽然函数类型和签名很多，但是JS_SetPropertyStr的第二个参数只接收JSCFunction类型。要如何绑定呢？有两种方法：\n使用特定的绑定函数，比如magic相关的就是JS_NewCFunctionMagic，getter/setter就是 JS_DefinePropertyGetSet()\n使用JSCFunctionType进行转换：\nJSCFunctionType fn_type; // JS_CFUNC_f_f_f pass two double elem and return one double elem fn_type.f_f_f = +[](double param1, double param2) -\u0026gt; double { return param1 + param2; }; JSValue fn = JS_NewCFunction2(ctx, fn_type.generic, \u0026#34;Sum\u0026#34;, 1, JS_CFUNC_f_f_f, 0); ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E7%BB%91%E5%AE%9A%E5%85%A8%E5%B1%80%E5%87%BD%E6%95%B0/","section":"books","summary":"","title":"","type":"books"},{"content":" 绑定全局变量 # Github代码\n我们首先从最简单的全局变量绑定开始。\nQuickJS的类型 # QuickJS中使用JSValue包装所有C/C++类型。支持的类型如下：\nNumber：数字类型 String：字符串类型（是C风格字符串不是std::string） Boolean：布尔值 Class：类 Module：模块 Exception：异常 Function：函数 Array：数组 例子 # 现在希望绑定一个数值类型的变量：\nint gGlobalVar = 123; 做法如下：\nvoid Bind(JSContext* ctx) { // Int32 value is directly copied into JSValue(no malloc), so we don\u0026#39;t need JS_FreeValue it JSValue new_obj = JS_NewInt32(ctx, gGlobalVar); if (JS_IsException(new_obj)) { js_std_dump_error(ctx); JS_FreeValue(ctx, new_obj); return; } JSValue global_this = JS_GetGlobalObject(ctx); // JS_WRITABLE | JS_ENUMERABLE | JS_CONFIGURABLE by default JS_SetPropertyStr(ctx, global_this, \u0026#34;global_var\u0026#34;, new_obj); // don\u0026#39;t forget cleanup JS_FreeValue(ctx, global_this); } C++和JS的所有数据交换都是通过JSValue进行的。这里步骤如下：\n首先使用JS_NewInt32创建一个整数类型的JSValue 做异常检查 得到全局对象JS_GetGlobalObject 使用JS_SetPropertyStr将我们的变量注册到全局对象中 清理内存 重点是注册的函数JS_SetPropertyStr。其原型如下：\nint JS_SetPropertyStr(JSContext *ctx, JSValueConst this_obj, const char *prop, JSValue val) this_object：要注册到的对象 prop：要注册的对象名称（在JS中使用的名称） val：要注册的对象 注册完之后就可以在JS中使用了：\nconsole.log(\u0026#34;global_var: \u0026#34;, global_var) 绑定基础类型变量的规则 # 绑定对象的步骤如下：\n使用JS_NewXXX来创建一个JS对象 使用JS_SetPropertyStr来将对象绑定在另一个JS对象中 清理内存 可创建的JS对象 # 通用的数值类创建使用JS_NewNumber。细分如下： JS_NewInt32：创建32位的整数 JS_NewInt64：创建64位整数（底层规则是：如果是32位，调用JS_NewInt32，否则直接调用JS_NewFloat64存在64位浮点数中） JS_NewUint32：创建无符号32位整数（规则通JS_NewInt64） JS_NewFloat64：创建double类型（JS中没有float类型都是double） 大数类型： JS_NewBigInt64 JS_NewBigUInt64 JS_NewBool：创建布尔值 JS_NewClass：创建类 JS_NewObject：创建对象 字符串类： JS_NewString JS_NewStringLen：可指定字符串长度 何时释放JSValue？ # JSValue的释放也是有讲究的。其底层实现为：\nvoid JS_FreeValueRT(JSRuntime *rt, JSValue v) { if (JS_VALUE_HAS_REF_COUNT(v)) { JSRefCountHeader *p = (JSRefCountHeader *)JS_VALUE_GET_PTR(v); if (--p-\u0026gt;ref_count \u0026lt;= 0) { js_free_value_rt(rt, v); } } } 是先判断是否有引用计数，如果有的话，当计数降为0释放内存。\n有引用计数的一般是类和对象类型。像基础的数值类型是直接值拷贝入JSValue的，也不会有内存分配/释放：\n// JSValue impl typedef union JSValueUnion { int32_t int32; double float64; void *ptr; int32_t short_big_int; } JSValueUnion; typedef struct JSValue { JSValueUnion u; int64_t tag; } JSValue; tag：即JS_TAG_XXX类型，标识JSValue的类型 u：如果是数值类型，就记录在非ptr中并且没有内存分配。否则进行内存分配，将指针记录在ptr中 而JS_VALUE_HAS_REF_COUNT也让我们知道哪些是会进行内存分配的：\n#define JS_VALUE_HAS_REF_COUNT(v) ((unsigned)JS_VALUE_GET_TAG(v) \u0026gt;= (unsigned)JS_TAG_FIRST) enum { // has memory allocation JS_TAG_FIRST = -9, /* first negative tag */ JS_TAG_BIG_INT = -9, JS_TAG_SYMBOL = -8, JS_TAG_STRING = -7, JS_TAG_MODULE = -3, /* used internally */ JS_TAG_FUNCTION_BYTECODE = -2, /* used internally */ JS_TAG_OBJECT = -1, // no memory allocation JS_TAG_INT = 0, JS_TAG_BOOL = 1, JS_TAG_NULL = 2, JS_TAG_UNDEFINED = 3, JS_TAG_UNINITIALIZED = 4, JS_TAG_CATCH_OFFSET = 5, JS_TAG_EXCEPTION = 6, JS_TAG_SHORT_BIG_INT = 7, JS_TAG_FLOAT64 = 8, }; 那么所有时候，只要使用了JS_NewXXX就一定要调用JS_FreeValue吗？答案是否定的。具体是否需要释放要看之后的函数是否影响了其引用计数。比如上面HelloWorld中的代码我们就没有释放new_obj。因为new_obj被创建出来时引用计数是1。而JS_SetPropertyStr传入new_obj后是不会改变其引用计数的。这个时候如果调用了JS_FreeValue则会将new_obj的引用计数降为0，进而释放内存。但此时其已被注册在global_this中了。在JSRuntime释放的时候会尝试释放所有被引用的节点，这个时候会再次释放new_obj造成程序崩溃。\n所以是否要释放JSValue，关键在于之后使用的函数是否增加了JSValue的引用计数。如果增加了就需要释放。\n或者说的更严谨一点，当其他函数内部使用了js_dup()时就是增加了引用计数，这个时候我们就得手动释放。\n绑定类对象 # 如果想要绑定类对象，需要使用JS_NewClassObject并且将类对象设置进去：\nPerson* p = new Person{}; // C++ class object JSValue result = JS_NewObjectClass(ctx, class_id); JS_SetOpaque(result, person); JS_SetPropertyStr(ctx, global_this, \u0026#34;person\u0026#34;, result); 这里需要一个class_id，class_id是类的唯一标识，在你注册类的时候会生成一个（等待后面绑定类的时候会说到）。\n然后使用JS_SetOpaque函数将我们的类对象塞给JSValue即可。\n绑定空对象 # 使用JS_NewObject创建一个空对象（即创建一个JS中的Object实例）。这个对象不和任何类相关联，所以也不需要一个class_id。\n内置JSValue常量 # JS_UNDEFINED JS_NULL 这两个是内置的字面常量，可以直接使用无需JS_NewXXX。\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E7%BB%91%E5%AE%9A%E5%85%A8%E5%B1%80%E5%8F%98%E9%87%8F/","section":"books","summary":"","title":"","type":"books"},{"content":" 绑定模块 # Github代码\n本节介绍如何绑定模块\n例子 # 首先需要创建模块：\nJSModuleDef* module_def = JS_NewCModule(ctx, \u0026#34;MyModule\u0026#34;, ModuleInitFn); JS_NewCModule的签名如下：\nJSModuleDef *JS_NewCModule(JSContext *ctx, const char *name_str, JSModuleInitFunc *func) 传入模块的名称，以及一个JSModuleInitFunc。\nJSModuleInitFunc是模块在初始化的时候调用的回调函数。一般在其中将属性绑定到模块上：\nint ModuleInitFn(JSContext* ctx, JSModuleDef* m) { // set JSValue to module JS_SetModuleExport(ctx, m, \u0026#34;Add\u0026#34;, JS_NewCFunction(ctx, AddFnBinding, \u0026#34;Add\u0026#34;, 2)) JS_SetModuleExport(ctx, m, \u0026#34;Person\u0026#34;, gClassConstructor) // 0 - success // \u0026lt; 0 - failed return 0; } 通过JS_SetModuleExport将JSValue绑定在模块定义m中（也可以使用函数JS_SetModuleExportList）。\n最后，我们需要指定哪些属性是需要导出的：\nJS_AddModuleExport(ctx, module_def, \u0026#34;Add\u0026#34;); JS_AddModuleExport(ctx, module_def, \u0026#34;Person\u0026#34;); 使用JS_AddModuleExport可以指定导出模块中的哪些属性（也可以使用JS_AddModuleExportList）。\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E7%BB%91%E5%AE%9A%E6%A8%A1%E5%9D%97/","section":"books","summary":"","title":"","type":"books"},{"content":" 绑定类 # Github代码\nJS中的原型对象(prototype) # 我们需要先理解JS中的原型对象。因为C++这边的绑定和JS的原理是一致的。\nJS类的一个典型古典定义如下：\nfunction Person(name) { this.name = name; } Person.prototype.sayHello = function() { console.log(\u0026#34;Hello, my name is \u0026#34; + this.name); }; let alice = new Person(\u0026#34;Alice\u0026#34;); alice.sayHello(); 类本身是一个函数Person。而其成员函数都是绑定在函数的原型prototype中。当实例化的时候，会调用Person函数初始化成员变量，并且拷贝prototype到对象中以找到成员函数。\nC++绑定类的例子 # 假设我们有个Person类：\nstruct Person { static int ID; char name[512] = {0}; float height; float weight; int age; Person(const std::string\u0026amp; name, float height, int age, float weight) : height{height}, age{age}, weight{weight} { ChangeName(name); } void Introduce() const { std::cout \u0026lt;\u0026lt; \u0026#34;I am \u0026#34; \u0026lt;\u0026lt; name \u0026lt;\u0026lt; \u0026#34;, age \u0026#34; \u0026lt;\u0026lt; age \u0026lt;\u0026lt; \u0026#34;, height \u0026#34; \u0026lt;\u0026lt; height \u0026lt;\u0026lt; \u0026#34;, weight \u0026#34; \u0026lt;\u0026lt; weight \u0026lt;\u0026lt; std::endl; } float GetBMI() const { return weight / (height * height); } void ChangeName(const std::string\u0026amp; name) { strcpy(this-\u0026gt;name, name.data()); } }; 绑定的过程如下：\n首先，需要创建一份JSClassID。QuickJS内部用JSClassID唯一标识一个类：\ngClassID = JS_NewClassID(runtime, \u0026amp;gClassID); if (gClassID == 0) { std::cerr \u0026lt;\u0026lt; \u0026#34;create class id failed\u0026#34; \u0026lt;\u0026lt; std::endl; } 然后需要一个类定义对象JSClassDef：\nJSClassDef def{}; // will call when value be freed def.finalizer = +[](JSRuntime*, JSValue self) { if (!JS_IsObject(self)) { std::cerr \u0026lt;\u0026lt; \u0026#34;in finalizer, self is not object\u0026#34; \u0026lt;\u0026lt; std::endl; } Person* opaque = static_cast\u0026lt;Person*\u0026gt;(JS_GetOpaque(self, gClassID)); if (!opaque) { std::cerr \u0026lt;\u0026lt; \u0026#34;self is nullptr\u0026#34; \u0026lt;\u0026lt; std::endl; } delete opaque; }; def.class_name = class_name; def.finalizer是类的析构函数。当JSValue被GC掉的时候会调用。我们需要在这里清理内存。\ndef.class_name则是类名。\n接下来需要将这个类定义注册到JSRuntime中：\nJS_NewClass(runtime, gClassID, \u0026amp;def); 然后我们需要根据原型对象的原理组建一个原型对象：\nJSValue proto = JS_NewObject(ctx); 接下来需要为成员变量/函数创建对应的JSValue。注意这里成员变量和函数在C++中都是函数（成员变量由getter/setter表示）。比如说getter/setter：\nJSValue NameGetter(JSContext* ctx, JSValue self) { // I\u0026#39;m lazy to check type :-) const Person* p = static_cast\u0026lt;const Person*\u0026gt;(JS_GetOpaque(self, gClassID)); return JS_NewString(ctx, p-\u0026gt;name); } JSValue NameSetter(JSContext* ctx, JSValue self, JSValueConst param) { // I\u0026#39;m lazy to check type :-) Person* p = static_cast\u0026lt;Person*\u0026gt;(JS_GetOpaque(self, gClassID)); p-\u0026gt;ChangeName(JS_ToCString(ctx, param)); return JS_UNDEFINED; } 使用JS_GetOpaque从JSValue中拿到特定类的指针（注意gClassID一定要对得上。如果class id是无效的会返回空指针，这也就意味着你必须先注册对应的类）。\n然后绑定给prototype：\nJSAtom name = JS_NewAtom(ctx, \u0026#34;name\u0026#34;); JS_DefinePropertyGetSet(ctx, proto, NameGetterJSValue, NameSetterJSValue, nameAtom, 0); JS_FreeAtom(ctx, atom); 其他成员函数同理。\n然后定义构造函数：\nJSValue ConstructorBinding(JSContext* ctx, JSValue self, int argc, JSValueConst* argv) { // I\u0026#39;m lazy to check argv type :-) const char* name = JS_ToCString(ctx, argv[0]); double height; JS_ToFloat64(ctx, \u0026amp;height, argv[1]); int age; JS_ToInt32(ctx, \u0026amp;age, argv[2]); double weight; JS_ToFloat64(ctx, \u0026amp;weight, argv[3]); Person* person = new Person(name, height, age, weight); JSValue result = JS_NewObjectClass(ctx, gClassID); JS_SetOpaque(result, person); return result; } 注意创建类对象的方法：\n使用JS_NewObjectClass创建类对象 使用JS_SetOpaque将C++对象传给JS 然后，告诉QuickJS我们需要将此prototype和哪个类相关联：\nJS_SetClassProto(ctx, gClassID, proto); 最后将构造函数注册给global_this，我们就可以在JS中使用此类啦：\nJSValue global_var = JS_GetGlobalObject(ctx); JS_DefinePropertyValueStr(ctx, global_var, \u0026#34;Person\u0026#34;, constructor, JS_CFUNC_constructor); JS_FreeValue(ctx, global_var); 在JS中使用：\nlet person = new Person(\u0026#34;QJSKid\u0026#34;, 150, 15, 40) console.log(person.name) person.name = \u0026#34;John\u0026#34; console.log(person.name) console.log(person.bmi) person.introduce() 如何绑定类静态函数/成员？ # 静态函数和成员直接绑定在构造函数上即可，无需绑定在prototype上：\nJSValue id_value = JS_NewInt32(ctx, Person::ID); JS_SetPropertyStr(ctx, constructor, \u0026#34;ID\u0026#34;, id_value); 使用：\nconsole.log(Person.ID) ","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E7%BB%91%E5%AE%9A%E7%B1%BB/","section":"books","summary":"","title":"","type":"books"},{"content":" 让我们开始绑定 # 从这一节开始进行C++代码绑定到QuickJS。我们先介绍如何绑定普通的全局变量和函数。然后介绍如何绑定类。最后介绍如何绑定一个模块到QuickJS\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E8%AE%A9%E6%88%91%E4%BB%AC%E5%BC%80%E5%A7%8B%E7%BB%91%E5%AE%9A/","section":"books","summary":"","title":"","type":"books"},{"content":" 更多设置对象属性的方法 # 在之前的绑定中，我们基本上是使用JS_SetPrototypeStr进行代码绑定。但还有其他的方法。\nJS_SetProperty系列 # JS_SetPrototype系列有如下函数：\nint JS_SetPropertyStr(JSContext *ctx, JSValueConst this_obj, const char *prop, JSValue val) this_obj：要设置到的值 prop：设置的成员名称 val：要设置的值 int JS_SetProperty(JSContext *ctx, JSValueConst this_obj, JSAtom prop, JSValue val) 与JS_SetPropertyStr的区别是第三个参数是JSAtom。这其实是更底层的函数。JSAtom就是一个字符串。只是如果你用JS_NewAtom创建之后你可以复用他，这样QuickJS就不用将字符串字面量在底层到处拷贝（比如使用JS_SetPropertyStr时）\nJS_DefineProperty系列 # 这个系列函数给了你更加精细的控制：\nint JS_DefineProperty(JSContext *ctx, JSValueConst this_obj, JSAtom prop, JSValueConst val, JSValueConst getter, JSValueConst setter, int flags) int JS_DefinePropertyValue(JSContext *ctx, JSValueConst this_obj, JSAtom prop, JSValue val, int flags) int JS_DefinePropertyValueStr(JSContext *ctx, JSValueConst this_obj, const char *prop, JSValue val, int flags) int JS_DefinePropertyGetSet(JSContext *ctx, JSValueConst this_obj, JSAtom prop, JSValue getter, JSValue setter, int flags) 同样的，有带有JSAtom和直接使用字符串的。但都多出了int flags这个参数。取值如下：\n#define JS_PROP_CONFIGURABLE (1 \u0026lt;\u0026lt; 0) #define JS_PROP_WRITABLE (1 \u0026lt;\u0026lt; 1) #define JS_PROP_ENUMERABLE (1 \u0026lt;\u0026lt; 2) #define JS_PROP_C_W_E (JS_PROP_CONFIGURABLE | JS_PROP_WRITABLE | JS_PROP_ENUMERABLE) #define JS_PROP_LENGTH (1 \u0026lt;\u0026lt; 3) /* used internally in Arrays */ #define JS_PROP_TMASK (3 \u0026lt;\u0026lt; 4) /* mask for NORMAL, GETSET, VARREF, AUTOINIT */ #define JS_PROP_NORMAL (0 \u0026lt;\u0026lt; 4) #define JS_PROP_GETSET (1 \u0026lt;\u0026lt; 4) #define JS_PROP_VARREF (2 \u0026lt;\u0026lt; 4) /* used internally */ #define JS_PROP_AUTOINIT (3 \u0026lt;\u0026lt; 4) /* used internally */ /* throw an exception if false would be returned (JS_DefineProperty/JS_SetProperty) */ #define JS_PROP_THROW (1 \u0026lt;\u0026lt; 14) /* throw an exception if false would be returned in strict mode (JS_SetProperty) */ #define JS_PROP_THROW_STRICT (1 \u0026lt;\u0026lt; 15) JS_PROP_NORMAL：默认值，无任何属性 JS_PROP_ENUMERABLE：属性可被枚举（可被for...in遍历到） JS_PROP_WRITABLE：可写入 JS_PROP_CONFIGURABLE：属性的配置可被修改。如果第一次使用JS_DefinePropertyXXX未指定这个值，那之后使用JS_DefinePropertyXXX不允许修改属性的PROP。 JS_PROP_C_W_E：JS_PROP_CONFIGURABLE,JS_PROP_WRITABLE,JS_PROP_ENUMERABLE的组合 JS_PROP_GETSET：标识属性是getter/setter（一般和JS_DefineProperty配合使用）。注意： 使用这个枚举的时候还需要配合JS_PROP_HAS_GET,JS_PROP_HAS_SET来告诉QuickJS是否有getter/setter 传入的getter/setter相关JSValue需要被JS_FreeValue，因为底层调用了js_dup JS_PROP_THROW：如果JS_SetPropertyXXX或JS_DefinePropertyXXX产生了非法行为，抛出一个异常。 JS_PROP_THROW_STRICT：在严格模式下判断是否非法并抛出异常 JS_SetPropertyFunctionList # 可以通过这个函数一次性绑定多个属性：\nconst JSCFunctionListEntry entries[] = { // bind member function JS_CFUNC_DEF(\u0026#34;introduce\u0026#34;, 0, IntroduceBinding), // bind getter/settrer JS_CGETSET_DEF(\u0026#34;name\u0026#34;, NameGetter, NameSetter), JS_CGETSET_DEF(\u0026#34;bmi\u0026#34;, BMIBinding, nullptr), }; JS_SetPropertyFunctionList(ctx, proto, entries, std::size(entries)); 首先构造一个JSCFunctionListEntry数组，然后使用一些方便的宏就可以进行绑定。\n只需要将我们的绑定函数的函数指针传给宏即可，不需要担心函数类型不一致，宏会自动帮你处理（也就不需要JSCFunctionType）\n最后使用JS_SetPropertyFunctionList即可完成。\n缺点：JSCFunctionListEntry[]是传指针进去的。也就是说你需要保证此数组在JS_Eval之前是有效的。\n删除属性 # 使用JS_DeleteProperty函数。\nJS_SetProperty和JS_DefineProperty的区别 # JS_SetPropertyXXX相当于JS中的复制。当没有这个属性的时候，赋值时会生成一份属性。但不能给const属性赋值：\nJS_SetPropertyStr(ctx, global_this, \u0026#34;const_global_var2\u0026#34;, new_obj); // error! 而JS_DefinePropertyXXX则是定义属性。他等于是完全重新创建属性。\n","externalUrl":null,"permalink":"/books/quickjs-binding-tutorial/src/%E8%AE%BE%E7%BD%AE%E5%AF%B9%E8%B1%A1%E5%B1%9E%E6%80%A7%E7%9A%84%E6%96%B9%E6%B3%95/","section":"books","summary":"","title":"","type":"books"},{"content":" NickelEngine——图形学试验场地 # NickelEngine是一个我用来学习图形学方方面面的引擎。大部分的底层都是纯手工造轮子。\n工程开源地址：VisualGMQ/NickelEngine: a game engine based on ECS (github.com)\n在线Demo # 引擎在线Demo（某些Demo因技术原因暂时无法放入网页（菜，就多练））：\nTriangle-显示一个三角形 Cube-显示一个彩色立方体 TextureCube-显示一个带有纹理的立方体 NormalMap-法线贴图 引擎内容 # 引擎底层采用ECS架构。ECS库为自研的Gecs，主要借鉴开源项目EnTT的代码设计和内部算法，并且在API上加以改良，向Bevy-ECS的API设计靠齐。\n引擎内部拥有序列化和反序列化/预制体创建操作，核心是自研C++17反射库mirrow，主要借鉴meta，refl-cpp，ponder，RTTR反射库的实现和API设计。在原有的记录类信息反射的功能上添加了自动序列化/反序列化功能（目前仅可序列化为TOML文件，但预留了接口，可非侵入地增加其他文件的序列化功能）\n引擎渲染部分后端如下：\n平台/图形学API Vulkan OpenGL 4.3 OpenGLES 3.0 PC（Windows,MacOS,Linux） \u0026#x2714;\u0026#xfe0f; \u0026#x2714;\u0026#xfe0f; Android \u0026#x1f528; \u0026#x274c; \u0026#x1f528; Web \u0026#x274c; \u0026#x274c; \u0026#x2714;\u0026#xfe0f; 暂不考虑苹果系列产品。\n渲染部分拥有一个抽象层，封装了Vulkan和OpenGL。API风格借鉴WebGPU。\n引擎编辑器 # 为了能够实时查看引擎的功能，制作了一个编辑器。\n演示视频可见 Bilibili【NickelEngine简要介绍】\n目前只有2D功能，可以创建/删除Entity和Component。可以显示2D图像。并且有一个动画编辑器可以制作简单动画。\n","externalUrl":null,"permalink":"/nickelengine/","section":"","summary":"","title":"","type":"nickelengine"},{"content":"","externalUrl":null,"permalink":"/posts/bvh%E4%B8%AD%E4%BD%8D%E6%95%B0%E6%9E%84%E5%BB%BA%E6%80%A7%E8%83%BD/","section":"Posts","summary":"","title":"","type":"posts"},{"content":"我同时也是一名B站的技术UP主，经常创作一些技术视频。\n由于我的兴趣方向和工作方向是游戏引擎开发，所以我的频道中有很多游戏引擎底层技术相关视频。\n我的频道：单身剑法传人\n目前的系列有：\nVulkan入门 # 于2022年制作，2024年翻新。当时被Games101坑入渲染方向，沉迷于实时渲染底层技术，加上国内Vulkan相关教程几乎没有，所以我做了这系列视频。\n但经过这么长时间，视频中的很多技术已经落伍。而由于我现在的工作并不和渲染相关，所以我也没有再次翻新此系列的打算。\n在 Bilibili 观看 使用Rust制作软渲染 # 于2023年制作。此时依旧沉迷于渲染技术，在学习完Vulkan之后进行更加底层的渲染学习后制作。\n在 Bilibili 观看 SDL2和3的教程 # 于2022年制作。SDL是我在大学就开始用来制作游戏的库。制作此视频以帮助使用SDL制作游戏的开发者。\n在 Bilibili 观看 以及新出的SDL3的新功能一览\n在 Bilibili 观看 从0开始实现C++反射 # 于2024年制作。此时开始研究游戏引擎开发中非渲染部分的技术。自己实现了一套C++静态/动态反射框架并制作此视频。\n在 Bilibili 观看 ECS源码分析 # 于2026年制作。因为2024年前后ECS的话题度很高，所以学习了ECS的源码做了这个解析视频。\n视频中解析了EnTT和Flecs两大开源库的源码。\n下载PPT 在 Bilibili 观看 未来可预见的大活 # 已在开发，预计一定会出视频的项目：\nSDL3游戏开发实战：从0使用SDL3制作一款俯视角2D的联机游戏。这将是我最后一个手搓游戏引擎向底层向的技术视频。做完这个游戏我就全面转向UE/Godot等开源游戏引擎了。自研引擎真的没出路的有懂的吗。 VisualGMQ/TreasureLooter SDL3 game C\u0026#43;\u0026#43; 16 2 游戏物理引擎开发：从0实现3D游戏物理引擎。 VisualGMQ/ToyPhysics My toy 3D physics engine(WIP) C\u0026#43;\u0026#43; 2 0 也有配套的文章教程： 从0开始制作游戏物理引擎（一） 16 July 2026\u0026middot;42 字\u0026middot;1 分钟 ","externalUrl":null,"permalink":"/videos/","section":"","summary":"","title":"","type":"videos"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"}]