1. 三角函数在C里到底能干什么很多人第一次在C里用三角函数都是被作业或者小游戏逼出来的。比如写一个“坦克大战”炮管要跟着鼠标转你得算角度写一个“我的世界”那种第一人称视角鼠标移动要转成俯仰角和偏航角再比如做一个“弹球模拟”球撞到斜面得按反射定律算方向。这些场景背后全是sin、cos、tan、atan这几个函数在撑着。C标准库把三角函数放在cmath头文件里C语言风格的是math.hC里更推荐前者。它们接收弧度制参数返回double类型结果。你不需要自己写泰勒展开也不需要查表直接调用就行。但问题在于很多新手第一次用的时候角度和弧度搞混结果画出来的东西要么不动要么乱飞。还有人编译时报错“undefined reference tosin”折腾半天不知道是链接库的问题。这篇文章就是给那些“已经会写for循环和if判断但一碰到三角函数就发怵”的C初学者准备的。我会把sin、cos、tan、atan这几个函数掰开揉碎从弧度转换讲到实际项目里的用法再补上一些我这些年踩过的坑。看完之后你至少能做到知道什么时候该用哪个函数参数怎么传结果怎么用以及为什么你的代码算出来跟计算器不一样。注意本文所有代码示例默认使用C17标准编译器推荐GCC 9或MSVC 2019。如果你还在用Dev C那种老古董建议换VS Code配MinGW或者直接上Visual Studio Community。2. 角度与弧度第一个必须跨过去的坎2.1 为什么C的三角函数不认“度”人类习惯用度degree来衡量角度一圈360度直角90度这很直观。但数学分析和数值计算里弧度radian才是“自然单位”。弧度的定义是弧长等于半径时对应的圆心角就是1弧度。一圈的弧长是2πr所以一圈就是2π弧度也就是约6.28318。C的sin、cos、tan全部接收弧度值。你传sin(90)进去它不会返回1而是返回sin(90 rad)大约是0.893996。这个坑我见过太多人踩了包括我自己当年写第一个“时钟程序”的时候指针死活转不对后来才发现是把角度直接塞进去了。转换公式很简单度转弧度rad deg * PI / 180.0弧度转度deg rad * 180.0 / PIPI在C里没有标准宏但cmath里有个M_PI不过它不是标准C的一部分是POSIX扩展。MSVC默认不开需要定义_USE_MATH_DEFINES。更稳妥的做法是自己定义一个常量const double PI 3.14159265358979323846;或者用C20的std::numbers::pi但前提是你的编译器支持。我一般直接写constexpr double PI 3.14159265358979323846;放全局或者命名空间里省事。2.2 一个实用的角度-弧度转换工具在实际项目里我习惯封装两个内联函数避免每次手写乘法#include cmath constexpr double PI 3.14159265358979323846; inline double toRadians(double degrees) { return degrees * PI / 180.0; } inline double toDegrees(double radians) { return radians * 180.0 / PI; }这样写的好处是第一语义清晰看到toRadians(45)就知道是转弧度第二如果以后要换精度比如用float改一处就行第三编译器大概率会内联没有性能损失。实操心得如果你在写图形程序角度经常在0到360之间循环建议统一用弧度存储只在输入输出时转换。因为sin和cos内部就是按弧度算的你转来转去反而容易出错。2.3 精度问题为什么sin(PI)不是0你如果写cout sin(PI)输出大概率是1.22465e-16而不是0。这不是bug是浮点数的固有特性。PI本身就是一个无限不循环小数double只能存64位截断误差导致sin的结果不是精确的0。在大多数游戏和图形程序里这个量级的误差完全可以忽略。但如果你在写需要高精度判断的代码比如“判断两个角度是否相等”千万别用而是用一个小的epsilonbool almostEqual(double a, double b, double eps 1e-9) { return std::fabs(a - b) eps; }我见过有人在碰撞检测里直接比较sin的结果结果球在特定角度会“穿模”查了一整天。后来改成epsilon比较问题消失。3. sin、cos、tan各自的地盘和用法3.1 sin和cos单位圆上的两个坐标sin和cos是最常用的两个。给定一个弧度θcos(θ)返回单位圆上对应点的x坐标sin(θ)返回y坐标。这个几何意义非常重要因为它直接对应到游戏开发里的“方向向量”。假设你有一个角色朝向角度是θ从x轴正方向逆时针算那么它的前进方向就是(cos(θ), sin(θ))。你把这个向量乘以速度就能得到每帧的位移double angle toRadians(45.0); double speed 5.0; double dx cos(angle) * speed; double dy sin(angle) * speed;这段代码在2D游戏里到处都是。坦克炮管、子弹方向、NPC巡逻路径全是这个套路。cos和sin的周期都是2π所以你可以用fmod把角度限制在[0, 2π)范围内避免数值过大导致精度下降。不过现代libm实现一般会自己做参数归约但自己归约一下更保险double normalizeAngle(double rad) { rad std::fmod(rad, 2 * PI); if (rad 0) rad 2 * PI; return rad; }3.2 tan斜率与垂直问题tan(θ)等于sin(θ)/cos(θ)几何意义是单位圆上该点与原点连线的斜率。在游戏里tan用得比sin和cos少但有些场景非它不可。比如你已知一个角度想算“水平移动1单位时垂直移动多少”那就是tan。但tan有个致命问题当θ接近π/290度时cos(θ)趋近于0tan会趋向无穷大。在浮点数里tan(PI/2)不会报错而是返回一个巨大的数大约1.633e16因为PI/2本身不是精确的π/2。如果你拿这个结果去做除法或者坐标变换程序可能直接崩掉或者画出诡异的图形。我的建议是能用sin和cos的地方尽量别用tan。如果非要用先判断cos(θ)的绝对值是否小于某个阈值比如1e-6如果是就特殊处理。double safeTan(double rad) { double c cos(rad); if (fabs(c) 1e-6) { // 接近垂直返回一个很大的值或者抛异常 return (sin(rad) 0) ? 1e16 : -1e16; } return sin(rad) / c; }3.3 atan和atan2从坐标反推角度atan是tan的反函数输入一个斜率返回对应的弧度范围是(-π/2, π/2)。但实际开发中我们更常用atan2(y, x)因为它能根据x和y的符号判断象限返回(-π, π]范围内的角度。举个例子你有一个敌人位于(ex, ey)玩家位于(px, py)你想让子弹朝向敌人。你需要计算从玩家到敌人的方向角double dx ex - px; double dy ey - py; double angle atan2(dy, dx);注意atan2的参数顺序是y在前x在后。这个顺序很多人记反结果子弹往反方向飞。我当年写第一个塔防游戏的时候炮塔总是朝背后打查了半天才发现是atan2(dx, dy)写反了。atan2的好处是当dx为0时它不会除零崩溃而是返回±π/2。当dx和dy都为0时它返回0行为由实现定义但主流实现都返回0。所以它比atan(dy/dx)安全得多。注意事项atan2返回的是弧度如果你要显示给玩家看记得用toDegrees转一下。另外它的范围是(-π, π]如果你想要[0, 2π)可以加个判断double angle atan2(dy, dx); if (angle 0) angle 2 * PI;4. 在真实项目里怎么用从计算器到小游戏4.1 写一个“角度计算器”验证你的理解学任何东西最快的方式是动手做一个能跑的东西。我建议你先写一个命令行版的角度计算器输入角度输出sin、cos、tan的值。这个练习能帮你把弧度转换、函数调用、输出格式化全部串起来。#include iostream #include cmath #include iomanip constexpr double PI 3.14159265358979323846; int main() { double deg; std::cout 输入角度度; std::cin deg; double rad deg * PI / 180.0; std::cout std::fixed std::setprecision(6); std::cout sin( deg °) sin(rad) \n; std::cout cos( deg °) cos(rad) \n; double c cos(rad); if (fabs(c) 1e-9) { std::cout tan( deg °) 未定义接近垂直\n; } else { std::cout tan( deg °) tan(rad) \n; } return 0; }编译命令GCCg -stdc17 -o trig_calc trig_calc.cpp -lm注意最后的-lm虽然大多数现代系统上libm已经默认链接但显式写上没坏处。如果你在Windows上用MinGW一般不需要-lm。跑一下输入45你应该看到sin和cos都是0.707107tan是1.000000。输入90cos是0tan会走“未定义”分支。这个练习能帮你建立信心。4.2 用三角函数做一个“圆周运动”动画命令行里做动画有点麻烦但你可以用字符画。比如在控制台里让一个*沿着圆周运动。核心逻辑就是for (double t 0; t 2 * PI; t 0.1) { int x 40 static_castint(20 * cos(t)); int y 15 static_castint(10 * sin(t)); // 清屏在(x, y)位置画* }这里40和15是圆心20和10是半径。因为控制台字符是长方形的x和y的缩放比例不同所以看起来才像个圆。这个例子虽然简单但它包含了游戏开发里最核心的“参数方程”思想用角度驱动坐标。如果你用Windows API或者SDL可以把*换成一张图片把t换成时间就是一个旋转的精灵。我当年用这个方法做了个“地球绕太阳转”的屏保虽然土但成就感满满。4.3 小游戏里的“朝向鼠标”功能这是atan2最经典的应用。假设你在写一个2D射击游戏玩家在屏幕中央鼠标在(mx, my)你想让枪口指向鼠标。代码大概是这样double dx mx - playerX; double dy my - playerY; double angle atan2(dy, dx); // 然后用angle去旋转枪的贴图如果你用的是SDL2可以用SDL_RenderCopyEx它的最后一个参数就是角度单位是度。所以你需要把atan2的结果转成度double deg angle * 180.0 / PI; SDL_RenderCopyEx(renderer, gunTexture, nullptr, destRect, deg, nullptr, SDL_FLIP_NONE);注意SDL的角度是顺时针为正而atan2是逆时针为正。如果你发现枪反了加个负号就行。这种坐标系差异在图形编程里很常见别慌试两次就调对了。实操心得在游戏循环里atan2每帧调用一次完全没问题现代CPU算这个也就几十纳秒。但如果你要算几千个粒子的方向可以考虑用查表法或者SIMD优化。不过对于初学者项目直接调atan2就够了别过早优化。5. 常见问题与排查技巧实录5.1 编译报错“undefined reference tosin”这是新手最常见的问题。原因通常有两个一是没包含cmath二是链接时没加数学库。在Linux上用GCC需要在编译命令末尾加-lm。在Windows上用MinGW一般不需要但如果报错也可以试试加-lm。还有一种情况你用的是C但包含了math.h而不是cmath。虽然大多数编译器能处理但标准规定cmath里的函数在std命名空间里math.h里的在全局命名空间。混用可能导致名字查找失败。统一用cmath然后写std::sin或者using namespace std;。5.2 计算结果和计算器不一样先检查单位。计算器默认可能是度而C是弧度。如果你输入sin(30)得到-0.988那就是把30弧度当角度了。转成弧度再算sin(30 * PI / 180)应该得到0.5。如果单位没错那就是精度问题。float只有7位有效数字double有15位。如果你用float存角度算出来的结果可能在小数点后第6位就不准了。图形程序里float够用但科学计算建议double。5.3tan(90度)返回巨大值而不是报错前面说过tan(PI/2)在浮点里不会抛异常因为PI/2不是精确的π/2。如果你需要严格处理垂直情况用cos判断if (fabs(cos(rad)) 1e-9) { // 垂直特殊处理 }阈值1e-9不是固定的取决于你的精度需求。如果你用float1e-6就够了。5.4atan2返回负角度导致物体反向旋转atan2的范围是(-π, π]所以当目标在第三、四象限时返回的是负角度。如果你直接拿这个角度去插值旋转物体会“抄近路”反向转。解决办法是先把角度归一化到[0, 2π)或者用“最短角度差”算法double angleDiff(double a, double b) { double diff fmod(b - a PI, 2 * PI) - PI; return diff; }这个函数返回从a到b的最短旋转角度范围是(-π, π]。用它来做平滑旋转物体就不会抽风了。5.5 性能问题三角函数调用太频繁如果你在每帧里对成千上万个对象调用sin和cos可能会成为瓶颈。优化方法有几种查表法预计算0到360度的sin值存数组里用角度索引。缺点是精度有限且内存占用随精度增加。增量法如果角度是均匀增加的可以用旋转矩阵递推每次只做乘法和加法不用调sin和cos。SIMD用SSE或AVX指令集批量计算但需要一定的底层知识。不过对于大多数初学者项目直接调sin和cos完全够用。我做过一个测试在i5-8250U上每秒能调约5000万次sin。你的游戏就算有1000个子弹每帧算一次也就6万次连零头都不到。常见问题速查表问题现象可能原因解决方法编译报错undefined reference没链接数学库编译命令加-lm结果和计算器差很多角度弧度搞混用toRadians转换tan返回巨大值接近90度用cos判断并特殊处理atan2方向反了参数顺序写反确认是atan2(dy, dx)旋转时物体抖动角度未归一化用normalizeAngle或angleDiff精度不够用了float改用double6. 进阶技巧从会用变成用好6.1 用std::sin还是sinC标准库把C的数学函数放进了std命名空间所以std::sin是标准写法。但很多代码里直接写sin也能编译因为cmath通常也会把名字引入全局命名空间。为了代码的可移植性和规范性我建议统一用std::sin、std::cos、std::atan2。这样在命名空间污染的时候不会出问题。6.2 模板化让三角函数支持float和long doubleC11之后cmath提供了重载版本std::sin(float)返回floatstd::sin(long double)返回long double。如果你写模板代码直接调std::sin就行编译器会自动选择正确的重载。但如果你用了using namespace std;可能会和C的sin冲突。所以还是那句话显式写std::。6.3 用constexpr做编译期计算C20开始std::sin和std::cos是constexpr的前提是编译器支持。这意味着你可以在编译期算好常量运行时零开销。比如constexpr double angle 45.0 * PI / 180.0; constexpr double s std::sin(angle);不过目前GCC 11和MSVC 2019对constexpr三角函数的支持还不完整Clang稍微好一点。如果你要用先查一下编译器文档。对于大多数项目运行时算也完全没问题。6.4 避免“角度累积误差”如果你每帧给角度加一个增量比如angle 0.01跑久了angle会变得很大sin(angle)的精度会下降。解决办法是定期归一化angle fmod(angle, 2 * PI);或者用normalizeAngle函数。这个习惯在长时间运行的程序里很重要比如服务器或者监控系统。6.5 用atan2做“朝向平滑”在RTS游戏里单位转向目标时不能瞬间转过去需要平滑旋转。用atan2算出目标角度然后用angleDiff算出最短旋转方向再乘以一个旋转速度double targetAngle atan2(dy, dx); double diff angleDiff(currentAngle, targetAngle); double maxTurn turnSpeed * deltaTime; if (fabs(diff) maxTurn) { currentAngle targetAngle; } else { currentAngle (diff 0 ? maxTurn : -maxTurn); }这段代码我用了好几年从塔防到RPG都适用。关键是angleDiff保证了不会绕远路。7. 我个人在实际操作中的体会三角函数这东西入门的时候觉得就是几个数学函数没什么大不了。但真正写起项目来你会发现坑都在细节里弧度转换忘了、atan2参数反了、tan在90度炸了、角度累积误差导致画面抖动。这些问题没有一个是“数学原理”层面的全是工程实践里的琐碎经验。我建议你在学完基础之后立刻找一个小项目练手。不用太复杂一个“鼠标跟随的箭头”或者“绕圈运动的球”就够。把sin、cos、atan2各用一遍把弧度转换写熟把epsilon比较养成习惯。等你下次再看到“三角函数”这四个字第一反应不是“数学课”而是“哦算方向的”那就算入门了。最后再分享一个小技巧如果你在调试角度相关的代码把中间结果打印出来用toDegrees转成度和你的直觉对比。比如你算出来atan2(1, 1)是0.785398转成度是45那就对了。如果算出来是-135那说明你的dx和dy符号有问题。这种“打印-对比-修正”的循环比盯着代码空想快十倍。
