#include "app/app.hpp" #include "util/logging.hpp" #include #include #include #include #include #include #include #include #include namespace pixelvr { namespace { // Quaternion/pose helpers (xyzw) for composing controller-model placement. void quatRotate(const float q[4], const float v[3], float o[3]) { const float ux = q[0], uy = q[1], uz = q[2], w = q[3]; const float c1x = uy * v[2] - uz * v[1] + w * v[0]; const float c1y = uz * v[0] - ux * v[2] + w * v[1]; const float c1z = ux * v[1] - uy * v[0] + w * v[2]; o[0] = v[0] + 2.0f * (uy * c1z - uz * c1y); o[1] = v[1] + 2.0f * (uz * c1x - ux * c1z); o[2] = v[2] + 2.0f * (ux * c1y - uy * c1x); } void quatMul(const float a[4], const float b[4], float o[4]) { o[0] = a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1]; o[1] = a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0]; o[2] = a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3]; o[3] = a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2]; } // out = A ∘ B composed as q = qA*qB, t = qA*tB + tA. void composePose(const float pa[3], const float qa[4], const float pb[3], const float qb[4], float po[3], float qo[4]) { quatMul(qa, qb, qo); float r[3]; quatRotate(qa, pb, r); po[0] = r[0] + pa[0]; po[1] = r[1] + pa[1]; po[2] = r[2] + pa[2]; } // Normalized lerp between quaternions (shortest path) for pose smoothing. void quatNlerp(const float a[4], const float b[4], float t, float o[4]) { const float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]; const float s = dot < 0.0f ? -1.0f : 1.0f; float len = 0.0f; for (int i = 0; i < 4; ++i) { o[i] = a[i] * (1.0f - t) + b[i] * s * t; len += o[i] * o[i]; } len = std::sqrt(len); if (len > 1e-6f) for (int i = 0; i < 4; ++i) o[i] /= len; } std::string to_lower(std::string s) { for (char& c : s) { c = static_cast(std::tolower(static_cast(c))); } return s; } bool contains(const std::string& s, const char* token) { return s.find(token) != std::string::npos; } // True if `needle` appears not immediately followed by a digit, so a marker like // "360" matches but a resolution like "3600" (or "3600p") does not. bool contains_num(const std::string& s, const char* needle) { const std::string n = needle; std::size_t pos = 0; while ((pos = s.find(n, pos)) != std::string::npos) { const std::size_t after = pos + n.size(); if (after >= s.size() || s[after] < '0' || s[after] > '9') return true; pos = after; } return false; } // Infers projection and stereo layout from common filename conventions, e.g. // "clip_360_tb.mp4", "movie_180_sbs.mkv", "trailer_vr180.mp4". `stereoFromName` // reports whether the stereo layout was stated explicitly (so the caller can // refine it from the frame aspect ratio otherwise). void detect_format(const std::string& path, VulkanRenderer::ProjectionMode& proj, VulkanRenderer::StereoMode& stereo, bool& stereoFromName) { const std::string p = to_lower(path); // Check 180 before 360 and ignore digit runs so "vr180.3600p" is 180, not 360 // (the resolution "3600" must not match the "360" marker). if (contains(p, "vr180") || contains_num(p, "180")) { proj = VulkanRenderer::ProjectionMode::Equirect180; } else if (contains_num(p, "360") || contains(p, "equirect")) { proj = VulkanRenderer::ProjectionMode::Equirect360; } else { proj = VulkanRenderer::ProjectionMode::Flat; } stereoFromName = true; if (contains(p, "_tb") || contains(p, "-tb") || contains(p, "_ou") || contains(p, "overunder") || contains(p, "over-under") || contains(p, "htab") || contains(p, "h-tab")) { stereo = VulkanRenderer::StereoMode::TopBottom; } else if (contains(p, "sbs") || contains(p, "_lr") || contains(p, "sidebyside") || contains(p, "side-by-side")) { stereo = VulkanRenderer::StereoMode::SideBySide; // incl. HSBS / half-SBS } else if (contains(p, "3d")) { // Bare "3D" with no explicit layout: half-SBS dominates movie rips. stereo = VulkanRenderer::StereoMode::SideBySide; } else { stereo = VulkanRenderer::StereoMode::Mono; stereoFromName = false; // no explicit layout; refine from aspect ratio } } } // namespace bool App::init(const std::string& mediaPath) { if (!xr_.createInstanceAndSystem("Pixel VR Player")) { return false; } if (!renderer_.createDevice(xr_)) { return false; } if (!xr_.createSession(renderer_.graphicsBinding())) { return false; } if (!xr_.createReferenceSpace()) { return false; } if (!xr_.createActions()) { PIXELVR_LOG_WARN("Controller input unavailable; continuing without it"); } renderModels_.init(xr_.instance(), xr_.session(), xr_.renderModelEnabled()); const std::vector formats = xr_.enumerateSwapchainFormats(); if (formats.empty()) { PIXELVR_LOG_ERROR("Runtime reported no swapchain formats"); return false; } const int64_t colorFormat = renderer_.chooseColorFormat(formats); if (!xr_.createSwapchains(colorFormat)) { return false; } if (!renderer_.createRenderResources(xr_.swapchains())) { return false; } #ifdef PIXELVR_HAVE_MEDIA if (!mediaPath.empty()) { loadMedia(mediaPath); } #else (void)mediaPath; #endif PIXELVR_LOG_INFO("Initialization complete"); return true; } #ifdef PIXELVR_HAVE_MEDIA void App::loadMedia(const std::string& path) { const bool sameFile = (path == currentMediaPath_); currentMediaPath_ = path; VulkanRenderer::ProjectionMode proj = VulkanRenderer::ProjectionMode::Flat; VulkanRenderer::StereoMode stereo = VulkanRenderer::StereoMode::Mono; bool stereoFromName = false; detect_format(path, proj, stereo, stereoFromName); renderer_.releaseVideoFrame(); // free the previous clip's dma-buf before reopening renderer_.setSphereZoom(1.0f); // reset the 360/180 viewpoint per clip renderer_.setSpherePitch(0.0f); media_.setPreferredAudioLanguage(settings_.audioLanguage()); if (!sameFile) { // a new file starts from automatic track selection media_.setPreferredVideoStream(-1); media_.setPreferredAudioStream(-1); } mediaOpen_ = media_.open(path); if (!mediaOpen_) { // Clear stale video from the previous clip so the renderer shows black // instead of the last decoded frame of the old file. renderer_.clearVideo(); PIXELVR_LOG_WARN("Failed to open %s", path.c_str()); return; } // Refine the stereo layout from the frame aspect ratio when the filename did // not state it: once the projection is a VR type the aspect is unambiguous // (360 mono 2:1, SBS 4:1, OU 1:1; 180 mono 1:1, SBS 2:1, OU 1:2). const int w = media_.width(); const int h = media_.height(); if (!stereoFromName && h > 0) { const float ar = static_cast(w) / static_cast(h); if (proj == VulkanRenderer::ProjectionMode::Equirect360) { stereo = ar >= 3.0f ? VulkanRenderer::StereoMode::SideBySide : ar <= 1.3f ? VulkanRenderer::StereoMode::TopBottom : VulkanRenderer::StereoMode::Mono; } else if (proj == VulkanRenderer::ProjectionMode::Equirect180) { stereo = ar >= 1.6f ? VulkanRenderer::StereoMode::SideBySide : ar <= 0.7f ? VulkanRenderer::StereoMode::TopBottom : VulkanRenderer::StereoMode::Mono; } else { // Flat: full-frame SBS roughly doubles the width. stereo = ar >= 2.9f ? VulkanRenderer::StereoMode::SideBySide : VulkanRenderer::StereoMode::Mono; } } renderer_.setProjectionMode(proj); renderer_.setStereoMode(stereo); settings_.setProjectionMode(static_cast(proj)); settings_.setStereoMode(static_cast(stereo)); // Publish selectable tracks for the settings Audio/Video section. std::vector> vlist, alist; for (const auto& s : media_.videoStreams()) vlist.emplace_back(s.index, s.label); for (const auto& s : media_.audioStreams()) alist.emplace_back(s.index, s.label); settings_.setStreams(vlist, alist, media_.currentVideoStream(), media_.currentAudioStream()); static const char* const kProj[] = {"flat", "360", "180"}; static const char* const kStereo[] = {"mono", "SBS", "TB"}; PIXELVR_LOG_INFO("Loaded %s (%dx%d, %s/%s)", path.c_str(), w, h, kProj[static_cast(proj)], kStereo[static_cast(stereo)]); } void App::switchTracks(bool byLanguage) { if (currentMediaPath_.empty() || !mediaOpen_) return; const double pos = media_.position(); media_.setPreferredAudioLanguage(settings_.audioLanguage()); media_.setPreferredVideoStream(settings_.selectedVideoStream()); media_.setPreferredAudioStream(byLanguage ? -1 : settings_.selectedAudioStream()); loadMedia(currentMediaPath_); // same file: keeps the preferences set above if (pos > 0.5 && media_.duration() > 0.0) media_.seek(pos); } void App::updateStats() { using clock = std::chrono::steady_clock; const auto now = clock::now(); ++statsFrameAccum_; if (lastStatsUpdate_.time_since_epoch().count() == 0) { lastStatsUpdate_ = now; statsLastDecoded_ = media_.decodedFrames(); return; } const double elapsed = std::chrono::duration(now - lastStatsUpdate_).count(); if (elapsed < 0.5) { return; // refresh the readout ~twice a second } const double fps = statsFrameAccum_ / elapsed; const uint64_t dec = media_.decodedFrames(); if (dec >= statsLastDecoded_) { const double decFps = static_cast(dec - statsLastDecoded_) / elapsed; decFpsEma_ = (decFpsEma_ > 0.0) ? decFpsEma_ * 0.6 + decFps * 0.4 : decFps; } else { decFpsEma_ = 0.0; // decodedFrames reset on a new clip: restart the average } statsFrameAccum_ = 0; statsLastDecoded_ = dec; lastStatsUpdate_ = now; // Aggregate CPU load from /proc/stat deltas. float cpuPct = -1.0f; { std::ifstream f("/proc/stat"); std::string cpu; unsigned long long u = 0, n = 0, s = 0, idle = 0, io = 0, irq = 0, sirq = 0, steal = 0; if (f >> cpu >> u >> n >> s >> idle >> io >> irq >> sirq >> steal) { const unsigned long long idleAll = idle + io; const unsigned long long total = u + n + s + idle + io + irq + sirq + steal; if (cpuPrevTotal_ != 0 && total > cpuPrevTotal_) { const unsigned long long dtot = total - cpuPrevTotal_; const unsigned long long didle = idleAll - cpuPrevIdle_; cpuPct = 100.0f * static_cast(dtot - didle) / static_cast(dtot); } cpuPrevTotal_ = total; cpuPrevIdle_ = idleAll; } } // RAM usage from /proc/meminfo. long memTotalKb = 0, memAvailKb = 0; { std::ifstream f("/proc/meminfo"); std::string key, unit; long val = 0; while (f >> key >> val >> unit) { if (key == "MemTotal:") memTotalKb = val; else if (key == "MemAvailable:") { memAvailKb = val; break; } } } // Adreno GPU: the Frame runs the Turnip/msm driver (no kgsl sysfs), so report // the GPU clock from devfreq (the governor scales it with load; pegged at max // means GPU-bound). long gpuCurMhz = 0, gpuMaxMhz = 0; { std::ifstream cf("/sys/class/devfreq/3d00000.gpu/cur_freq"); std::ifstream mf("/sys/class/devfreq/3d00000.gpu/max_freq"); long cur = 0, mx = 0; if (cf >> cur) gpuCurMhz = cur / 1000000; if (mf >> mx) gpuMaxMhz = mx / 1000000; } char buf[160]; statsLines_.clear(); std::snprintf(buf, sizeof(buf), "FPS %.1f (%.1f ms)", fps, fps > 0.0 ? 1000.0 / fps : 0.0); statsLines_.emplace_back(buf); if (mediaOpen_) { std::snprintf(buf, sizeof(buf), "Video %dx%d %s", media_.width(), media_.height(), media_.codecName().c_str()); statsLines_.emplace_back(buf); std::snprintf(buf, sizeof(buf), "Decode %.1f / %.2f fps", decFpsEma_, media_.frameRate()); statsLines_.emplace_back(buf); } if (cpuPct >= 0.0f) { std::snprintf(buf, sizeof(buf), "CPU %.0f%%", cpuPct); statsLines_.emplace_back(buf); } if (gpuMaxMhz > 0) { std::snprintf(buf, sizeof(buf), "GPU %ld/%ld MHz", gpuCurMhz, gpuMaxMhz); } else { std::snprintf(buf, sizeof(buf), "GPU n/a"); } statsLines_.emplace_back(buf); if (memTotalKb > 0) { std::snprintf(buf, sizeof(buf), "RAM %ld / %ld MB", (memTotalKb - memAvailKb) / 1024, memTotalKb / 1024); statsLines_.emplace_back(buf); } } #endif int App::navStep(float axisY, std::chrono::steady_clock::time_point now) { using namespace std::chrono_literals; if (axisY > 0.6f || axisY < -0.6f) { const int dir = (axisY > 0.0f) ? -1 : 1; // push up = toward the top row if (navArmed_) { // first press: step now, then pause before repeating navArmed_ = false; navNextRepeat_ = now + 400ms; return dir; } if (now >= navNextRepeat_) { // held down: auto-repeat navNextRepeat_ = now + 110ms; return dir; } return 0; } if (axisY > -0.3f && axisY < 0.3f) navArmed_ = true; // released: re-arm return 0; } int App::run() { using namespace std::chrono_literals; while (!exitRequested_) { if (!xr_.pollEvents(exitRequested_) || exitRequested_) { break; } if (xr_.sessionRunning()) { // Compute frame delta-time for continuous seek (F4). const auto now = std::chrono::steady_clock::now(); float dt = 0.0f; if (lastFrameTime_.time_since_epoch().count() > 0) { dt = std::chrono::duration(now - lastFrameTime_).count(); dt = std::clamp(dt, 0.0f, 0.1f); // cap to avoid huge jumps } lastFrameTime_ = now; #ifdef PIXELVR_HAVE_MEDIA // Release held dma-buf frames when the decoder is about to seek so it // can reclaim its V4L2 capture buffers (otherwise it wedges). if (mediaOpen_ && media_.needsFrameRelease()) { renderer_.releaseVideoFrame(); media_.confirmFrameRelease(); } if (mediaOpen_ && media_.drmActive()) { // Zero-copy dma-buf path: import the decoder's frame directly. if (media_.latestDrmFrame(drmFrame_)) { renderer_.updateVideoDmabuf(drmFrame_); } } else if (mediaOpen_ && media_.latestFrame(frame_)) { renderer_.updateVideoTexture(frame_.width, frame_.height, frame_.y.data(), frame_.uv.data(), frame_.colorspace, frame_.fullRange); } #endif xr_.renderFrame([this](uint32_t viewIndex, uint32_t imageIndex, const XrView& view, const ViewSwapchain& swapchain) { renderer_.renderView(viewIndex, imageIndex, view, swapchain); }); // Apply controller input that renderFrame synced this frame. XrInputState in = xr_.input(); // Route the remappable functions from the user's chosen physical // buttons (press-to-bind). While the settings menu is listening, the // next button press is captured as the binding, not a function. if (settings_.isListening()) { for (int b = 0; b < XrInputState::BtnCount; ++b) { if (in.buttonDown[b]) { settings_.assignListening(b); break; } } in.togglePlay = false; in.browserToggle = false; in.recenter = false; } else { in.togglePlay = in.buttonDown[settings_.bindPlay()]; in.browserToggle = in.buttonDown[settings_.bindMenu()]; in.recenter = in.buttonDown[settings_.bindRecenter()]; } bool interacted = false; bool browserOpen = false; bool settingsOpen = false; // Fetch the runtime-provided controller model once a controller is // tracked (one-shot; subsequent calls return immediately). if (in.controllers[0].active || in.controllers[1].active) { if (renderModels_.poll() && !controllerModelLoaded_) { controllerModelLoaded_ = renderer_.loadControllerModel(renderModels_.gltf()); } } // Drive the model's per-node animation (button presses, trigger pull, // thumbstick tilt) from the runtime's render-model state. if (controllerModelLoaded_) { std::vector ns; if (renderModels_.nodeStates(xr_.displayTime(), ns)) { const auto& names = renderModels_.nodeNames(); std::vector vis; vis.reserve(ns.size()); for (std::size_t i = 0; i < ns.size(); ++i) { VulkanRenderer::NodeStateVis v; v.name = (i < names.size()) ? names[i] : std::string(); for (int k = 0; k < 3; ++k) v.pos[k] = ns[i].pos[k]; for (int k = 0; k < 4; ++k) v.quat[k] = ns[i].quat[k]; v.visible = ns[i].visible; vis.push_back(v); } renderer_.setControllerNodeStates(vis); } } // Live settings + place the screen in front on first head tracking. renderer_.setSwapEyes(settings_.swapEyes()); renderer_.setDrawModels(settings_.drawModels()); if (!didInitialRecenter_ && in.headValid) { renderer_.recenterScreen(in.headPos, in.headForward); didInitialRecenter_ = true; } // Controller activity: moving a controller past a small threshold (or // any button/stick input) keeps the laser, models and controls shown; // after a few seconds of stillness they fade away. const auto nowT = std::chrono::steady_clock::now(); bool activity = false; for (int h = 0; h < 2; ++h) { const auto& c = in.controllers[h]; if (!c.active) { activityAnchorValid_[h] = false; continue; } if (!activityAnchorValid_[h]) { activityAnchorValid_[h] = true; for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k]; for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k]; continue; } float dp = 0.0f; for (int k = 0; k < 3; ++k) { const float d = c.position[k] - activityAnchorPos_[h][k]; dp += d * d; } float od = 0.0f; for (int k = 0; k < 4; ++k) od += c.orientation[k] * activityAnchorOrient_[h][k]; if (std::sqrt(dp) > 0.02f || std::fabs(od) < 0.995f) { activity = true; for (int k = 0; k < 3; ++k) activityAnchorPos_[h][k] = c.position[k]; for (int k = 0; k < 4; ++k) activityAnchorOrient_[h][k] = c.orientation[k]; } } if (in.togglePlay || in.browserToggle || in.select || in.selectHeld || in.recenter || std::fabs(in.thumbstickX) > 0.15f || std::fabs(in.thumbstickY) > 0.15f || std::fabs(in.thumbstickRX) > 0.15f) { activity = true; } if (activity) lastControllerActivity_ = nowT; const bool controllersEngaged = (nowT - lastControllerActivity_) < std::chrono::milliseconds(3000); #ifdef PIXELVR_HAVE_FREETYPE if (in.browserToggle) { if (downloadsWindow_) { downloadsWindow_ = false; } else if (settings_.isOpen()) { settings_.close(); } else if (browser_.isOpen()) { browser_.setOpen(false); } else { browser_.open(); } } // Downloads run on their own thread and keep going while the browser // is closed; poll every frame so completion is handled regardless of // whether the panel is visible. browser_.setDownloadDir(settings_.downloadDir()); { std::string donePath; if (browser_.poll(donePath) == Browser::Result::Play && !donePath.empty()) { #ifdef PIXELVR_HAVE_MEDIA loadMedia(donePath); #endif browser_.setOpen(false); } } if (browser_.downloadActive()) { downloadToastText_ = browser_.downloadText(); downloadToast_ = nowT + std::chrono::seconds(10); } const bool dlRecent = browser_.downloadActive() || nowT < downloadToast_; // No head-locked download text (it followed the view); progress lives // in the Downloads window and the control-bar badge. renderer_.setDownloadStatus(false, ""); renderer_.setDownloadsBadge(browser_.downloadActive(), !browser_.downloadActive() && browser_.hasDownloads() && dlRecent); // Downloads window: job list plus action buttons at the bottom. std::vector drows; std::vector dbtns; if (browser_.downloadActive()) { dbtns.push_back(browser_.downloadPaused() ? "Resume" : "Pause"); dbtns.push_back("Stop"); } dbtns.push_back("Clear finished"); { const auto& jobs = browser_.downloadJobs(); for (std::size_t i = 0; i < jobs.size(); ++i) { const auto& j = jobs[i]; std::string nm = j.name; if (nm.size() > 30) nm = nm.substr(0, 29) + "~"; char buf[192]; if (j.state == 0) { const int pct = j.total > 0 ? static_cast(j.bytes * 100 / j.total) : 0; std::snprintf(buf, sizeof(buf), "%s %d%% %.0f/%.0f MB", nm.c_str(), pct, j.bytes / 1048576.0, j.total / 1048576.0); } else if (j.state == 1) { std::snprintf(buf, sizeof(buf), "%s done", nm.c_str()); } else if (j.state == 3) { std::snprintf(buf, sizeof(buf), "%s stopped", nm.c_str()); } else { std::snprintf(buf, sizeof(buf), "%s failed", nm.c_str()); } drows.push_back(buf); } } const bool dlWin = downloadsWindow_ && !browser_.isOpen() && !settings_.isOpen() && pendingDl_.empty(); renderer_.setDownloadsModal(false); renderer_.setDownloads( dlWin, "Downloads", drows, browser_.downloadActive() ? browser_.downloadText() : "Point + Trigger to select B: close", dbtns); if (dlWin && in.select) { const int bi = renderer_.downloadsButtonHovered(); if (bi >= 0) { if (browser_.downloadActive()) { if (bi == 0) browser_.togglePause(); else if (bi == 1) browser_.stopDownload(); else if (bi == 2) browser_.clearFinished(); } else if (bi == 0) { browser_.clearFinished(); } interacted = true; } else { const int hov = renderer_.downloadsHovered(); const auto& jobs = browser_.downloadJobs(); if (hov >= 0 && hov < static_cast(jobs.size()) && jobs[static_cast(hov)].state == 1 && !jobs[static_cast(hov)].localPath.empty()) { #ifdef PIXELVR_HAVE_MEDIA loadMedia(jobs[static_cast(hov)].localPath); downloadsWindow_ = false; #endif interacted = true; } } } // Folder-download confirmation: a modal with the size and two buttons. if (!pendingDl_.empty()) { bool confirmNow = false; if (pendingDlFresh_) { pendingDlFresh_ = false; // ignore the press that opened it } else if (in.togglePlay) { confirmNow = true; } else if (in.browserToggle) { pendingDl_.clear(); } if (!pendingDl_.empty()) { char sz[96]; std::snprintf(sz, sizeof(sz), "Size: %.2f GB%s", browser_.measuredSize() / 1073741824.0, browser_.measuring() ? " (scanning...)" : ""); std::vector crows{"Download this folder?", pendingDlName_, sz}; std::vector cbtns{"Download", "Cancel"}; renderer_.setDownloadsModal(true); renderer_.setDownloads(true, "Confirm Download", crows, "A: Download B: Cancel", cbtns); const int cbi = renderer_.downloadsButtonHovered(); if (in.select && cbi == 0) { confirmNow = true; } else if (in.select && cbi == 1) { pendingDl_.clear(); } } if (confirmNow && !pendingDl_.empty()) { browser_.startDownload(pendingDl_, pendingDlName_, true); pendingDl_.clear(); } } else { renderer_.setDownloadsModal(false); } // Publish the browser panel first so the pointer can raycast it. if (browser_.isOpen()) { std::vector rows; rows.reserve(browser_.entries().size()); for (const auto& e : browser_.entries()) { std::string name = e.label; if (name.size() > 44) name = name.substr(0, 43) + "~"; std::string row = name; if (!e.detail.empty()) { while (row.size() < 50) row += ' '; row += e.detail; } rows.push_back(std::move(row)); } std::string status = browser_.status(); if (browser_.onNetwork() && !browser_.isBusy()) { // Keep any message (e.g. a download hint) visible; just add // the action hint rather than replacing it. status = status.empty() ? std::string("Point + Trigger: Stream A: Download") : status + " [A: Download]"; } renderer_.setBrowser(true, browser_.title(), rows, browser_.selected(), status); browserOpen = true; } else { renderer_.setBrowser(false, "", {}, 0, ""); } #endif // Settings panel overlay if (settings_.isOpen()) { std::vector sRows; std::vector sEnabled; sRows.reserve(settings_.rows().size()); sEnabled.reserve(settings_.rows().size()); for (const auto& r : settings_.rows()) { sRows.push_back(r.value.empty() ? r.label : (r.label + " : " + r.value)); sEnabled.push_back(r.enabled ? 1 : 0); } renderer_.setSettings(true, settings_.title(), sRows, settings_.selected(), settings_.status(), sEnabled); settingsOpen = true; } else { renderer_.setSettings(false, "", {}, 0, ""); } // Feed controller poses to the renderer (models, pointer ray, cursor); // it raycasts the pointer against UI panels and reports hit rows/buttons. { // Align the model to the runtime render-model space (where the // system draws the real controller). Locate it once per frame and // derive a local grip-space correction applied to both hands. { float mPos[3], mQuat[4]; if (renderModels_.locate(xr_.appSpace(), xr_.displayTime(), mPos, mQuat)) { int owner = -1; float best = 1e30f; for (int h = 0; h < 2; ++h) { if (!in.controllers[h].gripActive) continue; const float* g = in.controllers[h].gripPosition; const float dx = g[0] - mPos[0], dy = g[1] - mPos[1], dz = g[2] - mPos[2]; const float d = dx * dx + dy * dy + dz * dz; if (d < best) { best = d; owner = h; } } if (owner >= 0) { // correction = inverse(gripOwner) then modelPose. const float* gq = in.controllers[owner].gripOrientation; const float* gt = in.controllers[owner].gripPosition; const float giq[4] = {-gq[0], -gq[1], -gq[2], gq[3]}; float git[3]; quatRotate(giq, gt, git); git[0] = -git[0]; git[1] = -git[1]; git[2] = -git[2]; composePose(git, giq, mPos, mQuat, modelCorrPos_, modelCorrQuat_); modelCorrValid_ = true; } } } const bool uiPanelActive = browserOpen || settingsOpen || downloadsWindow_ || !pendingDl_.empty(); // Controls (buttons + timeline) show only while a controller is // actively moving; they fade after a few seconds of stillness. renderer_.setControlBar(!uiPanelActive && controllersEngaged && (in.controllers[0].active || in.controllers[1].active)); // Laser + models stay up while a panel is open (to point at it), // otherwise they follow the same activity timeout. const bool showLaser = uiPanelActive || controllersEngaged; // Build each controller's render state: corrected model pose, // low-pass smoothed (the raw hand pose jitters), while the aim // pose stays raw so the laser remains responsive. auto build = [&](int h) { const XrInputState::ControllerPose& c = in.controllers[h]; VulkanRenderer::ControllerVis v; v.active = c.active && showLaser; for (int k = 0; k < 3; ++k) v.position[k] = c.position[k]; for (int k = 0; k < 4; ++k) v.orientation[k] = c.orientation[k]; v.gripActive = c.gripActive; float gp[3], gq[4]; if (c.gripActive && modelCorrValid_) { composePose(c.gripPosition, c.gripOrientation, modelCorrPos_, modelCorrQuat_, gp, gq); } else { for (int k = 0; k < 3; ++k) gp[k] = c.gripPosition[k]; for (int k = 0; k < 4; ++k) gq[k] = c.gripOrientation[k]; } if (c.gripActive) { const float a = 0.4f; // lower = smoother (more lag) if (!modelSmoothInit_[h]) { modelSmoothInit_[h] = true; for (int k = 0; k < 3; ++k) modelSmoothPos_[h][k] = gp[k]; for (int k = 0; k < 4; ++k) modelSmoothQuat_[h][k] = gq[k]; } else { for (int k = 0; k < 3; ++k) modelSmoothPos_[h][k] += (gp[k] - modelSmoothPos_[h][k]) * a; quatNlerp(modelSmoothQuat_[h], gq, a, modelSmoothQuat_[h]); } for (int k = 0; k < 3; ++k) v.gripPosition[k] = modelSmoothPos_[h][k]; for (int k = 0; k < 4; ++k) v.gripOrientation[k] = modelSmoothQuat_[h][k]; } else { for (int k = 0; k < 3; ++k) v.gripPosition[k] = gp[k]; for (int k = 0; k < 4; ++k) v.gripOrientation[k] = gq[k]; } return v; }; renderer_.setControllers(build(0), build(1)); } #ifdef PIXELVR_HAVE_FREETYPE if (browser_.isOpen() && !browser_.isBusy()) { if (const int d = navStep(in.thumbstickY, nowT)) { browser_.move(d); renderer_.scrollBrowser(d); } const int hovered = renderer_.browserHovered(); if (hovered >= 0) { renderer_.setBrowserSelected(hovered); // The pointer drives the selection, so A acts on the entry // actually being pointed at (not just the last trigger press). browser_.setSelected(hovered); if (in.select) { std::string playPath; if (browser_.activate(playPath) == Browser::Result::Play && !playPath.empty()) { #ifdef PIXELVR_HAVE_MEDIA loadMedia(playPath); #endif browser_.setOpen(false); } } } // A: download the focused entry; folders prompt for confirmation. if (in.togglePlay && pendingDl_.empty()) { const Browser::Entry* fe = browser_.focused(); if (fe != nullptr && fe->isDir && !fe->isBack && browser_.onNetwork()) { pendingDl_ = fe->path; pendingDlName_ = fe->label; pendingDlFresh_ = true; browser_.measureFolder(fe->path); // size for the dialog } else { browser_.downloadFocused(); } } } if (browser_.isOpen() && in.recenter && in.headValid) { renderer_.recenterScreen(in.headPos, in.headForward); } #endif if (settings_.isOpen()) { if (const int d = navStep(in.thumbstickY, nowT)) { settings_.move(d); } const int hovered = renderer_.settingsHovered(); if (hovered >= 0) { renderer_.setSettingsSelected(hovered); if (in.select) { settings_.setSelected(hovered); auto act = settings_.activate(); if (act == SettingsMenu::Action::RecenterScreen) { if (in.headValid) { renderer_.recenterScreen(in.headPos, in.headForward); } } else if (act == SettingsMenu::Action::CycleProjection) { int pm = settings_.projectionMode(); renderer_.setProjectionMode( pm == 0 ? VulkanRenderer::ProjectionMode::Flat : pm == 1 ? VulkanRenderer::ProjectionMode::Equirect360 : VulkanRenderer::ProjectionMode::Equirect180); } else if (act == SettingsMenu::Action::CycleStereo) { int sm = settings_.stereoMode(); renderer_.setStereoMode( sm == 0 ? VulkanRenderer::StereoMode::Mono : sm == 1 ? VulkanRenderer::StereoMode::SideBySide : VulkanRenderer::StereoMode::TopBottom); } else if (act == SettingsMenu::Action::CycleDistance) { renderer_.adjustScreenDistance(settings_.screenDistance() - 3.0f); } #ifdef PIXELVR_HAVE_MEDIA else if (act == SettingsMenu::Action::PickVideoStream || act == SettingsMenu::Action::PickAudioStream) { switchTracks(false); } else if (act == SettingsMenu::Action::PickLanguage) { switchTracks(true); } #endif } } if (in.recenter && in.headValid) { renderer_.recenterScreen(in.headPos, in.headForward); } } if (!browserOpen && !settingsOpen) { // Point-and-click the media control bar with the trigger. const int btn = renderer_.pointerButton(); if (in.select && btn >= 0) { const auto cb = static_cast(btn); if (cb == VulkanRenderer::CtrlButton::Stop) { #ifdef PIXELVR_HAVE_MEDIA media_.close(); mediaOpen_ = false; renderer_.clearVideo(); #endif #ifdef PIXELVR_HAVE_FREETYPE browser_.open(); // large file selection #endif } else if (cb == VulkanRenderer::CtrlButton::Settings) { settings_.open(); } else if (cb == VulkanRenderer::CtrlButton::Downloads) { downloadsWindow_ = !downloadsWindow_; } #ifdef PIXELVR_HAVE_FREETYPE else if (cb == VulkanRenderer::CtrlButton::Files) { browser_.open(); } #endif #ifdef PIXELVR_HAVE_MEDIA else if (mediaOpen_ && cb == VulkanRenderer::CtrlButton::PlayPause) { media_.togglePause(); } else if (mediaOpen_ && cb == VulkanRenderer::CtrlButton::SeekBack) { media_.seekRelative(-10.0); } else if (mediaOpen_ && cb == VulkanRenderer::CtrlButton::SeekFwd) { media_.seekRelative(10.0); } #endif interacted = true; } if (in.select && renderer_.pointerOnTimeToggle()) { renderer_.toggleTimeDisplay(); // total <-> time remaining interacted = true; } if (in.recenter && in.headValid) { renderer_.recenterScreen(in.headPos, in.headForward); interacted = true; } // Viewpoint: flat screens move nearer/farther (thumbstick Y); // immersive 360/180 uses the RIGHT stick -- up/down tilts (angle), // left/right pushes content nearer/farther (distance). if (settings_.projectionMode() == 0) { if (in.thumbstickY > 0.15f || in.thumbstickY < -0.15f) { renderer_.adjustScreenDistance(in.thumbstickY * 0.04f); interacted = true; } } else if (dt > 0.0f) { if (std::fabs(in.thumbstickRY) > 0.15f) { renderer_.adjustSpherePitch(in.thumbstickRY * dt * 0.6f); interacted = true; } if (std::fabs(in.thumbstickRX) > 0.15f) { renderer_.adjustSphereZoom(in.thumbstickRX * dt * 0.6f); interacted = true; } } #ifdef PIXELVR_HAVE_MEDIA if (in.togglePlay && mediaOpen_) { media_.togglePause(); interacted = true; } if (mediaOpen_) { // Right stick left/right steps -/+30s (flat only; in 360/180 the // right stick drives the viewpoint instead). constexpr double kStep = 30.0; const bool sphereMode = settings_.projectionMode() != 0; if (!sphereMode && in.thumbstickRX > 0.6f && seekStepArmed_) { media_.seekRelative(kStep); seekStepArmed_ = false; interacted = true; } else if (!sphereMode && in.thumbstickRX < -0.6f && seekStepArmed_) { media_.seekRelative(-kStep); seekStepArmed_ = false; interacted = true; } else if (std::fabs(in.thumbstickRX) < 0.3f) { seekStepArmed_ = true; } // Scrub: accumulate a target while dragging and commit it with a // single seek on release. Seeking continuously floods the // hardware decoder (a reopen each) and stalls it; one seek per // gesture stays responsive. bool scrubInput = false; if (renderer_.pointerOnTimeline() && in.selectHeld && media_.duration() > 0.0) { scrubTarget_ = renderer_.pointerScrub() * media_.duration(); scrubInput = true; } else if (std::fabs(in.thumbstickLX) > 0.15f && dt > 0.0f && media_.duration() > 0.0) { if (!scrubbing_) scrubTarget_ = media_.position(); const float ax = in.thumbstickLX; const float t = (std::fabs(ax) - 0.15f) / 0.85f; const double rate = std::copysign(static_cast(t * t) * 60.0, ax); scrubTarget_ = std::clamp(scrubTarget_ + rate * dt, 0.0, media_.duration()); scrubInput = true; } if (scrubInput) { scrubbing_ = true; interacted = true; } else if (scrubbing_) { media_.seek(scrubTarget_); // commit the scrub with one seek scrubbing_ = false; } } if (interacted) { lastControllerActivity_ = nowT; } const bool paused = mediaOpen_ && media_.paused(); const bool controlsUp = mediaOpen_ && (paused || (controllersEngaged && (in.controllers[0].active || in.controllers[1].active))); float progress = 0.0f; double elapsed = 0.0, total = 0.0; if (mediaOpen_ && media_.duration() > 0.0) { total = media_.duration(); elapsed = scrubbing_ ? scrubTarget_ : media_.position(); progress = static_cast(elapsed / total); } renderer_.setTransport(controlsUp, progress, paused, elapsed, total); #else (void)interacted; #endif } #ifdef PIXELVR_HAVE_MEDIA if (settings_.showStats()) { updateStats(); } // Always-on periodic perf log so decode-bound vs render-bound stalls // show up in the run log even without the on-screen overlay. if (mediaOpen_) { ++perfFrames_; if (perfLastLog_.time_since_epoch().count() == 0) { perfLastLog_ = now; perfLastDecoded_ = media_.decodedFrames(); } else { const double since = std::chrono::duration(now - perfLastLog_).count(); if (since >= 2.0) { const uint64_t dec = media_.decodedFrames(); const double decFps = (dec >= perfLastDecoded_) ? static_cast(dec - perfLastDecoded_) / since : 0.0; PIXELVR_LOG_INFO( "PERF: render %.1f fps, decode %.1f/%.2f fps", perfFrames_ / since, decFps, media_.frameRate()); perfFrames_ = 0; perfLastDecoded_ = dec; perfLastLog_ = now; } } } #endif renderer_.setStats(settings_.showStats(), statsLines_); } else { // Idle until the runtime tells us to start rendering. std::this_thread::sleep_for(50ms); } } if (renderer_.device() != VK_NULL_HANDLE) { vkDeviceWaitIdle(renderer_.device()); } renderModels_.shutdown(); // destroy XR model handles before the session PIXELVR_LOG_INFO("Shutting down"); return 0; } } // namespace pixelvr