ifcviewer: first-person / fly view — share the desktop fly camera with web

DRY the fly camera into ViewportCore so desktop and web share identical math:
- flyMove(fwd,back,right,left,up,down,boost,dt): WASD in the view plane, QE
  along world +Z, forward = eye→target (no snap after orbiting), Shift = 5x,
  dt clamped to 0.1s. flyLook(dx,dy): turn in place (yaw/pitch, eye pinned,
  0.2 deg/px, pitch ±89.9). flyAdjustSpeed(notches): wheel scales speed x1.25.
  fly_move_speed_ + orbitEye now live in the core (orbitEye's duplicate removed
  from ViewportWindow).
- Desktop ViewportWindow: fpsIntegrate / mouse-look / wheel-speed call the core
  methods; behaviour unchanged, BonsaiViewer builds clean.
- Web main_web: Shift+F (or the Fly toolbar button) enters and pointer-locks the
  canvas; W/A/S/D/Q/E + Shift are held-tracked (keydown/keyup) and integrated
  each RAF frame with wall-clock dt; pointer-lock mouse deltas drive flyLook;
  wheel tunes speed. Exit on Esc OR a canvas click (matches desktop) — a
  pointerlockchange handler catches the browser eating the first Esc to release
  the lock (so a single Esc exits), guarded by fly_locked so a denied lock on
  entry doesn't insta-exit. Fly button reflects/ syncs active state.

Verified: web enter→WASD moves→click/Esc exits, 0 GPU errors; 113/113 desktop
core + 6/6 web smoke; desktop app builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-02 14:04:35 +10:00
parent 0b8c787ac0
commit f9acf8be3a
7 changed files with 217 additions and 113 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ target_link_options(IfcViewerWeb PRIVATE
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
# add them to Module. ccall lets shell.html pass a JS string (the ?model
# URL) to load_sidecar_from_url_c without manual heap marshalling.
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c']"
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c']"
# ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c.
# HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large
# sidecar streams by range instead of loading whole). Standard, zero-cost.
+112 -6
View File
@@ -68,6 +68,18 @@ struct AppState {
float nav_drag_px = 0.0f;
long down_x = 0;
long down_y = 0;
// ---- Fly (first-person) mode ----
// Shift+F enters (pointer-locks the canvas), Esc exits. While flying, held
// W/A/S/D/Q/E + Shift are integrated each frame via ViewportCore::flyMove,
// and pointer-lock mouse deltas drive flyLook. dt from fly_last_ms.
bool fly_mode = false;
// True once the browser actually granted pointer lock for this fly session.
// Distinguishes "lock lost" (Esc/click-out → exit fly) from "lock denied on
// entry" (headless / permission) where fly stays keyboard-drivable.
bool fly_locked = false;
bool k_w = false, k_a = false, k_s = false, k_d = false, k_q = false, k_e = false, k_shift = false;
double fly_last_ms = 0.0;
};
// Click vs drag threshold (CSS px). Below this total travel a left release is
@@ -78,6 +90,9 @@ constexpr float kClickDragThresholdPx = 4.0f;
// pointer round-trip (set into Module._app_ptr from on_complete).
AppState* g_app = nullptr;
// Defined below (with the fly helpers); onMouseDown needs it to exit fly on click.
void setFlyMode(AppState* app, bool on);
// Logical (CSS-pixel) height of the canvas. Mouse movementX/Y deltas are
// in CSS pixels, so pan's world-units-per-pixel must use CSS-pixel height
// too (not the DPR-scaled framebuffer height).
@@ -89,6 +104,8 @@ int canvasCssHeight() {
EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
// In fly mode a click exits (matches the desktop app).
if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; }
if (e->button == 0 || e->button == 1 || e->button == 2) {
app->nav_active = true;
app->nav_button = e->button;
@@ -101,7 +118,13 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) {
EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
if (!app->ready || !app->nav_active) return EM_FALSE;
if (!app->ready) return EM_FALSE;
// Fly mode: pointer-locked mouse deltas turn the camera in place.
if (app->fly_mode) {
app->core.flyLook(float(e->movementX), float(e->movementY));
return EM_TRUE;
}
if (!app->nav_active) return EM_FALSE;
const float dx = float(e->movementX);
const float dy = float(e->movementY);
@@ -151,19 +174,59 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) {
double dy = e->deltaY;
if (e->deltaMode == DOM_DELTA_LINE) dy *= 16.0;
else if (e->deltaMode == DOM_DELTA_PAGE) dy *= 800.0;
// In fly mode the wheel tunes move speed (Blender convention), not zoom.
if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; }
app->core.dollyBy(-float(dy) / 100.0f);
return EM_TRUE; // consume so the page doesn't scroll
}
// Viewport nav hotkeys, matching the desktop bindings (ViewportWindow):
// Home view all F zoom to selected P ortho/persp toggle
// X/Y/Z front/right/top Shift+X/Y/Z back/left/bottom
// `.code` is layout-independent (physical key), so this works on any keymap.
// Track a held fly movement key (W/A/S/D/Q/E/Shift). Returns true if `code` was
// one. Physical `.code` so it's keymap-independent.
bool setFlyKey(AppState* app, const char* code, bool down) {
if (!std::strcmp(code, "KeyW")) app->k_w = down;
else if (!std::strcmp(code, "KeyA")) app->k_a = down;
else if (!std::strcmp(code, "KeyS")) app->k_s = down;
else if (!std::strcmp(code, "KeyD")) app->k_d = down;
else if (!std::strcmp(code, "KeyQ")) app->k_q = down;
else if (!std::strcmp(code, "KeyE")) app->k_e = down;
else if (!std::strcmp(code, "ShiftLeft") || !std::strcmp(code, "ShiftRight"))
app->k_shift = down;
else return false;
return true;
}
// Enter/leave fly mode: pointer-lock the canvas for mouse-look on enter, release
// on exit. Shared camera math is ViewportCore::flyMove/flyLook.
void setFlyMode(AppState* app, bool on) {
if (app->fly_mode == on) return;
app->fly_mode = on;
if (on) {
app->fly_last_ms = emscripten_get_now();
emscripten_request_pointerlock(kCanvasSelector, EM_TRUE);
Log::info() << "[fly] on — WASD/QE move, mouse looks, Shift boosts, wheel = speed, Esc exits";
} else {
app->k_w = app->k_a = app->k_s = app->k_d = app->k_q = app->k_e = app->k_shift = false;
app->fly_locked = false;
emscripten_exit_pointerlock();
Log::info() << "[fly] off";
}
app->host.requestFrame();
}
// Viewport hotkeys, matching the desktop bindings (ViewportWindow):
// Home view all · F zoom to selected · P ortho/persp · X/Y/Z (+Shift) views
// Shift+F enter fly · Esc exit fly · while flying: W/A/S/D/Q/E held + Shift.
EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
if (!app->ready || e->repeat) return EM_FALSE;
if (!app->ready) return EM_FALSE;
const char* code = e->code;
const bool shift = e->shiftKey;
// Shift+F toggles fly; Esc leaves it.
if (!std::strcmp(code, "KeyF") && shift && !e->repeat) { setFlyMode(app, !app->fly_mode); return EM_TRUE; }
if (!std::strcmp(code, "Escape") && app->fly_mode) { setFlyMode(app, false); return EM_TRUE; }
// While flying, WASDQE/Shift are held-movement keys, not hotkeys.
if (app->fly_mode) { if (setFlyKey(app, code, true)) return EM_TRUE; return EM_FALSE; }
if (e->repeat) return EM_FALSE;
using SV = ViewportCore::StandardView;
if (!std::strcmp(code, "Home")) app->core.viewAll();
else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection();
@@ -176,6 +239,26 @@ EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) {
return EM_TRUE;
}
EM_BOOL onKeyUp(int, const EmscriptenKeyboardEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
return setFlyKey(app, e->code, false) ? EM_TRUE : EM_FALSE;
}
// Pointer-lock is the fly-look mechanism. When it's LOST (the browser eats the
// first Esc to release it, or the user clicks out) leave fly mode — this is what
// makes a single Esc exit cleanly. `fly_locked` guards against a denied lock on
// entry (headless / permission) firing this and insta-exiting: we only treat a
// loss as an exit if we'd actually acquired the lock.
EM_BOOL onPointerLockChange(int, const EmscriptenPointerlockChangeEvent* e, void* user) {
auto* app = static_cast<AppState*>(user);
if (e->isActive) {
app->fly_locked = true;
} else if (app->fly_locked && app->fly_mode) {
setFlyMode(app, false);
}
return EM_TRUE;
}
// Register pointer + wheel handlers once the app is live. mousedown binds
// to the canvas; mousemove/up bind to the window so a drag keeps tracking
// when the pointer leaves the canvas. A JS-side contextmenu suppressor
@@ -186,6 +269,9 @@ void installInputHandlers(AppState* app) {
emscripten_set_mouseup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onMouseUp);
emscripten_set_wheel_callback(kCanvasSelector, app, EM_FALSE, onWheel);
emscripten_set_keydown_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyDown);
emscripten_set_keyup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyUp);
emscripten_set_pointerlockchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, app, EM_FALSE,
onPointerLockChange);
EM_ASM({
var c = document.querySelector(UTF8ToString($0));
if (c) c.addEventListener('contextmenu', function(ev) { ev.preventDefault(); });
@@ -209,6 +295,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) {
app->last_h = h;
}
// Fly mode: integrate held-key movement each frame (dt from wall clock).
// flyMove schedules a frame when it actually moves, so a still fly camera
// costs nothing.
if (app->fly_mode) {
const double now = emscripten_get_now();
const float dt = float((now - app->fly_last_ms) / 1000.0);
app->fly_last_ms = now;
app->core.flyMove(app->k_w, app->k_s, app->k_d, app->k_a,
app->k_e, app->k_q, app->k_shift, dt);
}
if (app->host.consumeFrameRequest()) {
app->core.render();
}
@@ -250,6 +347,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE void toggle_projection_c() {
extern "C" EMSCRIPTEN_KEEPALIVE int projection_is_ortho_c() {
return (g_app && g_app->ready && g_app->core.projectionOrtho()) ? 1 : 0;
}
// Toggle fly (first-person) mode from the toolbar. The button click is a user
// gesture, so the pointer-lock request inside succeeds.
extern "C" EMSCRIPTEN_KEEPALIVE void toggle_fly_c() {
if (g_app && g_app->ready) setFlyMode(g_app, !g_app->fly_mode);
}
extern "C" EMSCRIPTEN_KEEPALIVE int fly_is_active_c() {
return (g_app && g_app->ready && g_app->fly_mode) ? 1 : 0;
}
// id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom.
extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) {
if (!g_app || !g_app->ready || id < 0 || id > 5) return;
+7
View File
@@ -36,6 +36,7 @@
#nav-toolbar button { background: #2d3748; color: #c8ccd6; border: none;
padding: 5px 9px; border-radius: 4px; font-size: 12px; cursor: pointer; }
#nav-toolbar button:hover { background: #3b465c; }
#nav-toolbar button.active { background: #2b6cb0; color: #fff; }
#nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; }
/* Streaming loading UI: a thin top progress strip (aggregate) + a centred
panel with a per-model segmented bar. Shown only while models stream. */
@@ -77,6 +78,7 @@
<button data-act="fit" title="Fit all (Home)">Fit</button>
<button data-act="focus" title="Zoom to selected (F)">Focus</button>
<button data-act="ortho" id="ortho-btn" title="Toggle orthographic / perspective (P)">Persp</button>
<button data-act="fly" id="fly-btn" title="Fly / first-person — WASD+mouse (⇧F)">Fly</button>
<span class="sep"></span>
<button data-view="0" title="Front (X)">Front</button>
<button data-view="1" title="Back (Shift+X)">Back</button>
@@ -156,6 +158,10 @@
});
}
window.updateLoadProgress();
if (Module._fly_is_active_c) {
var fb = document.getElementById('fly-btn');
if (fb) fb.classList.toggle('active', !!Module._fly_is_active_c());
}
Module._raf_tick_c(Module._app_ptr);
}
requestAnimationFrame(shellTick);
@@ -331,6 +337,7 @@
if (act === 'fit') Module._view_all_c();
else if (act === 'focus') Module._frame_selection_c();
else if (act === 'ortho') { Module._toggle_projection_c(); refreshOrthoLabel(); }
else if (act === 'fly') Module._toggle_fly_c();
else if (view !== null) Module._standard_view_c(parseInt(view, 10));
});
});
+60
View File
@@ -526,6 +526,66 @@ void ViewportCore::dollyBy(float notches) {
host_->requestFrame();
}
void ViewportCore::flyMove(bool fwd, bool back, bool right, bool left,
bool up, bool down, bool boost, float dt_seconds) {
if (dt_seconds <= 0.0f) return;
if (dt_seconds > 0.1f) dt_seconds = 0.1f; // stall clamp
// Forward = orbit eye -> target, kept as the view direction in fly mode too
// so entering fly right after orbiting doesn't snap to a new heading.
const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
Eigen::Vector3f forward = target - eye;
if (forward.norm() < 1e-6f) return;
forward.normalize();
// Looking straight up/down degenerates cross(forward, worldZ); fall back to
// worldY so `right` doesn't go NaN.
const Eigen::Vector3f world_up(0.0f, 0.0f, 1.0f);
const Eigen::Vector3f right_basis =
(std::abs(camera_pitch_deg_) >= 89.0f) ? Eigen::Vector3f(0.0f, 1.0f, 0.0f) : world_up;
Eigen::Vector3f right_vec = forward.cross(right_basis);
right_vec.normalize();
Eigen::Vector3f move(0.0f, 0.0f, 0.0f);
if (fwd) move += forward;
if (back) move -= forward;
if (right) move += right_vec;
if (left) move -= right_vec;
if (up) move += world_up;
if (down) move -= world_up;
if (move.isZero()) return;
move.normalize();
const float speed = fly_move_speed_ * (boost ? 5.0f : 1.0f); // absolute m/s
const Eigen::Vector3f delta = move * (speed * dt_seconds);
camera_target_[0] += delta.x();
camera_target_[1] += delta.y();
camera_target_[2] += delta.z();
host_->requestFrame();
}
void ViewportCore::flyLook(float dx_px, float dy_px) {
// Turn in place: pin the eye, rotate yaw/pitch, then re-derive the orbit
// target so orbitEye(target, dist, new_yaw, new_pitch) == the pinned eye.
const Eigen::Vector3f pinned_eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
camera_yaw_deg_ -= dx_px * 0.2f;
camera_pitch_deg_ += dy_px * 0.2f;
camera_pitch_deg_ = std::clamp(camera_pitch_deg_, -89.9f, 89.9f);
constexpr float kDeg2Rad = 0.01745329251994329577f;
const float yaw = camera_yaw_deg_ * kDeg2Rad;
const float pit = camera_pitch_deg_ * kDeg2Rad;
const float cp = std::cos(pit), sp = std::sin(pit);
const float cy = std::cos(yaw), sy = std::sin(yaw);
camera_target_[0] = pinned_eye.x() - camera_distance_ * cp * cy;
camera_target_[1] = pinned_eye.y() - camera_distance_ * cp * sy;
camera_target_[2] = pinned_eye.z() - camera_distance_ * sp;
host_->requestFrame();
}
void ViewportCore::flyAdjustSpeed(float notches) {
const float factor = std::pow(1.25f, notches); // up = faster
fly_move_speed_ = std::clamp(fly_move_speed_ * factor, 0.05f, 1000.0f);
}
void ViewportCore::toggleProjection() {
projection_ortho_ = !projection_ortho_;
std::fprintf(stderr, "[info] [wgpu] projection: %s\n",
+22
View File
@@ -217,6 +217,25 @@ public:
void panBy(float dx_px, float dy_px, int viewport_height_px);
void dollyBy(float notches);
// ---- First-person / fly navigation --------------------------------------
//
// Shared fly-camera math (desktop + web). The HOST owns the fly-mode flag,
// held-key tracking, cursor/pointer-lock, and per-frame timing; it calls
// these each frame while flying. Behaviour is identical across platforms.
//
// flyMove: WASD moves in the view plane, QE rises/falls along world +Z;
// forward = eye→target so it doesn't snap after orbiting. `boost`
// (Shift) is 5x. Speed is metres/second (flyAdjustSpeed tunes it);
// `dt` seconds is clamped to 0.1 so a stall can't warp the camera.
// flyLook: mouse-look — turn the camera in place (yaw/pitch) with the eye
// pinned; pixel deltas, 0.2 deg/px, pitch clamped to ±89.9.
// flyAdjustSpeed: wheel scales the move speed (x1.25 per notch, clamped).
void flyMove(bool fwd, bool back, bool right, bool left,
bool up, bool down, bool boost, float dt_seconds);
void flyLook(float dx_px, float dy_px);
void flyAdjustSpeed(float notches);
float flySpeed() const { return fly_move_speed_; }
void toggleProjection();
bool projectionOrtho() const { return projection_ortho_; }
std::string cameraString() const;
@@ -1116,6 +1135,9 @@ private:
float camera_fov_y_deg_ = 45.0f;
float camera_near_ = 0.1f;
float camera_far_ = 10000.0f;
// Fly-camera move speed (m/s), wheel-adjustable via flyAdjustSpeed. Shared
// by desktop + web fly mode; the mode flag itself lives in each host.
float fly_move_speed_ = 5.0f;
// Perspective by default; toggleProjection (P key) flips this. When
// true, buildViewProj uses an orthographic matrix sized by
// camera_distance_ × tan(fov/2) so toggling looks like a smooth
+13 -100
View File
@@ -77,10 +77,6 @@ static inline float srgbToLinear(float s) {
static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256;
// Forward declaration — defined below alongside updateFrameUniforms. Used
// by render() to extract camera/frustum state without duplicating the math.
static Eigen::Vector3f orbitEye(const float target[3], float dist,
float yaw_deg, float pitch_deg);
// computeMeshLocalVolumeQuantised moved to ViewportCore.cpp anon
// namespace (#84-n).
@@ -1467,21 +1463,8 @@ void ViewportWindow::driveStreamingLoads() { core_.driveStreamingLoads(); }
// rotation about Z (positive = anticlockwise looking down +Z); pitch is
// elevation above the XY plane.
static Eigen::Vector3f orbitEye(const float target[3], float dist,
float yaw_deg, float pitch_deg) {
// Matches the GL ViewportWindow::updateCamera convention exactly so the
// orbit pivot, framing, and benchmark camera path align between backends.
// eye.x = target.x + dist * cos(pitch) * cos(yaw)
// eye.y = target.y + dist * cos(pitch) * sin(yaw)
// eye.z = target.z + dist * sin(pitch)
const float yaw = qDegreesToRadians(yaw_deg);
const float pit = qDegreesToRadians(pitch_deg);
const float cp = std::cos(pit), sp = std::sin(pit);
const float cy = std::cos(yaw), sy = std::sin(yaw);
return Eigen::Vector3f(target[0] + dist * cp * cy,
target[1] + dist * cp * sy,
target[2] + dist * sp);
}
// orbitEye moved to ViewportCore (its last ViewportWindow uses — fly-mode step
// + mouse-look — now go through ViewportCore::flyMove / flyLook).
// Shared camera-math helper. Every site that needs (view, proj) for cull,
// streaming projection, pick, or render uniforms calls this so the
@@ -1565,58 +1548,12 @@ void ViewportWindow::fpsIntegrate() {
float dt = float(double(elapsed_ns) / 1e9);
if (dt > 0.1f) dt = 0.1f;
// Forward = orbit eye -> target, kept as the camera's view direction in
// fly mode too so a Shift+F right after orbiting doesn't snap to a new
// heading. WASD moves in the screen plane; QE rises/falls along world +Z.
const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]);
const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
Eigen::Vector3f forward = (target - eye); forward.normalize();
// When looking straight up/down, cross(forward, worldZ) degenerates;
// fall back to worldY so right doesn't go NaN and WASD still works.
const Eigen::Vector3f world_up(0.0f, 0.0f, 1.0f);
const Eigen::Vector3f right_basis = (std::abs(camera_pitch_deg_) >= 89.0f)
? Eigen::Vector3f(0.0f, 1.0f, 0.0f)
: world_up;
Eigen::Vector3f right = forward.cross(right_basis);
right.normalize();
Eigen::Vector3f move(0, 0, 0);
if (fps_keys_held_.count(Qt::Key_W)) move += forward;
if (fps_keys_held_.count(Qt::Key_S)) move -= forward;
if (fps_keys_held_.count(Qt::Key_D)) move += right;
if (fps_keys_held_.count(Qt::Key_A)) move -= right;
if (fps_keys_held_.count(Qt::Key_E)) move += world_up;
if (fps_keys_held_.count(Qt::Key_Q)) move -= world_up;
if (move.isZero()) return;
move.normalize();
// Absolute m/s, scrollwheel-adjustable (Blender / GL convention).
// Scaling with camera_distance_ produced "stuttery" speed on big scenes
// because distance varies frame-to-frame (and worse, wheel zoom kept
// changing it underneath fly mode).
const float speed = fps_move_speed_
* (fps_keys_held_.count(Qt::Key_Shift) ? 5.0f : 1.0f);
const Eigen::Vector3f delta = move * (speed * dt);
camera_target_[0] += delta.x();
camera_target_[1] += delta.y();
camera_target_[2] += delta.z();
requestUpdate();
if (fly_debug_) {
// dt timeline: see if values jitter (under/over-integration symptoms).
// Show in ms with 2dp so small jumps are visible.
const qint64 since_render_ns = fly_render_clock_.isValid()
? fly_render_clock_.nsecsElapsed() : 0;
fly_render_clock_.restart();
Log::info().noquote().nospace()
<< "[fly] dt=" << QString::number(dt * 1000.0f, 'f', 2) << "ms"
<< " render_gap=" << QString::number(double(since_render_ns) / 1e6, 'f', 2) << "ms"
<< " keys=" << fps_keys_held_.size()
<< " speed=" << QString::number(speed, 'f', 2) << "m/s"
<< " delta=" << QString::number(delta.norm(), 'f', 4) << "m";
}
// Fly-camera math lives in ViewportCore (shared with the web path).
core_.flyMove(
fps_keys_held_.count(Qt::Key_W) != 0, fps_keys_held_.count(Qt::Key_S) != 0,
fps_keys_held_.count(Qt::Key_D) != 0, fps_keys_held_.count(Qt::Key_A) != 0,
fps_keys_held_.count(Qt::Key_E) != 0, fps_keys_held_.count(Qt::Key_Q) != 0,
fps_keys_held_.count(Qt::Key_Shift) != 0, dt);
}
// chunkScreenAreaPx moved to ViewportCore (#84-h).
@@ -1980,32 +1917,9 @@ void ViewportWindow::mouseMoveEvent(QMouseEvent* event) {
const int dx = pos.x() - fps_press_center_.x();
const int dy = pos.y() - fps_press_center_.y();
// Save eye BEFORE rotating so we can pin it after.
const Eigen::Vector3f pinned_eye = orbitEye(camera_target_, camera_distance_,
camera_yaw_deg_, camera_pitch_deg_);
// Convention: mouse-up looks up, mouse-down looks down (non-inverted).
// orbitEye stores pitch with sin(pitch) controlling eye.z relative to
// target → larger pitch = eye higher = looking down. To make mouse-up
// (dy<0) look up (i.e. raise pitch in our stored convention so the
// camera tilts down toward the target… wait, with eye pinned in FPS
// mode the relationship inverts: increasing pitch pulls *target* up,
// which means forward tilts down). Net: dy>0 (down) increases pitch
// → forward tilts down → looking down. `+=` is correct here even
// though orbit-mode also uses `+=` for the opposite visual reason.
camera_yaw_deg_ -= float(dx) * 0.2f;
camera_pitch_deg_ += float(dy) * 0.2f;
camera_pitch_deg_ = std::clamp(camera_pitch_deg_, -89.9f, 89.9f);
// Re-derive target so orbitEye(target, dist, new_yaw, new_pitch) ==
// pinned_eye. eye = target + dist*(cp*cy, cp*sy, sp) → invert.
const float yaw = qDegreesToRadians(camera_yaw_deg_);
const float pit = qDegreesToRadians(camera_pitch_deg_);
const float cp = std::cos(pit), sp = std::sin(pit);
const float cy = std::cos(yaw), sy = std::sin(yaw);
camera_target_[0] = pinned_eye.x() - camera_distance_ * cp * cy;
camera_target_[1] = pinned_eye.y() - camera_distance_ * cp * sy;
camera_target_[2] = pinned_eye.z() - camera_distance_ * sp;
// Mouse-look math (turn-in-place) lives in ViewportCore, shared with
// the web pointer-lock path.
core_.flyLook(float(dx), float(dy));
fps_ignore_next_mouse_move_ = true;
QCursor::setPos(mapToGlobal(QPoint(fps_press_center_.x(), fps_press_center_.y())));
@@ -2231,10 +2145,9 @@ void ViewportWindow::wheelEvent(QWheelEvent* event) {
// Zooming would re-aim the orbit pivot and yank speed (if it were
// distance-scaled) — neither belongs in a free-fly camera.
if (fps_mode_) {
const float factor = std::pow(1.25f, notches);
fps_move_speed_ = std::clamp(fps_move_speed_ * factor, 0.05f, 1000.0f);
core_.flyAdjustSpeed(notches); // shared: x1.25/notch, clamped
Log::info().noquote().nospace()
<< "[wgpu] fly speed: " << QString::number(fps_move_speed_, 'f', 2) << " m/s";
<< "[wgpu] fly speed: " << QString::number(core_.flySpeed(), 'f', 2) << " m/s";
return;
}
// Orbit mode: zoom in/out around the pivot (math in ViewportCore).
+2 -6
View File
@@ -779,12 +779,8 @@ private:
Stopwatch fps_last_tick_;
Eigen::Vector2i fps_press_center_;
bool fps_ignore_next_mouse_move_ = false;
// Fly base speed in m/s at no-modifier (Shift gives a 5× boost). Default
// 5.0 matches GL fps_move_speed_. Scrollwheel in fly mode adjusts this
// by ×1.25 / ×0.8 per notch, Blender-style — wheel does NOT zoom while
// in fly mode (which would change camera_distance_ underneath us and
// make speed jitter if speed were distance-scaled).
float fps_move_speed_ = 5.0f;
// Fly base speed (m/s) + wheel adjustment now live in ViewportCore
// (fly_move_speed_ / flyAdjustSpeed), shared with the web fly path.
// Per-frame [fly] dt log when WGPU_FLY_DEBUG=1. Diagnoses stutter:
// print dt of each fpsIntegrate call and the prior render's elapsed
// ms. Off by default (env-gated) so the normal log stays clean.