zig 0.16.0 + raylib 5 中文注释讲解 3d camera fps (丝滑移动与camera bob)
https://www.raylib.com/examples.html 3d camera fps
逐步拆解笔记
有点乱……凑合看

中文代码注释
const rl = @import("raylib");
const std = @import("std");
const PI = std.math.pi;
/// movement
const GRAVITY = 32.0;
const MAX_SPEED = 20.0;
const CROUCH_SPEED = 5.0;
const JUMP_FORCE = 12.0;
/// 150 m/s²,约为重力 g=9.8 的 15 倍,现实中不可能长时间如此。
/// 但每帧实际增量是 MAX_ACCEL*delta ≈ 150/60 = 2.5 m/s(速度增量),并不夸张。
/// 属于经验常数,控制从 0 加速到满速的快慢。
const MAX_ACCEL = 150.0;
/// 地面摩擦力
const FRICTION = 0.86;
/// 空气阻力,提升 扫射(AD横向移动)速度
const AIR_DRAG = 0.98;
/// 转向响应能力:body.dir 向 desiredDir 每帧插值的系数,越大转向越快
const CONTROL = 15.0;
const CROUCH_HEIGHT = 0.0;
const STAND_HEIGHT = 1.0;
const BOTTOM_HEIGHT = 0.5;
const NORMALIZE_INPUT = false;
const Body = struct {
position: rl.Vector3,
velocity: rl.Vector3,
dir: rl.Vector3,
isGrounded: bool,
};
/// 鼠标灵敏度:把鼠标位移(mouseDelta)换算成视角旋转量,数值越小视角越“稳”
const sensitivity = rl.Vector2{ .x = 0.004, .y = 0.004 };
var player = Body{
.position = rl.Vector3.zero(),
.velocity = rl.Vector3.zero(),
.dir = rl.Vector3.zero(),
.isGrounded = true,
};
var lookRotation = rl.Vector2.zero();
/// 头部动画计时器, 似乎不会清零,一直线性增长
var headTimer: f32 = 0.0;
/// 0~1, 静止为0, 走起来后为1
var walkLerp: f32 = 0.0;
var headLerp: f32 = STAND_HEIGHT;
/// 脉动,x右, y前
var lean = rl.Vector2.zero();
pub fn main() anyerror!void {
const screenWidth = 800;
const screenHeight = 450;
rl.initWindow(screenWidth, screenHeight, "fps");
defer rl.closeWindow();
var camera = rl.Camera{
.position = rl.Vector3{
.x = player.position.x,
.y = player.position.y + (BOTTOM_HEIGHT + headLerp),
.z = player.position.z,
},
.target = rl.Vector3{
.x = player.position.x,
.y = player.position.y + (BOTTOM_HEIGHT + headLerp),
.z = player.position.z - 1.0,
},
.up = rl.Vector3{ .x = 0.0, .y = 1.0, .z = 0.0 },
.fovy = 60.0,
.projection = .perspective,
};
// update camera fps
rl.disableCursor();
rl.setTargetFPS(60);
while (!rl.windowShouldClose()) {
// update
const mouseDelta = rl.getMouseDelta();
// if (mouseDelta.x != 0 or mouseDelta.y != 0) {
// std.debug.print("mouseDelta: {d:.3}, {d:.3}\n", .{
// mouseDelta.x,
// mouseDelta.y,
// });
// }
// mouseDelta.x:向右为正。用负号让“鼠标右移→视角右转”(非翻转)。
// mouseDelta.y:raylib 中向下为正,y 直接加,使“鼠标下移→视角下看”。
lookRotation.x -= mouseDelta.x * sensitivity.x;
lookRotation.y += mouseDelta.y * sensitivity.y;
// 支持 急停 细节,右为+1, 左为-1
const sideway: i8 =
@as(i8, @intFromBool(rl.isKeyDown(.d))) -
@as(i8, @intFromBool(rl.isKeyDown(.a)));
// 前为+1, 后为-1
const forward: i8 =
@as(i8, @intFromBool(rl.isKeyDown(.w))) -
@as(i8, @intFromBool(rl.isKeyDown(.s)));
const crouching = rl.isKeyDown(.left_control);
UpdateBody(&player, lookRotation.x, sideway, forward, rl.isKeyPressed(.space), crouching);
const delta = rl.getFrameTime(); // 每帧约 1/60 ≈ 0.017s
// headLerp 用 lerp 逼近目标高度;系数 20*delta ≈ 20/60 ≈ 1/3,即每帧插值约 1/3
headLerp = rl.math.lerp(headLerp, if (crouching) CROUCH_HEIGHT else STAND_HEIGHT, 20 * delta);
camera.position = rl.Vector3{
.x = player.position.x,
.y = player.position.y + (BOTTOM_HEIGHT + headLerp),
.z = player.position.z,
};
if (player.isGrounded and ((forward != 0) or (sideway != 0))) {
// 按下wasd行走,且不在空中,则处于行走态
headTimer += delta * 3; // +3帧,用于驱动 sin(headTimer*PI) 的头部摆动
walkLerp = rl.math.lerp(walkLerp, 1, 10 * delta);
camera.fovy = rl.math.lerp(camera.fovy, 55, 5 * delta); // fovy 越小,视野越窄(=放大),行走时轻微变焦
} else {
// 在空中,或静止,则复原
walkLerp = rl.math.lerp(walkLerp, 0, 10 * delta);
camera.fovy = rl.math.lerp(camera.fovy, 60, 5 * delta);
}
// 倾斜, 视角旋转, 模仿走路
lean.x = rl.math.lerp(lean.x, @as(f32, @floatFromInt(sideway)) * 0.02, 10 * delta);
lean.y = rl.math.lerp(lean.y, @as(f32, @floatFromInt(forward)) * 0.015, 10 * delta);
UpdateCameraFPS(&camera);
// Draw --------------------------------------------------------
rl.beginDrawing();
defer rl.endDrawing();
rl.clearBackground(.ray_white);
{
rl.beginMode3D(camera);
defer rl.endMode3D();
DrawLevel();
}
// 2d内容必须在 endModel3D 后才可以绘制, 下面都是文字
rl.drawRectangle(5, 5, 330, 75, rl.fade(.sky_blue, 0.5));
rl.drawRectangleLines(5, 5, 330, 75, .blue);
rl.drawText("Cam ctrl:", 15, 15, 10, .black);
rl.drawText("- Move keys: WASD, Space, Left-ctrl", 15, 300, 10, .black);
rl.drawText("- Look around: arrow keys or mouse", 15, 45, 10, .black);
rl.drawText(rl.textFormat("- Velocity len: (%06.3f)", .{
(rl.Vector2{
.x = player.velocity.x,
.y = player.velocity.z,
}).length(),
}), 15, 60, 10, .black);
const mouseDeltaText = rl.textFormat(
"mouseDelta: x=%.3f, y=%.3f",
.{
@as(f64, @floatCast(mouseDelta.x)),
@as(f64, @floatCast(mouseDelta.y)),
},
);
rl.drawText(mouseDeltaText, 15, 35, 10, .black);
rl.drawGrid(20, 1.0);
}
}
/// 根据世界状态,更新身体. NOTE: 速度先减后加
fn UpdateBody(body: *Body, rot: f32, side: i8, forward: i8, jumpPressed: bool, crouchHold: bool) void {
const input = rl.Vector2{
.x = side,
.y = -forward,
};
if (NORMALIZE_INPUT and ((side != 0) and (forward != 0))) {
rl.Vector2.normalize(input);
}
const delta = rl.getFrameTime();
if (!body.isGrounded) {
// 只有没着地才受重力
body.velocity.y -= GRAVITY * delta;
} else if (jumpPressed) {
body.velocity.y = JUMP_FORCE; // NOTE: 马上给一个力
body.isGrounded = false; // 手动设置
}
const sin_rot = std.math.sin(rot);
const cos_rot = std.math.cos(rot);
// 世界坐标系(y 朝上),地面是 x-z 平面。
// NOTE: 俯视(x-z 平面)时:+x 朝右,+z 朝下,-z 朝上(前方)——相机默认朝 -z,见 target.z = pos.z - 1。
// front:rot=0 时指向 +z(0,0,1),rot 增大转向 +x(纯三角函数,无额外约定)。
const front = rl.Vector3{
.x = sin_rot,
.y = 0,
.z = cos_rot,
};
// right:rot=0 时指向 +x(1,0,0),rot 增大转向 -z。两者都是单位向量(模长 1)。
const right = rl.Vector3{
.x = cos_rot,
.y = 0,
.z = -sin_rot,
};
// 注:等价于 C 里的 cosf(-rot)、sinf(-rot),因为 cos(-rot)=cos(rot)、sin(-rot)=-sin(rot)。
// 以固定世界为坐标,把输入分解到 front/right 两个方向:
// side 沿 right、forward 沿 front(input.y = -forward,见上)。
// NOTE: 因 NORMALIZE_INPUT=false,斜走(如 W+D)会比直走快 √2 倍。
// desiredDir 是“玩家当前想去的方向”,旧惯性保存在 body.velocity。
const desiredDir = rl.Vector3{
.x = input.x * right.x + input.y * front.x,
.y = 0,
.z = input.x * right.z + input.y * front.z,
};
// 每帧向 desiredDir 插值(系数 CONTROL*delta 恒定)。
// 由于 desiredDir - body.dir 逐帧变小,位移逐帧变慢 → 呈现 easeOut(缓动) 效果。
// 这里得到本帧用于计算加速度方向的 body.dir。
body.dir = body.dir.lerp(desiredDir, CONTROL * delta);
// 每帧保留的速度比例:地面 FRICTION=0.86(丢 14%,减速大),空气 AIR_DRAG=0.98(丢 2%,减速小)。
// 所以“空气阻力 < 地面摩擦”,空中更容易保持速度/漂移。
const decel: f32 = if (body.isGrounded) FRICTION else AIR_DRAG;
// hvel 是水平速度每帧乘以 decel(<1) 后的结果;若不再给推力,会逐渐趋近 0。
var hvel = rl.Vector3{
.x = body.velocity.x * decel,
.y = 0,
.z = body.velocity.z * decel,
};
// 速度低于阈值(MAX_SPEED*0.01=0.2)时直接清零,避免残余微移/抖动
if (hvel.length() < (MAX_SPEED * 0.01)) {
hvel = rl.Vector3.zero();
}
// speed = hvel·dir:hvel 在“当前方向”上的投影,正是它驱动扫射(strife)。
// 在 dir.lerp 之后再算,是为了用上刚更新的最新方向去加速。
const speed = hvel.dotProduct(body.dir);
const maxSpeed: f32 = if (crouchHold) CROUCH_SPEED else MAX_SPEED;
// 当加速度被最大加速度常数限制时,玩家可以通过将方向更接近水平速度角来加快速度。见 https://youtu.be/v3zT3Z5apaM?t=165
// 此处需要合理地给玩家一个每帧的加速度值。
// 若 speed 未到 maxSpeed,理想加速度是补足到 maxSpeed(即 maxSpeed - speed);
// 但单帧最多加 MAX_ACCEL*delta ≈ 2.5 m/s,避免瞬间满速,让加速有过程感。
const accel = std.math.clamp(maxSpeed - speed, 0, MAX_ACCEL * delta);
hvel.x += body.dir.x * accel;
hvel.z += body.dir.z * accel;
body.velocity.x = hvel.x;
body.velocity.z = hvel.z;
body.position.x += body.velocity.x * delta;
body.position.y += body.velocity.y * delta;
body.position.z += body.velocity.z * delta;
// 地面拥有花哨碰撞
if (body.position.y <= 0.0) {
body.position.y = 0.0; // 强行重置
body.velocity.y = 0.0; // NOTE: 经验处理
body.isGrounded = true;
}
}
/// 更新FPS相机
fn UpdateCameraFPS(camera: *rl.Camera) void {
// 单位向量, 全局坐标系 朝天上
const up = rl.Vector3{
.x = 0.0,
.y = 1.0,
.z = 0.0,
};
// 相对玩家(局部)坐标系,摄像机默认朝着-z.
const targetOffset = rl.Vector3{
.x = 0.0,
.y = 0.0,
.z = -1.0,
};
// 偏航角: targetOffset 围绕 up向量, (看向-y,逆时针)旋转 lookRot.x
var yaw = targetOffset.rotateByAxisAngle(
up,
lookRotation.x,
);
// 锁死最大仰视角 恒为90° (up与yaw的夹角)
var maxAngleUp = up.angle(yaw);
// NOTE: 给相机俯仰角留一点余量,别让它正好锁在竖直方向
// 实际允许的最大上仰变成 89.94°,而不是正好 90°。
// 为什么要留这 0.001 的余量
// 如果允许镜头真正转到 正上/正下(±90°),会出现两类问题:
// 1. 万向节锁 / 方向退化:在正好 90° 时,视线方向 pitch 会与世界 up 完全平行,
// 此时camera 的 up、right 变成同向/共线,俯仰的“上下”含义变得不确定,画面会翻转、抖动
// 2. 浮点误差放大:Vector3Angle 用 atan2(|cross|, dot) 算,
// 浮点下 dot 可能是 1e-8 之类的小数而不是精确 0,
// 加之后续的叉乘、归一化、绕轴旋转
// 在“接近平行”时精度会急剧恶化(除以接近 0 的数)。
maxAngleUp -= 0.001;
// 如果「当前抬头角」> 89.94°,就强制把「抬头角」改成正好 89.94°。
if (-(lookRotation.y) > maxAngleUp) {
lookRotation.y = -maxAngleUp;
}
// 万向节锁(gimbal lock)+ 恰好竖直时数值退化
// NOTE: 俯仰角 clamp 到 ±90° 之间,且永远留一点余量,绝不真的到 ±90°。
var maxAngleDown = up.negate().angle(yaw);
maxAngleDown -= 0.001;
maxAngleDown *= -1.0;
if (-(lookRotation.y) < maxAngleDown) {
lookRotation.y = -maxAngleDown;
}
// 单位向量, 全局坐标系下,玩家的右边
const right = yaw.crossProduct(up).normalize();
// NOTE: 细节, 绕 right 旋转上下俯仰. 向上看 + 后退 为 正加正。lean相当于身体本身也会前倾/后倾
var pitchAngle = -lookRotation.y - lean.y;
pitchAngle = rl.math.clamp(
pitchAngle,
// 这里其实是上面 maxAngle 的另外一种写法。因为|maxAngle|恒为90°
-PI / 2.0 + 0.0001,
PI / 2.0 - 0.0001,
);
// 俯仰角
const pitch = yaw.rotateByAxisAngle(right, pitchAngle);
// 头部动画
const sin_head = std.math.sin(headTimer * PI);
const cos_head = std.math.cos(headTimer * PI);
const stepRotation = 0.01;
// NOTE: 相机用 up向量控制相机的旋转
// 走路左右晃脑的效果
camera.up = up.rotateByAxisAngle(
pitch,
// sin 从0开始,从静止到运动比cos自然。
sin_head * stepRotation + lean.x,
);
// 相机 BOB 运动摇晃
// TODO: 是否也会影响玩家运动?
const bobSide = 0.1;
const bobUp = 0.15;
// -bobSide ~ bobSide
var bobbing = right.scale(sin_head * bobSide);
// \_/‾\_/‾\_ 其中最大值为 bobUp, 最小值为0
bobbing.y = @abs(cos_head * bobUp);
// 先算位置,再算相机朝向
camera.position = camera.position.add(bobbing.scale(walkLerp));
// 相机需要2个点才能确定,一个是up(原点),一个是朝向的位置。
camera.target = camera.position.add(pitch);
}
fn DrawLevel() void {
const floorExtent = 25;
const tileSize = 5.0;
const tileColor1 = rl.Color{
.r = 150,
.g = 200,
.b = 200,
.a = 255,
};
// 地板平铺瓷砖, 灰白相间
var y: i32 = -floorExtent;
while (y < floorExtent) : (y += 1) {
var x: i32 = -floorExtent;
while (x < floorExtent) : (x += 1) {
// NOTE: zig 位运算 == 1的写法
if ((y & 1) == 1 and (x & 1) == 1) {
// x y 都是奇数
rl.drawPlane(
rl.Vector3{
.x = @as(f32, @floatFromInt(x)) * tileSize,
.y = 0.0,
.z = @as(f32, @floatFromInt(y)) * tileSize,
},
rl.Vector2{
.x = tileSize,
.y = tileSize,
},
tileColor1,
);
} else if ((y & 1) == 0 and (x & 1) == 0) {
// 都是偶数
rl.drawPlane(
rl.Vector3{
.x = @as(f32, @floatFromInt(x)) * tileSize,
.y = 0.0,
.z = @as(f32, @floatFromInt(y)) * tileSize,
},
rl.Vector2{
.x = tileSize,
.y = tileSize,
},
.light_gray,
);
}
}
}
const towerSize = rl.Vector3{
.x = 16.0,
.y = 32.0,
.z = 16.0,
};
const towerColor = rl.Color{ .r = 150, .g = 200, .b = 200, .a = 255 };
var towerPos = rl.Vector3{
.x = 16.0,
.y = 16.0,
.z = 16.0,
};
// TODO: 以 towerPos 为起点(中心or靠近原点的顶点),绘制长宽高为 towerSize的长方体。
rl.drawCubeV(towerPos, towerSize, towerColor);
rl.drawCubeWiresV(towerPos, towerSize, .dark_blue);
towerPos.x *= -1;
rl.drawCubeV(towerPos, towerSize, towerColor);
rl.drawCubeWiresV(towerPos, towerSize, .dark_blue);
towerPos.z *= -1;
rl.drawCubeV(towerPos, towerSize, towerColor);
rl.drawCubeWiresV(towerPos, towerSize, .dark_blue);
towerPos.x *= -1;
rl.drawCubeV(towerPos, towerSize, towerColor);
rl.drawCubeWiresV(towerPos, towerSize, .dark_blue);
// 红太阳
rl.drawSphere(rl.Vector3{ .x = 300.0, .y = 300.0, .z = 0.0 }, 100.0, .red);
}
linux 按下按键崩溃修复
上游 GLFW 没有做按键检查。可能其他机器不会遇到这种情况。
--- linux_joystick.c
+++ linux_joystick.c
@@ -49,6 +49,11 @@
//
static void handleKeyEvent(_GLFWjoystick* js, int code, int value)
{
+ // Guard against non-joystick (keyboard) key codes outside [BTN_MISC, KEY_CNT)
+ // which would index keyMap[] out of bounds. GLFW bug: pressing a key crashes.
+ if (code < BTN_MISC || code >= KEY_CNT)
+ return;
+
_glfwInputJoystickButton(js,
js->linjs.keyMap[code - BTN_MISC],
value ? GLFW_PRESS : GLFW_RELEASE);

浙公网安备 33010602011771号