zig 0.16.0 + raylib 5 中文注释讲解 3d camera fps (丝滑移动与camera bob)

https://www.raylib.com/examples.html 3d camera fps

逐步拆解笔记

有点乱……凑合看

note

中文代码注释

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);
posted @ 2026-08-30 21:17  Nolca  阅读(7)  评论(0)    收藏  举报