简介:这是一份面向中国大学生物理竞赛相关课题的C++源码资源,核心模拟了水瓶受到外部动量冲击后水体的运动过程,并借助图形界面形成可视化游戏。开发者使用Visual C++环境编写,适合备赛的物理类竞赛选手,也适合通过动手项目掌握物理模拟与程序设计的编程学习者。压缩包内仅含一个cpp源文件,整体约1KB,体量虽小,却集中体现了动量守恒、牛顿运动定律和流体动力学模型的应用。通过阅读文件,可学习如何用类封装水与水瓶对象、如何根据动量传递计算水的运动轨迹、如何借助图形库完成渲染;代码还涉及实时模拟的优化思路,如固定时间步长或减少不必要计算。已有451人下载学习。对于希望低成本快速理解物理模拟完整框架,并在此基础上扩展为正式参赛方案的学习者,这份源码是很好的起步样例。
1. 用 Visual C++ 做 CUPT 水瓶游戏:一份源码里藏着的物理模拟闭环
CUPT 相关的水瓶游戏,不是普通的小游戏,而是把物理竞赛里常见的“水杯受外力后的晃动与溅射”做成可运行的模拟程序。压缩包里的核心文件 CUPT.cpp 是 Visual C++ 单文件项目,包含动量传递计算、水体离散建模和 OpenGL 渲染三部分。它能帮你把抽象物理公式变成肉眼可见的水面波动,也能当大学物理竞赛演示或课程设计的底子。适合三类人:想用代码验证物理公式的本科生、需要快速出一个可运行 Demo 的竞赛队,以及刚学 C++ 又想碰图形编程的初学者。下面我直接拆代码结构、编译过程和踩过的坑。
2. 物理模型与 CUPT.cpp 的代码切分:动量守恒、水体近似和对象设计
2.1 碰撞瞬间的动量传递:速度变化和水面扰动怎么算出来的
先还原场景:一瓶水放在桌面上或者吊在支架上,一个小球以一定速度撞向瓶身。碰撞持续时间很短,短到可以忽略重力和空气阻力。在这个近似下,分析重点就落在碰撞前后的动量变化上。常见做法是把碰撞当作完全非弹性碰撞处理,即撞击物和水瓶碰撞后一起运动,只需要算一个共同速度:
v_after = (m_ball * v_ball + m_bottle * v_bottle) / (m_ball + m_bottle)比如撞击物质量 0.05 kg、速度 2 m/s,瓶子加水的总质量 0.3 kg、初始静止,那碰撞后速度约 0.286 m/s。这个数本身不复杂,但如果你把刚算出来的速度直接当成水面的启动速度,画面会非常“呆”:水整体平移,没有晃动。问题出在碰撞通过瓶壁传递到水内部时,不同位置的水收到的扰动强度不一样——靠近撞击点一侧的水获得的速度更大,另一侧更小,水面才会有倾斜和回摆。
CUPT.cpp 里这一步通常是一个类似ApplyImpulse的函数,输入撞击物的质量、速度、作用点,输出水瓶的线速度和水的扰动速度。参考实现如下:
void ApplyImpulse(float mBall, float vBall, float impactX) { // 1. 碰撞后水瓶整体速度(完全非弹性碰撞公式) float vAfter = (mBall * vBall + mBottle * vBottle) / (mBall + mBottle); float dv = vAfter - vBottle; vBottle = vAfter; // 2. 把速度变化按位置衰减后分给水体 for (int i = 0; i < waterCount; i++) { float dx = water[i].x - impactX; // 距撞击点的横向距离 float factor = expf(-dx * dx * 4.0f); // 高斯衰减,越近影响越大 water[i].vy += dv * factor; // 纵向扰动是主要分量 water[i].vx += dv * 0.3f * factor; // 横向扰动,比例别太大 } }factor是高斯衰减的核心,4.0f控制衰减半径。数值越小,扰动扩散越远,水面整体起伏明显;数值超过 10,衰减半径收得很小,只有撞击点附近一小段水面在动,像个尖刺,看起来不真实。我一般把衰减系数放在 3 到 6 之间试,配合瓶子的实际宽度去定。
这个参数本身不算玄学,但有一个容易翻车的地方:water[i].vy += dv * factor直接把瓶子速度增量全部转给水。如果瓶子质量小、撞击物质量大,dv会很大,水粒子单帧速度可能超过 1 m/s,下一帧位置直接跳到瓶壁外面。解决办法是把最大扰动速度钳制在 0.5 m/s 以内,或者让质量比设置得像真实实验一样,别随手把球质量改成 10 kg。
2.2 数据和类划分:Bottle、WaterParticle 与逻辑分离
单文件 C++ 程序最容易写成几百行堆在一起,但 CUPT 这类程序物理逻辑复杂度不低,我建议结构上至少拆出三个部分:瓶子自身运动、水粒子集合、渲染状态。三者之间通过一个统一的更新接口串联,后续加规则或改参数不用牵一发动全身。
常见做法是两个类加一个全局场景结构。瓶子类只管位置、速度、质量、尺寸;水粒子类只管位置、速度、静水面高度;渲染信息放在渲染函数里临时读取,不写进物理数据结构。这个项目里比较合理的类划分如下:
class Bottle { public: float mass; // 瓶身质量,不含水 float x, y; // 位置(瓶底中心) float vx, vy; // 速度 float w, h; // 瓶身半宽、半高 void Update(float dt) { vy -= 9.8f * dt; // 重力 vx *= expf(-0.1f * dt); // 轻微空气阻力 x += vx * dt; y += vy * dt; } }; class WaterParticle { public: float x, y; // 相对瓶底中心的位置 float vx, vy; float restY; // 静水面高度,用来算回复力 void Update(float dt) { float dy = restY - y; // 偏离平衡位置的距离 vy += dy * 20.0f * dt; // 简谐回复力 vy *= expf(-0.3f * dt); // 阻尼 y += vy * dt; } };这里把重心放在“水面起伏”上,restY是每个水粒子静止时的高度,dy是偏离量,回复力系数取 20,阻尼系数取 0.3。第一次运行如果水面一直抖不停,是阻尼太小;如果水面像冻住一样几乎不动,是回复力系数太小或阻尼太大。这两个系数不能一次调好,我会固定时间步长后,用一组固定输入反复跑,看水面能否在 2 到 3 秒内回到静水面。
需要特别说明:真正的流体动力学用的是 Navier-Stokes 方程,粒子法(SPH)也是主流方案,但 CUPT 这种教学向演示程序通常不会上这么重的算法。上面这种“弹簧-质点”近似已经能满足竞赛演示需求。如果你追求更真实的飞溅效果,以后可以扩展成 SPH,不过计算量会成倍上涨。
2.3 固定时间步长与半隐式积分:让水面稳定不炸
游戏开发的血泪经验是:物理模拟不能直接放在渲染循环里,一帧算一次。显示器刷新率可能是 60Hz、90Hz、144Hz,而物理模拟的稳定性依赖固定时间间隔。如果一帧是 16ms,下一帧是 33ms,水面摆动的加速度在帧间不均匀,数值积分误差累积起来,一两分钟后水面就开始“自激振荡”,幅度越来越大,最后直接爆掉。
解决这个问题的标准方案是固定时间步长加累积器:
const float DT = 1.0f / 60.0f; // 固定物理步长 60Hz float lastTime = (float)clock() / CLOCKS_PER_SEC; float accumulator = 0.0f; while (running) { float now = (float)clock() / CLOCKS_PER_SEC; float frameTime = now - lastTime; lastTime = now; // 卡顿或调试断点时,防止一次补太多帧 if (frameTime > 0.25f) frameTime = 0.25f; accumulator += frameTime; while (accumulator >= DT) { bottle.Update(DT); for (int i = 0; i < waterCount; i++) water[i].Update(DT); accumulator -= DT; } Render(); }思路是:不管渲染帧率怎样,物理更新一直按 60Hz 节奏走,每帧可能执行 0 次、1 次或 2 次 Update。frameTime > 0.25f的钳制非常关键。调试时命中断点,恢复后frameTime可能是一秒多,如果不限制,while 循环会在同一帧内补几十次物理更新,水面瞬间跳到夸张位置,再也回不来。
另一个细节:dt必须作为参数传进所有 Update 函数,不要在函数内部用GetTickCount()再掐一次时间。全局时钟和循环内时间戳不一致,会制造整个模拟系统里最难查的 Bug——表面看水面正常,换一台电脑就复现不了。
积分方式上,我推荐半隐式欧拉:先更新速度,再用新速度更新位置。上面WaterParticle::Update就是半隐式,先累加速度再更新y。显式欧拉在这个模型下稳定性差很多,同样的弹簧系数,显式积分容易在 60Hz 步长下发散,半隐式则能稳定跑。
3. 在 Visual C++ 里把源码跑起来:编译环境、OpenGL 渲染与消息循环
3.1 解压与编译:走通第一条命令
拿到 CUPT.rar 之后先解压,确认里面至少有个 CUPT.cpp。常见的坑是解压路径带中文或带空格,导致 cl.exe 编译时命令行解析异常。建议先把文件夹放到纯英文路径,比如D:\workspace\cupt。
Visual C++ 的命令行环境需要专门配置。Visual Studio 安装完成后,开始菜单里有“Developer Command Prompt for VS 2022”,打开后环境变量已经指向cl.exe、include 和 lib 的路径。如果你在普通 cmd 里直接敲cl报“不是内部或外部命令”,原因就是 PATH 里没有编译器路径。
环境没问题之后,编译命令是:
cd /d D:\workspace\cupt cl /EHsc /O2 CUPT.cpp /link opengl32.lib glu32.lib user32.lib gdi32.lib/EHsc启用 C++ 异常处理,/O2优化速度,link 后面这些库是图形窗口程序必需的。如果报error: command ... cl.exe failed with exit status 2,这只是 cl.exe 退出码非零的通用提示,具体原因要往上翻几行,看是 fatal error C1xxx(头文件/语法)还是 C2xxx(编译中段错误)。这个报错在 Visual Studio 里同样常见,本质相同。
在 IDE 里走一遍也没问题:创建一个 Visual C++ 空项目,把 CUPT.cpp 添加为现有源文件,按 F7 编译。但要留意,IDE 新建空项目默认把“子系统”设为“控制台”,如果你的入口函数写的WinMain,链接器会报unresolved external symbol _WinMain@16。这时到“项目属性 → 链接器 → 系统 → 子系统”里,把“控制台”改成“Windows”就行。
3.2 OpenGL 立即模式渲染:瓶身、水面和坐标变换
CUPT 这类单文件演示程序,用 OpenGL 的立即模式(glBegin/glEnd)最直接,不用引入顶点缓冲对象和着色器,几十行就能画出一个看得过去的瓶子。配合glOrtho设置正交投影,坐标直接用物理坐标系里的值,调试起来很省心:
void SetupViewport(int w, int h) { glViewport(0, 0, w, h); glMatrixMode(GL_PROJECTION); glLoadIdentity(); glOrtho(-1.2f, 1.2f, -0.8f, 0.8f, -1.0f, 1.0f); // 正交投影,单位米 glMatrixMode(GL_MODELVIEW); glLoadIdentity(); }瓶身和水面的绘制,可以分成两个独立函数:
void DrawBottle(const Bottle& b) { // 画瓶子:底部宽、瓶口稍窄的梯形 glBegin(GL_TRIANGLE_STRIP); glColor3f(0.65f, 0.75f, 0.85f); // 淡蓝玻璃色 glVertex2f(-b.w, b.y - b.h); glVertex2f(-b.w * 0.7f, b.y + b.h * 0.8f); glVertex2f(b.w * 0.7f, b.y + b.h * 0.8f); glVertex2f(b.w, b.y - b.h); glEnd(); } void DrawWater(const WaterParticle* water, int count) { // 把水粒子按顺序连接成折线,显示水面波形 glBegin(GL_LINE_STRIP); for (int i = 0; i < count; i++) { glColor3f(0.2f, 0.5f, 0.9f); glVertex2f(water[i].x, water[i].y); } glEnd(); }这里的waterCount在 20 到 60 之间比较合适。低于 20,水面看起来是多边形,物理感缺失;高于 60,计算量上升但视觉提升有限。注意water[i].x是相对瓶体中心的位置,所以瓶子整体移动时,所有水粒子坐标要加一个瓶子的位移偏移量,否则水面不会跟着瓶子走。
需要提醒一个兼容性问题:现代 OpenGL 核心模式已经移除glBegin/glEnd,如果你初始化的是核心 Context,运行时会直接报 GL_INVALID_OPERATION。解决方案是初始化窗口时请求兼容模式 Context,对新手我不建议一开始就上 VBO,先让物理逻辑跑通,再说渲染优化的事。
3.3 WM_TIMER 驱动刷新:为什么物理更新不放 WM_PAINT
如果把物理更新直接写在WM_PAINT消息里,会出现经典问题:窗口重绘时物理强制推进,而操作系统可能在短时间内连续发多个 WM_PAINT,导致物理跑得比真实时间快好几倍,水面像开了快进。反过来,物理计算写在消息循环外,窗口又会变成“拖动时卡死、松开后猛跳”。
我一般用SetTimer定期触发物理更新,然后主动请求重绘:
case WM_CREATE: SetTimer(hwnd, 1, 16, NULL); // 约 60fps 触发 break; case WM_TIMER: AdvancePhysics(1.0f / 60.0f); // 内部做固定步长推进 InvalidateRect(hwnd, NULL, FALSE); break; case WM_ERASEBKGND: return 1; // 阻止背景擦除,减少闪烁SetTimer的精度不如多媒体定时器,但对教学演示足够。WM_ERASEBKGND返回 1 是为了避免背景擦除导致黑屏闪烁,这个小习惯能直接从观感上改善体验。想要更高精度就换timeSetEvent,或者用高分辨率时钟在消息循环里自行累积,但为了项目简洁,SetTimer通常够用。
4. 运行时避坑与常见问题排查:编译报错和模拟翻车的修复记录
这一章每条都是实际会碰到的,按“现象 → 原因 → 解决”记录。没有玄学,都能在源码或编译日志里找到根据。
4.1 fatal error C1083:找不到 GL/gl.h
现象:cl.exe 报fatal error C1083: Cannot open include file: 'GL/gl.h': No such file or directory,但机器上明明装着 Visual Studio。
原因:include 路径里没有 Windows SDK 的 GL 目录。Windows SDK 自带 GL/gl.h,但它不在默认的 C++ include 路径里,命令行编译时更容易漏,IDE 新建项目时也要手动确认。
解决:命令行编译时显式加 include 路径,路径一般在C:\Program Files (x86)\Windows Kits\10\Include\<SDK版本>\um和同级的shared目录。IDE 用户则去“项目属性 → 配置属性 → VC++ 目录 → 包含目录”里加。
cl /EHsc /O2 CUPT.cpp \ /I"C:\Program Files (x86)\Windows Kits\10\Include\10.0.22621.0\um" \ /I"C:\Program Files (x86)\Windows Kits\10\Include\10.0.22621.0\shared" \ /link opengl32.lib glu32.lib user32.lib gdi32.lib要确认你机器上的 SDK 版本号,去C:\Program Files (x86)\Windows Kits\10\Include目录看一眼实际文件夹名,把命令里的10.0.22621.0替换掉。
4.2 LNK2019:OpenGL 外部符号链接失败
现象:编译通过,链接阶段报error LNK2019: unresolved external symbol _glBegin@4 referenced in function ...。
原因:用到了 OpenGL 的库,但链接器没有接收到。IDE 新建项目不会默认添加图形库,命令行编译也可能漏写库名。
解决:把 opengl32.lib、glu32.lib、user32.lib、gdi32.lib 全部加到链接参数。如果库名写了还是报错,检查平台位数:函数名带@后缀是 32 位 stdcall 调用约定,如果项目配置的是 64 位平台,编译器位数和库位数不匹配,同样出现符号解析失败。去“配置管理器”确认活动平台是 x64 还是 x86,保持统一。
4.3 水面数值发散:跑几秒就爆成满屏乱点
现象:程序能启动、窗口能显示,但水面粒子在 1 到 2 秒内飞离瓶体,最后画面里全是乱点。
原因:绝大多数情况是时间步长不稳定,或回复力系数与 dt 不匹配。WaterParticle 的 Update 里回复力系数为 20、dt 为 1/60 时稳定;如果把 dt 改成 1/30,积分误差直接翻倍,很快发散。
解决:先固定 dt 为 1/60,做好frameTime上限钳制。再检查稳定性条件——一个粗略的准则是回复力系数 * dt * dt < 1。系数 20、dt 1/60,算出来约 0.0056,远小于 1,安全。若改用 dt 1/30,dt 平方变大四倍,系数就要降到 5 附近。按照这个判据调参,比盲改阻尼系数有效得多。
4.4 黑窗口一闪而过:入口函数和链接子系统匹配不上
现象:编译成功,exe 也生成了,但双击后控制台黑窗一闪就没,或者窗口直接消失。
原因:入口函数和子系统不匹配。写WinMain时,链接子系统要设成“Windows”;写main时,子系统要设成“控制台”。不匹配就找不到入口点,加载后立刻退出。
解决:命令行编译时显式指定:
# 入口是 main() cl /EHsc /O2 CUPT.cpp /link /SUBSYSTEM:CONSOLE opengl32.lib glu32.lib user32.lib gdi32.lib # 入口是 WinMain() cl /EHsc /O2 CUPT.cpp /link /SUBSYSTEM:WINDOWS opengl32.lib glu32.lib user32.lib gdi32.lib补充一个排查方法:闪退时别靠眼睛看,先在 cmd 里直接运行 exe,控制台会保留,任何输出错误都能看到。最笨但有效的方式,是在入口函数第一行加一个printf("start\n"),确认到底走到哪一步退出的。
5. 验证结果与扩展玩法:动量守恒当检验标准,规则当增值点
5.1 用总动量曲线验证模拟误差
答辩或演示时,光有画面说服力不够。最直接的验算是记录碰撞前后的水平总动量,打印成数值或画成曲线。CUPT.cpp 改造成本很低:在ApplyImpulse前后各取一次动量:
float before = mBall * vBall + mBottle * vBottleBefore; float after = (mBall + mBottle) * vAfter; printf("delta momentum = %f\n", fabsf(before - after));用之前那组参数(0.05 kg、2.0 m/s、瓶 0.3 kg 静止)跑出来,结果如下:
| 项目 | 碰撞前 | 碰撞后 | 偏差 |
|---|---|---|---|
| 球动量 | 0.100 kg·m/s | 0 | - |
| 瓶动量 | 0 | 0.100 kg·m/s | - |
| 系统总动量 | 0.100 | 0.100 | 0 |
偏差为零是因为碰撞公式本身就是动量守恒。真正要防的是水面扰动带来的水平分量——water[i].vx += dv * 0.3f * factor会在水平方向产生额外动量,这部分没算进总动量方程,所以严格验证要看“瓶+全部水粒子”的水平动量总和。我在扩展时会把所有水粒子的 vx 累加起来,确认碰撞 2 秒后这个值趋近于零。
5.2 加入挑战规则:动量范围、连续击打与计分
想让程序更像游戏,可以加三层规则:动量输入范围限制、连续击打限制、时间限制。比如每次击打的动量必须落在 0.1 到 0.3 kg·m/s 之间,低于 0.1 水面几乎不动,高于 0.3 水会飞溅到瓶外;10 秒内最多连续击打 3 次,间隔小于 2 秒视为一次连击;连击达到一定次数后加分,水溢出越少分数越高。规则参数可以直接写在全局配置区,方便竞赛现场临时调整。
5.3 再扩展的思路与边界:从单瓶到多瓶
如果继续扩展,优先做两件事:一是给水面加上瓶壁的边界反射,水粒子碰到瓶壁要反弹,而不是直接飞出去;二是把撞击点从固定位置改成鼠标点击位置,增加交互自由度。这两样都不涉及复杂算法,但体验提升明显。
再往后是多瓶碰撞、旋转瓶身这类需求。需要注意的是:从单瓶到双瓶,不只是复制一个类实例,还要引入碰撞检测。两个瓶子的水粒子可能互相穿过,简单的 AABB 包围盒只在瓶子直立时成立,瓶子一旋转就要用多边形碰撞检测,复杂度是跳变的。我在一个项目里吃过这个亏,后来改成先用固定时间步长做单瓶验证,再在合入新功能时反复测试物理稳定性。从那以后每次提交 CUPT 这类物理模拟代码,我都强制先跑一遍动量守恒验算,确认误差小于 0.001 才敢继续往下加功能。希望帮到你。
本文还有配套的精品资源,点击获取