☰
从变换树到矩阵栈:人形机器人层级建模与关节动画
2026/10/27 6:00:46 网站建设 项目流程

人形机器人这道题在图形学课程里通常挂在"实验补充"的位置,看着像送分题,实际上是个分水岭——前面画三角形、画立方体、做旋转,本质都是把一堆顶点摆到世界坐标里;到了这道题,才第一次要求你放弃"直接算世界坐标"的想法,改用一个父子嵌套的变换树来组织整个人形机器人。我见过太多份作业卡在同一个地方:零件单个看都对,一抬胳膊整条手臂连着手一起飞到天花板,或者一迈腿上半身跟着歪成比萨斜塔。问题几乎从来不是几何画错了,而是层级建模里最核心的那件事没做对——子节点的几何体到底定义在哪个坐标系里。下面这些内容就是围绕这道题拆开的:关节树怎么设计、矩阵栈怎么维护、绘制顺序为什么不能随便改、动画怎么驱动、以及出错之后按什么顺序去查。适合已经会画基本图元、知道矩阵乘法大概在干什么,但一碰到嵌套变换就发懵的同学;也适合做完主实验想再练一轮的人。

1. 人形机器人这道补充题的真正考点:建一棵变换树,而不是摆一堆零件

先把最容易走偏的思路摆出来。很多人拿到题的第一反应是:机器人嘛,不就是躯干一个长方体、头一个球、四肢各两根圆柱,那我把每根圆柱的圆心坐标算出来,一个个画上去不就行了。用这种思路能画出一个静止的、看起来还不错的机器人,甚至可以手动算出"手臂抬 30 度之后手腕在哪"然后直接把几何体挪过去。但它有个致命问题:只要关节一动,所有下游零件的世界坐标都得重算,而且重算的公式会随层数增加迅速变成一坨没法维护的东西。抬个手臂要算肩、算肘、算腕三层,走一步路要算髋、膝、踝、脚掌四层,代码里全是三角函数嵌套,改一个参数全线崩溃。

层级建模要解决的就是这件事。它的核心思想用一句话说清楚:把每个零件的顶点定义在它所属关节的局部坐标系里,父关节的变换会自动往下传递。肩膀转了,上臂的局部坐标没变,但它所在的那个坐标系整体转了,于是上臂跟着转;肘关节是上臂的子节点,它继承的变换里已经含了肩膀的旋转,再叠加自己的弯曲,手腕自然就跟到了正确位置。你不需要在任何地方显式地计算"手腕的世界坐标是多少",这个值是从根节点一路乘下来的副产品。这跟人体骨架是一回事:你抬大臂的时候并没有单独指挥小臂和手掌,它们是被骨骼链条带过去的。

1.1 从"世界坐标摆放"到"父子关节链"的思维切换

思维切换的关键在于承认一件事:机器人的每个部件,它的位置是相对于父关节的原点描述的,不是相对于场景中心。举个具体例子,左上臂这根圆柱,它的几何顶点应该这么定义——关节原点在肩膀处,圆柱从原点沿着负 Y 轴往下延伸 1.2 个单位,半径 0.15。肘关节节点的偏移量写 (0, -1.2, 0),因为它在肩膀坐标系里就是往下 1.2。前臂那根圆柱同理,原点在肘、往下 1.1。这么定义之后,你在建模阶段完全不用关心肩膀到底在世界的哪个位置,那个信息由根节点(躯干)的变换提供。

这个切换带来的最直接好处是对称性免费。人的左右手是镜像的,如果你用世界坐标硬摆,左右两边得写两套数据,还容易不对称。用父子链的话,右侧肩关节的偏移是 (0.9, 2.6, 0),左侧就是 (-0.9, 2.6, 0),其余子树的结构和参数完全一样,只是旋转方向取反。整个左臂可以复用右臂的构建函数,传一个 sign 参数进去就行。同理,腿也是。

还有一个隐性好处是可复用。你写好的这套节点结构,换一个比例参数就能变成四足机器人、机械臂、或者一棵会随风摆的树,因为"父子继承变换"这件事跟具体长什么样无关。真正值钱的是这套结构,人形机器人只是它的一个练习题面。

1.2 关节自由度怎么分配才够用又不失控

一开始很容易犯的错是给每个关节塞满自由度。肩膀上挂三个欧拉角,髋关节再来三个,全身加起来二十几个参数,调起来完全是灾难——你改一个角,另外两个角的效果跟着变,根本不知道自己在调什么。实战里更稳的做法是一个节点只负责一个旋转轴,需要多个自由度就串几个节点。这条经验在层级建模里特别管用,因为它把"多轴旋转顺序歧义"这个问题直接消掉了。绕 X 再绕 Y,和绕 Y 再绕 X,结果不一样,而且中间会出现万向节锁;但只要每个节点只有一根轴,顺序就是结构本身决定的,不存在歧义,也不会锁死。

按这个原则,人形机器人的关节分配大致是这样:肩部拆成三个节点,第一个管前后摆(绕 Z),第二个管侧平举(绕 X),第三个管自体旋转(绕 Y),三个节点原点重合、偏移都是零;肘部一个节点,只允许单向弯曲(绕 X 或者绕 Z,看你的朝向约定);腕部一到两个;髋部同样拆三个;膝部一个单向;踝部两个。这样算下来单侧手臂 5 到 6 个自由度、单侧腿 6 个自由度,躯干和颈各给两三个,总数二十出头,但因为是单轴节点,每一个角度都是可以独立理解、独立调试的。

提示:所有"单向关节"(肘、膝)一定要在代码里加限位,肘只能往一个方向弯 0 到 150 度左右,膝只能往后弯 0 到 140 度。少了限位,你调动画的时候一个手滑输入负角度,小臂就从小臂内侧穿出去了,视觉上非常难看,而且这种错误在静止画面里不容易发现。

2. 关节树的落地数据结构:每个节点到底该携带哪些量

结构设计这一步值得多花十分钟想清楚,因为它决定了后面所有代码好不好写。我习惯的写法是每个关节节点携带五个东西:一个相对父节点原点的平移偏移量 offset、一个旋转轴 axis、一个当前角度 angle、一个可选的局部缩放 scale,以及一个子节点列表。绘制这个节点自己的肢体几何时,用的是当前累积矩阵;绘制子节点时,先把当前矩阵压栈、乘上本节点的变换、递归、再弹栈。

这里有个容易忽略的设计细节:节点自己的几何体和节点的变换之间是什么关系。正确的顺序是——先用 offset 把自己平移到父坐标系里的正确位置,再施加自己的旋转,然后在这个已经旋转好的局部坐标系里画自己的肢体。也就是说,"上臂这根圆柱"是在肩膀旋转之后的空间里画的,它天然跟着肩膀转。如果你把顺序写反,先旋转后平移,那根圆柱会绕着父节点原点画一个圆弧飞出去,这就是很多人第一次跑出来"手臂脱离身体"的直接原因。

struct Joint { std::string name; glm::vec3 offset{0.0f}; // 相对父节点原点的平移 glm::vec3 axis{0, 0, 1}; // 本节点的唯一旋转轴 float angle = 0.0f; // 当前角度(弧度) float minAngle = -1.0f; // 限位 float maxAngle = 1.0f; void (*drawBody)(const glm::mat4&) = nullptr; // 画本节点肢体 std::vector<Joint*> children; };

绘制函数就是一个标准的前序递归,短得几乎不像核心代码:

void drawJoint(Joint* j, MatrixStack& ms) { ms.push(); ms.apply(glm::translate(glm::mat4(1.0f), j->offset)); ms.apply(glm::rotate(glm::mat4(1.0f), j->angle, j->axis)); if (j->drawBody) j->drawBody(ms.top()); for (Joint* c : j->children) drawJoint(c, ms); ms.pop(); }

十几行代码搞定全身绘制,这就是层级建模的回报。但前提是 offset 和 drawBody 的坐标系约定必须严格统一,一旦有人把某个部件的几何体按世界坐标建模了,整条链立刻失效。

2.1 用统一单位和固定比例先把骨架"量"出来

动手写代码之前,强烈建议先在纸上把骨架尺寸列成一张表。人形机器人不需要解剖学精确,但比例一乱,怎么调动画都有股说不出的怪。常用的人体比例是 7 到 7.5 个头高,机器人可以简化到 6.5 到 7,头略大一点反而更可爱。我通常把"躯干高度"定成基准单位 1.0,其他所有尺寸都以它为单位表示,这样换比例的时候改一个数就行。

部位相对父节点偏移本段长度/半径关节轴建议限位
躯干(根节点)(0, 0, 0)高 1.00,宽 0.55——
颈(0, 0.52, 0)高 0.14Y / Z±25°
头(0, 0.14, 0)半径 0.16X / Y±40°
右肩(0.32, 0.44, 0)—Z→X→Y 三层各 ±90°
右上臂(0, 0, 0)长 0.42,半径 0.06——
右肘(0, -0.42, 0)—Z0 ~ 150°
右前臂(0, 0, 0)长 0.38,半径 0.05——
右手(0, -0.38, 0)长 0.12X / Y±45°
右髋(0.14, -0.50, 0)—Z→X→Y 三层各 ±90°
右大腿(0, 0, 0)长 0.46,半径 0.075——
右膝(0, -0.46, 0)—X0 ~ 140°
右小腿(0, 0, 0)长 0.44,半径 0.06——
右踝(0, -0.44, 0)—X±35°
右脚掌(0, 0, 0)长 0.20,宽 0.10——

表里"偏移"这一列全是相对父节点说的,这一点务必核对。比如右肘的 (0, -0.42, 0),是"在肩关节坐标系里往下 0.42",不是世界坐标。左半边把 X 取负就完事。有了这张表,代码里的数字就都有了出处,不会出现"这个 0.7 是哪来的"这种半年后自己都看不懂的情况。

2.2 把"关节原点"放在解剖学关节位置,别放在肢体中心

这条经验吃过亏才记得住。初学者画圆柱体的习惯是用默认的居中几何体——圆柱中心在原点,上下各一半。如果直接把这种几何体挂到关节上,得到的视觉效果是"关节位于肢体正中间",一弯肘,小臂会绕着自己的中点转,整条手臂像折纸一样错位。正确做法是让每个肢体的几何体从关节原点沿肢体方向延伸,也就是说,圆柱的顶面圆心在原点、底面在负 Y 方向 length 处。

实现上有两条路:一是在建模时就把顶点按这个约定生成,比如圆柱用参数化循环自己算,Y 从 0 到 -length;二是沿用居中几何体,但在 drawBody 内部先乘一个 translate(0, -length/2, 0),把它挪到位。第二种更省事,思路也更清晰——几何体保持原样,位置补偿放在绘制函数里,和关节的变换逻辑分开。我倾向于第二种,因为几何体可以复用(同一个单位圆柱缩放到不同长度),补偿量又是显式写出来的,一看就懂。

3. 变换顺序与矩阵栈:整道题翻车率最高的一段

如果只能给这道题留一条经验,我会留这条:先平移,后旋转,再缩放。这个顺序在层级建模里不是可选项,而是定义。原因很简单,矩阵是对顶点做变换的,而矩阵乘法从右往左作用——写出来的M = T * R * S,作用到顶点上是先 S、再 R、最后 T。它的几何含义是:先把几何体自身缩放好、在原地转起来,然后整体搬到父坐标系里在 offset 指定的位置。这正是我们想要的"关节在某个位置,绕自己的原点转"。

反过来写M = R * T的含义完全不同:它表示先把几何体搬到 offset 位置,然后整个东西绕着父坐标系的原点转。放在肩膀上是这样:上臂被搬到了 (0,-0.42,0),然后绕肩膀原点旋转——看着好像也对?问题出在更深的层次。肘关节的偏移是在肩膀坐标系里说的,如果肩膀这一层用了R * T,那么肘关节所在的坐标系原点被甩到了别处,接下来所有的位置推导全部错位。表现出来的症状就是"越往下的关节偏得越离谱",指尖飘在离手掌半米远的空气里。

3.1 一个可以立刻手工验算的最小例子

拿右上臂和右肘做验证最直观。设肩关节角 90 度,绕 Z 轴。肩膀的 offset 是 (0.32, 0.44, 0),上臂长度 0.42,肘的 offset 是 (0, -0.42, 0)。

正确顺序下,肘关节的世界位置应该是:先把 (0,-0.42,0) 绕 Z 转 90 度,得到 (0.42, 0, 0),再加上肩的偏移 (0.32, 0.44, 0),最终落在 (0.74, 0.44, 0)。视觉上是手臂从竖直变成水平,肘从肩膀下方转到了右侧,距离肩膀还是 0.42。拿尺子量一下渲染结果,两点距离应该正好等于上臂长度——这是一个特别好用的自检方法:层级建模里,相邻关节之间的距离必须恒定,任何角度下都不该变。如果你发现关节间距随角度变化,那一定是变换链里混进了多余的缩放或者平移位置错了。

顺序写反的结果是:肘先被搬到 (0,-0.42,0),然后加上肩偏移变成 (0.32, 0.02, 0),再整体绕某个原点转,位置就完全不受控了。这种错误在单关节场景里不一定显眼,一旦全身二十几个节点串起来,整个机器人会像被拧过的毛巾。

3.2 不用固定管线时,矩阵栈得自己写

老式接口里有现成的压栈出栈函数,现代管线里这些都没了,得自己维护一个栈。实现极其简单,但有几个地方要注意:栈里存的应该是完整的世界矩阵而不是增量矩阵,压栈就是复制一份当前的栈顶,出栈就是弹出;应用变换是在栈顶做右乘,这样父子关系才能正确叠加。

class MatrixStack { std::vector<glm::mat4> s; public: MatrixStack() { s.push_back(glm::mat4(1.0f)); } const glm::mat4& top() const { return s.back(); } void push() { s.push_back(s.back()); } void pop() { s.pop_back(); } void apply(const glm::mat4& m) { s.back() = s.back() * m; } // 右乘 };

apply里必须是右乘,左乘会让新变换叠加在已有变换的外面,父子关系直接倒过来。这个 bug 特别隐蔽,因为在只有一层的时候左右乘结果一样,到第二层才开始崩。

另一点是push 和 pop 必须严格配对,且 pop 要放在递归子节点之后。少了 pop,兄弟节点会继承上一个兄弟的变换,表现是"左手臂长在右手的位置上"或者"另一条腿跟着旋转"。如果你写的是递归函数,最稳妥的结构就是函数开头 push、结尾 pop,中间无论多少分支都不会漏。我一般还会在 pop 前加一句断言检查栈深不为零,防止括号配对错误导致的越界。

3.3 缩放要格外小心,它不该出现在关节层

关节节点里我留了个 scale 字段,但实际项目里几乎不用。原因是非均匀缩放会污染子节点的坐标系。假设你把躯干沿 Y 拉长 1.5 倍,那么所有子节点继承到的矩阵里就带了这个非均匀缩放,手臂被拉长、头变扁、关节原点位置也跟着变形,而且旋转在这种被压扁的坐标系里会变成剪切,看起来非常诡异。合理做法是把缩放限制在叶节点,也就是"画几何体的时候"缩放,而不是"节点变换的时候"缩放。这样每个肢体该多粗多长由几何体参数控制,变换链里只有平移和旋转,是最干净的刚体变换。

顺带提一个光照上的坑:非均匀缩放之后,法线不能再用顶点法线乘同一个模型矩阵,得用模型矩阵左上 3×3 的逆转置去变换法线,否则法线方向会偏,光照看起来发脏发黑,或者明暗交界位置对不上。这条在只画纯色的时候完全看不出来,一旦开了光照就会暴露。

4. 从躯干一路递归到指尖:绘制流程拆开看

整个绘制是一个深度优先遍历。根节点(躯干)先应用自己的变换——通常是位置和整体朝向;然后画躯干几何;接着依次递归每个子节点。顺序上有个小讲究:先画父,再画子,因为子节点的矩阵依赖父节点已经压栈的状态。如果你的遍历是先把所有子节点处理完再画自己,那 push 的时机就要跟着调整,容易出错,不如统一用前序。

具体到代码执行,一次绘制的调用栈深度大概是这样的:drawJoint(躯干) → push,乘躯干变换,画躯干 → 对每个子节点 drawJoint(颈)、drawJoint(右肩)、drawJoint(左肩)、drawJoint(右髋)、drawJoint(左髋) → 每个子节点内部再 push、乘、画、递归。到指尖的时候,栈里已经压了七八层矩阵,每一层都是"从根到当前节点"的累积变换。栈顶那个矩阵就是当前肢体的世界矩阵,直接传给着色器当 model 矩阵用。

4.1 每帧重新遍历,而不是缓存世界矩阵

一个常见的优化冲动是:把每个关节的世界矩阵算一次缓存起来,每帧只更新变化的部分。对于这道题的规模(二三十个节点)完全没必要,而且容易引入"某个角度改了但子节点没更新"的一致性 bug。每帧从根节点完整遍历一次,代价是几十次 4×4 矩阵乘法,对现代硬件来说可以忽略。等你真的做到几百个节点的角色骨骼时再谈缓存,那时候的正确做法是维护脏标记而不是全量缓存。

真正需要每帧做的是先更新所有关节的角度,再统一绘制。千万别在绘制过程中修改角度,因为绘制顺序和关节树的逻辑顺序一致,你在画左臂的时候改右臂的角度,本帧画面就会出现左右不同步的鬼影。分两步走:update(dt) 更新所有角度,render() 只读不写,逻辑清晰得多。

4.2 关节可视化和层级着色,是排查问题的第一把工具

调试层级模型最有效的手段,不是打印矩阵,是在每个关节原点画一个小的球体或者三根短线。小球用当前矩阵画,位置就是关节原点,大小固定。只要小球出现在正确位置,说明矩阵链是对的;如果小球跑到奇怪的地方,那就从根节点开始逐层看是哪一层开始偏的。这一招我几乎每做一个新的层级模型都会先加上,做完再删。

第二个工具是按深度上色。给每个节点传一个 depth 参数,色相随深度变化,躯干一个色、上臂一个色、前臂一个色、手一个色。一眼就能看出"这段几何体到底是哪个节点的",尤其是在几何体互相穿插的时候。等你确认结构没问题,再把颜色换回正常配色。这两个调试手段加起来不到三十行代码,能省掉好几个小时。

提示:如果机器人整体看起来是对的,但某一侧的手臂怎么调角度都是歪的,先怀疑左右镜像的时候轴的正负号搞错了。镜像不是简单地把 X 取反——绕 Z 轴的旋转在镜像之后,符号也要跟着反,否则左手会做出和右手完全相反的动作。最省事的做法是给左右两侧传一个 sign,所有角度的最终值乘上它。

5. 让机器人动起来:相位驱动的走路循环怎么调

静止的层级模型只是及格线,能让它走两步才算把这道题做透。很多人第一版的动画是"每个关节各自正弦摆动、频率随便给",结果看起来像个抽搐的提线木偶——因为人的走路是有相位关系的:左腿向前的时候右腿向后,手臂和腿反相摆动,膝盖的弯曲总是发生在腿往后抬起的阶段。这些关系不对,动画就散。

正确的做法是定义一个全局相位 φ = 2π·f·t,然后所有关节的角度都写成 φ 的函数,而不是各自独立摆动。以髋关节为例,左右两侧的角度就是 ±A·sin(φ);膝关节因为是单向的,用 0.5·(1 - cos(φ)) 这种非负函数再乘上幅度,保证只在特定相位弯曲;踝关节加一点延迟相位,模拟脚掌落地时的翻转。手臂与同侧腿反相,也就是用 sin(φ + π)。

关节驱动表达式(右/左)说明
髋(前后摆)A·sin(φ) / A·sin(φ+π)幅度约 25°,左右反相
膝(弯曲)B·max(0, sin(φ+π/2))只往一个方向弯,幅度约 50°
肩(前后摆)C·sin(φ+π) / C·sin(φ)与同侧腿反相,幅度约 20°
肘D·(1 + sin(φ))/2轻微跟随摆动,别太大
躯干(上下)E·sin(2φ)
颈小幅度跟随躯干增加一点"活"的感觉

调这套参数的过程建议一次只调一个关节,把其他全部归零,看它单独动起来对不对;确认一个再加下一个。全身一起调的时候人眼是分辨不出哪个关节在抽的。如果条件允许,把速度降下来慢放,问题会明显得多。

5.1 用真实时间驱动,别用帧计数

一个很典型的问题:在性能好的机器上动画飞快,在性能差的机器上慢吞吞。原因是角度用"每帧加 0.01"来递增,帧率一变速度就变。正确做法是记录两帧之间的时间差 dt,角度按 dt 累加:phase += 2.0f * M_PI * freq * dt。这样无论 30 帧还是 144 帧,一秒走的步数都是一样的。

dt 本身也要做处理。窗口最小化恢复、或者卡了一下,dt 可能突然变成几秒,动画就会瞬间跳一大截。常规做法是给 dt 加个上限,比如超过 0.1 秒就按 0.1 算,避免大步长穿越。如果你的角色以后要做碰撞或者物理,还需要固定步长累加器,不过这道题用不到。

5.2 加一点"不完美"会让动画看起来可信得多

纯正弦驱动的走路循环有个特点:太规整了,像节拍器。让效果上一个台阶的做法是往里掺一点噪声和不对称。比如左右两侧的幅度略有差异(0.95 和 1.0)、每个周期的频率有个微小抖动、手臂的摆动稍微滞后于腿。这些都是几行代码的事,但视觉上立刻从"机械摆动"变成"像在走路"。

另一个小技巧是给静止状态也留一点余量。全身所有角度都精确归零的时候,模型会是一根笔直的棍子,看起来像立正的塑料人。初始状态就让肘微微弯 10 度、膝微屈 5 度、肩稍微外展,姿态立刻就自然了。这个细节跟层级建模的技术本身没关系,但它决定了成品的第一印象。

6. 排错实录:从"散架"到"错位",按症状定位问题

跑出来不对是必然的,重要的是按什么顺序查。层级建模的 bug 有很强的规律性,几乎每种视觉症状都对应着有限的几个原因。下面这张表是我实际排错时最常用的对照,可以先照着对号入座,再去下面的小节看具体怎么验证。

症状最可能的原因快速验证手段
整条手臂绕身体中心转变换顺序写成 R·T把该关节角设为 90°,量关节到父节点距离
越往下偏得越离谱某一层少了 push/pop,或用了左乘逐层打印栈顶矩阵的平移分量
左手臂长在右肩位置兄弟节点缺少 pop,变换被继承在 pop 前断言栈深
关节间距随角度变化链里混入了非均匀缩放关掉所有 scale,只留 T 和 R
小臂从肘部内部穿出几何体以中心建模,未做补偿检查 drawBody 里有没有 -length/2 的平移
法线发黑、光照不对用了模型矩阵直接变换法线换成左上 3×3 的逆转置
肢体互相闪烁、深浅不一深度问题开启深度测试并清深度缓冲

6.1 症状一:手臂跟着转,但转轴不在肩膀上

这是最经典的一个。表现是:抬肩关节的时候,整条右臂确实转了,但它的旋转中心不在肩膀,而是偏到了身体中线附近,看起来像整条胳膊绕着脊椎转。原因几乎可以确定是变换顺序写成了先旋转再平移,或者平移量写成了"从世界原点算起"的绝对位置。

验证方法很直接:把肩关节角设为 90 度,暂停动画,量一下肩膀和肘关节小球之间的距离。如果这个距离不等于上臂长度,那就不是数值误差,是坐标系用错了。再把这层矩阵单独打印出来看它的第四列(平移部分),如果平移里出现了和旋转角相关的项,说明平移被旋转影响了,顺序肯定错了。

修法是把ms.apply(rotate)和ms.apply(translate)调换位置,确保平移在前。同时检查 offset 的数值本身是不是相对父节点的——有的人把肩膀的世界坐标 (0.32, 0.44, 0) 直接写进了肘节点的 offset,那肘就会飞到身体外面去。

6.2 症状二:越靠下的关节偏得越厉害

这个症状说明错误发生在链条的上游,而且是会累积的那类错误。最可能是某一层忘了 pop,导致变换在兄弟之间串了;或者 apply 用了左乘,父子顺序反了。它的特征是:靠上的关节大致对,越往下偏得越多,偏差方向还和父节点的旋转有关。

排查方式是给每个节点加一行调试输出,打印节点名和当前矩阵的平移分量,然后把所有关节角清零。角度全零的时候,所有节点的世界位置应该完全由 offset 累加决定,这时候打印出来的数值可以直接和纸上算的对一遍,一眼就能找到第一层出错的位置。全零状态是对的,再逐个关节加角度,哪一层加上去之后开始崩,问题就在那一层。

6.3 症状三:肢体穿插、脚踩进地面、小臂穿出手心

这类问题通常不是变换链的错,而是几何体本身的尺寸或者限位没配好。比如上臂半径 0.06、前臂半径 0.05,肘关节弯曲到 150 度的时候,两根圆柱的端面会插进彼此,因为它们在关节处是硬碰硬的。解决办法是在关节处放一个半径略大于肢体的球体当"关节窝",既遮住了接缝,也符合机械关节的视觉。这个球体用当前节点的矩阵画,位置在原点,简单又有效。

脚踩进地面是另一回事,属于根节点高度没算对。人在行走时重心是有起伏的,支撑腿伸直的时候骨盆最高,双腿交替的中间时刻最低。如果你的根节点 Y 是固定的,摆动腿的脚一定会穿进地面。要么把根节点的 Y 做成关于相位的函数,要么接受轻微穿插——课程作业里前者更稳妥,加一行rootY = baseY + 0.02 * fabs(sin(2 * phase))之类的表达式就够。

6.4 症状四:头发黑、模型整体发暗

开了光照之后模型变暗,八成是法线的问题。如果模型矩阵里只有平移和旋转,直接用左上 3×3 变换法线没问题;但只要掺了任何非均匀缩放,法线就会偏。判断方法很简单:把缩放全设为 1,如果光照恢复正常,就确认是这个问题。修法是在 CPU 端算好法线的变换矩阵(模型矩阵左上 3×3 的逆转置)传进着色器,或者干脆在着色器里对每个顶点的法线单独做处理。

还有一种发暗的原因更基础——法线方向整个反了,背面朝外。绕序(winding order)反了会导致正面剔除把该显示的面剔掉,看起来就是模型缺一块或者里外翻转。检查办法是临时关掉背面剔除,如果模型立刻完整了,那就是绕序问题,把索引顺序改一下即可。

7. 交付之前,我会额外补的几处细节

结构跑通、动画能走之后,还有几件事值得花时间,因为它们直接影响成品的观感,而且做起来都不难。第一件是给每个圆圈状的关节补一个球,前面提过,能显著减少穿插感。第二件是给地面加一个淡淡的网格或者阴影,让悬空的机器人有落点感,最简单的做法是在 Y=0 平面上画一个大方格,用低饱和度的颜色。第三件是相机交互,加一个鼠标拖拽旋转视角、滚轮缩放,评审的人能自己转着看,比固定视角好得多。

第四件是参数外置。把骨架尺寸、关节限位、动画幅度这些数字全部集中到一个结构体或者一份配置文件里,而不是散落在各个绘制函数里。做完之后你会想调比例,调比例的时候才发现数字满天飞有多痛苦。集中管理之后,改一个数字就能让机器人从瘦高变成矮壮,试错成本极低。

最后提一个我觉得最能体现"做懂了"的验收标准:把这个机器人的构建代码复制一份,把躯干换成一条鱼的身体,四肢换成一串摆动的鳍,看能不能在十分钟内改出来。如果能,说明你写的是层级建模;如果不能,说明你写的其实还是硬编码坐标,只是恰好长得像人。这道补充题真正想练的,是那个可以迁移到任何带关节结构上的思维方式,人形机器人只是它最直观的那张皮。我在第一次做的时候也是这样,把一堆坐标和角度堆在绘制函数里,跑出了当时觉得挺酷的东西;后来重写第二遍,把所有变换都收拢到一个节点结构里,代码从三百多行掉到一百出头,改起来还更自由。这个从"摆零件"到"搭骨架"的转变,比最后做出来那个会走路的模型本身值钱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询