
1. 从贝塞尔曲线到曲面细分作业4到底在练什么很多人第一次打开作业4的说明文档时注意力会被“曲线”“曲面”这两个词带偏以为这是一次纯数学推导练习。实际上这份作业真正想让你掌握的是把一条参数曲线从数学定义变成屏幕上可交互、可调节、可细分的几何对象。它承接了前面几次作业里关于变换、光栅化和插值的思路又往前推了一步从离散的点走向连续的函数再从连续的函数重新采样回离散的线段。我在第一次做这个作业时犯过一个很典型的错误把贝塞尔曲线的递归求值直接写成了固定层数的循环结果在控制点数量变化时曲线形状完全不对。后来才意识到作业4的核心不是“画出一条曲线”而是理解de Casteljau算法为什么天然适合任意数量的控制点以及它和Bernstein多项式之间的等价关系。这两者一个从几何插值角度出发一个从代数展开角度出发最终得到同一条曲线但实现时的数值稳定性和代码结构差别很大。作业4通常包含两个主要部分一是实现贝塞尔曲线的递归求值并在此基础上完成曲线的绘制和反走样二是把曲线推广到贝塞尔曲面通过二维控制网格生成光滑曲面并支持按键盘切换不同细分层级。关键词里的“贝塞尔曲线”“de Casteljau”“曲面细分”“递归插值”基本覆盖了全部技术点。适合正在学习计算机图形学基础、需要动手理解参数曲线曲面的人参考也适合已经写过一遍但想搞清楚数值细节和交互逻辑的人重新梳理。下面我会按照实际编码顺序从算法选择、递归实现、绘制策略、曲面扩展、交互调试几个方面展开中间会穿插我踩过的坑和验证过的参数设置。你如果正在做这份作业可以直接对照着改代码如果只是好奇曲线曲面怎么在代码里落地也能从原理部分拿到足够直观的解释。2. 为什么de Casteljau比直接展开Bernstein更值得写2.1 两种等价定义的取舍逻辑贝塞尔曲线有两种常见定义方式。一种是Bernstein多项式形式曲线上的点等于控制点乘以对应的Bernstein基函数再求和。另一种是de Casteljau递归插值不断在相邻控制点之间按参数t做线性插值直到只剩一个点。数学上两者完全等价但写进代码里差别非常明显。Bernstein形式需要你显式计算组合数、幂次和阶乘。控制点数量一多阶乘很容易溢出浮点误差也会累积。而且每次求值都要重新算一遍所有基函数时间复杂度是O(n²)甚至更高。de Casteljau则是纯粹的线性插值嵌套每一步只做乘加运算数值稳定性好得多代码也更短。更重要的是递归结构天然对应控制点数量的变化你不需要为3个控制点和10个控制点写两套逻辑。我实测下来对于作业4这种控制点数量不固定、还要支持曲面二维扩展的场景de Casteljau是唯一合理的选择。Bernstein形式更适合用来做理论推导和验证比如你可以写一个小脚本对比两种方法在t0.5处的输出确认自己的递归实现没有写错。2.2 递归求值的代码骨架与边界条件de Casteljau的核心可以写成这样一段伪代码def de_casteljau(points, t): if len(points) 1: return points[0] new_points [] for i in range(len(points) - 1): x (1 - t) * points[i][0] t * points[i 1][0] y (1 - t) * points[i][1] t * points[i 1][1] new_points.append((x, y)) return de_casteljau(new_points, t)这段代码看起来简单但有几个边界条件必须注意。第一当控制点列表为空时应该直接报错或返回None而不是让递归无限进行。第二t的取值范围通常是[0,1]但如果你要做曲线延伸或外插t可以超出这个范围此时曲线会延伸到控制点凸包之外作业里一般不需要。第三浮点数比较不要用判断len(points)1之外的情况递归终止条件只依赖列表长度这是安全的。还有一个容易被忽略的点每次递归都创建新列表对于控制点很多、采样点很密的情况内存分配会比较频繁。如果你追求性能可以改成原地更新一个数组只保留当前层的结果。但在作业4的规模下递归写法完全够用可读性也更好。2.3 用中间过程验证递归正确性de Casteljau的一个好处是每一层插值得到的中间点都有几何意义。第一层插值得到的是控制多边形边上的点第二层得到的是更小控制多边形的顶点最后一层得到的就是曲线上的点。你可以在递归过程中把每一层的点画出来直观看到控制多边形逐步收敛到曲线。我当初验证实现是否正确时用了两个简单用例三个控制点共线时曲线应该退化成一条直线四个控制点构成正方形时t0.5处的点应该在正方形中心附近。如果这两个用例的输出不对说明插值权重写反了或者递归层数有问题。这种用几何特例做单元测试的方法比单纯对比数值更直观也更容易发现符号错误。3. 从曲线到曲面二维控制网格的递归展开3.1 双线性插值与两次曲线求值贝塞尔曲面的自然扩展方式是先对每一行控制点做一次曲线求值得到一组中间点再对这些中间点沿列方向做一次曲线求值。这本质上是对u和v两个参数分别做de Casteljau最终得到一个曲面上的点。数学上这叫张量积曲面代码上就是两层循环加两次递归调用。具体来说假设你有一个4x4的控制网格想求参数(u,v)处的曲面点。第一步对每一行四个控制点用参数u做de Casteljau得到四个中间点。第二步把这四个中间点作为新的控制点用参数v再做一次de Casteljau得到最终点。顺序反过来也可以先v后u结果一样因为张量积是可交换的。这里有个实现细节如果你把曲线求值函数写成接受一维点列表的形式那么曲面求值就是先构造行列表对每行调用曲线函数收集结果后再调用一次。代码复用度很高不需要为曲面单独写一套递归。3.2 细分层级与网格密度的关系作业4通常要求支持按键盘数字键切换细分层级。这里的“细分”指的是在u和v方向上各取多少个采样点。比如层级为1时u和v各取2个点曲面用2x2的四边形网格表示层级为2时各取3个点网格变成3x3层级为n时各取n1个点。这个层级和曲线绘制时的采样密度是同一个概念。层级越高曲面看起来越光滑但计算量和绘制线段数量也成倍增长。我一般把默认层级设在4到6之间既能看出曲面形状又不会让交互卡顿。如果作业要求实时响应键盘切换建议把每次求值的采样点数量控制在20x20以内再高的话普通笔记本上会有明显延迟。还有一个容易混淆的地方控制网格的细分层级和曲面本身的采样层级是两回事。控制网格是输入通常是4x4或3x3固定不变采样层级是输出决定你画多少条线来近似曲面。作业里说的“切换细分层级”一般指后者。3.3 曲面线框的绘制顺序与隐藏线处理画曲面线框时你需要决定先画u方向还是v方向的线。如果两个方向都画最终会得到一个网格。绘制顺序不影响最终图像但影响你调试时看到的中间状态。我习惯先画u方向固定、v变化的线再画v方向固定、u变化的线这样在调试时可以先确认一个方向的曲线是对的再叠加另一个方向。关于隐藏线作业4通常不要求做深度测试或消隐直接画所有线段即可。但如果你想让曲面看起来更有立体感可以按参数v从小到大绘制利用画家算法让后面的线覆盖前面的线。不过在线框模式下覆盖关系并不明显所以大多数实现都是直接全部画出来。真正需要消隐的是后续的着色作业不是这一次的重点。4. 绘制与反走样让曲线看起来不像折线4.1 采样密度与线段数量的平衡贝塞尔曲线在代码里最终是用一系列短线段逼近的。采样点越多曲线越平滑但绘制开销越大。作业4一般要求你根据曲线长度或固定步长来采样。固定步长实现简单但控制点分布不均匀时曲率大的地方线段会显得不够密。我试过两种策略一种是固定采样点数比如每条曲线取100个点另一种是自适应采样根据相邻采样点的距离或角度变化决定是否加密。固定点数在作业规模下完全够用而且代码简单不容易出bug。自适应采样更适合生产环境但调试起来麻烦不建议在作业里给自己增加难度。一个经验值是对于屏幕空间内跨度几百像素的曲线取50到100个采样点就能看不出折线感。如果曲线特别长或特别弯可以适当增加到200。超过200之后视觉提升很小但计算量线性增长。4.2 反走样的必要性直接画线段时由于像素网格的离散性斜线会出现明显的锯齿。作业4通常要求做一定程度的反走样常见做法是超采样在每个像素内取多个子样本根据覆盖比例决定最终颜色。另一种做法是用Wu直线算法但作业框架一般已经提供了画线函数你只需要在采样时增加密度或者利用框架自带的反走样选项。我实际做的时候发现最省事的方法是提高采样密度让线段足够短短到每个像素内都有多个线段端点这样锯齿会被自然平滑掉一部分。如果框架支持开启多重采样直接在初始化时打开即可不需要自己写反走样逻辑。但要注意开启多重采样会增加显存和计算开销如果曲面层级很高可能会掉帧。4.3 控制点与控制多边形的可视化作业4通常要求把控制点和控制多边形也画出来方便观察曲线和控制点的关系。控制点一般用不同颜色的小圆点表示控制多边形用虚线或细线连接。这部分不涉及复杂算法但有几个细节值得注意。第一控制点的绘制应该在曲线之后这样点不会被曲线遮挡。第二控制多边形的线段颜色要和曲线区分开我一般用灰色画控制多边形用亮色画曲线。第三如果控制点可以拖动需要实现鼠标拾取判断鼠标位置是否在某个控制点的半径范围内。拾取半径不要太小否则很难点中也不要太大否则会误选相邻点。我一般用8到10像素作为拾取半径在1080p屏幕上手感比较合适。5. 交互调试中那些文档不会告诉你的坑5.1 参数t的步长与曲线端点采样时t从0到1步长通常是1/(N-1)其中N是采样点数。如果你写成1/N最后一个采样点会略小于1曲线末端会缺一小段。这个错误很隐蔽因为缺的那段很短不仔细看看不出来。我当初调了半天以为是递归实现有问题后来才发现是步长少了一个端点。正确的做法是让t取到1也就是循环N次t i / (N - 1)i从0到N-1。如果N1需要特殊处理直接取t0。这个细节在文档里通常不会强调但实际写代码时很容易踩。5.2 键盘回调与状态刷新作业框架一般用GLUT或类似库处理键盘输入。切换细分层级时你需要在键盘回调里更新一个全局变量然后触发重绘。这里有个常见问题如果只更新变量而不调用重绘函数画面不会变化。另一个问题是键盘回调里的变量作用域要正确如果是局部变量重绘时读不到更新后的值。我建议把细分层级、控制点坐标这些状态都放在全局或类成员里键盘回调只负责修改状态和请求重绘。这样逻辑清晰也不容易出作用域错误。如果框架支持可以在修改状态后打印一行日志确认回调确实被触发了。5.3 曲面求值时的参数映射曲面求值时u和v的采样是独立的。如果你用两层循环外层u内层v那么每次内层循环都会重新计算一行控制点的曲线求值。这其实有优化空间对于固定的u行方向的曲线求值结果可以缓存下来内层v循环直接复用。但在作业规模下这种优化带来的提升有限代码复杂度却会增加。我建议先写清楚版本确认结果正确后再考虑优化。另一个坑是参数范围。曲线是[0,1]曲面也是[0,1]x[0,1]。如果你不小心把u或v映射到了其他范围曲面会变形或超出预期区域。调试时可以先固定v0.5只让u变化画出一条曲线确认这条曲线在控制网格的中间位置再放开v。6. 从作业4延伸出去曲线曲面还能怎么玩作业4的代码框架其实可以扩展出不少有意思的东西。比如把贝塞尔曲线换成B样条只需要修改基函数和递归逻辑控制点的影响范围会变得更局部曲线也更可控。再比如把曲面求值的结果用于生成三角网格接上后续的光照模型就能得到带明暗的曲面渲染。我在做完作业4之后试着把控制点做成可拖拽的实时更新曲线和曲面。拖拽时只更新被拖动的控制点其他点不变然后重新求值。由于de Casteljau每次求值都是独立的这种实时更新非常流畅在普通笔记本上也能跑到60帧。这个交互体验让我对参数曲线的理解从公式变成了手感比单纯看推导有用得多。如果你也想做类似扩展建议先把作业4的基础版本跑通确认曲线和曲面都能正确绘制、层级切换正常再往上加交互。否则一旦出问题你很难判断是基础算法错了还是交互逻辑错了。先稳后快这个顺序在图形学作业里尤其重要。