mirror of
https://github.com/MichaelScottsman/LiquidAss-Frame.git
synced 2026-10-29 02:01:29 +01:00
Stereo (glassd cap stereo, flag glassStereo, on) - The window glass was one image both eyes saw on the glass plane, so the room behind it read as a picture pasted on the glass. glassd now renders each eye's view (SteamVR's eye-to-head offsets, refreshed every 5 s): every region of a stereo surface's texture is a pair, left eye's copy then right eye's, and the scene graph's panel spans the pair with stereoscopy Parallel. - Measured on the device with a lab probe (lgs_sg test.stereoProbe): SteamVR splits a Parallel panel at the middle of the panel's own uv rect, not at the texture's halves; hence the pair layout, and stereo only where the scene graph shows a surface whole (main, SteamVR's panels). The bar, the frame menu and popups show crops and stay mono. A slab clipped sideways is left out while it is. - GPU about 2.9 -> 3.5 ms a frame. Sidebar without the laser - frame.menu is no longer laser-only: SteamVR shows it in gamepad mode (laser mouse off) and beside its own pages; keyed on the gamepad flag its native glass went off and the CSS glass showed. Verified with the flag forced to gamepad on Home and in VR Settings: the cover stays. Home -> Library - The shell stamps the leaving Home view with the moment it starts leaving (only on a way out of Home, kept across Steam's /library redirect); Home fades on the route exit and the reporter lets its plates go on that clock. Measured: all Home discs reported leaving in the first report, gone with the page. - Plates whose element leaves the DOM are reported leaving from the mutation itself (no measuring), not after a frame-driven tick the new route's render starves. Glass tuner - The popup fits Steam's 600 px bar popup cap (rows 76 px: label and value on one line over a 40 px slider); open()/close() for tests.
3510 lines
174 KiB
C++
3510 lines
174 KiB
C++
// glassd: the native glass renderer of Glass Shell (see docs/NATIVE.md).
|
|
//
|
|
// For every Steam surface listed in /dev/shm/lgs/glassd.json it publishes one
|
|
// SteamVR overlay "glassd.<name>" whose texture is a triple-buffered dmabuf
|
|
// (GBM + surfaceless EGL + GLES 3). The texture holds the surface's window
|
|
// glass (the "cover": frosted and lensed room, tint, sheen, rims) on top and an
|
|
// atlas of Liquid Glass slabs for popped elements underneath. The room comes
|
|
// from an equirectangular room map built from the passthrough feed
|
|
// (/dev/video99) with every Steam surface masked out. The daemon's scene graph
|
|
// shows these overlays by key; glassd never calls ShowOverlay.
|
|
//
|
|
// Writes the layout (UV rects, backdropScale) plus fps / gpu_ms / health to
|
|
// /dev/shm/lgs/glassd-out.json. Renders only while the dashboard is visible.
|
|
//
|
|
// Exit codes: 0 stopped (signal, --timeout, parent gone), 1 setup error,
|
|
// 2 bad arguments, 75 temporary (another glassd holds the lock, SteamVR is not
|
|
// running or went away, overlays could not be kept alive): not a crash.
|
|
#include <openvr.h>
|
|
|
|
#include <dirent.h>
|
|
#include <signal.h>
|
|
#include <sys/file.h>
|
|
#include <sys/inotify.h>
|
|
#include <sys/prctl.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <set>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "atlas.h"
|
|
#include "feed.h"
|
|
#include "gfx.h"
|
|
#include "json.h"
|
|
#include "room.h"
|
|
#include "vmath.h"
|
|
// Motion tokens (P5, generated by docs/phase2/research/springs.py): the phase
|
|
// ramps below use them, so glassd's materialize equals the token table (MO-5).
|
|
#include "motion_tokens.h"
|
|
|
|
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
|
#include "stb_image_write.h"
|
|
|
|
static_assert(sizeof(vr::SharedTextureHandle_t) == sizeof(uint64_t), "shared handle size");
|
|
|
|
static const std::pair<const char *, const char *> kShaders[] = {
|
|
#include "shaders.inc"
|
|
};
|
|
|
|
namespace {
|
|
|
|
constexpr int kExitTemp = 75; // EX_TEMPFAIL: not a crash, try again later
|
|
constexpr const char *kMainKey = "valve.steam.gamepadui.main";
|
|
constexpr const char *kDefaultPrefix = "glassd.";
|
|
constexpr const char *kShmDir = "/dev/shm/lgs";
|
|
constexpr int kMaxShapes = 8;
|
|
constexpr int kMaxPlates = 32; // per surface (docs/phase2/contracts/glassd.md §1.3)
|
|
constexpr int kMaxListedDrops = 32; // dropped plate ids listed in glassd-out.json (all are counted)
|
|
constexpr float kPlateLiftM = 0.002f; // R1: optics of a plate over a cover sit this far in front of it
|
|
constexpr int kMaxCasters = 16; // slab shadows and holes on one piece of glass (glass.frag uShBox[])
|
|
constexpr int kMaxMasks = 24; // feed mask quads (room_update.frag uQO[])
|
|
constexpr int kMaxExtraMasks = 8; // per surface and at the top level
|
|
constexpr uint64_t kMs = 1000000ull;
|
|
constexpr uint64_t kGraceNs = 400 * kMs; // no room integration this long after a dashboard change
|
|
constexpr uint64_t kGhostNs = 600 * kMs; // a removed slab's cell stays drawn (and reserved) this long
|
|
constexpr uint64_t kMaskPeriodNs = 250 * kMs; // feed masks are rebuilt (with fresh geometry) this often
|
|
const v3 kTestEye{0, 1.1f, 0}; // --test-backdrop: head position (standing space)
|
|
|
|
std::atomic<bool> g_quit{false};
|
|
std::atomic<bool> g_dumpRequest{false}, g_loseRequest{false};
|
|
std::atomic<int> g_exitCode{0};
|
|
void onSignal(int s) {
|
|
if (s == SIGUSR1) g_dumpRequest = true;
|
|
else if (s == SIGUSR2) g_loseRequest = true;
|
|
else g_quit = true;
|
|
}
|
|
void quitWith(int code) {
|
|
g_exitCode = code;
|
|
g_quit = true;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ options
|
|
struct Options {
|
|
std::string spec = "/dev/shm/lgs/glassd.json";
|
|
std::string out; // default: /dev/shm/lgs/glassd-out.json, or /dev/shm/lgs/<prefix>out.json for another prefix
|
|
std::string dumpDir, dumpRoom, shaderDir;
|
|
std::string keyPrefix = kDefaultPrefix;
|
|
std::string feedDev = "/dev/video99";
|
|
bool once = false, force = false, demo = false, noFeed = false, noMask = false, orphanOk = false;
|
|
bool mono = false; // --mono: one copy per texture, from the head (no per-eye stereo)
|
|
float zone[3] = {0.15f, 0.08f, 0.25f}; // main window mask zone: side, top, bottom (m)
|
|
int debugView = 0;
|
|
int dashOverride = -1; // --dash on|off (tests): -1 = ask SteamVR
|
|
float scale = 0.75f; // backdropScale
|
|
float fps = 72.f; // max render rate while visible
|
|
float feedHz = 60.f; // room map updates while visible (the feed streams): what is behind the glass moves at this rate
|
|
float idleHz = 0.2f; // one-frame feed shots per second otherwise (0 = none)
|
|
float warmup = 3.f; // seconds of feed before --once / --dump output
|
|
float margin = 0.06f; // metres around Steam surfaces in the feed mask
|
|
float roomDepth = 2.2f; // radius of the room sphere
|
|
float predictMs = 20.f; // head pose prediction for rendering
|
|
float timeout = 0.f; // exit after N seconds (0 = run until stopped)
|
|
int feedDownsample = 4;
|
|
// Verification without a wearer (README "Verifying without the headset"):
|
|
// a procedural room instead of the feed, a fixed head, composite views.
|
|
int testPattern = -1; // --test-backdrop: 0 room, 1 stripes; -1 off
|
|
bool testHole = false; // room-hole: the room behind the UI stays unknown
|
|
float testHead[3] = {0, 0, 0}; // eye offset from the test head (m)
|
|
bool dumpView = false; // also write <name>-view.png (room + cover + slabs)
|
|
bool bench = false; // render every frame (GPU timing)
|
|
float phasePin = -1; // --phase M: every piece of glass at materialize progress M (dumps)
|
|
std::string selftest; // --selftest phase: offline checks, no SteamVR
|
|
int viewContent = 0; // --view-content hero: dump views draw a stand-in page (art + crops)
|
|
};
|
|
|
|
void usage() {
|
|
std::printf(
|
|
"glassd [options]\n"
|
|
" --spec FILE layer spec to follow (default /dev/shm/lgs/glassd.json; re-read on change)\n"
|
|
" --out FILE layout + stats output (default /dev/shm/lgs/glassd-out.json; with another\n"
|
|
" --key-prefix P: /dev/shm/lgs/<P>out.json)\n"
|
|
" --demo use a built-in spec (main window + 3 slabs) instead of --spec\n"
|
|
" --once integrate the feed for --warmup s, render one frame of every surface, exit\n"
|
|
" --dump DIR write every surface texture to DIR/<name>.png after one frame\n"
|
|
" --dump-room PATH write the filled room map PNG (and PATH-known.png); delete after looking\n"
|
|
" --force render even while the dashboard is hidden\n"
|
|
" --scale S backdropScale, glassd px per Steam px (default 0.75)\n"
|
|
" --fps N max render rate while visible (default 72)\n"
|
|
" --feed-hz N room map updates per second while the glass shows (default 36)\n"
|
|
" --idle-hz N one-frame feed shots per second otherwise (default 0.2, 0 = none)\n"
|
|
" --warmup S feed seconds before dumps (default 3)\n"
|
|
" --margin M feed mask margin around Steam surfaces in metres (default 0.06)\n"
|
|
" --zone S,T,B extra mask around the main window: side, top, bottom (default 0.15,0.08,0.25)\n"
|
|
" --no-mask debug: integrate the feed without masking the UI\n"
|
|
" --debug-view N debug shading: 1 backdrop, 2 light, 3 bezel, 4 room uv\n"
|
|
" --dash on|off|auto test override for SteamVR's dashboard visibility (default auto)\n"
|
|
" --key-prefix P overlay key prefix (default glassd.)\n"
|
|
" --shaders DIR load shaders from DIR instead of the built-in copies\n"
|
|
" --feed DEV passthrough feed device (default /dev/video99); --no-feed to skip it\n"
|
|
" --timeout S exit after S seconds\n"
|
|
" --orphan-ok keep running when the parent process exits (detached lab runs)\n"
|
|
" --test-backdrop P no camera: a procedural room (room, room-hole, stripes), a fixed head and\n"
|
|
" fixed geometry (the spec's quad, else the window 1.43 m ahead); dumps are safe to keep\n"
|
|
" --test-head X,Y,Z with --test-backdrop: move the eye by X,Y,Z metres (off-axis views)\n"
|
|
" --dump-view with --dump: also write DIR/<name>-view.png, the room with the cover and slabs on it\n"
|
|
" --bench render every frame at --fps (GPU timing in the status line)\n"
|
|
" --phase M pin every cover and slab at materialize progress M (0..1), for dumps\n"
|
|
" --selftest phase check the materialize ramps against motion_tokens.h (no SteamVR), exit 0 = pass\n"
|
|
" --view-content C with --dump-view: none (default) or hero, a stand-in page in front of the\n"
|
|
" cover (opaque art with the popped rects cut out, and the popped crops);\n"
|
|
" hero-art: the same without the cut-outs (GL-2's reference for a hole's dL)\n"
|
|
"SIGUSR1 dumps the room map and surfaces (to the --dump paths, else /tmp/lgs/glassd-dump/).\n"
|
|
"SIGUSR2 (test aid) treats every overlay as lost: destroys and recreates it.\n"
|
|
"Exit codes: 0 stopped, 1 setup error, 2 bad arguments, 75 temporary (lock held, SteamVR absent or gone).\n");
|
|
}
|
|
|
|
bool parseArgs(int argc, char **argv, Options &o) {
|
|
for (int i = 1; i < argc; i++) {
|
|
std::string a = argv[i];
|
|
auto next = [&](std::string &v) {
|
|
if (i + 1 >= argc) return false;
|
|
v = argv[++i];
|
|
return true;
|
|
};
|
|
auto nextf = [&](float &v) {
|
|
if (i + 1 >= argc) return false;
|
|
v = std::strtof(argv[++i], nullptr);
|
|
return true;
|
|
};
|
|
bool ok = true;
|
|
if (a == "--spec") ok = next(o.spec);
|
|
else if (a == "--out") ok = next(o.out);
|
|
else if (a == "--dump") ok = next(o.dumpDir);
|
|
else if (a == "--dump-room") ok = next(o.dumpRoom);
|
|
else if (a == "--shaders") ok = next(o.shaderDir);
|
|
else if (a == "--key-prefix") ok = next(o.keyPrefix);
|
|
else if (a == "--feed") ok = next(o.feedDev);
|
|
else if (a == "--no-feed") o.noFeed = true;
|
|
else if (a == "--mono") o.mono = true;
|
|
else if (a == "--no-mask") o.noMask = true;
|
|
else if (a == "--orphan-ok") o.orphanOk = true;
|
|
else if (a == "--debug-view") { std::string v; ok = next(v); o.debugView = std::atoi(v.c_str()); }
|
|
else if (a == "--dash") {
|
|
std::string v;
|
|
ok = next(v) && (v == "on" || v == "off" || v == "auto");
|
|
o.dashOverride = v == "on" ? 1 : v == "off" ? 0 : -1;
|
|
}
|
|
else if (a == "--zone") {
|
|
std::string v;
|
|
ok = next(v) && std::sscanf(v.c_str(), "%f,%f,%f", &o.zone[0], &o.zone[1], &o.zone[2]) == 3;
|
|
}
|
|
else if (a == "--test-backdrop") {
|
|
std::string v;
|
|
ok = next(v) && (v == "room" || v == "room-hole" || v == "stripes");
|
|
o.testPattern = v == "stripes" ? 1 : 0;
|
|
o.testHole = v == "room-hole";
|
|
}
|
|
else if (a == "--test-head") {
|
|
std::string v;
|
|
ok = next(v) && std::sscanf(v.c_str(), "%f,%f,%f", &o.testHead[0], &o.testHead[1], &o.testHead[2]) == 3;
|
|
}
|
|
else if (a == "--dump-view") o.dumpView = true;
|
|
else if (a == "--bench") o.bench = true;
|
|
else if (a == "--phase") ok = nextf(o.phasePin);
|
|
else if (a == "--selftest") ok = next(o.selftest) && o.selftest == "phase";
|
|
else if (a == "--view-content") {
|
|
std::string v;
|
|
ok = next(v) && (v == "none" || v == "hero" || v == "hero-art");
|
|
o.viewContent = v == "hero" ? 1 : v == "hero-art" ? 2 : 0;
|
|
}
|
|
else if (a == "--once") o.once = true;
|
|
else if (a == "--force") o.force = true;
|
|
else if (a == "--demo") o.demo = true;
|
|
else if (a == "--scale") ok = nextf(o.scale);
|
|
else if (a == "--fps") ok = nextf(o.fps);
|
|
else if (a == "--feed-hz") ok = nextf(o.feedHz);
|
|
else if (a == "--idle-hz") ok = nextf(o.idleHz);
|
|
else if (a == "--warmup") ok = nextf(o.warmup);
|
|
else if (a == "--margin") ok = nextf(o.margin);
|
|
else if (a == "--room-depth") ok = nextf(o.roomDepth);
|
|
else if (a == "--predict-ms") ok = nextf(o.predictMs);
|
|
else if (a == "--timeout") ok = nextf(o.timeout);
|
|
else if (a == "-h" || a == "--help") { usage(); std::exit(0); }
|
|
else { std::fprintf(stderr, "unknown option %s\n", a.c_str()); return false; }
|
|
if (!ok) { std::fprintf(stderr, "%s needs a value\n", a.c_str()); return false; }
|
|
}
|
|
if (o.keyPrefix.empty()) { std::fprintf(stderr, "--key-prefix must not be empty\n"); return false; }
|
|
// A test instance (another key prefix) never shares the daemon's output file.
|
|
if (o.out.empty())
|
|
o.out = o.keyPrefix == kDefaultPrefix ? std::string(kShmDir) + "/glassd-out.json"
|
|
: std::string(kShmDir) + "/" + o.keyPrefix + "out.json";
|
|
if (o.testPattern >= 0) o.noFeed = true; // the test room replaces the camera
|
|
o.scale = std::clamp(o.scale, 0.25f, 1.5f);
|
|
o.fps = std::clamp(o.fps, 1.f, 144.f);
|
|
o.feedHz = std::clamp(o.feedHz, 1.f, 60.f);
|
|
o.idleHz = std::clamp(o.idleHz, 0.f, 60.f);
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- materials
|
|
// Material v2 (docs/phase2/glassd-material.md). Every edge cue is physical:
|
|
// no term draws a line of constant width.
|
|
struct Material {
|
|
float thetaRef; // thickness the preset is tuned for (see thickness())
|
|
float tintA; // mix toward a neutral of the band luminance
|
|
float frost; // interior frost, room-map mip (one map texel = 0.35 deg)
|
|
float edgeFrost; // frost where the bezel bends the most (the bend must show)
|
|
float edgeClear; // how much the lens band drops tint and tone compression (a polished bevel)
|
|
float bezelM; // squircle bezel width, metres (clamped to the shape's half size and corner radius)
|
|
float thick; // slab thickness / bezel width as the light sees it: where the highlight sits, how thin it is
|
|
float lensThick; // the same for refraction: deeper, so the bend spans the lens band
|
|
float lensDeg; // ray deviation at the rim, degrees (lensing strength)
|
|
float disp; // dispersion: R bends (1 - disp) x, B (1 + disp) x
|
|
float bandK; // share of the room's luminance swing kept (rest pulled to the band)
|
|
float dim; // backdrop dimming (clear glass over media)
|
|
float spec; // key specular (crescent on the light-facing bezel)
|
|
float gloss; // key specular exponent
|
|
float fill; // opposite transmitted highlight, fraction of the key
|
|
float fres; // Fresnel reflection of the room on grazing bezel
|
|
float sheen; // top-down sheen
|
|
float dark; // darkened band inside the edge (E4)
|
|
float darkW; // its width, metres
|
|
float occ; // occlusion inside the lower edge (E5)
|
|
float shadow; // contact shadow outside a cover's shapes (needs room in the texture)
|
|
float slabShadow; // shadow a slab of this material casts on the cover behind it
|
|
};
|
|
|
|
// Presets at dial 0.5 and their reference thickness (DESIGN2 §6.1).
|
|
Material materialPreset(const std::string &name) {
|
|
// thRef tint frost edgeF eClr bezel thick lensT lens disp bandK dim spec gloss fill fres sheen dark darkW occ shadow slabSh
|
|
if (name == "liquid") return {0.25f, 0.14f, 1.3f, 0.3f, 0.60f, 0.016f, 0.50f, 1.5f, 7.0f, 0.020f, 0.60f, 0.00f, 0.90f, 40.f, 0.07f, 0.20f, 0.8f, 0.05f, 0.006f, 0.06f, 0.10f, 0.22f};
|
|
if (name == "clear") return {0.25f, 0.06f, 0.6f, 0.1f, 0.60f, 0.016f, 0.50f, 1.6f, 9.0f, 0.025f, 0.75f, 0.35f, 0.90f, 40.f, 0.07f, 0.20f, 0.6f, 0.04f, 0.006f, 0.05f, 0.08f, 0.20f};
|
|
if (name == "panel") return {0.60f, 0.40f, 3.4f, 1.0f, 0.40f, 0.012f, 0.40f, 1.2f, 3.0f, 0.020f, 0.35f, 0.00f, 0.80f, 50.f, 0.07f, 0.15f, 1.0f, 0.08f, 0.008f, 0.08f, 0.12f, 0.24f};
|
|
if (name == "thick") return {0.85f, 0.30f, 4.0f, 1.2f, 0.35f, 0.014f, 0.40f, 1.2f, 2.5f, 0.020f, 0.32f, 0.00f, 0.75f, 50.f, 0.07f, 0.14f, 1.0f, 0.09f, 0.010f, 0.09f, 0.12f, 0.28f};
|
|
/* window */ return {1.00f, 0.50f, 3.6f, 1.2f, 0.30f, 0.020f, 0.35f, 1.2f, 2.0f, 0.020f, 0.30f, 0.00f, 0.70f, 60.f, 0.05f, 0.12f, 1.0f, 0.10f, 0.014f, 0.07f, 0.00f, 0.00f};
|
|
}
|
|
|
|
// Thickness from the shorter side (Steam px at the window's scale): 0 at
|
|
// 44 px, 0.5 at 176 px, 1 at >= 704 px. DESIGN2 §6.1 defines θ from the
|
|
// longer side; the shorter side is used because it is what bounds a slab's
|
|
// bezel, and a 600 px wide, 64 px tall tab bar must stay thin liquid glass.
|
|
float thickness(float minSide) { return std::clamp(std::log2(std::max(minSide, 1.f) / 44.f) / 4.f, 0.f, 1.f); }
|
|
|
|
// The preset for this size (P5 "size changes the material": bigger glass is
|
|
// thicker, frostier and lenses less; small glass is thinner and lenses more)
|
|
// and the dial (tint x0.7 -> x1.3, frost -1 -> +1 mip, the band tighter).
|
|
Material materialFor(const std::string &name, float dial, float minSide) {
|
|
Material m = materialPreset(name);
|
|
const float dt = thickness(minSide) - m.thetaRef; // -1..1
|
|
m.frost = std::max(0.f, m.frost + 0.8f * dt);
|
|
m.edgeFrost = std::max(0.f, m.edgeFrost + 0.6f * dt);
|
|
m.tintA = std::clamp(m.tintA + 0.12f * dt, 0.f, 0.9f);
|
|
m.lensDeg *= std::clamp(1.f - 0.55f * dt, 0.4f, 1.6f);
|
|
m.spec *= std::clamp(1.f - 0.25f * dt, 0.6f, 1.3f);
|
|
m.dark *= std::clamp(1.f + 0.5f * dt, 0.5f, 1.5f);
|
|
m.occ *= std::clamp(1.f + 0.5f * dt, 0.5f, 1.5f);
|
|
m.slabShadow *= std::clamp(1.f + 0.6f * dt, 0.5f, 1.6f);
|
|
dial = std::clamp(dial, 0.f, 1.f);
|
|
m.tintA = std::min(0.95f, m.tintA * (0.7f + 0.6f * dial));
|
|
m.frost = std::max(0.f, m.frost - 1.f + 2.f * dial);
|
|
m.edgeFrost = std::min(m.frost, std::max(0.f, m.edgeFrost - 0.5f + dial));
|
|
m.bandK = std::clamp(m.bandK * (1.3f - 0.6f * dial), 0.f, 1.f);
|
|
return m;
|
|
}
|
|
|
|
// Materialize (MO §9): one progress m per piece of glass; the optics come in
|
|
// in order: light first, then the bend, then frost and tint, then shading.
|
|
// The ranges are P5's (motion_tokens.h).
|
|
float ramp(float x, float a, float b) { return std::clamp((x - a) / (b - a), 0.f, 1.f); }
|
|
struct Phased {
|
|
float light, lens, frost, tint, shade, shadow, alpha;
|
|
};
|
|
Phased phaseMap(float m, bool reduceMotion) {
|
|
namespace mt = lgs_motion;
|
|
m = std::clamp(m, 0.f, 1.f);
|
|
if (reduceMotion) return {1, 1, 1, 1, 1, 1, m}; // coverage only
|
|
return {mt::ramp(m, mt::kRampLight), mt::ramp(m, mt::kRampLens), mt::ramp(m, mt::kRampFrost), mt::ramp(m, mt::kRampTint),
|
|
mt::ramp(m, mt::kRampShade), mt::ramp(m, mt::kRampShadow), mt::coverage(m)};
|
|
}
|
|
|
|
// Displayed materialize progress of one piece of glass, animated toward the
|
|
// spec's target (docs/phase2/glassd-material.md, "Materialize"). Large glass
|
|
// (covers, window and thick slabs and plates) rides lgs_motion::kPhaseUp /
|
|
// kPhaseDown (sheet-in / sheet-out), small glass the linear kMatInMs /
|
|
// kMatOutMs ramps, Reduce Motion a kReduceMs coverage fade.
|
|
struct PhaseAnim {
|
|
bool init = false;
|
|
float target = 1, x = 1, v = 0; // target, value and velocity now
|
|
float y0 = 0, v0 = 0; // spring start (relative to target)
|
|
float lin0 = 1; // linear ramp start value
|
|
uint64_t t0 = 0;
|
|
bool spring = false; // large glass rides a spring, small glass a linear ramp
|
|
float ms = -1; // linear ramp duration override (ms), -1 = by size
|
|
// Sets the target; first sight starts at `from` (0 = materialize) or at the target.
|
|
void set(float tgt, bool springMode, float msOverride, float from, uint64_t now) {
|
|
tgt = std::clamp(tgt, 0.f, 1.f);
|
|
if (!init) {
|
|
init = true;
|
|
x = from >= 0 ? std::clamp(from, 0.f, 1.f) : tgt;
|
|
target = x;
|
|
v = 0;
|
|
t0 = now;
|
|
}
|
|
spring = springMode;
|
|
ms = msOverride;
|
|
if (tgt != target) {
|
|
target = tgt;
|
|
y0 = x - tgt;
|
|
v0 = v;
|
|
lin0 = x;
|
|
t0 = now;
|
|
}
|
|
}
|
|
// The value at t seconds after the last retarget (no state change).
|
|
float valueAt(float t, bool reduceMotion, float *vel = nullptr) const {
|
|
namespace mt = lgs_motion;
|
|
const bool up = target > lin0;
|
|
if (reduceMotion || !spring || ms >= 0) {
|
|
// a full 0 <-> 1 sweep takes dur; a retarget keeps that speed
|
|
const float dur = ms >= 0 ? ms / 1000.f : (reduceMotion ? mt::kReduceMs : up ? mt::kMatInMs : mt::kMatOutMs) / 1000.f;
|
|
if (vel) *vel = 0;
|
|
if (dur <= 0) return target;
|
|
return up ? std::min(target, lin0 + t / dur) : std::max(target, lin0 - t / dur);
|
|
}
|
|
const mt::Token &k = up ? mt::kPhaseUp : mt::kPhaseDown;
|
|
float y, vv;
|
|
mt::spring(k, y0, v0, t, y, vv);
|
|
if (vel) *vel = vv;
|
|
if (mt::settled(y, vv, std::max(std::fabs(y0), 0.01f), k.d)) {
|
|
if (vel) *vel = 0;
|
|
return target;
|
|
}
|
|
return std::clamp(target + y, 0.f, 1.f);
|
|
}
|
|
// Advances to `now`; returns true while still moving.
|
|
bool step(uint64_t now, bool reduceMotion) {
|
|
if (!init) return false;
|
|
// (t0 may be a little later than `now`: the spec is applied mid-iteration)
|
|
const float t = now > t0 ? float(double(now - t0) / 1e9) : 0.f;
|
|
if (x == target && v == 0) return false;
|
|
x = valueAt(t, reduceMotion, &v);
|
|
return !(x == target && v == 0);
|
|
}
|
|
};
|
|
|
|
// --selftest phase (MO-5, GL-4): PhaseAnim against motion_tokens.h at 1 ms
|
|
// steps, for a full 0 -> 1 and 1 -> 0 sweep of both curves and Reduce Motion.
|
|
// No SteamVR needed. Prints the largest difference; exit 0 when <= 0.002.
|
|
int selftestPhase() {
|
|
namespace mt = lgs_motion;
|
|
struct Case {
|
|
const char *name;
|
|
bool spring, rm;
|
|
float from, to;
|
|
} cases[] = {{"spring up (sheet-in)", true, false, 0, 1}, {"spring down (sheet-out)", true, false, 1, 0},
|
|
{"linear up (materialize-in)", false, false, 0, 1}, {"linear down (materialize-out)", false, false, 1, 0},
|
|
{"reduce motion up", true, true, 0, 1}, {"reduce motion down", false, true, 1, 0}};
|
|
float worst = 0;
|
|
std::printf("selftest phase: motion_tokens.h %s (sheet-in d %.2f settle %.0f ms, sheet-out d %.2f settle %.0f ms, "
|
|
"linear %.0f / %.0f ms, reduce %.0f ms)\n",
|
|
mt::kVersion, double(mt::kSheetIn.d), double(mt::kSheetIn.settleMs), double(mt::kSheetOut.d),
|
|
double(mt::kSheetOut.settleMs), double(mt::kMatInMs), double(mt::kMatOutMs), double(mt::kReduceMs));
|
|
for (const Case &c : cases) {
|
|
PhaseAnim a;
|
|
a.set(c.from, c.spring, -1, c.from, 0);
|
|
a.set(c.to, c.spring, -1, -1, 0);
|
|
float err = 0, settleMs = -1;
|
|
for (int ms = 0; ms <= 1200; ms++) {
|
|
a.step(uint64_t(ms) * kMs, c.rm);
|
|
float expect;
|
|
if (c.rm) {
|
|
expect = c.to > c.from ? std::min(1.f, ms / mt::kReduceMs) : std::max(0.f, 1.f - ms / mt::kReduceMs);
|
|
} else if (!c.spring) {
|
|
expect = c.to > c.from ? std::min(1.f, ms / mt::kMatInMs) : std::max(0.f, 1.f - ms / mt::kMatOutMs);
|
|
} else {
|
|
const mt::Token &k = c.to > c.from ? mt::kSheetIn : mt::kSheetOut;
|
|
const float p = mt::sample(k, float(ms)); // 0 -> 1 progress
|
|
expect = c.to > c.from ? p : 1.f - p;
|
|
}
|
|
err = std::max(err, std::fabs(a.x - expect));
|
|
if (settleMs < 0 && a.x == c.to) settleMs = float(ms);
|
|
}
|
|
std::printf(" %-30s max |glassd - table| %.4f, settles at %.0f ms\n", c.name, double(err), double(settleMs));
|
|
worst = std::max(worst, err);
|
|
}
|
|
std::printf("selftest phase: %s (worst %.4f, limit 0.002)\n", worst <= 0.002f ? "PASS" : "FAIL", double(worst));
|
|
return worst <= 0.002f ? 0 : 1;
|
|
}
|
|
|
|
// --------------------------------------------------------------------- spec
|
|
// Materialize fields, optional on a surface and on a slab (README "Phase").
|
|
struct PhaseSpec {
|
|
float phase = 1; // target materialize progress 0..1
|
|
bool materialize = false; // "appear": "materialize": first sight starts at 0
|
|
float ms = -1; // "phaseMs": linear ramp duration (ms) instead of the size's default
|
|
double atMs = 0; // "appearAt": epoch ms the materialize started (the page's own open animation)
|
|
};
|
|
// A colour from the spec: sRGB, straight alpha (v3: tint, fill, hole fill).
|
|
struct Color {
|
|
bool set = false;
|
|
float r = 0, g = 0, b = 0, a = 0;
|
|
};
|
|
// v3 hole treatment under a popped element (G2): its contact shadow, not
|
|
// excluded under the slab, clipped to the crop's rect, over a fill tone.
|
|
constexpr int kMaxEdgeSamples = 8; // per hole edge (glass.frag uHoleTex)
|
|
struct HoleSpec {
|
|
bool on = false;
|
|
float shadow = 0.35f, y = 9.f, blur = 27.f; // Steam px (6 / 18 CSS px at 1.5x)
|
|
Color fill;
|
|
// R1: tones just outside each edge of the crop (top, right, bottom, left),
|
|
// 0..8 samples along it (left to right, top to bottom); none = `fill`
|
|
std::vector<Color> edges[4];
|
|
bool hasEdges() const { return !edges[0].empty() || !edges[1].empty() || !edges[2].empty() || !edges[3].empty(); }
|
|
bool hasClip = false;
|
|
float cx0 = 0, cy0 = 0, cx1 = 0, cy1 = 0; // crop rect, Steam px
|
|
};
|
|
struct SlabSpec {
|
|
std::string id, material = "liquid";
|
|
float w = 0, h = 0, r = 0;
|
|
bool hasPos = false;
|
|
float x = 0, y = 0;
|
|
float dz = 0.015f; // scene units (metres = dz x unitM)
|
|
float ox = 0, oy = 0; // v3: world-point offset of a moved crop (Steam px)
|
|
Color tint; // v3 (G3)
|
|
Color fill; // v3 G7: a "dim" slab's tone (default black .30)
|
|
HoleSpec hole; // v3 (G2)
|
|
PhaseSpec ph;
|
|
bool none() const { return material == "none"; }
|
|
bool dim() const { return material == "dim"; } // v3 G7: a flat dark cell, no optics
|
|
};
|
|
struct ShapeSpec {
|
|
float x = 0, y = 0, w = 0, h = 0, r = 0;
|
|
};
|
|
// v3 plate (G1): one rounded rect of glass at the cover's depth, own material,
|
|
// phase, tint, fill; the occluder variant sits under a pop over its own plate.
|
|
struct PlateSpec {
|
|
std::string id, material = "liquid";
|
|
float x = 0, y = 0, w = 0, h = 0, r = 0;
|
|
PhaseSpec ph;
|
|
Color tint, fill;
|
|
bool occluder = false;
|
|
float shadow = -1; // contact shadow alpha, -1 = the material's
|
|
// "exitAt"/"exitMs": its content fades out (a route or sheet leaving) from that epoch ms over that
|
|
// long; the glass fades with it, linearly, its optics unchanged (cap "exitAt")
|
|
double exitAtMs = 0;
|
|
float exitMs = 0;
|
|
};
|
|
// v3 extra feed mask on a surface (Steam px, may lie outside the texture).
|
|
struct MaskSpec {
|
|
float x = 0, y = 0, w = 0, h = 0, dz = 0;
|
|
};
|
|
struct WorldMask {
|
|
v3 O, U, V; // a corner and the two full edge vectors (metres, standing)
|
|
};
|
|
struct SurfSpec {
|
|
std::string name, overlayKey, material = "window";
|
|
int texW = 0, texH = 0;
|
|
float radius = 0;
|
|
bool visible = true;
|
|
bool armed = false; // hidden, but its content is ready: draw its glass ahead (a pooled popup about to show)
|
|
// Cover shapes. Explicit (hasShapes, possibly empty = no cover), or absent:
|
|
// then a "window" surface is covered whole and any other gets no cover.
|
|
bool hasShapes = false;
|
|
std::vector<ShapeSpec> shapes;
|
|
// Optional world quad from the daemon (overrides the overlay transform):
|
|
// world position of Steam px (0,0) and world steps per Steam px right / down.
|
|
bool hasQuad = false;
|
|
v3 qO, qU, qV;
|
|
std::vector<SlabSpec> slabs;
|
|
PhaseSpec ph;
|
|
// "morph" (optional): the reporter's cover.morph. The shapes shown move toward `shapes` on the
|
|
// named spring (motion_tokens.h), starting at epoch ms `morphAtMs`: with the page's own motion
|
|
bool hasMorph = false;
|
|
std::string morphToken;
|
|
double morphAtMs = 0;
|
|
// v3
|
|
std::vector<PlateSpec> plates; // at most kMaxPlates
|
|
std::vector<std::string> droppedPlates; // beyond kMaxPlates or empty after clipping (the first kMaxListedDrops)
|
|
size_t droppedPlatesN = 0; // all of them
|
|
float coverDz = 0.001f; // scene units: the cover and plates relative to the surface
|
|
std::vector<MaskSpec> masks;
|
|
bool scaleFromOverlay = false; // "scaleFrom": "overlay": no rescale to the window's mpp
|
|
bool stereo = false; // "stereo": per-eye layout (a surface the scene graph shows whole)
|
|
};
|
|
// The wearer's glass tune (the bar's paintbrush panel; cap "tune"): multipliers on every material's
|
|
// refraction (lens and dispersion), frost and light (key specular, Fresnel, sheen), and a colour for the
|
|
// glass body (the neutral the tint pulls toward), applied in drawGlass on top of the presets and the dial.
|
|
struct Tune {
|
|
float refract = 1, frost = 1, light = 1; // 0..2
|
|
float hueK = 0; // 0..1: how far the body colour moves from neutral to `hue`
|
|
Color hue; // sRGB; unset = neutral
|
|
};
|
|
struct Spec {
|
|
long long seq = 0;
|
|
float dial = 0.5f;
|
|
Tune tune;
|
|
bool stereo = true; // "stereo": false = every surface mono (cap stereo)
|
|
bool reduceMotion = false;
|
|
float unitM = 0.369f; // v3: metres per scene unit (S x r)
|
|
float roomDim = 0; // v3 G7: every piece of glass sees the room darkened by this (0..1)
|
|
std::vector<WorldMask> masks; // v3: extra world mask quads
|
|
std::vector<SurfSpec> surfaces;
|
|
};
|
|
|
|
// v3 colours: [r, g, b(, a)] in 0..1, "#rgb", "#rrggbb", "#rrggbbaa",
|
|
// "rgb(r g b / a)", "rgba(r, g, b, a)" (0..255 channels, alpha 0..1 or %),
|
|
// and the reporter's names "green", "blue", "red", "scrim".
|
|
bool parseColorValue(const JVal *v, Color &c);
|
|
bool parseColor(const JVal *v, Color &c) {
|
|
if (parseColorValue(v, c)) return true;
|
|
if (v && v->type != JVal::Null) {
|
|
// ignored (treated as absent) and logged once per value, at most 16 values
|
|
static std::set<std::string> logged;
|
|
std::string what = v->type == JVal::Str ? "\"" + v->s.substr(0, 60) + "\"" : v->type == JVal::Arr ? "an array" : "a non-colour";
|
|
if (logged.size() < 16 && logged.insert(what).second) std::printf("spec: unparsable colour %s ignored\n", what.c_str());
|
|
}
|
|
return false;
|
|
}
|
|
bool parseColorValue(const JVal *v, Color &c) {
|
|
c = Color{};
|
|
if (!v) return false;
|
|
float ch[4] = {0, 0, 0, 1};
|
|
if (v->type == JVal::Arr) {
|
|
if (v->a.size() < 3 || v->a.size() > 4) return false;
|
|
for (size_t i = 0; i < v->a.size(); i++) {
|
|
if (v->a[i].type != JVal::Num || !std::isfinite(v->a[i].n)) return false;
|
|
ch[i] = float(v->a[i].n);
|
|
}
|
|
} else if (v->type == JVal::Str) {
|
|
const std::string &s = v->s;
|
|
// the reporter's named tones (DESIGN2 tokens): --lgs-green, --lgs-blue, --lgs-red, the .35 scrim
|
|
if (s == "green") { ch[0] = 48 / 255.f; ch[1] = 209 / 255.f; ch[2] = 88 / 255.f; }
|
|
else if (s == "blue") { ch[0] = 0; ch[1] = 145 / 255.f; ch[2] = 1; }
|
|
else if (s == "red") { ch[0] = 1; ch[1] = 66 / 255.f; ch[2] = 69 / 255.f; }
|
|
else if (s == "scrim") { ch[3] = 0.35f; }
|
|
else if (!s.empty() && s[0] == '#') {
|
|
std::string h = s.substr(1);
|
|
for (char k : h)
|
|
if (!std::isxdigit((unsigned char)k)) return false;
|
|
auto hx = [&](size_t i, size_t n) { return float(std::strtoul(h.substr(i, n).c_str(), nullptr, 16)); };
|
|
if (h.size() == 3 || h.size() == 4) {
|
|
for (size_t i = 0; i < h.size(); i++) ch[i] = hx(i, 1) * 17.f / 255.f;
|
|
} else if (h.size() == 6 || h.size() == 8) {
|
|
for (size_t i = 0; i < h.size() / 2; i++) ch[i] = hx(i * 2, 2) / 255.f;
|
|
} else {
|
|
return false;
|
|
}
|
|
} else if (s.rfind("rgb", 0) == 0) {
|
|
size_t open = s.find('('), close = s.rfind(')');
|
|
if (open == std::string::npos || close == std::string::npos || close < open) return false;
|
|
std::string body = s.substr(open + 1, close - open - 1);
|
|
for (char &k : body)
|
|
if (k == ',' || k == '/') k = ' ';
|
|
std::stringstream ss(body);
|
|
std::string tok;
|
|
int n = 0;
|
|
while (ss >> tok && n < 4) {
|
|
bool pct = !tok.empty() && tok.back() == '%';
|
|
float f = std::strtof(tok.c_str(), nullptr);
|
|
if (!std::isfinite(f)) return false;
|
|
ch[n] = n < 3 ? (pct ? f / 100.f : f / 255.f) : (pct ? f / 100.f : f);
|
|
n++;
|
|
}
|
|
if (n < 3) return false;
|
|
} else {
|
|
return false;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
c.set = true;
|
|
c.r = std::clamp(ch[0], 0.f, 1.f);
|
|
c.g = std::clamp(ch[1], 0.f, 1.f);
|
|
c.b = std::clamp(ch[2], 0.f, 1.f);
|
|
c.a = std::clamp(ch[3], 0.f, 1.f);
|
|
return true;
|
|
}
|
|
float srgbToLin1(float c) { return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); }
|
|
|
|
// A numeric id as text: integers exactly, anything beyond 2^53 (or not
|
|
// finite) as %.17g, never through an overflowing integer cast (R1 m7).
|
|
std::string numId(double n) {
|
|
if (std::isfinite(n) && std::fabs(n) < 9.0e15 && n == std::floor(n)) return std::to_string((long long)n);
|
|
char b[40];
|
|
std::snprintf(b, sizeof b, "%.17g", n);
|
|
return b;
|
|
}
|
|
|
|
// Hole edge tones (R1): {"top": c | [c, ...], "right": ..., "bottom": ..., "left": ...},
|
|
// each a colour or 1..8 colours along that edge; unparsable entries are skipped.
|
|
void readEdges(const JVal *e, HoleSpec &h) {
|
|
if (!e || e->type != JVal::Obj) return;
|
|
static const char *names[4] = {"top", "right", "bottom", "left"};
|
|
for (int k = 0; k < 4; k++) {
|
|
const JVal *v = e->get(names[k]);
|
|
if (!v) continue;
|
|
// a list of colours, or one colour (a string, or [r, g, b(, a)] numbers)
|
|
const bool list = v->type == JVal::Arr && !v->a.empty() && v->a[0].type != JVal::Num;
|
|
if (list) {
|
|
for (const JVal &c : v->a) {
|
|
Color col;
|
|
if (h.edges[k].size() < size_t(kMaxEdgeSamples) && parseColor(&c, col)) h.edges[k].push_back(col);
|
|
}
|
|
} else {
|
|
Color col;
|
|
if (parseColor(v, col)) h.edges[k].push_back(col);
|
|
}
|
|
}
|
|
}
|
|
|
|
void readPhase(const JVal &o, PhaseSpec &ph) {
|
|
ph.phase = std::clamp(float(o.num("phase", 1.0)), 0.f, 1.f);
|
|
if (!std::isfinite(ph.phase)) ph.phase = 1;
|
|
ph.materialize = o.str("appear", "") == "materialize";
|
|
ph.ms = o.has("phaseMs") ? std::clamp(float(o.num("phaseMs", -1)), 0.f, 10000.f) : -1.f;
|
|
ph.atMs = o.has("appearAt") ? o.num("appearAt", 0) : 0;
|
|
if (!std::isfinite(ph.atMs) || ph.atMs < 0) ph.atMs = 0;
|
|
}
|
|
|
|
const char *kDemoSpec = R"({"seq": 1, "dial": 0.5, "surfaces": [
|
|
{"name": "main", "overlayKey": "valve.steam.gamepadui.main", "texW": 1920, "texH": 1080, "radius": 48,
|
|
"material": "window", "visible": true,
|
|
"slabs": [{"id": "hdr-back", "w": 210, "h": 60, "r": 30, "material": "liquid", "x": 18, "y": 6, "dz": 0.015},
|
|
{"id": "tabs", "w": 600, "h": 64, "r": 32, "material": "liquid", "x": 900, "y": 70, "dz": 0.012},
|
|
{"id": "play", "w": 260, "h": 72, "r": 36, "material": "liquid", "x": 120, "y": 840, "dz": 0.015}]}
|
|
]})";
|
|
|
|
bool readVec3(const JVal *v, v3 &out) {
|
|
if (!v || v->type != JVal::Arr || v->a.size() != 3) return false;
|
|
for (const JVal &e : v->a)
|
|
if (e.type != JVal::Num || !std::isfinite(e.n)) return false;
|
|
out = {float(v->a[0].n), float(v->a[1].n), float(v->a[2].n)};
|
|
return true;
|
|
}
|
|
|
|
bool parseSpec(const std::string &text, Spec &out, std::string &err) {
|
|
JVal root;
|
|
if (!JParser(text).parse(root, err)) return false;
|
|
if (root.type != JVal::Obj) { err = "spec is not an object"; return false; }
|
|
out = Spec{};
|
|
{
|
|
const double sq = root.num("seq", 0);
|
|
out.seq = std::isfinite(sq) ? (long long)std::clamp(sq, -9.0e18, 9.0e18) : 0;
|
|
}
|
|
out.dial = float(root.num("dial", 0.5));
|
|
if (!std::isfinite(out.dial)) out.dial = 0.5f;
|
|
out.dial = std::clamp(out.dial, 0.f, 1.f);
|
|
if (const JVal *t = root.get("tune"); t && t->type == JVal::Obj) {
|
|
auto mult = [&](const char *k) {
|
|
const float v = float(t->num(k, 1));
|
|
return std::isfinite(v) ? std::clamp(v, 0.f, 2.f) : 1.f;
|
|
};
|
|
out.tune.refract = mult("refract");
|
|
out.tune.frost = mult("frost");
|
|
out.tune.light = mult("light");
|
|
if (parseColor(t->get("hue"), out.tune.hue)) {
|
|
const float k = float(t->num("hueK", 0.5));
|
|
out.tune.hueK = std::isfinite(k) ? std::clamp(k, 0.f, 1.f) : 0.f;
|
|
}
|
|
}
|
|
out.reduceMotion = root.boolean("reduceMotion", false);
|
|
out.stereo = root.boolean("stereo", true);
|
|
{
|
|
const float d = float(root.num("roomDim", 0));
|
|
out.roomDim = std::isfinite(d) ? std::clamp(d, 0.f, 0.9f) : 0.f;
|
|
}
|
|
{
|
|
const float u = float(root.num("unitM", 0.369));
|
|
out.unitM = std::isfinite(u) && u > 0.01f && u < 10.f ? u : 0.369f;
|
|
}
|
|
auto finite = [](float v) { return std::isfinite(v) ? v : 0.f; };
|
|
if (const JVal *m = root.get("masks"); m && m->type == JVal::Arr) {
|
|
for (const JVal &q : m->a) {
|
|
WorldMask wm;
|
|
if (q.type == JVal::Obj && readVec3(q.get("O"), wm.O) && readVec3(q.get("U"), wm.U) && readVec3(q.get("V"), wm.V) &&
|
|
length(wm.U) > 1e-4f && length(wm.V) > 1e-4f && length(wm.U) < 20.f && length(wm.V) < 20.f &&
|
|
out.masks.size() < size_t(kMaxExtraMasks))
|
|
out.masks.push_back(wm);
|
|
}
|
|
}
|
|
const JVal *surfs = root.get("surfaces");
|
|
if (!surfs || surfs->type != JVal::Arr) return true;
|
|
for (const JVal &s : surfs->a) {
|
|
if (s.type != JVal::Obj) continue;
|
|
SurfSpec ss;
|
|
ss.name = s.str("name", "");
|
|
ss.overlayKey = s.str("overlayKey", "");
|
|
ss.texW = int(s.num("texW", 0));
|
|
ss.texH = int(s.num("texH", 0));
|
|
ss.radius = float(s.num("radius", 0));
|
|
ss.material = s.str("material", "window");
|
|
ss.visible = s.boolean("visible", true);
|
|
ss.armed = !ss.visible && s.boolean("armed", false);
|
|
readPhase(s, ss.ph);
|
|
if (const JVal *m = s.get("morph"); m && m->type == JVal::Obj) {
|
|
ss.morphToken = m->str("token", "");
|
|
ss.morphAtMs = m->num("at", 0);
|
|
ss.hasMorph = !ss.morphToken.empty() && std::isfinite(ss.morphAtMs) && ss.morphAtMs > 0;
|
|
}
|
|
if (ss.name.empty() || ss.name.size() > 128 || ss.texW <= 0 || ss.texH <= 0 || ss.texW > 8192 || ss.texH > 8192) continue;
|
|
if (const JVal *sh = s.get("shapes"); sh && sh->type == JVal::Arr) {
|
|
ss.hasShapes = true;
|
|
for (const JVal &q : sh->a) {
|
|
ShapeSpec p{float(q.num("x", 0)), float(q.num("y", 0)), float(q.num("w", 0)), float(q.num("h", 0)),
|
|
float(q.num("r", ss.radius))};
|
|
// clip to the texture
|
|
const float x0 = std::max(0.f, p.x), y0 = std::max(0.f, p.y);
|
|
const float x1 = std::min(float(ss.texW), p.x + p.w), y1 = std::min(float(ss.texH), p.y + p.h);
|
|
if (x1 - x0 < 1 || y1 - y0 < 1) continue;
|
|
p = {x0, y0, x1 - x0, y1 - y0, std::max(0.f, p.r)};
|
|
ss.shapes.push_back(p);
|
|
}
|
|
}
|
|
if (const JVal *q = s.get("quad"); q && q->type == JVal::Obj)
|
|
ss.hasQuad = readVec3(q->get("O"), ss.qO) && readVec3(q->get("U"), ss.qU) && readVec3(q->get("V"), ss.qV);
|
|
if (const JVal *sl = s.get("slabs"); sl && sl->type == JVal::Arr) {
|
|
for (const JVal &q : sl->a) {
|
|
SlabSpec b;
|
|
b.id = q.str("id", "");
|
|
if (const JVal *idn = q.get("id"); idn && idn->type == JVal::Num) b.id = numId(idn->n);
|
|
b.w = float(q.num("w", 0));
|
|
b.h = float(q.num("h", 0));
|
|
b.r = float(q.num("r", 0));
|
|
b.material = q.str("material", "liquid");
|
|
b.hasPos = q.has("x") && q.has("y");
|
|
b.x = float(q.num("x", 0));
|
|
b.y = float(q.num("y", 0));
|
|
b.dz = finite(float(q.num("dz", 0.015)));
|
|
b.ox = finite(float(q.num("ox", 0)));
|
|
b.oy = finite(float(q.num("oy", 0)));
|
|
parseColor(q.get("tint"), b.tint);
|
|
parseColor(q.get("fill"), b.fill);
|
|
if (const JVal *h = q.get("hole")) {
|
|
if (h->type == JVal::Bool) {
|
|
b.hole.on = h->b;
|
|
} else if (h->type == JVal::Obj) {
|
|
b.hole.on = true;
|
|
b.hole.shadow = std::clamp(finite(float(h->num("shadow", 0.35))), 0.f, 1.f);
|
|
b.hole.y = std::clamp(finite(float(h->num("y", 9))), -200.f, 200.f);
|
|
b.hole.blur = std::clamp(finite(float(h->num("blur", 27))), 0.f, 400.f);
|
|
parseColor(h->get("fill"), b.hole.fill);
|
|
readEdges(h->get("edges"), b.hole);
|
|
if (const JVal *c = h->get("clip"); c && c->type == JVal::Arr && c->a.size() == 4) {
|
|
bool ok = true;
|
|
float v[4];
|
|
for (int i = 0; i < 4; i++) {
|
|
ok &= c->a[size_t(i)].type == JVal::Num && std::isfinite(c->a[size_t(i)].n);
|
|
v[i] = ok ? float(c->a[size_t(i)].n) : 0.f;
|
|
}
|
|
if (ok && v[2] > v[0] && v[3] > v[1]) {
|
|
b.hole.hasClip = true;
|
|
b.hole.cx0 = v[0]; b.hole.cy0 = v[1]; b.hole.cx1 = v[2]; b.hole.cy1 = v[3];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
readPhase(q, b.ph);
|
|
if (b.id.empty() || b.id.size() > 128 || b.w < 1 || b.h < 1) continue;
|
|
ss.slabs.push_back(b);
|
|
}
|
|
}
|
|
// v3 plates (G1): at most kMaxPlates, clipped to the texture
|
|
if (const JVal *pl = s.get("plates"); pl && pl->type == JVal::Arr) {
|
|
int index = 0;
|
|
for (const JVal &q : pl->a) {
|
|
if (q.type != JVal::Obj) { index++; continue; }
|
|
PlateSpec p;
|
|
p.id = q.str("id", "");
|
|
if (const JVal *idn = q.get("id"); idn && idn->type == JVal::Num) p.id = numId(idn->n);
|
|
if (p.id.empty()) p.id = "p" + std::to_string(index);
|
|
if (p.id.size() > 128) p.id = p.id.substr(0, 128);
|
|
index++;
|
|
p.material = q.str("material", "liquid");
|
|
p.x = finite(float(q.num("x", 0)));
|
|
p.y = finite(float(q.num("y", 0)));
|
|
p.w = finite(float(q.num("w", 0)));
|
|
p.h = finite(float(q.num("h", 0)));
|
|
p.r = q.has("r") ? std::max(0.f, finite(float(q.num("r", 0)))) : 0.5f * std::min(p.w, p.h);
|
|
readPhase(q, p.ph);
|
|
parseColor(q.get("tint"), p.tint);
|
|
parseColor(q.get("fill"), p.fill);
|
|
p.occluder = q.boolean("occluder", false);
|
|
if (q.has("shadow")) p.shadow = std::clamp(finite(float(q.num("shadow", -1))), 0.f, 1.f);
|
|
if (q.has("exitAt")) {
|
|
const double at = q.num("exitAt", 0);
|
|
if (std::isfinite(at) && at > 0) {
|
|
p.exitAtMs = at;
|
|
p.exitMs = std::clamp(finite(float(q.num("exitMs", 150))), 0.f, 2000.f);
|
|
}
|
|
}
|
|
const float x0 = std::max(0.f, p.x), y0 = std::max(0.f, p.y);
|
|
const float x1 = std::min(float(ss.texW), p.x + p.w), y1 = std::min(float(ss.texH), p.y + p.h);
|
|
if (x1 - x0 < 1 || y1 - y0 < 1 || ss.plates.size() >= size_t(kMaxPlates)) {
|
|
if (ss.droppedPlates.size() < size_t(kMaxListedDrops)) ss.droppedPlates.push_back(p.id);
|
|
ss.droppedPlatesN++;
|
|
continue;
|
|
}
|
|
p.r = std::min(p.r, 0.5f * std::min(x1 - x0, y1 - y0));
|
|
p.x = x0; p.y = y0; p.w = x1 - x0; p.h = y1 - y0;
|
|
ss.plates.push_back(p);
|
|
}
|
|
}
|
|
if (s.has("coverDz")) ss.coverDz = std::clamp(finite(float(s.num("coverDz", 0.001))), -0.5f, 0.5f);
|
|
if (const JVal *m = s.get("masks"); m && m->type == JVal::Arr) {
|
|
for (const JVal &q : m->a) {
|
|
if (q.type != JVal::Obj || ss.masks.size() >= size_t(kMaxExtraMasks)) continue;
|
|
MaskSpec k{finite(float(q.num("x", 0))), finite(float(q.num("y", 0))), finite(float(q.num("w", 0))),
|
|
finite(float(q.num("h", 0))), finite(float(q.num("dz", 0)))};
|
|
if (k.w >= 1 && k.h >= 1 && k.w < 20000 && k.h < 20000) ss.masks.push_back(k);
|
|
}
|
|
}
|
|
ss.scaleFromOverlay = s.str("scaleFrom", "main") == "overlay";
|
|
ss.stereo = s.boolean("stereo", false);
|
|
out.surfaces.push_back(std::move(ss));
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::string sanitizeKey(const std::string &s) {
|
|
std::string o;
|
|
for (char c : s) o += (std::isalnum((unsigned char)c) || c == '.' || c == '_' || c == '-') ? c : '_';
|
|
return o.substr(0, 100);
|
|
}
|
|
|
|
// ----------------------------------------------------------------- surfaces
|
|
struct Geometry {
|
|
bool valid = false, fallback = false;
|
|
v3 O, U, V, N; // world: Steam pixel (0,0) top-left; per-pixel right; per-pixel down; unit normal
|
|
float mpp = 0;
|
|
};
|
|
|
|
struct SlabSlot {
|
|
SlabSpec s;
|
|
int x = 0, y = 0, w = 0, h = 0; // cell in the glassd texture
|
|
uint64_t ghostUntilNs = 0; // ghosts: removed from the spec, drawn until then
|
|
uint64_t stillSinceNs = 0; // its element's x/y last changed then (its shadow waits for it to settle)
|
|
PhaseAnim anim; // materialize progress
|
|
};
|
|
|
|
// A plate's state across specs (its materialize progress follows its id).
|
|
struct PlateSlot {
|
|
PlateSpec s;
|
|
PhaseAnim anim;
|
|
uint64_t exitT0 = 0; // its content's fade-out started then (monotonic ns); 0 = not leaving
|
|
};
|
|
|
|
struct Surface {
|
|
SurfSpec spec;
|
|
std::vector<PlateSlot> plates; // v3 plates, in spec order
|
|
std::string key;
|
|
vr::VROverlayHandle_t ov = vr::k_ulOverlayHandleInvalid;
|
|
vr::VROverlayHandle_t steam = vr::k_ulOverlayHandleInvalid;
|
|
int gw = 0, gh = 0, bw = 0, bh = 0;
|
|
int eyes = 1; // 2: the buffers hold the layout twice, left eye's on the left (stereo)
|
|
float scale = 0.75f;
|
|
Atlas atlas;
|
|
std::vector<SlabSlot> slots, ghosts;
|
|
std::set<std::string> dropped; // slabs that did not fit (logged once)
|
|
DmaTarget bufs[3];
|
|
bool cleared[3] = {false, false, false}; // buffer fully cleared once since it was created
|
|
int next = 0, last = -1;
|
|
// geometry: geo is the last good quad (or a fallback); geoOk = the latest
|
|
// fetch succeeded and was plausible; rawGeo = the overlay's own quad when
|
|
// geo was rescaled to the main window's scale
|
|
Geometry geo, rawGeo;
|
|
bool geoOk = false, rescaled = false;
|
|
uint64_t geoOkNs = 0, findNs = 0;
|
|
std::string geoNote = "none", geoLogged;
|
|
bool rendered = false, announced = false, warnedShapes = false, atlasOn = false, warnedHoles = false;
|
|
PhaseAnim anim; // the cover's materialize progress (0 while it has no cover)
|
|
// cover morph: the shapes shown move from morphFrom toward the spec's on morphTok's spring from
|
|
// morphT0 (monotonic ns); fadeShapes: the shapes a cover that went away dematerializes with
|
|
std::vector<ShapeSpec> morphFrom, fadeShapes;
|
|
const lgs_motion::Token *morphTok = nullptr;
|
|
uint64_t morphT0 = 0;
|
|
bool clearPending = false; // hidden since the last frame: one transparent frame, so no stale glass shows on the next open
|
|
Target lowTex; // the cover at 1/4 resolution, mipmapped (glass.frag pass 1): its flat
|
|
// interior for pass 2, and what slabs see behind them
|
|
Target coverTex; // R1: a copy of lowTex with the cover only (no plates), mipmapped:
|
|
// what plates over a cover see behind them (glass over glass)
|
|
int submittedW = 0, submittedH = 0; // what SteamVR holds (mouse scale follows it)
|
|
int submitFails = 0, createTries = 0;
|
|
uint64_t createNs = 0, submitFailSinceNs = 0, lastSubmitNs = 0;
|
|
};
|
|
|
|
struct Retired {
|
|
DmaTarget buf;
|
|
uint64_t freeAtNs;
|
|
};
|
|
|
|
// --------------------------------------------------------------- GPU timer
|
|
struct GpuTimer {
|
|
Gfx *g = nullptr;
|
|
GLuint q[4] = {0, 0, 0, 0};
|
|
bool busy[4] = {false, false, false, false};
|
|
int head = 0;
|
|
bool active = false;
|
|
float ms = 0; // EMA
|
|
float last = 0; // the latest frame's
|
|
bool have = false;
|
|
bool keep = false; // --bench: keep every frame's time
|
|
std::vector<float> samples;
|
|
void init(Gfx &gfx) {
|
|
g = &gfx;
|
|
if (g->timerQuery) glGenQueries(4, q);
|
|
}
|
|
void begin() {
|
|
if (!g || !g->timerQuery || busy[head]) return;
|
|
glBeginQuery(GL_TIME_ELAPSED_EXT, q[head]);
|
|
active = true;
|
|
}
|
|
void end() {
|
|
if (!active) return;
|
|
glEndQuery(GL_TIME_ELAPSED_EXT);
|
|
busy[head] = true;
|
|
head = (head + 1) % 4;
|
|
active = false;
|
|
}
|
|
void poll() {
|
|
if (!g || !g->timerQuery) return;
|
|
GLint disjoint = 0;
|
|
glGetIntegerv(GL_GPU_DISJOINT_EXT, &disjoint);
|
|
for (int i = 0; i < 4; i++) {
|
|
if (!busy[i]) continue;
|
|
GLuint avail = 0;
|
|
glGetQueryObjectuiv(q[i], GL_QUERY_RESULT_AVAILABLE, &avail);
|
|
if (!avail) continue;
|
|
GLuint64 ns = 0;
|
|
g->getQueryObjectui64v(q[i], GL_QUERY_RESULT, &ns);
|
|
busy[i] = false;
|
|
if (disjoint) continue;
|
|
float v = float(ns) / 1e6f;
|
|
last = v;
|
|
if (keep) samples.push_back(v);
|
|
ms = have ? ms + (v - ms) * 0.1f : v;
|
|
have = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// First process whose comm is `name` (comm is at most 15 chars), or -1.
|
|
int findPid(const char *name) {
|
|
DIR *d = opendir("/proc");
|
|
if (!d) return -1;
|
|
int found = -1;
|
|
while (dirent *e = readdir(d)) {
|
|
if (!std::isdigit((unsigned char)e->d_name[0])) continue;
|
|
std::string p = std::string("/proc/") + e->d_name + "/comm";
|
|
FILE *f = std::fopen(p.c_str(), "r");
|
|
if (!f) continue;
|
|
char comm[64] = {};
|
|
if (std::fgets(comm, sizeof comm, f)) {
|
|
comm[std::strcspn(comm, "\n")] = 0;
|
|
if (std::strcmp(comm, name) == 0) found = std::atoi(e->d_name);
|
|
}
|
|
std::fclose(f);
|
|
if (found > 0) break;
|
|
}
|
|
closedir(d);
|
|
return found;
|
|
}
|
|
bool pidIs(int pid, const char *name) {
|
|
std::string p = "/proc/" + std::to_string(pid) + "/comm";
|
|
FILE *f = std::fopen(p.c_str(), "r");
|
|
if (!f) return false;
|
|
char comm[64] = {};
|
|
bool ok = std::fgets(comm, sizeof comm, f) != nullptr;
|
|
std::fclose(f);
|
|
comm[std::strcspn(comm, "\n")] = 0;
|
|
return ok && std::strcmp(comm, name) == 0;
|
|
}
|
|
|
|
void mkdirs(const std::string &dir) {
|
|
std::string cur;
|
|
std::stringstream ss(dir);
|
|
std::string part;
|
|
if (!dir.empty() && dir[0] == '/') cur = "/";
|
|
while (std::getline(ss, part, '/')) {
|
|
if (part.empty()) continue;
|
|
cur += part + "/";
|
|
mkdir(cur.c_str(), 0755);
|
|
}
|
|
}
|
|
std::string dirname(const std::string &p) {
|
|
size_t s = p.rfind('/');
|
|
return s == std::string::npos ? "." : s == 0 ? "/" : p.substr(0, s);
|
|
}
|
|
std::string basename(const std::string &p) {
|
|
size_t s = p.rfind('/');
|
|
return s == std::string::npos ? p : p.substr(s + 1);
|
|
}
|
|
bool readFile(const std::string &p, std::string &out) {
|
|
std::ifstream f(p, std::ios::binary);
|
|
if (!f) return false;
|
|
std::stringstream ss;
|
|
ss << f.rdbuf();
|
|
out = ss.str();
|
|
return true;
|
|
}
|
|
bool writeAtomic(const std::string &p, const std::string &text) {
|
|
std::string tmp = p + ".tmp";
|
|
FILE *f = std::fopen(tmp.c_str(), "w");
|
|
if (!f) return false;
|
|
std::fwrite(text.data(), 1, text.size(), f);
|
|
std::fclose(f);
|
|
return std::rename(tmp.c_str(), p.c_str()) == 0;
|
|
}
|
|
// Exclusive, non-blocking flock on `path` (created). Returns the fd or -1.
|
|
int lockPath(const std::string &path) {
|
|
mkdirs(dirname(path));
|
|
int fd = open(path.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0644);
|
|
if (fd < 0) return -1;
|
|
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
|
close(fd);
|
|
return -1;
|
|
}
|
|
return fd;
|
|
}
|
|
int roundUp(int v, int m) { return ((v + m - 1) / m) * m; }
|
|
|
|
// A slab's shadow on the cover (and plates) behind it (glass.frag uShBox /
|
|
// uShPar); with a hole (v3 G2) also its crop rect and fill tone.
|
|
struct Caster {
|
|
float cx, cy, hw, hh, r, alpha, off, soft;
|
|
bool hole = false;
|
|
float x0 = 0, y0 = 0, x1 = 0, y1 = 0; // crop rect (holes)
|
|
Color fill; // hole fill (alpha already phased)
|
|
const HoleSpec *spec = nullptr; // its edge tones (R1), alpha phased by `edgeA`
|
|
float edgeA = 1;
|
|
};
|
|
// v3 per-piece look: colour tint, flat fill, occluder variant, dim plate.
|
|
struct Look {
|
|
Color tint, fill;
|
|
bool occluder = false, flat = false;
|
|
float shadow = -1; // contact shadow alpha override (-1 = material)
|
|
};
|
|
|
|
// ===================================================================== glassd
|
|
class Glassd {
|
|
public:
|
|
explicit Glassd(const Options &o) : opt(o) {}
|
|
|
|
int run() {
|
|
int rc = 1;
|
|
if (!setup(rc)) {
|
|
teardown();
|
|
return rc;
|
|
}
|
|
loop();
|
|
teardown();
|
|
return g_exitCode.load();
|
|
}
|
|
|
|
private:
|
|
Options opt;
|
|
Gfx gfx;
|
|
Program progGlass, progUpdate, progPush, progPull, progRow, progHfill, progTest, progView;
|
|
bool testRoomDone = false;
|
|
GLuint vao = 0;
|
|
GLuint holeTex = 0; // hole edge tones of the casters being drawn (glass.frag uHoleTex, unit 4)
|
|
Room room;
|
|
FeedCapture feed;
|
|
FeedFrame frame;
|
|
uint64_t haveSeq = 0;
|
|
FeedCalib cal;
|
|
Pose eyeToHead;
|
|
v3 eyeOffset[2] = {{-0.032f, 0, 0}, {0.032f, 0, 0}}; // each eye in head space (SteamVR's IPD)
|
|
uint64_t eyeOffsetAt = 0;
|
|
std::deque<std::pair<uint64_t, Pose>> hist;
|
|
Spec spec;
|
|
bool haveSpec = false;
|
|
struct stat specStat {};
|
|
std::string specError;
|
|
std::vector<std::unique_ptr<Surface>> surfaces;
|
|
std::vector<Retired> retired;
|
|
std::vector<MaskQuad> masks;
|
|
uint64_t masksNs = 0, masksBuiltNs = 0;
|
|
bool masksComplete = false, masksOverflowLogged = false;
|
|
int lockOut = -1, lockKey = -1;
|
|
int inoFd = -1, inoWd = -1;
|
|
uint64_t inoRetryNs = 0;
|
|
GpuTimer renderTimer, roomTimer;
|
|
float envLuma = 0.25f;
|
|
bool envSet = false;
|
|
float mainMpp = 0; // metres per Steam px of the main window, from its overlay (0 = unknown)
|
|
// health
|
|
int origPpid = 0, vrserverPid = -1, compositorPid = -1;
|
|
uint64_t lastHealthNs = 0, lastSubmitNs = 0;
|
|
std::deque<uint64_t> overlayLosses;
|
|
// dashboard / room gating
|
|
uint64_t dashChangeNs = 0, lastShotNs = 0;
|
|
uint64_t hideShotNs[2] = {0, 0}; // extra feed shots right after the dashboard hides
|
|
bool graceDone = true;
|
|
uint64_t roomSkipped = 0;
|
|
// stats
|
|
uint64_t startNs = 0, frames = 0, framesWindow = 0, roomWindow = 0, lastStatusNs = 0, lastOutNs = 0;
|
|
uint64_t feedSeenAtStatus = 0, feedAttachedAtStatus = 0, firstRoomNs = 0;
|
|
float fpsNow = 0, cpuMs = 0, roomCpuMs = 0;
|
|
bool lastDash = false;
|
|
Pose lastRenderHead;
|
|
bool roomDirty = true, layoutDirty = true, outPending = false;
|
|
static constexpr const char *kRoomKeep = "/dev/shm/lgs/glassd-room.bin"; // the room map across restarts
|
|
uint64_t roomKeptAt = 0, lastKeepNs = 0;
|
|
uint64_t outPendingSinceNs = 0;
|
|
Pose lastHead;
|
|
bool haveLastHead = false;
|
|
uint64_t nextRenderNs = 0, lastLoopNs = 0, lastRenderNs = 0;
|
|
bool dumpedOnce = false;
|
|
uint64_t lastPhaseNs = 0; // when the phases were last stepped (dump lines)
|
|
PhaseAnim roomDimAnim; // v3 G7: the room dim, on sheet-in up and sheet-out down
|
|
// The dial and the tune as drawn: eased toward the spec's (time constant kTuneTau), so a slider dragged
|
|
// in the paintbrush panel moves the glass smoothly instead of in steps; the body colour is linear rgb
|
|
// at luminance 1
|
|
struct TuneLive {
|
|
bool init = false;
|
|
float dial = 0.5f, refract = 1, frost = 1, light = 1, hueK = 0, hr = 1, hg = 1, hb = 1;
|
|
} tuneLive;
|
|
uint64_t tuneNs = 0;
|
|
static constexpr float kTuneTau = 0.04f; // s: 95 % of a change in 120 ms
|
|
|
|
// ------------------------------------------------------------- shaders
|
|
std::string shaderSource(const std::string &name) {
|
|
if (!opt.shaderDir.empty()) {
|
|
std::string s;
|
|
if (readFile(opt.shaderDir + "/" + name, s)) return s;
|
|
std::fprintf(stderr, "%s/%s missing; using the built-in copy\n", opt.shaderDir.c_str(), name.c_str());
|
|
}
|
|
for (auto &kv : kShaders)
|
|
if (name == kv.first) return kv.second;
|
|
std::fprintf(stderr, "no shader %s\n", name.c_str());
|
|
return "";
|
|
}
|
|
bool loadPrograms() {
|
|
const std::string head = "#version 300 es\nprecision highp float;\nprecision highp int;\nprecision highp sampler2D;\n";
|
|
const std::string common = shaderSource("common.glsl");
|
|
const std::string vs = head + common + "\n" + shaderSource("fullscreen.vert");
|
|
auto fs = [&](const char *n) { return head + common + "\n" + shaderSource(n); };
|
|
const std::string testLib = shaderSource("testroom.glsl");
|
|
auto fsTest = [&](const char *n) { return head + common + "\n" + testLib + "\n" + shaderSource(n); };
|
|
return linkProgram(progGlass, vs, fs("glass.frag"), "glass") &&
|
|
linkProgram(progUpdate, vs, fs("room_update.frag"), "room_update") &&
|
|
linkProgram(progPush, vs, fs("push.frag"), "push") && linkProgram(progPull, vs, fs("pull.frag"), "pull") &&
|
|
linkProgram(progRow, vs, fs("row.frag"), "row") && linkProgram(progHfill, vs, fs("hfill.frag"), "hfill") &&
|
|
linkProgram(progTest, vs, fsTest("testroom.frag"), "testroom") &&
|
|
linkProgram(progView, vs, fsTest("view.frag"), "view");
|
|
}
|
|
|
|
// --------------------------------------------------------------- setup
|
|
bool setup(int &rc) {
|
|
startNs = monoNowNs();
|
|
// Stop with the parent (lgs_shell): an orphan would keep its overlays
|
|
// and the lock, and block its successor.
|
|
origPpid = getppid();
|
|
if (!opt.orphanOk && origPpid > 1) {
|
|
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
|
if (getppid() != origPpid) {
|
|
std::fprintf(stderr, "parent already gone\n");
|
|
rc = 0;
|
|
return false;
|
|
}
|
|
}
|
|
// One instance per output file and per overlay key prefix.
|
|
mkdirs(dirname(opt.out));
|
|
const std::string outLock = opt.out + ".lock";
|
|
const std::string keyLock = std::string(kShmDir) + "/" + sanitizeKey(opt.keyPrefix) + "lock";
|
|
if ((lockOut = lockPath(outLock)) < 0) {
|
|
std::fprintf(stderr, "another glassd holds %s\n", outLock.c_str());
|
|
rc = kExitTemp;
|
|
return false;
|
|
}
|
|
if ((lockKey = lockPath(keyLock)) < 0) {
|
|
std::fprintf(stderr, "another glassd holds %s (key prefix %s)\n", keyLock.c_str(), opt.keyPrefix.c_str());
|
|
rc = kExitTemp;
|
|
return false;
|
|
}
|
|
vrserverPid = findPid("vrserver");
|
|
if (vrserverPid <= 0) {
|
|
std::fprintf(stderr, "SteamVR (vrserver) is not running; not starting it\n");
|
|
rc = kExitTemp;
|
|
return false;
|
|
}
|
|
compositorPid = findPid("vrcompositor");
|
|
vr::EVRInitError err = vr::VRInitError_None;
|
|
vr::VR_Init(&err, vr::VRApplication_Overlay);
|
|
if (err != vr::VRInitError_None) {
|
|
std::fprintf(stderr, "VR_Init: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(err));
|
|
rc = kExitTemp;
|
|
return false;
|
|
}
|
|
if (!vr::VROverlay() || !vr::VRSystem() || !vr::VRIPCResourceManager()) {
|
|
std::fprintf(stderr, "OpenVR interfaces missing\n");
|
|
return false;
|
|
}
|
|
if (!gfx.init()) return false;
|
|
std::printf("GL %s | %s | timer queries %s | half-float targets %s\n", gfx.version.c_str(), gfx.renderer.c_str(),
|
|
gfx.timerQuery ? "yes" : "no", gfx.halfFloatTargets ? "yes" : "no");
|
|
std::printf("vrserver pid %d, vrcompositor pid %d, parent pid %d%s\n", vrserverPid, compositorPid, origPpid,
|
|
opt.orphanOk ? " (orphan ok)" : "");
|
|
glGenVertexArrays(1, &vao);
|
|
glBindVertexArray(vao);
|
|
if (!loadPrograms()) return false;
|
|
room.radius = opt.roomDepth;
|
|
if (!room.init(gfx, progUpdate, progPush, progPull, progRow, progHfill, vao)) return false;
|
|
// the room this glassd's predecessor knew (Room::save): glass refracts the room from the first frame
|
|
if (opt.testPattern < 0 && room.load(kRoomKeep)) {
|
|
float known = 0, lum = 0;
|
|
room.stats(known, lum);
|
|
std::printf("room map restored from %s (%.0f%% known)\n", kRoomKeep, double(known) * 100.0);
|
|
}
|
|
renderTimer.init(gfx);
|
|
roomTimer.init(gfx);
|
|
renderTimer.keep = opt.bench;
|
|
cal.load();
|
|
std::printf("feed calibration %s: u=%.4fx%+.4f v=%.4fy%+.4f latency %.1f ms, %s eye\n", cal.source.c_str(), cal.a, cal.b,
|
|
cal.c, cal.d, cal.latencyMs, cal.eye ? "right" : "left");
|
|
eyeToHead = fromVR(vr::VRSystem()->GetEyeToHeadTransform(cal.eye ? vr::Eye_Right : vr::Eye_Left));
|
|
if (!opt.noFeed) feed.start(opt.feedDev, opt.feedDownsample);
|
|
if (opt.testPattern >= 0)
|
|
std::printf("test backdrop: no camera, fixed head at %.2f %.2f %.2f (eye offset %.2f %.2f %.2f), window 1.43 m ahead\n",
|
|
double(kTestEye.x), double(kTestEye.y), double(kTestEye.z), double(opt.testHead[0]), double(opt.testHead[1]),
|
|
double(opt.testHead[2]));
|
|
if (opt.demo) {
|
|
Spec s;
|
|
std::string e;
|
|
if (parseSpec(kDemoSpec, s, e)) applySpec(s);
|
|
std::printf("using the built-in demo spec\n");
|
|
} else {
|
|
watchSpec();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void keepRoom() {
|
|
if (opt.testPattern >= 0 || room.updates == roomKeptAt) return;
|
|
mkdirs(dirname(kRoomKeep));
|
|
if (room.save(kRoomKeep)) roomKeptAt = room.updates;
|
|
}
|
|
|
|
void teardown() {
|
|
benchSummary();
|
|
keepRoom();
|
|
feed.stop();
|
|
if (vr::VRSystem()) writeOut(true); // fps 0, "exiting": the layout stays readable
|
|
if (vr::VROverlay()) {
|
|
for (auto &s : surfaces) releaseSurface(*s, true);
|
|
for (auto &r : retired) releaseBuffer(r.buf);
|
|
}
|
|
surfaces.clear();
|
|
retired.clear();
|
|
if (gfx.dpy != EGL_NO_DISPLAY) gfx.shutdown();
|
|
if (vr::VRSystem()) vr::VR_Shutdown();
|
|
if (inoFd >= 0) close(inoFd);
|
|
if (lockKey >= 0) close(lockKey);
|
|
if (lockOut >= 0) close(lockOut);
|
|
}
|
|
|
|
// ------------------------------------------------------- spec watching
|
|
// inotify on the spec's directory, so a new spec is picked up at once
|
|
// (the 10 Hz stat poll stays as a fallback).
|
|
void watchSpec() {
|
|
if (inoFd < 0) inoFd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
|
|
if (inoFd < 0 || inoWd >= 0) return;
|
|
inoWd = inotify_add_watch(inoFd, dirname(opt.spec).c_str(), IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE | IN_DELETE_SELF | IN_MOVE_SELF);
|
|
inoRetryNs = monoNowNs() + 2000000000ull;
|
|
}
|
|
// True when an event concerns the spec file (or the watch needs renewing).
|
|
bool drainSpecEvents() {
|
|
if (inoFd < 0) return false;
|
|
bool hit = false;
|
|
alignas(inotify_event) char buf[4096];
|
|
const std::string base = basename(opt.spec);
|
|
while (true) {
|
|
ssize_t n = read(inoFd, buf, sizeof buf);
|
|
if (n <= 0) break;
|
|
for (ssize_t i = 0; i < n;) {
|
|
auto *e = reinterpret_cast<inotify_event *>(buf + i);
|
|
if (e->mask & (IN_IGNORED | IN_DELETE_SELF | IN_MOVE_SELF)) inoWd = -1;
|
|
if (e->len && base == e->name) hit = true;
|
|
i += ssize_t(sizeof(inotify_event) + e->len);
|
|
}
|
|
}
|
|
return hit;
|
|
}
|
|
|
|
// ---------------------------------------------------------- overlays
|
|
void releaseBuffer(DmaTarget &b) {
|
|
if (b.vrHandle) vr::VRIPCResourceManager()->UnrefResource(vr::SharedTextureHandle_t(b.vrHandle));
|
|
b.destroy(gfx);
|
|
}
|
|
void releaseSurface(Surface &s, bool destroyOverlay) {
|
|
if (destroyOverlay && s.ov != vr::k_ulOverlayHandleInvalid) {
|
|
vr::VROverlay()->DestroyOverlay(s.ov);
|
|
s.ov = vr::k_ulOverlayHandleInvalid;
|
|
}
|
|
if (destroyOverlay) { // the surface goes away: its copies too
|
|
s.lowTex.destroy();
|
|
s.coverTex.destroy();
|
|
}
|
|
for (auto &b : s.bufs) releaseBuffer(b);
|
|
}
|
|
|
|
bool ensureOverlay(Surface &s) {
|
|
if (s.ov != vr::k_ulOverlayHandleInvalid) return true;
|
|
const uint64_t now = monoNowNs();
|
|
if (s.createTries && now - s.createNs < 5000000000ull) return false; // retry every 5 s
|
|
s.createNs = now;
|
|
std::string friendly = "Glass Shell glass (" + s.spec.name + ")";
|
|
auto e = vr::VROverlay()->CreateOverlay(s.key.c_str(), friendly.c_str(), &s.ov);
|
|
if (e != vr::VROverlayError_None) {
|
|
if (s.createTries++ == 0)
|
|
std::printf("CreateOverlay(%s): %s (retrying every 5 s)\n", s.key.c_str(), vr::VROverlay()->GetOverlayErrorNameFromEnum(e));
|
|
s.ov = vr::k_ulOverlayHandleInvalid;
|
|
return false;
|
|
}
|
|
s.createTries = 0;
|
|
s.submittedW = s.submittedH = 0;
|
|
vr::VROverlay()->SetOverlayFlag(s.ov, vr::VROverlayFlags_IsPremultiplied, true);
|
|
vr::VROverlay()->SetOverlayWidthInMeters(s.ov, 1.0f);
|
|
std::printf("created overlay %s\n", s.key.c_str());
|
|
return true;
|
|
}
|
|
|
|
// Our overlay is gone or refuses textures (e.g. vrcompositor restarted):
|
|
// drop it and its imports; the next render recreates both. Three losses
|
|
// within 30 s end glassd (exit 75) so the daemon starts a fresh one.
|
|
void loseOverlay(Surface &s, const char *why) {
|
|
const uint64_t now = monoNowNs();
|
|
std::printf("overlay %s lost (%s); recreating it\n", s.key.c_str(), why);
|
|
if (s.ov != vr::k_ulOverlayHandleInvalid) vr::VROverlay()->DestroyOverlay(s.ov);
|
|
s.ov = vr::k_ulOverlayHandleInvalid;
|
|
for (auto &b : s.bufs) releaseBuffer(b);
|
|
for (bool &c : s.cleared) c = false;
|
|
s.next = 0;
|
|
s.last = -1;
|
|
s.rendered = s.announced = false;
|
|
s.submittedW = s.submittedH = 0;
|
|
s.submitFails = 0;
|
|
s.createTries = 0;
|
|
layoutDirty = outPending = true;
|
|
outPendingSinceNs = now;
|
|
// losses within 1 s of each other (all surfaces at once) are one incident
|
|
if (!overlayLosses.empty() && now - overlayLosses.back() < 1000000000ull) return;
|
|
overlayLosses.push_back(now);
|
|
while (!overlayLosses.empty() && now - overlayLosses.front() > 30000000000ull) overlayLosses.pop_front();
|
|
if (overlayLosses.size() >= 3) {
|
|
std::printf("overlays keep failing (%zu losses in 30 s); exiting for a fresh start\n", overlayLosses.size());
|
|
quitWith(kExitTemp);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------ layout
|
|
// Texture = backdrop (bw x bh) on top, slab atlas below. The atlas has its
|
|
// final height from the start (the backdrop's height, 256..1024 rows), so
|
|
// slabs coming and going (focus moves, a sheet opening) never change the
|
|
// texture size, the backdrop UV or the mouse scale while the Steam window
|
|
// keeps its size. Rendering touches only the backdrop and live cells, so
|
|
// the headroom costs memory, not bandwidth.
|
|
static int atlasHeight(int bh) { return roundUp(bh + Atlas::kGutter + std::clamp(bh, 256, 1024), 32); }
|
|
|
|
int wantEyes(const Surface &s) const { return !opt.mono && spec.stereo && s.spec.stereo ? 2 : 1; }
|
|
void resizeTexture(Surface &s, int gw, int gh) {
|
|
const int eyes = wantEyes(s);
|
|
if (s.gw == gw && s.gh == gh && s.eyes == eyes) return;
|
|
const uint64_t now = monoNowNs();
|
|
for (auto &b : s.bufs) {
|
|
if (b.bo) retired.push_back({b, now + 500000000ull});
|
|
b = DmaTarget{};
|
|
}
|
|
s.gw = gw;
|
|
s.gh = gh;
|
|
s.eyes = eyes;
|
|
s.next = 0;
|
|
s.last = -1;
|
|
s.rendered = false;
|
|
for (bool &c : s.cleared) c = false;
|
|
std::printf("layout %s -> %s %dx%d, backdrop %dx%d (scale %.4f)\n", s.spec.name.c_str(), s.key.c_str(), gw, gh, s.bw, s.bh,
|
|
double(s.scale));
|
|
}
|
|
|
|
void dropSlab(Surface &s, const SlabSpec &b, int w, int h, const char *why) {
|
|
if (s.dropped.insert(b.id).second)
|
|
std::printf("slab %s/%s (%dx%d px) %s; it stays flat\n", s.spec.name.c_str(), b.id.c_str(), w, h, why);
|
|
}
|
|
|
|
// Re-packs every live slab plus `extra` into the atlas. Commits (moving
|
|
// live cells: their UVs change, logged) only if everything fits.
|
|
bool repack(Surface &s, SlabSlot *extra) {
|
|
std::vector<SlabSlot *> items;
|
|
for (auto &sl : s.slots) items.push_back(&sl);
|
|
if (extra) items.push_back(extra);
|
|
std::stable_sort(items.begin(), items.end(), [](const SlabSlot *a, const SlabSlot *b) {
|
|
if (a->h != b->h) return a->h > b->h;
|
|
return a->s.id < b->s.id;
|
|
});
|
|
Atlas a;
|
|
a.reset(s.bw, s.bh + Atlas::kGutter, s.gh);
|
|
a.tallHint = s.atlas.tallHint;
|
|
std::vector<std::pair<int, int>> pos;
|
|
for (SlabSlot *it : items) {
|
|
int x, y;
|
|
if (!a.alloc(it->w, it->h, x, y)) return false;
|
|
pos.push_back({x, y});
|
|
}
|
|
for (size_t i = 0; i < items.size(); i++) {
|
|
items[i]->x = pos[i].first;
|
|
items[i]->y = pos[i].second;
|
|
}
|
|
s.atlas = a;
|
|
std::printf("atlas %s re-packed (%zu slabs placed again)\n", s.spec.name.c_str(), items.size());
|
|
return true;
|
|
}
|
|
|
|
bool place(Surface &s, SlabSlot &sl) {
|
|
int x, y;
|
|
if (s.atlas.alloc(sl.w, sl.h, x, y)) {
|
|
sl.x = x;
|
|
sl.y = y;
|
|
return true;
|
|
}
|
|
// reclaim the ghosts' cells early
|
|
if (!s.ghosts.empty()) {
|
|
for (auto &g : s.ghosts) s.atlas.release(g.x, g.y);
|
|
s.ghosts.clear();
|
|
if (s.atlas.alloc(sl.w, sl.h, x, y)) {
|
|
sl.x = x;
|
|
sl.y = y;
|
|
return true;
|
|
}
|
|
}
|
|
// compact (the texture never grows)
|
|
return repack(s, &sl);
|
|
}
|
|
|
|
// Brings the surface's slab cells in line with its spec. Existing cells
|
|
// never move; removed or resized slabs become ghosts for kGhostNs (still
|
|
// drawn, so a scene graph that lags behind shows the right glass).
|
|
// Returns true when the layout (texture size or announced UVs) changed.
|
|
bool syncSlots(Surface &s, uint64_t now) {
|
|
const SurfSpec &sp = s.spec;
|
|
const int bw = std::max(1, int(std::lround(sp.texW * opt.scale)));
|
|
const float scale = float(bw) / float(sp.texW);
|
|
const int bh = std::max(1, int(std::lround(sp.texH * scale)));
|
|
bool changed = false;
|
|
// The window (main) has its atlas from the start; other surfaces get
|
|
// one with their first slab (one texture resize) and keep it. Popups
|
|
// that never pop anything stay backdrop-sized.
|
|
const bool wantAtlas = s.atlasOn || sp.material == "window" || !sp.slabs.empty();
|
|
if (bw != s.bw || bh != s.bh || s.gh == 0 || wantAtlas != s.atlasOn || s.eyes != wantEyes(s)) {
|
|
s.bw = bw;
|
|
s.bh = bh;
|
|
s.scale = scale;
|
|
s.slots.clear();
|
|
s.ghosts.clear();
|
|
s.dropped.clear();
|
|
s.atlasOn = wantAtlas;
|
|
resizeTexture(s, bw, wantAtlas ? atlasHeight(bh) : bh);
|
|
s.atlas.reset(bw, bh + Atlas::kGutter, s.gh);
|
|
changed = true;
|
|
}
|
|
auto cellSize = [&](const SlabSpec &b, int &w, int &h) {
|
|
w = std::max(1, int(std::lround(b.w * s.scale)));
|
|
h = std::max(1, int(std::lround(b.h * s.scale)));
|
|
};
|
|
auto findSpec = [&](const std::string &id) -> const SlabSpec * {
|
|
for (const SlabSpec &b : sp.slabs)
|
|
if (b.id == id) return &b;
|
|
return nullptr;
|
|
};
|
|
// removed or resized -> ghosts; the rest take the new positions
|
|
for (size_t i = 0; i < s.slots.size();) {
|
|
SlabSlot &sl = s.slots[i];
|
|
const SlabSpec *b = findSpec(sl.s.id);
|
|
int w = 0, h = 0;
|
|
if (b) cellSize(*b, w, h);
|
|
if (b && w == sl.w && h == sl.h) {
|
|
if (b->x != sl.s.x || b->y != sl.s.y || b->hasPos != sl.s.hasPos) sl.stillSinceNs = now;
|
|
sl.s = *b;
|
|
setSlabPhase(sl, now);
|
|
i++;
|
|
continue;
|
|
}
|
|
sl.ghostUntilNs = now + kGhostNs;
|
|
s.ghosts.push_back(sl);
|
|
s.slots.erase(s.slots.begin() + long(i));
|
|
changed = true;
|
|
}
|
|
// new slabs, tallest first
|
|
std::vector<const SlabSpec *> fresh;
|
|
for (const SlabSpec &b : sp.slabs) {
|
|
bool have = false;
|
|
for (auto &sl : s.slots) have |= sl.s.id == b.id;
|
|
if (!have) fresh.push_back(&b);
|
|
}
|
|
std::stable_sort(fresh.begin(), fresh.end(), [](const SlabSpec *a, const SlabSpec *b) {
|
|
if (a->h != b->h) return a->h > b->h;
|
|
return a->id < b->id;
|
|
});
|
|
const int maxH = s.gh - (s.bh + Atlas::kGutter);
|
|
for (const SlabSpec *b : fresh) {
|
|
SlabSlot sl;
|
|
sl.s = *b;
|
|
sl.stillSinceNs = now;
|
|
setSlabPhase(sl, now);
|
|
cellSize(*b, sl.w, sl.h);
|
|
// a ghost of the same id and size comes back to life
|
|
bool revived = false;
|
|
for (size_t k = 0; k < s.ghosts.size(); k++) {
|
|
if (s.ghosts[k].s.id == b->id && s.ghosts[k].w == sl.w && s.ghosts[k].h == sl.h) {
|
|
SlabSlot g = s.ghosts[k];
|
|
g.s = *b;
|
|
g.ghostUntilNs = 0;
|
|
g.stillSinceNs = now;
|
|
setSlabPhase(g, now);
|
|
s.ghosts.erase(s.ghosts.begin() + long(k));
|
|
s.slots.push_back(g);
|
|
revived = changed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (revived) continue;
|
|
if (sl.w > s.bw) { dropSlab(s, *b, sl.w, sl.h, "is wider than the texture"); continue; }
|
|
if (sl.h > maxH) { dropSlab(s, *b, sl.w, sl.h, "is taller than the atlas"); continue; }
|
|
if (!place(s, sl)) { dropSlab(s, *b, sl.w, sl.h, "does not fit the atlas"); continue; }
|
|
s.dropped.erase(b->id);
|
|
s.slots.push_back(sl);
|
|
changed = true;
|
|
}
|
|
// forget drop notes of slabs that left the spec
|
|
for (auto it = s.dropped.begin(); it != s.dropped.end();) it = findSpec(*it) ? std::next(it) : s.dropped.erase(it);
|
|
return changed;
|
|
}
|
|
|
|
// Materialize targets: covers and thick slabs (menus, sheets) ride the
|
|
// sheet springs, other slabs the linear 250/350 ms ramps.
|
|
void setSlabPhase(SlabSlot &sl, uint64_t now) {
|
|
sl.anim.set(sl.s.ph.phase, sl.s.material == "thick" || sl.s.dim(), sl.s.ph.ms, sl.s.ph.materialize ? 0.f : -1.f, now);
|
|
}
|
|
// Does this spec draw a cover (README, *Input*: shapes, or a window surface without shapes)?
|
|
static bool specCover(const SurfSpec &sp) { return sp.hasShapes ? !sp.shapes.empty() : sp.material == "window"; }
|
|
static bool sameShapes(const std::vector<ShapeSpec> &a, const std::vector<ShapeSpec> &b) {
|
|
if (a.size() != b.size()) return false;
|
|
for (size_t i = 0; i < a.size(); i++)
|
|
if (std::fabs(a[i].x - b[i].x) > .5f || std::fabs(a[i].y - b[i].y) > .5f || std::fabs(a[i].w - b[i].w) > .5f ||
|
|
std::fabs(a[i].h - b[i].h) > .5f || std::fabs(a[i].r - b[i].r) > .5f)
|
|
return false;
|
|
return true;
|
|
}
|
|
static const lgs_motion::Token *motionToken(const std::string &name) {
|
|
for (const lgs_motion::Token &k : lgs_motion::kTokens)
|
|
if (name == k.name) return &k;
|
|
return nullptr;
|
|
}
|
|
// An epoch time (ms, the reporter's Date.now()) on the monotonic clock; within 2 s of now, else now.
|
|
static uint64_t epochToMono(double ms, uint64_t now) {
|
|
timespec rt;
|
|
clock_gettime(CLOCK_REALTIME, &rt);
|
|
const double d = ms - (double(rt.tv_sec) * 1e3 + double(rt.tv_nsec) / 1e6);
|
|
if (!(std::fabs(d) < 2000.0)) return now;
|
|
return uint64_t(int64_t(now) + int64_t(d * 1e6));
|
|
}
|
|
// Morph progress (0 -> 1, with the spring's overshoot) at `now`.
|
|
float morphProgress(const Surface &s, uint64_t now) const {
|
|
const float ms = now > s.morphT0 ? float(double(now - s.morphT0) / 1e6) : 0.f;
|
|
if (spec.reduceMotion) return std::clamp(ms / lgs_motion::kReduceMs, 0.f, 1.f);
|
|
return lgs_motion::sample(*s.morphTok, ms);
|
|
}
|
|
// The cover's materialize target follows whether it has a cover: on a surface that stays on screen
|
|
// (main between a windowless route and a window route) the window glass materializes in on sheet-in
|
|
// and dematerializes out on sheet-out with the shapes it had, instead of popping. A surface that
|
|
// appears or goes as a whole is at its target at once (a phaseMs of 0 makes any change instant).
|
|
// A spec with "morph" moves the shapes from what is shown toward the new ones (morphProgress).
|
|
void setCoverPhase(Surface &s, const SurfSpec &ns, uint64_t now) {
|
|
const bool init = s.anim.init;
|
|
const bool wasOn = s.spec.visible || s.spec.armed, on = ns.visible || ns.armed;
|
|
const bool wasShown = init && wasOn && specCover(s.spec);
|
|
const bool shown = on && specCover(ns);
|
|
const bool kept = init && wasOn && on;
|
|
std::vector<ShapeSpec> before;
|
|
if (init && wasShown) before = coverShapes(s);
|
|
const std::vector<ShapeSpec> oldTarget = s.spec.shapes;
|
|
const bool wasVisible = init && wasOn;
|
|
s.spec = ns;
|
|
if (kept && wasShown && !shown) s.fadeShapes = before;
|
|
else if (shown || !kept) s.fadeShapes.clear();
|
|
if (!kept) s.anim.init = false;
|
|
if (wasVisible && !on) s.clearPending = true;
|
|
s.anim.set(shown ? ns.ph.phase : 0.f, true, ns.ph.ms, ns.ph.materialize ? 0.f : -1.f, now);
|
|
// appearing with the page's open animation: the ramp runs from when that started. Also on a
|
|
// surface that stays on screen and gains its cover (main: Home -> Library), so the window glass
|
|
// springs in from the route change, not from when the report reached glassd
|
|
if ((!kept || !wasShown) && shown && ns.ph.materialize && ns.ph.atMs > 0) s.anim.t0 = epochToMono(ns.ph.atMs, now);
|
|
if (!(kept && wasShown && shown) || sameShapes(oldTarget, ns.shapes)) {
|
|
if (!(kept && wasShown && shown)) s.morphTok = nullptr;
|
|
return; // nothing moved: a running morph goes on
|
|
}
|
|
const lgs_motion::Token *tok = ns.hasMorph ? motionToken(ns.morphToken) : nullptr;
|
|
if (tok && before.size() == ns.shapes.size() && !before.empty()) {
|
|
s.morphFrom = before;
|
|
s.morphTok = tok;
|
|
s.morphT0 = epochToMono(ns.morphAtMs, now);
|
|
} else {
|
|
s.morphTok = nullptr; // the shapes jump
|
|
}
|
|
}
|
|
// Plates: window and thick plates ride the sheet springs like covers,
|
|
// the others (liquid discs, panel tiles, dim) the linear ramps like slabs.
|
|
static bool plateSprings(const PlateSpec &p) { return p.material == "window" || p.material == "thick"; }
|
|
// Brings the surface's plate states in line with its spec (by id, in spec order).
|
|
void syncPlates(Surface &s, uint64_t now) {
|
|
std::vector<PlateSlot> next;
|
|
next.reserve(s.spec.plates.size());
|
|
for (const PlateSpec &p : s.spec.plates) {
|
|
PlateSlot slot;
|
|
double exitWas = 0;
|
|
for (auto &old : s.plates)
|
|
if (old.s.id == p.id) {
|
|
slot.anim = old.anim;
|
|
slot.exitT0 = old.exitT0;
|
|
exitWas = old.s.exitAtMs;
|
|
break;
|
|
}
|
|
const bool fresh = !slot.anim.init;
|
|
slot.s = p;
|
|
slot.anim.set(p.ph.phase, plateSprings(p), p.ph.ms, p.ph.materialize ? 0.f : -1.f, now);
|
|
// a new plate materializing with the page's own animation: the ramp runs from when that started
|
|
if (fresh && p.ph.materialize && p.ph.atMs > 0) slot.anim.t0 = epochToMono(p.ph.atMs, now);
|
|
// its content fading out: from when that started (kept while the spec repeats the same fade)
|
|
if (p.exitAtMs <= 0) slot.exitT0 = 0;
|
|
else if (!slot.exitT0 || exitWas != p.exitAtMs) slot.exitT0 = epochToMono(p.exitAtMs, now);
|
|
next.push_back(slot);
|
|
}
|
|
s.plates.swap(next);
|
|
}
|
|
// Advances every materialize animation; true while any still moves.
|
|
bool stepPhases(uint64_t now) {
|
|
bool moving = false;
|
|
for (auto &s : surfaces) {
|
|
moving |= s->anim.step(now, spec.reduceMotion);
|
|
if (s->morphTok) {
|
|
const float ms = now > s->morphT0 ? float(double(now - s->morphT0) / 1e6) : 0.f;
|
|
if (ms >= (spec.reduceMotion ? lgs_motion::kReduceMs : s->morphTok->settleMs)) {
|
|
s->morphTok = nullptr;
|
|
s->morphFrom.clear();
|
|
} else {
|
|
moving = true;
|
|
}
|
|
}
|
|
for (auto &sl : s->slots) moving |= sl.anim.step(now, spec.reduceMotion);
|
|
for (auto &sl : s->ghosts) moving |= sl.anim.step(now, spec.reduceMotion);
|
|
for (auto &p : s->plates) {
|
|
moving |= p.anim.step(now, spec.reduceMotion);
|
|
if (p.exitT0 && now < p.exitT0 + uint64_t(double(p.s.exitMs) * 1e6)) moving = true;
|
|
}
|
|
}
|
|
moving |= roomDimAnim.step(now, spec.reduceMotion);
|
|
moving |= stepTune(now);
|
|
return moving;
|
|
}
|
|
// A colour as the glass body's hue: linear rgb at luminance 1 (glass.frag's luma), no channel over 4.
|
|
static void hueLin(const Color &c, float &r, float &g, float &b) {
|
|
r = srgbToLin1(c.r), g = srgbToLin1(c.g), b = srgbToLin1(c.b);
|
|
const float l = std::max(1e-3f, 0.2126f * r + 0.7152f * g + 0.0722f * b);
|
|
r /= l, g /= l, b /= l;
|
|
const float mx = std::max({r, g, b});
|
|
if (mx > 4.f) r *= 4.f / mx, g *= 4.f / mx, b *= 4.f / mx;
|
|
}
|
|
// Eases the drawn dial and tune toward the spec's; true while they still move. The first spec, and any
|
|
// under Reduce Motion, applies at once.
|
|
bool stepTune(uint64_t now) {
|
|
TuneLive &L = tuneLive;
|
|
const Tune &T = spec.tune;
|
|
float hr = L.hr, hg = L.hg, hb = L.hb;
|
|
if (T.hue.set) hueLin(T.hue, hr, hg, hb);
|
|
const float tgt[8] = {std::clamp(spec.dial, 0.f, 1.f), T.refract, T.frost, T.light, T.hue.set ? T.hueK : 0.f, hr, hg, hb};
|
|
float *cur[8] = {&L.dial, &L.refract, &L.frost, &L.light, &L.hueK, &L.hr, &L.hg, &L.hb};
|
|
const float dt = tuneNs && now > tuneNs ? std::min(0.1f, float(double(now - tuneNs) / 1e9)) : 0.f;
|
|
tuneNs = now;
|
|
if (!L.init || spec.reduceMotion) {
|
|
for (int i = 0; i < 8; i++) *cur[i] = tgt[i];
|
|
L.init = true;
|
|
return false;
|
|
}
|
|
const float a = 1.f - std::exp(-dt / kTuneTau);
|
|
bool moving = false;
|
|
for (int i = 0; i < 8; i++) {
|
|
const float d = tgt[i] - *cur[i];
|
|
if (std::fabs(d) < 1e-3f) {
|
|
*cur[i] = tgt[i];
|
|
} else {
|
|
*cur[i] += d * a;
|
|
moving = true;
|
|
}
|
|
}
|
|
return moving;
|
|
}
|
|
float dialNow() const { return tuneLive.init ? tuneLive.dial : std::clamp(spec.dial, 0.f, 1.f); }
|
|
float phaseOf(const PhaseAnim &a) const { return opt.phasePin >= 0 ? std::clamp(opt.phasePin, 0.f, 1.f) : a.init ? a.x : 1.f; }
|
|
// A leaving plate's share of coverage at `now`: 1 -> 0 linearly over its content's fade-out.
|
|
static float exitAlpha(const PlateSlot &p, uint64_t now) {
|
|
if (!p.exitT0 || now <= p.exitT0) return 1.f;
|
|
if (p.s.exitMs <= 0.f) return 0.f;
|
|
return std::clamp(1.f - float(double(now - p.exitT0) / 1e6) / p.s.exitMs, 0.f, 1.f);
|
|
}
|
|
// A plate's materialize ramps with its exit fade folded into coverage and shadow.
|
|
Phased platePhase(const PlateSlot &p, uint64_t now) const {
|
|
Phased ph = phaseMap(phaseOf(p.anim), spec.reduceMotion);
|
|
const float e = exitAlpha(p, now);
|
|
ph.alpha *= e;
|
|
ph.shadow *= e;
|
|
return ph;
|
|
}
|
|
float roomDimNow() const { return roomDimAnim.init ? std::clamp(roomDimAnim.x, 0.f, 0.9f) : 0.f; }
|
|
|
|
void expireGhosts(uint64_t now) {
|
|
for (auto &s : surfaces)
|
|
for (size_t i = 0; i < s->ghosts.size();) {
|
|
if (now >= s->ghosts[i].ghostUntilNs) {
|
|
s->atlas.release(s->ghosts[i].x, s->ghosts[i].y);
|
|
s->ghosts.erase(s->ghosts.begin() + long(i));
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool ensureBuffers(Surface &s) {
|
|
for (auto &b : s.bufs) {
|
|
if (b.bo) continue;
|
|
if (!b.create(gfx, s.gw * s.eyes, s.gh)) return false;
|
|
vr::DmabufAttributes_t a{};
|
|
a.pNext = nullptr;
|
|
a.unWidth = uint32_t(s.gw * s.eyes);
|
|
a.unHeight = uint32_t(s.gh);
|
|
a.unDepth = a.unMipLevels = a.unArrayLayers = a.unSampleCount = 1;
|
|
a.unFormat = GBM_FORMAT_ABGR8888;
|
|
a.ulModifier = DRM_FORMAT_MOD_LINEAR;
|
|
a.unPlaneCount = 1;
|
|
a.plane[0].unOffset = b.offset;
|
|
a.plane[0].unStride = b.stride;
|
|
a.plane[0].nFd = b.fd;
|
|
vr::SharedTextureHandle_t h = 0;
|
|
if (!vr::VRIPCResourceManager()->ImportDmabuf(vr::VRApplication_Overlay, &a, &h) || !h) {
|
|
std::printf("ImportDmabuf failed for %s (%dx%d)\n", s.key.c_str(), s.gw, s.gh);
|
|
b.destroy(gfx);
|
|
return false;
|
|
}
|
|
b.vrHandle = h;
|
|
}
|
|
// The mouse scale is set together with the first texture of this size
|
|
// (renderAll), not here: the scene graph sizes the panel from it.
|
|
return true;
|
|
}
|
|
|
|
void applySpec(const Spec &ns) {
|
|
spec = ns;
|
|
haveSpec = true;
|
|
const uint64_t now = monoNowNs();
|
|
// test runs (dumps): when each spec took effect, for tools/test_phase.sh
|
|
if (!opt.dumpDir.empty()) std::printf("spec seq %lld applied (t=%.4f)\n", ns.seq, double(now) / 1e9);
|
|
// v3 G7: the room dim rides sheet-in / sheet-out (a first spec applies it at once)
|
|
roomDimAnim.set(ns.roomDim, true, -1, -1, now);
|
|
// drop surfaces that left the spec
|
|
for (size_t i = 0; i < surfaces.size();) {
|
|
bool keep = false;
|
|
for (auto &s : ns.surfaces) keep |= s.name == surfaces[i]->spec.name;
|
|
if (!keep) {
|
|
std::printf("surface %s removed\n", surfaces[i]->spec.name.c_str());
|
|
releaseSurface(*surfaces[i], true);
|
|
surfaces.erase(surfaces.begin() + long(i));
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
for (const SurfSpec &ss : ns.surfaces) {
|
|
Surface *s = nullptr;
|
|
for (auto &e : surfaces)
|
|
if (e->spec.name == ss.name) s = e.get();
|
|
if (!s) {
|
|
surfaces.push_back(std::make_unique<Surface>());
|
|
s = surfaces.back().get();
|
|
s->key = opt.keyPrefix + sanitizeKey(ss.name);
|
|
}
|
|
if (s->spec.overlayKey != ss.overlayKey) {
|
|
s->steam = vr::k_ulOverlayHandleInvalid;
|
|
s->findNs = 0;
|
|
s->geo = s->rawGeo = Geometry{};
|
|
s->geoOk = false;
|
|
}
|
|
if (s->spec.hasShapes != ss.hasShapes || s->spec.material != ss.material || s->spec.shapes.size() != ss.shapes.size())
|
|
s->warnedShapes = false;
|
|
if (!ss.droppedPlates.empty() && ss.droppedPlates != s->spec.droppedPlates)
|
|
std::printf("surface %s: %zu plates not drawn (more than %d, or outside the texture)\n", ss.name.c_str(),
|
|
ss.droppedPlatesN, kMaxPlates);
|
|
setCoverPhase(*s, ss, now); // sets s->spec
|
|
syncPlates(*s, now);
|
|
syncSlots(*s, now);
|
|
ensureOverlay(*s);
|
|
ensureBuffers(*s);
|
|
}
|
|
// The new layout is announced after the next (priming) render, so the
|
|
// scene graph never points at buffers that hold nothing yet.
|
|
layoutDirty = true;
|
|
roomDirty = true;
|
|
outPending = true;
|
|
outPendingSinceNs = now;
|
|
}
|
|
|
|
void checkSpec() {
|
|
if (opt.demo) return;
|
|
struct stat st {};
|
|
if (stat(opt.spec.c_str(), &st) != 0) return;
|
|
if (haveSpec && st.st_mtim.tv_sec == specStat.st_mtim.tv_sec && st.st_mtim.tv_nsec == specStat.st_mtim.tv_nsec &&
|
|
st.st_size == specStat.st_size && st.st_ino == specStat.st_ino)
|
|
return;
|
|
std::string text, err;
|
|
if (!readFile(opt.spec, text)) return;
|
|
Spec ns;
|
|
if (!parseSpec(text, ns, err)) {
|
|
if (err != specError) std::printf("%s: %s (keeping the previous spec)\n", opt.spec.c_str(), err.c_str());
|
|
specError = err;
|
|
return; // retried on the next check (a writer may be mid-write)
|
|
}
|
|
specError.clear();
|
|
specStat = st;
|
|
applySpec(ns);
|
|
}
|
|
|
|
// ---------------------------------------------------------- geometry
|
|
bool fetchGeometry(vr::VROverlayHandle_t h, float texW, float texH, Geometry &g) {
|
|
vr::HmdVector2_t ms{};
|
|
if (vr::VROverlay()->GetOverlayMouseScale(h, &ms) != vr::VROverlayError_None) return false;
|
|
const float mw = ms.v[0] > 1 ? ms.v[0] : texW, mh = ms.v[1] > 1 ? ms.v[1] : texH;
|
|
vr::HmdMatrix34_t a{}, b{}, c{};
|
|
auto *ov = vr::VROverlay();
|
|
if (ov->GetTransformForOverlayCoordinates(h, vr::TrackingUniverseStanding, {0.f, 0.f}, &a) != vr::VROverlayError_None ||
|
|
ov->GetTransformForOverlayCoordinates(h, vr::TrackingUniverseStanding, {mw, 0.f}, &b) != vr::VROverlayError_None ||
|
|
ov->GetTransformForOverlayCoordinates(h, vr::TrackingUniverseStanding, {0.f, mh}, &c) != vr::VROverlayError_None)
|
|
return false;
|
|
// origin bottom-left, y up: a = bottom-left, b = bottom-right, c = top-left
|
|
v3 BL{a.m[0][3], a.m[1][3], a.m[2][3]}, BR{b.m[0][3], b.m[1][3], b.m[2][3]}, TL{c.m[0][3], c.m[1][3], c.m[2][3]};
|
|
g.O = TL;
|
|
g.U = (BR - BL) * (1.f / texW);
|
|
g.V = (BL - TL) * (1.f / texH);
|
|
g.mpp = length(g.U);
|
|
if (!std::isfinite(g.mpp) || g.mpp < 1e-6f || length(g.V) < 1e-6f || g.mpp > 0.05f) return false;
|
|
g.N = normalize(cross(g.V, g.U));
|
|
g.valid = true;
|
|
g.fallback = false;
|
|
return true;
|
|
}
|
|
|
|
// Why a quad cannot be where Steam's panel shows, or nullptr. An overlay
|
|
// without a world placement reports overlay-local coordinates (a quad at
|
|
// the origin), and a hidden one can sit anywhere.
|
|
const char *implausible(const Geometry &g, float texW, float texH, const Pose &head) const {
|
|
const v3 mid = g.O + g.U * (texW * 0.5f) + g.V * (texH * 0.5f);
|
|
if (length(mid) < 0.05f) return "quad at the origin";
|
|
if (!haveLastHead) return nullptr;
|
|
const v3 toHead = head.t - mid;
|
|
const float dist = length(toHead);
|
|
if (dist < 0.3f || dist > 5.f) return "quad not 0.3-5 m from the head";
|
|
if (std::fabs(dot(normalize(toHead), normalize(cross(g.V, g.U)))) < 0.2f) return "quad edge-on to the head";
|
|
return nullptr;
|
|
}
|
|
|
|
// In front of the head at 1.43 m (the measured dashboard distance); used
|
|
// when Steam's overlay can't be read, e.g. for headless dumps.
|
|
Geometry fallbackGeometry(const Surface &s, const Pose &head) {
|
|
Geometry g;
|
|
v3 fwd = head.rotate({0, 0, -1});
|
|
fwd.y = 0;
|
|
fwd = length(fwd) > 1e-3f ? normalize(fwd) : v3{0, 0, -1};
|
|
v3 right = normalize(cross(fwd, {0, 1, 0}));
|
|
const float mpp = 0.98f / 1920.f;
|
|
v3 centre = head.t + fwd * 1.43f;
|
|
g.U = right * mpp;
|
|
g.V = v3{0, -1, 0} * mpp;
|
|
g.O = centre - g.U * (s.spec.texW * 0.5f) - g.V * (s.spec.texH * 0.5f);
|
|
g.N = -fwd;
|
|
g.mpp = mpp;
|
|
g.valid = true;
|
|
g.fallback = true;
|
|
return g;
|
|
}
|
|
|
|
// Steam's popups (bar, footer, ...) report overlay transforms whose scale
|
|
// disagrees with where the scene graph draws them: the bar claims
|
|
// 0.2 mm/px against main's 0.51, although its scene-graph panel uses the
|
|
// same metres-per-pixel as the window. Keep the reported centre and
|
|
// orientation, but use the main window's scale. Returns the ratio found.
|
|
float rescaleToMain(Geometry &g, float texW, float texH) const {
|
|
if (mainMpp <= 0) return 1.f;
|
|
const float ratio = g.mpp / mainMpp;
|
|
if (ratio > 0.92f && ratio < 1.087f) return 1.f;
|
|
const v3 mid = g.O + g.U * (texW * 0.5f) + g.V * (texH * 0.5f);
|
|
g.U = normalize(g.U) * mainMpp;
|
|
g.V = normalize(g.V) * mainMpp;
|
|
g.O = mid - g.U * (texW * 0.5f) - g.V * (texH * 0.5f);
|
|
g.mpp = mainMpp;
|
|
return ratio;
|
|
}
|
|
|
|
void faceHead(Geometry &g, float texW, float texH, const Pose &head) {
|
|
const v3 mid = g.O + g.U * (texW * 0.5f) + g.V * (texH * 0.5f);
|
|
if (dot(g.N, head.t - mid) < 0) g.N = -g.N;
|
|
}
|
|
|
|
// Brings s.geo up to date. With fetch = false (priming renders while the
|
|
// dashboard is hidden) it never asks SteamVR and only fills a fallback.
|
|
void refreshGeometry(Surface &s, const Pose &head, uint64_t now, bool fetch) {
|
|
const float tw = float(s.spec.texW), th = float(s.spec.texH);
|
|
if (s.spec.hasQuad) {
|
|
Geometry g;
|
|
g.O = s.spec.qO;
|
|
g.U = s.spec.qU;
|
|
g.V = s.spec.qV;
|
|
g.mpp = length(g.U);
|
|
g.N = normalize(cross(g.V, g.U));
|
|
g.valid = g.mpp > 1e-6f && length(g.V) > 1e-6f;
|
|
if (g.valid) {
|
|
s.geo = s.rawGeo = g;
|
|
s.geoOk = true;
|
|
s.rescaled = false;
|
|
s.geoOkNs = now;
|
|
s.geoNote = "spec";
|
|
faceHead(s.geo, tw, th, head);
|
|
return;
|
|
}
|
|
}
|
|
if (opt.testPattern >= 0) {
|
|
// Test backdrop: the window 1.43 m ahead of the fixed test head,
|
|
// at the window's nominal 0.98 m width (moving the eye with
|
|
// --test-head does not move the window).
|
|
Geometry g;
|
|
const float mpp = 0.98f / 1920.f;
|
|
g.U = {mpp, 0, 0};
|
|
g.V = {0, -mpp, 0};
|
|
g.N = {0, 0, 1};
|
|
g.O = kTestEye + v3{0, 0, -1.43f} - g.U * (tw * 0.5f) - g.V * (th * 0.5f);
|
|
g.mpp = mpp;
|
|
g.valid = true;
|
|
s.geo = s.rawGeo = g;
|
|
s.geoOk = true;
|
|
s.rescaled = false;
|
|
s.geoOkNs = now;
|
|
s.geoNote = "test";
|
|
if (s.spec.overlayKey == kMainKey) mainMpp = mpp;
|
|
return;
|
|
}
|
|
if (!fetch) {
|
|
if (!s.geo.valid) {
|
|
s.geo = fallbackGeometry(s, head);
|
|
s.geoNote = "fallback(not fetched)";
|
|
}
|
|
faceHead(s.geo, tw, th, head);
|
|
return;
|
|
}
|
|
// (Re)find Steam's overlay: every 1 s while unknown, and every 2 s
|
|
// re-validate the handle (Steam's overlays come back with new handles
|
|
// after a Steam restart).
|
|
if (!s.spec.overlayKey.empty() && now - s.findNs > (s.steam == vr::k_ulOverlayHandleInvalid ? 1000000000ull : 2000000000ull)) {
|
|
s.findNs = now;
|
|
vr::VROverlayHandle_t h = vr::k_ulOverlayHandleInvalid;
|
|
if (vr::VROverlay()->FindOverlay(s.spec.overlayKey.c_str(), &h) != vr::VROverlayError_None) h = vr::k_ulOverlayHandleInvalid;
|
|
if (h != s.steam && s.steam != vr::k_ulOverlayHandleInvalid && h != vr::k_ulOverlayHandleInvalid)
|
|
std::printf("%s: Steam overlay %s has a new handle\n", s.spec.name.c_str(), s.spec.overlayKey.c_str());
|
|
s.steam = h;
|
|
}
|
|
Geometry g;
|
|
const char *why = nullptr;
|
|
if (s.spec.overlayKey.empty()) why = "no overlay key";
|
|
else if (s.steam == vr::k_ulOverlayHandleInvalid) why = "no overlay";
|
|
else if (!fetchGeometry(s.steam, tw, th, g)) {
|
|
why = "no transform";
|
|
// a stale handle errors out: find it again (rate-limited above)
|
|
s.steam = vr::k_ulOverlayHandleInvalid;
|
|
} else {
|
|
why = implausible(g, tw, th, head); // the handle is fine, the overlay is not placed
|
|
}
|
|
if (!why) {
|
|
const bool isMain = s.spec.overlayKey == kMainKey;
|
|
if (isMain) mainMpp = g.mpp;
|
|
s.rawGeo = g;
|
|
const float ratio = isMain || s.spec.scaleFromOverlay ? 1.f : rescaleToMain(g, tw, th);
|
|
s.rescaled = ratio != 1.f;
|
|
s.geo = g;
|
|
s.geoOk = true;
|
|
s.geoOkNs = now;
|
|
if (s.rescaled) {
|
|
char b[64];
|
|
std::snprintf(b, sizeof b, "steam(rescaled x%.2f)", double(1.f / ratio));
|
|
s.geoNote = b;
|
|
} else {
|
|
s.geoNote = "steam";
|
|
}
|
|
} else {
|
|
s.geoOk = false;
|
|
if (s.geo.valid && !s.geo.fallback) {
|
|
s.geoNote = std::string("stale(") + why + ")";
|
|
} else {
|
|
s.geo = fallbackGeometry(s, head);
|
|
s.geoNote = std::string("fallback(") + why + ")";
|
|
}
|
|
}
|
|
if (s.geoNote != s.geoLogged) {
|
|
const v3 mid = s.geo.O + s.geo.U * (tw * 0.5f) + s.geo.V * (th * 0.5f);
|
|
std::printf("geometry %s: %s (centre %.2f %.2f %.2f, %.2f x %.2f m)\n", s.spec.name.c_str(), s.geoNote.c_str(), mid.x,
|
|
mid.y, mid.z, length(s.geo.U) * tw, length(s.geo.V) * th);
|
|
s.geoLogged = s.geoNote;
|
|
}
|
|
faceHead(s.geo, tw, th, head);
|
|
}
|
|
|
|
// A surface quad as a feed mask; zone = extra metres beyond the margin:
|
|
// side, top, bottom.
|
|
// Returns false when the quad did not fit (kMaxMasks): the map must not
|
|
// integrate then, or it would take in UI.
|
|
static bool addMask(std::vector<MaskQuad> &out, const Geometry &g, float texW, float texH, const float zone[3], float mg) {
|
|
if (!g.valid || g.fallback) return true;
|
|
if (out.size() >= size_t(kMaxMasks)) return false;
|
|
MaskQuad m;
|
|
float lu = length(g.U), lv = length(g.V);
|
|
m.O = g.O;
|
|
m.U = g.U * (1.f / lu);
|
|
m.V = g.V * (1.f / lv);
|
|
m.u0 = -mg - zone[0];
|
|
m.u1 = texW * lu + mg + zone[0];
|
|
m.v0 = -mg - zone[1];
|
|
m.v1 = texH * lv + mg + zone[2];
|
|
out.push_back(m);
|
|
return true;
|
|
}
|
|
// v3 extra masks: a rect of a surface (Steam px, at dz units toward the
|
|
// viewer), or a world quad (corner plus full edge vectors).
|
|
static bool addRectMask(std::vector<MaskQuad> &out, const Geometry &g, const MaskSpec &k, float dzM, float mg) {
|
|
if (!g.valid || g.fallback) return true;
|
|
Geometry r = g;
|
|
r.O = g.O + g.U * k.x + g.V * k.y + g.N * dzM;
|
|
const float none[3] = {0, 0, 0};
|
|
return addMask(out, r, k.w, k.h, none, mg);
|
|
}
|
|
static bool addWorldMask(std::vector<MaskQuad> &out, const WorldMask &w, float mg) {
|
|
Geometry g;
|
|
g.valid = true;
|
|
g.O = w.O;
|
|
g.U = w.U;
|
|
g.V = w.V;
|
|
const float none[3] = {0, 0, 0};
|
|
return addMask(out, g, 1.f, 1.f, none, mg);
|
|
}
|
|
|
|
// --test-backdrop: fill the room map with the procedural room once the
|
|
// spec is known; room-hole leaves what the UI would hide from the feed
|
|
// unknown (the same masks as live, seen from the map centre).
|
|
void makeTestRoom(const Pose &head, uint64_t now) {
|
|
std::vector<MaskQuad> hole;
|
|
const float none[3] = {0, 0, 0};
|
|
for (auto &s : surfaces) {
|
|
if (!s->spec.visible) continue;
|
|
refreshGeometry(*s, head, now, false);
|
|
const bool isMain = s->spec.overlayKey == kMainKey;
|
|
addMask(hole, s->geo, float(s->spec.texW), float(s->spec.texH), isMain ? opt.zone : none, opt.margin);
|
|
for (const MaskSpec &k : s->spec.masks) addRectMask(hole, s->geo, k, k.dz * spec.unitM, opt.margin);
|
|
}
|
|
for (const WorldMask &w : spec.masks) addWorldMask(hole, w, opt.margin);
|
|
room.generate(progTest, opt.testPattern, opt.testHole, hole);
|
|
float known = 0, lum = 0;
|
|
room.stats(known, lum);
|
|
envLuma = std::max(0.06f, lum);
|
|
envSet = true;
|
|
std::printf("test room %s%s: %.0f%% known, mean luma %.2f\n", opt.testPattern == 1 ? "stripes" : "room",
|
|
opt.testHole ? " (hole behind the UI)" : "", double(known * 100), double(lum));
|
|
testRoomDone = true;
|
|
roomDirty = true;
|
|
if (!firstRoomNs) firstRoomNs = now;
|
|
}
|
|
|
|
// Steam surfaces as seen by the feed, from geometry fetched now. Only while
|
|
// the dashboard shows them. masksComplete says whether every visible spec
|
|
// surface got a mask; the room map does not integrate while it is false.
|
|
void updateMasks(bool dash, const Pose &head, uint64_t now) {
|
|
if (now - masksNs < kMaskPeriodNs) return;
|
|
masksNs = now;
|
|
masks.clear();
|
|
masksBuiltNs = now;
|
|
masksComplete = true;
|
|
if (!dash || opt.noMask) return;
|
|
bool fits = true;
|
|
auto add = [&](const Geometry &g, float texW, float texH, const float zone[3], float marginScale) {
|
|
fits &= addMask(masks, g, texW, texH, zone, opt.margin * marginScale);
|
|
};
|
|
// The main window gets a wider "dashboard zone": SteamVR's own panels
|
|
// (grab bar, frame controls, frame menus, side panels) sit around it and
|
|
// glassd cannot see them. A rescaled popup is masked at both its
|
|
// reported and its rescaled quad, with twice the margin.
|
|
const float none[3] = {0, 0, 0};
|
|
auto addSurface = [&](const Geometry &g, const Geometry &raw, bool rescaled, const std::string &key, float tw, float th) {
|
|
const bool isMain = key == kMainKey;
|
|
add(g, tw, th, isMain ? opt.zone : none, rescaled ? 2.f : 1.f);
|
|
if (rescaled) add(raw, tw, th, none, 2.f);
|
|
};
|
|
std::vector<std::string> done;
|
|
int missing = 0;
|
|
// main first, so popups rescale to this moment's main scale
|
|
std::vector<Surface *> order;
|
|
for (auto &s : surfaces)
|
|
if (s->spec.overlayKey == kMainKey) order.insert(order.begin(), s.get());
|
|
else order.push_back(s.get());
|
|
for (Surface *s : order) {
|
|
if (!s->spec.visible) continue;
|
|
refreshGeometry(*s, head, now, true);
|
|
if (!s->geoOk) {
|
|
missing++;
|
|
continue;
|
|
}
|
|
addSurface(s->geo, s->rawGeo, s->rescaled, s->spec.overlayKey, float(s->spec.texW), float(s->spec.texH));
|
|
done.push_back(s->spec.overlayKey);
|
|
}
|
|
// Steam's fixed surfaces, if visible and not masked above.
|
|
static const char *kSteam[] = {kMainKey, "valve.steam.gamepadui.bar", "valve.steam.gamepadui.floatingfooter",
|
|
"valve.steam.gamepadui.keyboard", "valve.steam.gamepadui.notifications",
|
|
"valve.steam.gamepadui.volumelevel"};
|
|
for (const char *k : kSteam) {
|
|
if (std::find(done.begin(), done.end(), k) != done.end()) continue;
|
|
vr::VROverlayHandle_t h;
|
|
if (vr::VROverlay()->FindOverlay(k, &h) != vr::VROverlayError_None || !vr::VROverlay()->IsOverlayVisible(h)) continue;
|
|
vr::HmdVector2_t ms{};
|
|
vr::VROverlay()->GetOverlayMouseScale(h, &ms);
|
|
if (ms.v[0] < 2 || ms.v[1] < 2) continue;
|
|
Geometry g;
|
|
if (!fetchGeometry(h, ms.v[0], ms.v[1], g) || implausible(g, ms.v[0], ms.v[1], head)) continue;
|
|
const bool isMain = std::strcmp(k, kMainKey) == 0;
|
|
if (isMain) mainMpp = g.mpp;
|
|
Geometry raw = g;
|
|
const bool rescaled = !isMain && rescaleToMain(g, ms.v[0], ms.v[1]) != 1.f;
|
|
addSurface(g, raw, rescaled, k, ms.v[0], ms.v[1]);
|
|
done.push_back(k);
|
|
// a spec surface that failed above but is covered here is fine
|
|
for (Surface *s : order)
|
|
if (s->spec.visible && !s->geoOk && s->spec.overlayKey == k) missing--;
|
|
}
|
|
// v3 extra masks: rects of the spec surfaces (ornaments beside the
|
|
// window), then world quads
|
|
for (Surface *s : order) {
|
|
if (!s->spec.visible || !s->geoOk) continue;
|
|
for (const MaskSpec &k : s->spec.masks) fits &= addRectMask(masks, s->geo, k, k.dz * spec.unitM, opt.margin);
|
|
}
|
|
for (const WorldMask &w : spec.masks) fits &= addWorldMask(masks, w, opt.margin);
|
|
if (!fits && !masksOverflowLogged) {
|
|
std::printf("more than %d feed mask quads; the room map waits until they fit\n", kMaxMasks);
|
|
masksOverflowLogged = true;
|
|
}
|
|
masksComplete = missing <= 0 && fits;
|
|
}
|
|
|
|
// -------------------------------------------------------------- poses
|
|
bool samplePose(float predictS, Pose &out) {
|
|
vr::TrackedDevicePose_t p[1];
|
|
vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, predictS, p, 1);
|
|
if (!p[0].bPoseIsValid) return false;
|
|
out = fromVR(p[0].mDeviceToAbsoluteTracking);
|
|
return true;
|
|
}
|
|
bool poseAt(uint64_t ns, Pose &out) {
|
|
if (hist.empty()) return false;
|
|
if (ns <= hist.front().first) { out = hist.front().second; return true; }
|
|
if (ns >= hist.back().first) { out = hist.back().second; return true; }
|
|
for (size_t i = 1; i < hist.size(); i++) {
|
|
if (hist[i].first >= ns) {
|
|
const auto &a = hist[i - 1], &b = hist[i];
|
|
float t = float(double(ns - a.first) / double(std::max<uint64_t>(1, b.first - a.first)));
|
|
out = poseLerp(a.second, b.second, t);
|
|
return true;
|
|
}
|
|
}
|
|
out = hist.back().second;
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------- room map
|
|
void updateRoom(bool dash, uint64_t now) {
|
|
if (!feed.latest(frame, haveSeq)) return;
|
|
haveSeq = frame.seq;
|
|
envLuma = envSet ? envLuma + (std::max(0.06f, frame.meanLuma) - envLuma) * 0.1f : std::max(0.06f, frame.meanLuma);
|
|
envSet = true;
|
|
// Not around a dashboard change (the UI fades in or out and SteamVR
|
|
// places the window again), and while the dashboard shows only with
|
|
// masks built from geometry fetched after that.
|
|
const uint64_t settled = dashChangeNs + kGraceNs;
|
|
if (now < settled || frame.recvNs < settled ||
|
|
(dash && !opt.noMask && !(masksComplete && masksBuiltNs >= settled))) {
|
|
roomSkipped++;
|
|
return;
|
|
}
|
|
Pose hmd;
|
|
const uint64_t target = frame.recvNs - uint64_t(cal.latencyMs * 1e6);
|
|
if (!poseAt(target, hmd)) return;
|
|
const uint64_t t0 = monoNowNs();
|
|
roomTimer.begin();
|
|
// While streaming, frames come 30 times a second: blend slowly. While
|
|
// the dashboard is hidden they are sparse shots: each one counts more,
|
|
// so a changed room (lights on) is caught within a few shots.
|
|
// while the glass shows, a new frame carries most of the texel: at 0.22 a moving hand or a change behind
|
|
// the glass took ~200 ms to come through, smeared (the background lagged); 0.45 still averages sensor noise
|
|
room.ema = dash ? 0.45f : 0.6f;
|
|
room.uploadFeed(frame);
|
|
room.integrate(hmd * eyeToHead, cal, masks);
|
|
room.fill();
|
|
roomTimer.end();
|
|
roomCpuMs = float(double(monoNowNs() - t0) / 1e6);
|
|
roomDirty = true;
|
|
roomWindow++;
|
|
if (!firstRoomNs) firstRoomNs = now;
|
|
}
|
|
|
|
// ------------------------------------------------------------ render
|
|
// Metres per Steam texture pixel: the main window's, as SteamVR reports it.
|
|
float designMpp() const { return mainMpp > 0 ? mainMpp : 0.98f / 1920.f; }
|
|
|
|
// The cover's shapes in Steam px; empty = no cover.
|
|
std::vector<ShapeSpec> coverShapes(Surface &s) {
|
|
std::vector<ShapeSpec> v;
|
|
if (s.spec.hasShapes) {
|
|
v = s.spec.shapes;
|
|
if (v.size() > size_t(kMaxShapes)) {
|
|
if (!s.warnedShapes) std::printf("surface %s: %zu cover shapes, drawing the first %d\n", s.spec.name.c_str(), v.size(), kMaxShapes);
|
|
s.warnedShapes = true;
|
|
v.resize(size_t(kMaxShapes));
|
|
}
|
|
} else if (s.spec.material == "window") {
|
|
v.push_back({0, 0, float(s.spec.texW), float(s.spec.texH), s.spec.radius});
|
|
} else if (!s.warnedShapes) {
|
|
std::printf("surface %s (material %s) came without cover shapes; drawing no cover\n", s.spec.name.c_str(),
|
|
s.spec.material.c_str());
|
|
s.warnedShapes = true;
|
|
}
|
|
// a cover that went away dematerializes with the shapes it had
|
|
if (v.empty() && !s.fadeShapes.empty()) {
|
|
if (phaseOf(s.anim) > 0.002f) return s.fadeShapes;
|
|
s.fadeShapes.clear();
|
|
}
|
|
// a morph: from the shapes shown when it began toward the spec's
|
|
if (s.morphTok && s.morphFrom.size() == v.size()) {
|
|
const float t = morphProgress(s, monoNowNs());
|
|
for (size_t i = 0; i < v.size(); i++) {
|
|
const ShapeSpec &a = s.morphFrom[i], &b = v[i];
|
|
v[i] = {a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, std::max(1.f, a.w + (b.w - a.w) * t),
|
|
std::max(1.f, a.h + (b.h - a.h) * t), std::max(0.f, a.r + (b.r - a.r) * t)};
|
|
}
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// One piece of glass: the union of `shapes` (region-local Steam px) in the
|
|
// region (rx, ry, rw, rh) of the current target. m is already sized
|
|
// (materialFor), ph the materialize ramps. under: a slab that sees the
|
|
// cover copy behind it. casters: slab shadows and holes to draw on it.
|
|
// dz in metres.
|
|
void drawGlass(Surface &s, const Geometry &g, const Pose &head, const Material &m, const Phased &ph, int rx, int ry, int rw,
|
|
int rh, v3 elem, const std::vector<ShapeSpec> &shapes, float dz, bool under, const std::vector<Caster> &casters,
|
|
int pass = 0, float scale = 0.f, const Look &look = Look{}) {
|
|
if (shapes.empty()) return;
|
|
Program &p = progGlass;
|
|
glViewport(rx, ry, rw, rh);
|
|
p.set("uOrigin", float(rx), float(ry));
|
|
p.set("uScale", scale > 0 ? scale : s.scale);
|
|
p.set("uPass", pass);
|
|
p.set("uElemOff", elem.x, elem.y);
|
|
const int n = std::min(int(shapes.size()), kMaxShapes);
|
|
float sh[4 * kMaxShapes] = {}, rads[kMaxShapes] = {};
|
|
float x0 = 1e9f, y0 = 1e9f, x1 = -1e9f, y1 = -1e9f, minHalf = 1e9f;
|
|
for (int i = 0; i < n; i++) {
|
|
const ShapeSpec &q = shapes[size_t(i)];
|
|
sh[i * 4] = q.x + q.w * 0.5f;
|
|
sh[i * 4 + 1] = q.y + q.h * 0.5f;
|
|
sh[i * 4 + 2] = q.w * 0.5f;
|
|
sh[i * 4 + 3] = q.h * 0.5f;
|
|
rads[i] = std::min(q.r, 0.5f * std::min(q.w, q.h));
|
|
x0 = std::min(x0, q.x);
|
|
y0 = std::min(y0, q.y);
|
|
x1 = std::max(x1, q.x + q.w);
|
|
y1 = std::max(y1, q.y + q.h);
|
|
minHalf = std::min(minHalf, 0.5f * std::min(q.w, q.h));
|
|
}
|
|
glUniform1i(p.loc("uNS"), n);
|
|
glUniform4fv(p.loc("uShapes"), n, sh);
|
|
glUniform1fv(p.loc("uRads"), n, rads);
|
|
p.set("uShapeC", 0.5f * (x0 + x1), 0.5f * (y0 + y1));
|
|
p.set("uHalf", 0.5f * (x1 - x0), 0.5f * (y1 - y0));
|
|
p.set("uO", g.O.x, g.O.y, g.O.z);
|
|
p.set("uU", g.U.x, g.U.y, g.U.z);
|
|
p.set("uV", g.V.x, g.V.y, g.V.z);
|
|
p.set("uN", g.N.x, g.N.y, g.N.z);
|
|
p.set("uDz", dz);
|
|
p.set("uCoverDz", s.spec.coverDz * spec.unitM);
|
|
p.set("uEye", head.t.x, head.t.y, head.t.z);
|
|
// Material widths are converted with one design scale for all surfaces:
|
|
// Steam's panels share a metres-per-pixel in the scene graph.
|
|
const float mpp = designMpp();
|
|
p.set("uBezel", std::max(1.f, std::min(m.bezelM / mpp, minHalf)));
|
|
p.set("uThick", m.thick);
|
|
p.set("uLensThick", m.lensThick);
|
|
// the wearer's tune (eased): refraction, frost (at most mip 6: 6.5 is the coarse tone read), light,
|
|
// and the body colour, which also deepens the tint a little so coloured glass reads as coloured
|
|
const TuneLive &tu = tuneLive;
|
|
const float light = look.occluder ? 1.f : tu.light;
|
|
p.set("uLens", m.lensDeg * tu.refract * ph.lens * 0.0174533f);
|
|
p.set("uDisp", m.disp * tu.refract);
|
|
p.set("uFrost", std::min(6.f, m.frost * tu.frost) * ph.frost);
|
|
p.set("uEdgeFrost", std::min(6.f, m.edgeFrost * tu.frost) * ph.frost);
|
|
p.set("uEdgeClear", m.edgeClear);
|
|
p.set("uTintA", std::min(0.95f, m.tintA + 0.2f * tu.hueK) * ph.tint);
|
|
p.set("uHue", tu.hr, tu.hg, tu.hb, tu.hueK);
|
|
p.set("uBandMid", 0.314f); // L 80 of 255: the middle of text-bearing glass (DESIGN2 §6.3)
|
|
p.set("uBandK", 1.f + (m.bandK - 1.f) * ph.tint);
|
|
// v3 G7: the room dim folds into the backdrop dimming of glass that sees the room; a slab
|
|
// over its cover sees the cover, which is dimmed already
|
|
p.set("uDim", 1.f - (1.f - m.dim * ph.tint) * (1.f - (under ? 0.f : roomDimNow())));
|
|
p.set("uSpec", m.spec * light * ph.light);
|
|
p.set("uGloss", m.gloss);
|
|
p.set("uFill", m.fill);
|
|
p.set("uFres", m.fres * light * ph.light);
|
|
p.set("uSheen", m.sheen * light * ph.tint);
|
|
p.set("uDark", m.dark * ph.shade);
|
|
p.set("uDarkW", std::max(2.f, m.darkW / mpp));
|
|
p.set("uOcc", m.occ * ph.shade);
|
|
const float contact = look.occluder ? 0.f : look.shadow >= 0 ? look.shadow : m.shadow;
|
|
p.set("uShadow", contact * ph.shadow);
|
|
p.set("uShadowW", 0.006f / mpp);
|
|
p.set("uShadowY", 0.003f / mpp);
|
|
p.set("uAlpha", ph.alpha);
|
|
p.set("uUnder", under ? 1 : 0);
|
|
// v3 looks
|
|
if (look.tint.set && look.tint.a > 0)
|
|
p.set("uTintC", srgbToLin1(look.tint.r), srgbToLin1(look.tint.g), srgbToLin1(look.tint.b), std::min(0.9f, look.tint.a) * ph.tint); // never fully opaque paint: the glass stays glass
|
|
else
|
|
p.set("uTintC", 0.f, 0.f, 0.f, 0.f);
|
|
const float fa = look.fill.set ? look.fill.a * (look.flat ? 1.f : ph.tint) : look.flat ? 0.30f : 0.f;
|
|
p.set("uFillC", look.fill.set ? look.fill.r : 0.f, look.fill.set ? look.fill.g : 0.f, look.fill.set ? look.fill.b : 0.f, fa);
|
|
p.set("uOccl", look.occluder ? 1 : 0);
|
|
p.set("uFlat", look.flat ? 1 : 0);
|
|
p.set("uFeather", 8.f);
|
|
if (under) {
|
|
// room-map mip -> cover mip: a map texel (0.35 deg) over a cover texel's angle
|
|
const float lowScale = s.scale / 4.f;
|
|
const v3 mid = g.O + g.U * (s.spec.texW * 0.5f) + g.V * (s.spec.texH * 0.5f);
|
|
const float dist = std::max(0.3f, length(mid - head.t));
|
|
const float coverDeg = mpp / lowScale / dist * 57.2958f;
|
|
p.set("uCoverLod", std::log2((360.f / float(room.W)) / coverDeg));
|
|
p.set("uCoverSize", float(s.lowTex.w), float(s.lowTex.h));
|
|
p.set("uCoverScale", lowScale);
|
|
p.set("uCoverTaps", coverTaps);
|
|
p.set("uSteamSize", float(s.spec.texW), float(s.spec.texH));
|
|
}
|
|
const int nc = std::min<int>(int(casters.size()), kMaxCasters);
|
|
glUniform1i(p.loc("uNSh"), nc);
|
|
if (nc) {
|
|
float box[4 * kMaxCasters], par[4 * kMaxCasters], clip[4 * kMaxCasters], fill[4 * kMaxCasters];
|
|
for (int i = 0; i < nc; i++) {
|
|
const Caster &c = casters[size_t(i)];
|
|
box[i * 4] = c.cx; box[i * 4 + 1] = c.cy; box[i * 4 + 2] = c.hw; box[i * 4 + 3] = c.hh;
|
|
par[i * 4] = c.r; par[i * 4 + 1] = c.alpha; par[i * 4 + 2] = c.off; par[i * 4 + 3] = c.soft;
|
|
clip[i * 4] = c.hole ? c.x0 : 0.f; clip[i * 4 + 1] = c.hole ? c.y0 : 0.f;
|
|
clip[i * 4 + 2] = c.hole ? c.x1 : 0.f; clip[i * 4 + 3] = c.hole ? c.y1 : 0.f;
|
|
fill[i * 4] = c.fill.r; fill[i * 4 + 1] = c.fill.g; fill[i * 4 + 2] = c.fill.b;
|
|
fill[i * 4 + 3] = c.hole && c.fill.set ? c.fill.a : 0.f;
|
|
}
|
|
glUniform4fv(p.loc("uShBox"), nc, box);
|
|
glUniform4fv(p.loc("uShPar"), nc, par);
|
|
glUniform4fv(p.loc("uShClip"), nc, clip);
|
|
glUniform4fv(p.loc("uShFill"), nc, fill);
|
|
}
|
|
glDrawArrays(GL_TRIANGLES, 0, 3);
|
|
}
|
|
|
|
// Where a slab's element sits on its surface (Steam px).
|
|
static void slabRect(const Surface &s, const SlabSlot &sl, float &ex, float &ey, float &sw, float &sh) {
|
|
ex = sl.s.hasPos ? sl.s.x : (s.spec.texW - sl.s.w) * 0.5f;
|
|
ey = sl.s.hasPos ? sl.s.y : (s.spec.texH - sl.s.h) * 0.5f;
|
|
sw = sl.w / s.scale; // the cell is sl.w x sl.h glassd px = (w, h) Steam px at this scale
|
|
sh = sl.h / s.scale;
|
|
}
|
|
|
|
// Slab shadows and holes for the cover and plates of a surface: only for
|
|
// slabs whose element has held still for 150 ms (the daemon's pop rule;
|
|
// while an element moves it stays flat, so its shadow and hole wait).
|
|
// Holes come first, so a crowded surface never loses one.
|
|
std::vector<Caster> buildCasters(Surface &s, uint64_t now) {
|
|
const float mpp = designMpp();
|
|
std::vector<Caster> holes, shadows;
|
|
for (const SlabSlot &sl : s.slots) {
|
|
if (sl.s.dim() && !sl.s.hole.on) continue; // v3 G7: a dim cell casts nothing
|
|
const float settle = ramp(float(double(now - sl.stillSinceNs) / 1e6), 150.f, 300.f);
|
|
const Phased ph = phaseMap(phaseOf(sl.anim), spec.reduceMotion);
|
|
float ex, ey, sw, sh;
|
|
slabRect(s, sl, ex, ey, sw, sh);
|
|
Caster c;
|
|
c.cx = ex + sw * 0.5f;
|
|
c.cy = ey + sh * 0.5f;
|
|
c.hw = sw * 0.5f;
|
|
c.hh = sh * 0.5f;
|
|
c.r = std::min(sl.s.r, 0.5f * std::min(sw, sh));
|
|
if (sl.s.hole.on) {
|
|
const HoleSpec &h = sl.s.hole;
|
|
c.hole = true;
|
|
c.alpha = h.shadow * settle * ph.shadow;
|
|
c.off = h.y;
|
|
c.soft = std::max(1.f, h.blur);
|
|
c.x0 = h.hasClip ? h.cx0 : ex;
|
|
c.y0 = h.hasClip ? h.cy0 : ey;
|
|
c.x1 = h.hasClip ? h.cx1 : ex + sw;
|
|
c.y1 = h.hasClip ? h.cy1 : ey + sh;
|
|
c.fill = h.fill;
|
|
c.fill.a *= settle * ph.alpha;
|
|
c.spec = &h;
|
|
c.edgeA = settle * ph.alpha;
|
|
if (c.alpha < 0.005f && (!c.fill.set || c.fill.a < 0.005f) && (!h.hasEdges() || c.edgeA < 0.005f)) continue;
|
|
holes.push_back(c);
|
|
} else {
|
|
const Material sm = materialFor(sl.s.none() ? "liquid" : sl.s.material, dialNow(), std::min(sl.s.w, sl.s.h));
|
|
c.alpha = sm.slabShadow * settle * ph.shadow;
|
|
if (c.alpha < 0.005f) continue;
|
|
const float dzM = std::max(0.f, sl.s.dz * spec.unitM);
|
|
// VP P-48: offset 0.4 CSS px per mm of depth (= 0.3 m per m on the
|
|
// surface at 0.77 mm per CSS px; R1 m4: was 2 mm + 0.4 dz, about
|
|
// twice that), softness 3 mm + 0.4 dz (P-48's 1.2 px/mm within its band)
|
|
c.off = (0.3f * dzM) / mpp;
|
|
c.soft = (0.003f + dzM * 0.4f) / mpp;
|
|
shadows.push_back(c);
|
|
}
|
|
}
|
|
if (holes.size() > size_t(kMaxCasters) && !s.warnedHoles) {
|
|
std::printf("surface %s: %zu holes, drawing the first %d\n", s.spec.name.c_str(), holes.size(), kMaxCasters);
|
|
s.warnedHoles = true;
|
|
}
|
|
for (const Caster &c : shadows) holes.push_back(c);
|
|
if (holes.size() > size_t(kMaxCasters)) holes.resize(size_t(kMaxCasters));
|
|
return holes;
|
|
}
|
|
|
|
// How a piece of glass reads the copy behind it (glass.frag uCoverTaps): the
|
|
// round nine-tap blur only where something small and sharp lies behind it
|
|
// (a plate, or a hole's fill tone), which one coarse tap would turn into a
|
|
// square (R1 M3); one tap over plain cover glass, where it looks the same
|
|
// and costs a ninth (about 0.3 ms in the library scene).
|
|
int coverTaps = 9;
|
|
bool copyHasDetail(const Surface &s, const std::vector<Caster> &casters, float x0, float y0, float x1, float y1,
|
|
bool plates) const {
|
|
const float pad = 48.f; // Steam px: the blur's reach plus the bend
|
|
x0 -= pad, y0 -= pad, x1 += pad, y1 += pad;
|
|
auto hit = [&](float a0, float b0, float a1, float b1) { return a0 < x1 && x0 < a1 && b0 < y1 && y0 < b1; };
|
|
if (plates)
|
|
for (const PlateSlot &p : s.plates)
|
|
if (phaseOf(p.anim) > 0.002f && hit(p.s.x, p.s.y, p.s.x + p.s.w, p.s.y + p.s.h)) return true;
|
|
for (const Caster &c : casters)
|
|
if (c.hole && ((c.fill.set && c.fill.a > 0.01f) || (c.spec && c.spec->hasEdges())) && hit(c.x0, c.y0, c.x1, c.y1))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
// The casters' hole edge tones for glass.frag (uHoleTex, unit 4): row i =
|
|
// caster i, texel e * 8 + k = sample k of edge e (sRGB bytes, straight
|
|
// alpha, phased), texel 32 = the sample count of each edge (0 = the flat
|
|
// fill). Uploaded only while some hole has edge tones (and once after).
|
|
bool holeTexUsed = true;
|
|
void uploadHoles(const std::vector<Caster> &casters) {
|
|
constexpr int W = 4 * kMaxEdgeSamples + 1;
|
|
if (!holeTex) {
|
|
glGenTextures(1, &holeTex);
|
|
glActiveTexture(GL_TEXTURE4);
|
|
glBindTexture(GL_TEXTURE_2D, holeTex);
|
|
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, W, kMaxCasters);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
holeTexUsed = true; // its first contents are undefined: upload zeros at least once
|
|
}
|
|
bool any = false;
|
|
for (const Caster &c : casters) any |= c.hole && c.spec && c.spec->hasEdges();
|
|
if (!any && !holeTexUsed) return; // every count is zero already
|
|
static uint8_t px[kMaxCasters][W][4];
|
|
std::memset(px, 0, sizeof px);
|
|
auto q8 = [](float v) { return uint8_t(std::lround(std::clamp(v, 0.f, 1.f) * 255.f)); };
|
|
const size_t n = std::min(casters.size(), size_t(kMaxCasters));
|
|
for (size_t i = 0; i < n; i++) {
|
|
const Caster &c = casters[i];
|
|
if (!c.hole || !c.spec) continue;
|
|
for (int e = 0; e < 4; e++) {
|
|
const std::vector<Color> &v = c.spec->edges[e];
|
|
const size_t m = std::min(v.size(), size_t(kMaxEdgeSamples));
|
|
px[i][W - 1][e] = uint8_t(m);
|
|
for (size_t k = 0; k < m; k++) {
|
|
uint8_t *t = px[i][size_t(e) * kMaxEdgeSamples + k];
|
|
t[0] = q8(v[k].r);
|
|
t[1] = q8(v[k].g);
|
|
t[2] = q8(v[k].b);
|
|
t[3] = q8(v[k].a * c.edgeA);
|
|
}
|
|
}
|
|
}
|
|
glActiveTexture(GL_TEXTURE4);
|
|
glBindTexture(GL_TEXTURE_2D, holeTex);
|
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
|
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, W, kMaxCasters, GL_RGBA, GL_UNSIGNED_BYTE, px);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
holeTexUsed = any;
|
|
}
|
|
|
|
// The quarter-resolution copy of the backdrop (covers and plates): pass 1
|
|
// of the cover's interior, and what slabs see behind them.
|
|
void ensureLow(Surface &s) {
|
|
const int lw = (s.bw + 3) / 4, lh = (s.bh + 3) / 4;
|
|
if (s.lowTex.w == lw && s.lowTex.h == lh && s.lowTex.tex) return;
|
|
s.lowTex.destroy();
|
|
s.lowTex.create(lw, lh, gfx.halfFloatTargets ? GL_RGBA16F : GL_RGBA8, true);
|
|
// transparent border: where a bent ray leaves the cover, its edge blurs into the room
|
|
glBindTexture(GL_TEXTURE_2D, s.lowTex.tex);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 6); // slabs sample up to mip 4.5
|
|
}
|
|
// R1: the cover alone, copied from lowTex after the cover's pass 1, for
|
|
// plates drawn over that cover (they sample it as slabs sample lowTex).
|
|
// (Drawing pass 1 a second time instead of the blit cost 0.1 ms more.)
|
|
void copyCover(Surface &s) {
|
|
if (s.coverTex.w != s.lowTex.w || s.coverTex.h != s.lowTex.h || !s.coverTex.tex) {
|
|
s.coverTex.destroy();
|
|
s.coverTex.create(s.lowTex.w, s.lowTex.h, s.lowTex.fmt, true);
|
|
glBindTexture(GL_TEXTURE_2D, s.coverTex.tex);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 6);
|
|
}
|
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, s.lowTex.fbo);
|
|
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, s.coverTex.fbo);
|
|
glBlitFramebuffer(0, 0, s.lowTex.w, s.lowTex.h, 0, 0, s.lowTex.w, s.lowTex.h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
|
glActiveTexture(GL_TEXTURE2);
|
|
s.coverTex.mipmap();
|
|
glActiveTexture(GL_TEXTURE0);
|
|
}
|
|
// Nothing may sample the low copy while it is the render target.
|
|
static void unbindLow() {
|
|
glActiveTexture(GL_TEXTURE2);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
glActiveTexture(GL_TEXTURE3);
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
}
|
|
|
|
// A plate as one piece of glass (v3 G1).
|
|
Look plateLook(const PlateSlot &p) const {
|
|
Look k;
|
|
k.tint = p.s.tint;
|
|
k.fill = p.s.fill;
|
|
k.occluder = p.s.occluder;
|
|
k.flat = p.s.material == "dim";
|
|
k.shadow = p.s.shadow;
|
|
return k;
|
|
}
|
|
// Steam px around a plate that its draw may touch (contact shadow).
|
|
float platePad(const PlateSlot &p, const Material &m) const {
|
|
if (p.s.material == "dim" || p.s.occluder) return 2.f;
|
|
const float a = p.s.shadow >= 0 ? p.s.shadow : m.shadow;
|
|
return a > 0 ? (0.006f + 0.003f) / designMpp() + 2.f : 2.f;
|
|
}
|
|
|
|
// Stereo (s.eyes 2): each region of the layout is a pair, the left eye's copy and then the right eye's
|
|
// (every x doubled: the backdrop at [0, bw) and [bw, 2bw), a slab cell at x at [2x, 2x + w) and
|
|
// [2x + w, 2x + 2w)), each rendered from that eye (its own rays through the glass, its own highlights).
|
|
// SteamVR shows a "Parallel" panel's uv rect split in half, the left half to the left eye (measured on
|
|
// the device 2026-10-09: it splits the panel's own crop, not the texture), so a panel over a whole pair
|
|
// gives each eye its view and the room behind the glass has its own depth instead of lying on the glass
|
|
// like a picture. Mono: one copy, from the head.
|
|
void renderSurface(Surface &s, const Pose &head, uint64_t now) {
|
|
DmaTarget &b = s.bufs[s.next];
|
|
glBindFramebuffer(GL_FRAMEBUFFER, b.fbo);
|
|
glViewport(0, 0, s.gw * s.eyes, s.gh);
|
|
glClearColor(0, 0, 0, 0);
|
|
// Only the backdrop and the live cells (plus their gutters) are
|
|
// cleared and drawn; the rest of a buffer is cleared once.
|
|
if (!s.cleared[s.next]) {
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
s.cleared[s.next] = true;
|
|
}
|
|
for (int e = 0; e < s.eyes; e++) {
|
|
Pose eye = head;
|
|
if (s.eyes == 2) eye.t = head.t + head.rotate(eyeOffset[e]);
|
|
renderEye(s, b, eye, now, e);
|
|
}
|
|
glDisable(GL_SCISSOR_TEST);
|
|
s.last = s.next;
|
|
s.next = (s.next + 1) % 3;
|
|
s.rendered = true;
|
|
}
|
|
|
|
// Eye e's copy of the surface's layout (e 0 in mono).
|
|
void renderEye(Surface &s, DmaTarget &b, const Pose &head, uint64_t now, int e) {
|
|
const int ox = e * s.bw; // the backdrop's copy
|
|
glBindFramebuffer(GL_FRAMEBUFFER, b.fbo);
|
|
glClearColor(0, 0, 0, 0);
|
|
glEnable(GL_SCISSOR_TEST);
|
|
glScissor(ox, 0, s.bw, s.bh);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
const Geometry &g = s.geo;
|
|
const float mpp = designMpp();
|
|
const float coverDzM = s.spec.coverDz * spec.unitM;
|
|
const bool hasSlabs = !s.slots.empty() || !s.ghosts.empty();
|
|
const std::vector<Caster> casters = buildCasters(s, now);
|
|
uploadHoles(casters);
|
|
// cover: the union of its shapes, one pass, in the backdrop region,
|
|
// with the shadows and holes of the settled slabs in front of it
|
|
const std::vector<ShapeSpec> shapes = coverShapes(s);
|
|
std::vector<const PlateSlot *> live;
|
|
for (const PlateSlot &p : s.plates)
|
|
if (phaseOf(p.anim) > 0.002f && exitAlpha(p, now) > 0.002f) live.push_back(&p);
|
|
// R1 (m1): plates over a cover see that cover behind them (glass over
|
|
// glass, like a slab), so pass 1 also runs to give them its copy
|
|
const bool platesOverCover = !shapes.empty() && !live.empty();
|
|
bool coverDrawn = false, lowReady = false, coverCopied = false;
|
|
if (!shapes.empty()) {
|
|
float x0 = 1e9f, y0 = 1e9f, x1 = -1e9f, y1 = -1e9f;
|
|
for (const ShapeSpec &q : shapes) {
|
|
x0 = std::min(x0, q.x);
|
|
y0 = std::min(y0, q.y);
|
|
x1 = std::max(x1, q.x + q.w);
|
|
y1 = std::max(y1, q.y + q.h);
|
|
}
|
|
float minSide = 1e9f;
|
|
for (const ShapeSpec &q : shapes) minSide = std::min(minSide, std::min(q.w, q.h));
|
|
const Material cm = materialFor(s.spec.material, dialNow(), minSide);
|
|
// the contact shadow falls outside the shapes: widen the scissor
|
|
const float pad = cm.shadow > 0 ? (0.006f + 0.003f) / mpp : 0.f;
|
|
const int sx0 = std::max(0, int(std::floor((x0 - pad) * s.scale)) - 1);
|
|
const int sy0 = std::max(0, int(std::floor((y0 - pad) * s.scale)) - 1);
|
|
const int sx1 = std::min(s.bw, int(std::ceil((x1 + pad) * s.scale)) + 1);
|
|
const int sy1 = std::min(s.bh, int(std::ceil((y1 + pad) * s.scale)) + 1);
|
|
if (sx1 > sx0 && sy1 > sy0) {
|
|
const Phased cph = phaseMap(phaseOf(s.anim), spec.reduceMotion);
|
|
// Pass 1: the flat interior at 1/4 resolution (it is frosted by
|
|
// several degrees); pass 2: the edge band in full, pass 1 inside.
|
|
// Every edge term ends within `inner` of the edge: the bezel,
|
|
// the shading bands (1.6 x their width), plus 1.5 low texels.
|
|
const float lowScale = s.scale / 4.f;
|
|
const float inner = std::max(std::min(cm.bezelM / mpp, 0.5f * minSide), 1.6f * std::max(2.f, cm.darkW / mpp)) +
|
|
1.5f / lowScale + 2.f;
|
|
const bool twoPass = opt.debugView == 0 && minSide > 2.f * inner + 8.f / lowScale;
|
|
if (twoPass || hasSlabs || platesOverCover) {
|
|
ensureLow(s);
|
|
unbindLow();
|
|
glDisable(GL_SCISSOR_TEST);
|
|
glDisable(GL_BLEND);
|
|
s.lowTex.bind();
|
|
drawGlass(s, g, head, cm, cph, 0, 0, s.lowTex.w, s.lowTex.h, {0, 0, 0}, shapes, coverDzM, false, casters, 1, lowScale);
|
|
lowReady = true;
|
|
if (platesOverCover) {
|
|
copyCover(s); // scissor off: a blit obeys it
|
|
coverCopied = true;
|
|
}
|
|
glBindFramebuffer(GL_FRAMEBUFFER, b.fbo);
|
|
glEnable(GL_SCISSOR_TEST);
|
|
glActiveTexture(GL_TEXTURE3);
|
|
glBindTexture(GL_TEXTURE_2D, s.lowTex.tex);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
progGlass.set("uLowSize", float(s.lowTex.w), float(s.lowTex.h));
|
|
progGlass.set("uLowScale", lowScale);
|
|
progGlass.set("uInner", inner);
|
|
}
|
|
glScissor(ox + sx0, sy0, sx1 - sx0, sy1 - sy0);
|
|
drawGlass(s, g, head, cm, cph, ox, 0, s.bw, s.bh, {0, 0, 0}, shapes, coverDzM, false, casters, twoPass ? 2 : 0);
|
|
coverDrawn = true;
|
|
}
|
|
}
|
|
// v3 plates, in spec order, over the cover (premultiplied "over"); the
|
|
// low copy gets them too when slabs need to see them. Over a cover a
|
|
// plate samples the cover's copy (unit 2) and its optics sit
|
|
// kPlateLiftM in front of it, so its bezel bends the window glass, not
|
|
// the room behind the window (R1 m1: a closed rim of bent room)
|
|
bool platesDrawn = false;
|
|
const bool plateUnder = coverCopied;
|
|
const float plateDz = coverDzM + (plateUnder ? kPlateLiftM : 0.f);
|
|
auto bindCoverCopy = [&] {
|
|
glActiveTexture(GL_TEXTURE2);
|
|
glBindTexture(GL_TEXTURE_2D, s.coverTex.tex);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
};
|
|
if (!live.empty()) {
|
|
glEnable(GL_BLEND);
|
|
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
|
if (hasSlabs) {
|
|
ensureLow(s);
|
|
unbindLow();
|
|
if (plateUnder) bindCoverCopy();
|
|
glDisable(GL_SCISSOR_TEST);
|
|
s.lowTex.bind();
|
|
if (!lowReady) {
|
|
glClearColor(0, 0, 0, 0);
|
|
glClear(GL_COLOR_BUFFER_BIT);
|
|
lowReady = true;
|
|
}
|
|
glEnable(GL_SCISSOR_TEST);
|
|
const float lowScale = s.scale / 4.f;
|
|
for (const PlateSlot *p : live) {
|
|
// pass 1 has no coverage alpha: a leaving plate leaves the slabs' view halfway through
|
|
if (exitAlpha(*p, now) < 0.5f) continue;
|
|
const Material pm = materialFor(p->s.material, dialNow(), std::min(p->s.w, p->s.h));
|
|
const float pad = platePad(*p, pm);
|
|
const int x0 = std::max(0, int(std::floor((p->s.x - pad) * lowScale)) - 1);
|
|
const int y0 = std::max(0, int(std::floor((p->s.y - pad) * lowScale)) - 1);
|
|
const int x1 = std::min(s.lowTex.w, int(std::ceil((p->s.x + p->s.w + pad) * lowScale)) + 1);
|
|
const int y1 = std::min(s.lowTex.h, int(std::ceil((p->s.y + p->s.h + pad) * lowScale)) + 1);
|
|
if (x1 <= x0 || y1 <= y0) continue;
|
|
glScissor(x0, y0, x1 - x0, y1 - y0);
|
|
coverTaps = copyHasDetail(s, casters, p->s.x, p->s.y, p->s.x + p->s.w, p->s.y + p->s.h, false) ? 9 : 1;
|
|
drawGlass(s, g, head, pm, phaseMap(phaseOf(p->anim), spec.reduceMotion), 0, 0, s.lowTex.w, s.lowTex.h, {0, 0, 0},
|
|
{{p->s.x, p->s.y, p->s.w, p->s.h, p->s.r}}, plateDz, plateUnder, casters, 1, lowScale, plateLook(*p));
|
|
}
|
|
glBindFramebuffer(GL_FRAMEBUFFER, b.fbo);
|
|
}
|
|
if (plateUnder) bindCoverCopy();
|
|
for (const PlateSlot *p : live) {
|
|
const Material pm = materialFor(p->s.material, dialNow(), std::min(p->s.w, p->s.h));
|
|
const float pad = platePad(*p, pm);
|
|
const int x0 = std::max(0, int(std::floor((p->s.x - pad) * s.scale)) - 1);
|
|
const int y0 = std::max(0, int(std::floor((p->s.y - pad) * s.scale)) - 1);
|
|
const int x1 = std::min(s.bw, int(std::ceil((p->s.x + p->s.w + pad) * s.scale)) + 1);
|
|
const int y1 = std::min(s.bh, int(std::ceil((p->s.y + p->s.h + pad) * s.scale)) + 1);
|
|
if (x1 <= x0 || y1 <= y0) continue;
|
|
glScissor(ox + x0, y0, x1 - x0, y1 - y0);
|
|
coverTaps = copyHasDetail(s, casters, p->s.x, p->s.y, p->s.x + p->s.w, p->s.y + p->s.h, false) ? 9 : 1;
|
|
drawGlass(s, g, head, pm, platePhase(*p, now), ox, 0, s.bw, s.bh, {0, 0, 0},
|
|
{{p->s.x, p->s.y, p->s.w, p->s.h, p->s.r}}, plateDz, plateUnder, casters, 0, 0.f, plateLook(*p));
|
|
platesDrawn = true;
|
|
}
|
|
glDisable(GL_BLEND);
|
|
}
|
|
// what the slabs see behind them: the quarter-resolution cover and
|
|
// plates (mipmapped; the cover's highlights are left out, its frost hides them)
|
|
const bool under = (coverDrawn || platesDrawn) && lowReady && hasSlabs;
|
|
if (under) {
|
|
glActiveTexture(GL_TEXTURE3);
|
|
s.lowTex.mipmap();
|
|
glActiveTexture(GL_TEXTURE2);
|
|
glBindTexture(GL_TEXTURE_2D, s.lowTex.tex);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
}
|
|
// slabs (and fading ghosts): the element's exact size; world point =
|
|
// element position (+ the moved crop's offset) + dz
|
|
auto drawSlab = [&](const SlabSlot &sl) {
|
|
float ex, ey, sw, sh;
|
|
slabRect(s, sl, ex, ey, sw, sh);
|
|
// the cell's copy for this eye; the pair (and its gutter) is cleared once, before the left eye
|
|
const int cx = s.eyes == 2 ? 2 * sl.x + e * sl.w : sl.x;
|
|
if (e == 0) {
|
|
const int px = s.eyes == 2 ? 2 * sl.x : sl.x, pw = s.eyes == 2 ? 2 * sl.w : sl.w;
|
|
const int gx0 = std::max(0, px - 1), gy0 = std::max(s.bh, sl.y - 1);
|
|
const int gx1 = std::min(s.gw * s.eyes, px + pw + 1), gy1 = std::min(s.gh, sl.y + sl.h + 1);
|
|
glScissor(gx0, gy0, gx1 - gx0, gy1 - gy0);
|
|
glClear(GL_COLOR_BUFFER_BIT); // the cell's gutter may hold an older cell's pixels
|
|
}
|
|
if (sl.s.none()) return; // v3: a cell that draws nothing (its hole is on the cover)
|
|
if (sl.s.dim()) {
|
|
// v3 G7: a flat dark cell (the room dim the scene graph stretches behind the window)
|
|
Look flat;
|
|
flat.flat = true;
|
|
flat.fill = sl.s.fill;
|
|
glScissor(cx, sl.y, sl.w, sl.h);
|
|
drawGlass(s, g, head, materialFor("liquid", dialNow(), std::min(sl.s.w, sl.s.h)),
|
|
phaseMap(phaseOf(sl.anim), spec.reduceMotion), cx, sl.y, sl.w, sl.h, {ex, ey, 0},
|
|
{{0, 0, sw, sh, sl.s.r}}, 0.f, false, {}, 0, 0.f, flat);
|
|
return;
|
|
}
|
|
const Material m = materialFor(sl.s.material, dialNow(), std::min(sl.s.w, sl.s.h));
|
|
Look look;
|
|
look.tint = sl.s.tint;
|
|
glScissor(cx, sl.y, sl.w, sl.h);
|
|
coverTaps = copyHasDetail(s, casters, ex + sl.s.ox, ey + sl.s.oy, ex + sl.s.ox + sw, ey + sl.s.oy + sh, true) ? 9 : 1;
|
|
drawGlass(s, g, head, m, phaseMap(phaseOf(sl.anim), spec.reduceMotion), cx, sl.y, sl.w, sl.h,
|
|
{ex + sl.s.ox, ey + sl.s.oy, 0}, {{0, 0, sw, sh, sl.s.r}}, sl.s.dz * spec.unitM - 0.0008f, under, {}, 0, 0.f, look);
|
|
};
|
|
for (const SlabSlot &sl : s.slots) drawSlab(sl);
|
|
for (const SlabSlot &sl : s.ghosts) drawSlab(sl);
|
|
coverTaps = 9;
|
|
}
|
|
|
|
// Renders every visible surface, waits for the GPU, then hands the new
|
|
// buffers to SteamVR. fetchGeo: ask SteamVR for the surfaces' quads.
|
|
int renderAll(const Pose &head, bool force, bool fetchGeo, uint64_t now) {
|
|
std::vector<Surface *> todo;
|
|
for (auto &s : surfaces) {
|
|
if (!(s->spec.visible || s->spec.armed || force || s->clearPending)) continue;
|
|
if (s->ov == vr::k_ulOverlayHandleInvalid && !ensureOverlay(*s)) continue;
|
|
if (!s->bufs[0].bo && !ensureBuffers(*s)) continue;
|
|
if (!s->bufs[s->next].bo) continue;
|
|
refreshGeometry(*s, head, now, fetchGeo);
|
|
todo.push_back(s.get());
|
|
}
|
|
if (todo.empty()) return 0;
|
|
// where each eye sits in the head (the wearer's IPD), every few seconds
|
|
if (!eyeOffsetAt || now - eyeOffsetAt > 5000000000ull) {
|
|
eyeOffsetAt = now;
|
|
for (int e = 0; e < 2; e++) {
|
|
const vr::HmdMatrix34_t m = vr::VRSystem()->GetEyeToHeadTransform(e ? vr::Eye_Right : vr::Eye_Left);
|
|
const v3 o{m.m[0][3], m.m[1][3], m.m[2][3]};
|
|
if (std::isfinite(o.x) && std::fabs(o.x) > 0.015f && std::fabs(o.x) < 0.05f) eyeOffset[e] = o;
|
|
}
|
|
}
|
|
const uint64_t t0 = monoNowNs();
|
|
renderTimer.begin();
|
|
Program &p = progGlass;
|
|
p.use();
|
|
glBindVertexArray(vao);
|
|
glDisable(GL_BLEND);
|
|
glActiveTexture(GL_TEXTURE1);
|
|
glBindTexture(GL_TEXTURE_2D, room.push.back().tex); // 1x1: the known room's mean
|
|
glActiveTexture(GL_TEXTURE0);
|
|
glBindTexture(GL_TEXTURE_2D, room.filled.tex);
|
|
p.set("uRoom", 0);
|
|
p.set("uRoomAvg", 1);
|
|
p.set("uCover", 2);
|
|
p.set("uLow", 3);
|
|
p.set("uHoleTex", 4);
|
|
p.set("uRoomSize", float(room.W), float(room.H));
|
|
p.set("uCenter", room.center.x, room.center.y, room.center.z);
|
|
p.set("uRadius", room.radius);
|
|
// One key light for every surface, fixed in the world: from above,
|
|
// about 20 deg left of vertical, a little in front (DESIGN2 §6.2 R2).
|
|
const v3 key = normalize(v3{-0.30f, 0.90f, 0.32f});
|
|
p.set("uKeyL", key.x, key.y, key.z);
|
|
p.set("uDebug", opt.debugView);
|
|
for (Surface *s : todo) {
|
|
renderSurface(*s, head, now);
|
|
s->clearPending = false;
|
|
}
|
|
renderTimer.end();
|
|
glFinish(); // SteamVR samples the dmabufs from another process
|
|
cpuMs = cpuMs * 0.9f + float(double(monoNowNs() - t0) / 1e6) * 0.1f;
|
|
renderTimer.poll();
|
|
for (Surface *s : todo) {
|
|
const DmaTarget &b = s->bufs[s->last];
|
|
vr::SharedTextureHandle_t h = vr::SharedTextureHandle_t(b.vrHandle);
|
|
vr::Texture_t tex = {&h, vr::TextureType_SharedTextureHandle, vr::ColorSpace_Gamma};
|
|
auto e = vr::VROverlay()->SetOverlayTexture(s->ov, &tex);
|
|
if (e != vr::VROverlayError_None) {
|
|
if (s->submitFails++ == 0) s->submitFailSinceNs = now;
|
|
if (s->submitFails <= 3)
|
|
std::printf("SetOverlayTexture(%s): %s\n", s->key.c_str(), vr::VROverlay()->GetOverlayErrorNameFromEnum(e));
|
|
if (e == vr::VROverlayError_UnknownOverlay || e == vr::VROverlayError_InvalidHandle)
|
|
loseOverlay(*s, vr::VROverlay()->GetOverlayErrorNameFromEnum(e));
|
|
else if (s->submitFails >= 5 && now - s->submitFailSinceNs > 1000000000ull)
|
|
loseOverlay(*s, "texture submits keep failing");
|
|
continue;
|
|
}
|
|
s->submitFails = 0;
|
|
s->lastSubmitNs = lastSubmitNs = now;
|
|
// The scene graph sizes the panel from the mouse scale: change it
|
|
// together with the first texture of a new size.
|
|
if (b.w != s->submittedW || b.h != s->submittedH) {
|
|
// one eye's size: the panel is sized from it, and a stereo texture holds two
|
|
vr::HmdVector2_t mouse = {float(b.w / std::max(1, s->eyes)), float(b.h)};
|
|
vr::VROverlay()->SetOverlayMouseScale(s->ov, &mouse);
|
|
s->submittedW = b.w;
|
|
s->submittedH = b.h;
|
|
}
|
|
if (!s->announced) {
|
|
// what SteamVR now holds for this overlay
|
|
uint32_t tw = 0, th = 0;
|
|
auto te = vr::VROverlay()->GetOverlayTextureSize(s->ov, &tw, &th);
|
|
vr::HmdVector2_t ms{};
|
|
vr::VROverlay()->GetOverlayMouseScale(s->ov, &ms);
|
|
std::printf("first texture on %s: SteamVR reports %ux%u (%s), mouse scale %.0fx%.0f\n", s->key.c_str(), tw, th,
|
|
vr::VROverlay()->GetOverlayErrorNameFromEnum(te), double(ms.v[0]), double(ms.v[1]));
|
|
s->announced = true;
|
|
}
|
|
}
|
|
frames++;
|
|
framesWindow++;
|
|
return int(todo.size());
|
|
}
|
|
|
|
// -------------------------------------------------------------- health
|
|
// Every 2 s: SteamVR's processes are still the ones we connected to, and
|
|
// our overlays still exist. A vrserver or vrcompositor that went away
|
|
// (crash, respawn) ends glassd with 75; the daemon starts a fresh one.
|
|
void healthCheck(uint64_t now) {
|
|
if (now - lastHealthNs < 2000000000ull) return;
|
|
lastHealthNs = now;
|
|
if (vrserverPid > 0 && !pidIs(vrserverPid, "vrserver")) {
|
|
std::printf("SteamVR (vrserver %d) went away; exiting\n", vrserverPid);
|
|
quitWith(kExitTemp);
|
|
return;
|
|
}
|
|
if (compositorPid > 0 && !pidIs(compositorPid, "vrcompositor")) {
|
|
std::printf("vrcompositor %d went away; exiting so a fresh glassd can reconnect\n", compositorPid);
|
|
quitWith(kExitTemp);
|
|
return;
|
|
}
|
|
for (auto &s : surfaces) {
|
|
if (s->ov == vr::k_ulOverlayHandleInvalid) continue;
|
|
vr::VROverlayHandle_t h = vr::k_ulOverlayHandleInvalid;
|
|
auto e = vr::VROverlay()->FindOverlay(s->key.c_str(), &h);
|
|
if (e != vr::VROverlayError_None || h != s->ov) loseOverlay(*s, "overlay vanished");
|
|
}
|
|
}
|
|
|
|
// Visible surfaces all have an overlay that accepts textures.
|
|
bool healthy() const {
|
|
for (auto &s : surfaces) {
|
|
if (!s->spec.visible) continue;
|
|
if (s->ov == vr::k_ulOverlayHandleInvalid || s->submitFails >= 3) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------- output
|
|
void writeOut(bool exiting) {
|
|
std::string o;
|
|
char b[1024]; // parseSpec caps names and slab ids at 128 chars, sanitizeKey keys at 100
|
|
timespec rt;
|
|
clock_gettime(CLOCK_REALTIME, &rt);
|
|
const uint64_t now = monoNowNs();
|
|
std::snprintf(b, sizeof b, "{\"seq\": %lld, \"pid\": %d, \"updated\": %.3f, \"healthy\": %s, \"fps\": %.1f, \"gpu_ms\": %.2f, \"frames\": %llu, \"dashboard\": %s",
|
|
spec.seq, int(getpid()), double(rt.tv_sec) + double(rt.tv_nsec) / 1e9, !exiting && healthy() ? "true" : "false",
|
|
exiting ? 0.0 : double(fpsNow), double(gpuMs()), (unsigned long long)frames, lastDash ? "true" : "false");
|
|
o += b;
|
|
o += ", \"version\": 3, \"caps\": [\"plates\", \"holes\", \"tint\", \"masks\", \"coverDz\", \"none\", \"offset\", \"dim\", \"scaleFrom\", \"unitM\", \"roomDim\", \"dimSlab\", \"holeEdges\", \"morph\", \"appearAt\", \"armed\", \"exitAt\", \"tune\", \"stereo\"]";
|
|
// the dial and the tune as drawn (eased toward the spec's)
|
|
std::snprintf(b, sizeof b, ", \"tune\": {\"dial\": %.3f, \"refract\": %.3f, \"frost\": %.3f, \"light\": %.3f, \"hueK\": %.3f}",
|
|
double(dialNow()), double(tuneLive.refract), double(tuneLive.frost), double(tuneLive.light), double(tuneLive.hueK));
|
|
o += b;
|
|
std::snprintf(b, sizeof b, ", \"last_submit_s\": %.1f, \"room_ms\": %.2f, \"room_updates\": %llu, \"feed\": %s%s",
|
|
lastSubmitNs ? double(now - lastSubmitNs) / 1e9 : -1.0, double(roomTimer.have ? roomTimer.ms : roomCpuMs),
|
|
(unsigned long long)room.updates, jsonEscape(feedState()).c_str(), exiting ? ", \"exiting\": true" : "");
|
|
o += b;
|
|
o += ", \"surfaces\": {";
|
|
bool first = true;
|
|
for (auto &s : surfaces) {
|
|
if (!first) o += ", ";
|
|
first = false;
|
|
const float gw = float(std::max(1, s->gw)), gh = float(std::max(1, s->gh));
|
|
const size_t cover = !specCover(s->spec) ? 0
|
|
: s->spec.hasShapes ? std::min(s->spec.shapes.size(), size_t(kMaxShapes)) : size_t(1);
|
|
std::snprintf(b, sizeof b,
|
|
"%s: {\"key\": %s, \"texW\": %d, \"texH\": %d, \"backdrop\": [0, 0, %.6f, %.6f], \"backdropScale\": %.6f, \"cover\": %zu",
|
|
jsonEscape(s->spec.name).c_str(), jsonEscape(s->key).c_str(), s->gw, s->gh, s->bw / (gw * s->eyes), s->bh / gh,
|
|
double(s->scale), cover);
|
|
o += b;
|
|
// where the surface is from the head: the angle off the view direction and the distance (lab:
|
|
// `glass.py hv` waits for the wearer to face the dashboard before it grabs)
|
|
if (haveLastHead && s->spec.texW > 0) {
|
|
const Geometry &g = s->geo;
|
|
const v3 mid = g.O + g.U * (s->spec.texW * 0.5f) + g.V * (s->spec.texH * 0.5f);
|
|
const v3 to = mid - lastHead.t;
|
|
const float dist = length(to);
|
|
const v3 fwd = lastHead.rotate({0, 0, -1});
|
|
const float off = dist > 1e-4f ? std::acos(std::clamp(dot(to * (1.f / dist), fwd), -1.f, 1.f)) * 57.2958f : 180.f;
|
|
std::snprintf(b, sizeof b, ", \"view\": {\"offDeg\": %.1f, \"distM\": %.2f}", double(off), double(dist));
|
|
o += b;
|
|
}
|
|
if (s->eyes == 2) o += ", \"stereo\": true";
|
|
o += ", \"geometry\": " + jsonEscape(s->geoNote) + ", \"slabs\": {";
|
|
for (size_t i = 0; i < s->slots.size(); i++) {
|
|
const SlabSlot &sl = s->slots[i];
|
|
// the cell (stereo: its left eye's copy; the right eye's follows it)
|
|
const float sx = float(s->eyes == 2 ? 2 * sl.x : sl.x), tw = gw * float(s->eyes);
|
|
std::snprintf(b, sizeof b, "%s%s: [%.6f, %.6f, %.6f, %.6f]", i ? ", " : "", jsonEscape(sl.s.id).c_str(), sx / tw, sl.y / gh,
|
|
(sx + sl.w) / tw, (sl.y + sl.h) / gh);
|
|
o += b;
|
|
}
|
|
o += "}";
|
|
if (!s->dropped.empty()) {
|
|
o += ", \"dropped\": [";
|
|
size_t n = 0; // the first kMaxListedDrops (counts.dropped has them all)
|
|
for (const std::string &id : s->dropped) {
|
|
if (n == size_t(kMaxListedDrops)) break;
|
|
o += (n++ ? ", " : "") + jsonEscape(id);
|
|
}
|
|
o += "]";
|
|
}
|
|
// v3: the plates drawn (in the backdrop region) and those left out
|
|
o += ", \"plates\": [";
|
|
for (size_t i = 0; i < s->plates.size(); i++) o += (i ? ", " : "") + jsonEscape(s->plates[i].s.id);
|
|
o += "]";
|
|
if (!s->spec.droppedPlates.empty()) {
|
|
o += ", \"droppedPlates\": [";
|
|
for (size_t i = 0; i < s->spec.droppedPlates.size(); i++) o += (i ? ", " : "") + jsonEscape(s->spec.droppedPlates[i]);
|
|
o += "]";
|
|
}
|
|
size_t holes = 0;
|
|
for (const SlabSlot &sl : s->slots) holes += sl.s.hole.on;
|
|
std::snprintf(b, sizeof b,
|
|
", \"counts\": {\"shapes\": %zu, \"plates\": %zu, \"slabs\": %zu, \"holes\": %zu, \"dropped\": %zu, \"droppedPlates\": %zu}",
|
|
cover, s->plates.size(), s->slots.size(), holes, s->dropped.size(), s->spec.droppedPlatesN);
|
|
o += b;
|
|
o += "}";
|
|
}
|
|
o += "}}\n";
|
|
if (!writeAtomic(opt.out, o)) std::printf("cannot write %s\n", opt.out.c_str());
|
|
lastOutNs = monoNowNs();
|
|
}
|
|
|
|
float gpuMs() const { return renderTimer.have ? renderTimer.ms : cpuMs; }
|
|
std::string feedState() const {
|
|
if (opt.noFeed) return "off";
|
|
int st = feed.state.load();
|
|
if (st == -1) return "failed: " + feed.error();
|
|
if (feed.attached()) return (feed.isStreaming() ? "live " : "shot ") + feed.mode();
|
|
return "standby";
|
|
}
|
|
|
|
// --------------------------------------------------------------- dumps
|
|
void dumpSurfaces(const std::string &dir) {
|
|
mkdirs(dir);
|
|
for (auto &s : surfaces) {
|
|
if (s->last < 0) continue;
|
|
// materialize progress of every piece of glass at this frame
|
|
char tb[48];
|
|
std::snprintf(tb, sizeof tb, " (t=%.4f, spec seq %lld)", double(lastPhaseNs) / 1e9, spec.seq);
|
|
std::string ph = "phase " + s->spec.name + "=" + std::to_string(phaseOf(s->anim)).substr(0, 5);
|
|
for (const SlabSlot &sl : s->slots) ph += " " + sl.s.id + "=" + std::to_string(phaseOf(sl.anim)).substr(0, 5);
|
|
for (const PlateSlot &pl : s->plates) ph += " plate:" + pl.s.id + "=" + std::to_string(phaseOf(pl.anim)).substr(0, 5);
|
|
std::printf("%s%s\n", ph.c_str(), tb);
|
|
DmaTarget &b = s->bufs[s->last];
|
|
std::vector<uint8_t> px = readPixels(b.fbo, s->gw, s->gh);
|
|
for (size_t i = 0; i < px.size(); i += 4) { // premultiplied -> straight for PNG
|
|
unsigned a = px[i + 3];
|
|
if (a > 0 && a < 255)
|
|
for (int c = 0; c < 3; c++) px[i + c] = uint8_t(std::min(255u, (px[i + c] * 255u + a / 2) / a));
|
|
}
|
|
std::string path = dir + "/" + sanitizeKey(s->spec.name) + ".png";
|
|
stbi_write_png(path.c_str(), s->gw, s->gh, 4, px.data(), s->gw * 4);
|
|
const Geometry &g = s->geo;
|
|
const v3 mid = g.O + g.U * (s->spec.texW * 0.5f) + g.V * (s->spec.texH * 0.5f);
|
|
const v3 toHead = lastRenderHead.t - mid;
|
|
const float ang = std::acos(std::clamp(dot(normalize(toHead), g.N), -1.f, 1.f)) * 57.2958f;
|
|
std::printf("dumped %s (%dx%d, geometry %s: centre %.2f %.2f %.2f, %.2f x %.2f m, head %.2f m away at %.0f deg off-normal)\n",
|
|
path.c_str(), s->gw, s->gh, s->geoNote.c_str(), mid.x, mid.y, mid.z, length(g.U) * s->spec.texW,
|
|
length(g.V) * s->spec.texH, length(toHead), ang);
|
|
if (opt.dumpView) dumpView(*s, px, dir + "/" + sanitizeKey(s->spec.name) + "-view.png");
|
|
}
|
|
}
|
|
|
|
// What the wearer would see of one surface (view.frag): the room around
|
|
// it, the cover and the slabs at their world positions, from the current
|
|
// head. `straight` is the surface texture as dumped (straight alpha).
|
|
void dumpView(Surface &s, const std::vector<uint8_t> &straight, const std::string &path) {
|
|
if (!s.geo.valid) return;
|
|
// back to premultiplied for the compositing shader
|
|
std::vector<uint8_t> px = straight;
|
|
for (size_t i = 0; i < px.size(); i += 4)
|
|
for (int c = 0; c < 3; c++) px[i + c] = uint8_t((px[i + c] * unsigned(px[i + 3]) + 127) / 255);
|
|
GLuint tex = 0;
|
|
glGenTextures(1, &tex);
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, s.gw, s.gh, 0, GL_RGBA, GL_UNSIGNED_BYTE, px.data());
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
|
const float tw = float(s.spec.texW), th = float(s.spec.texH);
|
|
const float m = std::clamp(0.08f * std::max(tw, th), 48.f, 200.f);
|
|
const float x0 = -m, y0 = -m, x1 = tw + m, y1 = th + m;
|
|
const int ow = std::max(1, int(std::lround((x1 - x0) * s.scale))), oh = std::max(1, int(std::lround((y1 - y0) * s.scale)));
|
|
Target out;
|
|
if (!out.create(ow, oh, GL_RGBA8, false)) {
|
|
std::printf("view target %dx%d failed\n", ow, oh);
|
|
out.destroy();
|
|
glDeleteTextures(1, &tex);
|
|
return;
|
|
}
|
|
out.bind();
|
|
glDisable(GL_BLEND);
|
|
glDisable(GL_SCISSOR_TEST);
|
|
Program &p = progView;
|
|
p.use();
|
|
glBindVertexArray(vao);
|
|
glActiveTexture(GL_TEXTURE0);
|
|
glBindTexture(GL_TEXTURE_2D, room.filled.tex);
|
|
glActiveTexture(GL_TEXTURE1);
|
|
glBindTexture(GL_TEXTURE_2D, tex);
|
|
p.set("uRoom", 0);
|
|
p.set("uTex", 1);
|
|
p.set("uRect", x0, y0, x1, y1);
|
|
const Geometry &g = s.geo;
|
|
p.set("uO", g.O.x, g.O.y, g.O.z);
|
|
p.set("uU", g.U.x, g.U.y, g.U.z);
|
|
p.set("uV", g.V.x, g.V.y, g.V.z);
|
|
p.set("uN", g.N.x, g.N.y, g.N.z);
|
|
p.set("uEye", lastRenderHead.t.x, lastRenderHead.t.y, lastRenderHead.t.z);
|
|
p.set("uCenter", room.center.x, room.center.y, room.center.z);
|
|
p.set("uRadius", room.radius);
|
|
p.set("uPattern", opt.testPattern);
|
|
p.set("uTexSize", float(s.gw), float(s.gh));
|
|
p.set("uScale", s.scale);
|
|
p.set("uSteamSize", tw, th);
|
|
p.set("uCoverDz", s.spec.coverDz * spec.unitM);
|
|
std::vector<const SlabSlot *> sl;
|
|
for (const SlabSlot &q : s.slots) sl.push_back(&q);
|
|
std::stable_sort(sl.begin(), sl.end(), [](const SlabSlot *a, const SlabSlot *b) { return a->s.dz < b->s.dz; });
|
|
if (sl.size() > 16) sl.resize(16);
|
|
float rect[64] = {}, cell[64] = {}, dz[16] = {}, hole[64] = {}, info[64] = {}, col[64] = {};
|
|
for (size_t i = 0; i < sl.size(); i++) {
|
|
const SlabSlot &q = *sl[i];
|
|
const float ex = q.s.hasPos ? q.s.x : (tw - q.s.w) * 0.5f, ey = q.s.hasPos ? q.s.y : (th - q.s.h) * 0.5f;
|
|
rect[i * 4] = ex + q.s.ox;
|
|
rect[i * 4 + 1] = ey + q.s.oy;
|
|
rect[i * 4 + 2] = q.w / s.scale;
|
|
rect[i * 4 + 3] = q.h / s.scale;
|
|
cell[i * 4] = float(q.x);
|
|
cell[i * 4 + 1] = float(q.y);
|
|
cell[i * 4 + 2] = float(q.w);
|
|
cell[i * 4 + 3] = float(q.h);
|
|
dz[i] = q.s.dz * spec.unitM;
|
|
// hero stand-in: the hole the pop leaves in the mosaic, the crop's
|
|
// offset, radius and colour (its tint, else a light grey capsule)
|
|
const HoleSpec &h = q.s.hole;
|
|
hole[i * 4] = h.hasClip ? h.cx0 : ex;
|
|
hole[i * 4 + 1] = h.hasClip ? h.cy0 : ey;
|
|
hole[i * 4 + 2] = h.hasClip ? h.cx1 : ex + q.w / s.scale;
|
|
hole[i * 4 + 3] = h.hasClip ? h.cy1 : ey + q.h / s.scale;
|
|
info[i * 4] = q.s.ox;
|
|
info[i * 4 + 1] = q.s.oy;
|
|
info[i * 4 + 2] = q.s.r;
|
|
info[i * 4 + 3] = q.s.none() ? 1.f : 0.f;
|
|
const bool tinted = q.s.tint.set && q.s.tint.a > 0;
|
|
col[i * 4] = tinted ? q.s.tint.r : 0.78f;
|
|
col[i * 4 + 1] = tinted ? q.s.tint.g : 0.80f;
|
|
col[i * 4 + 2] = tinted ? q.s.tint.b : 0.84f;
|
|
col[i * 4 + 3] = 1.f;
|
|
}
|
|
p.set("uContent", opt.viewContent);
|
|
p.set("uRoomDim", roomDimNow()); // v3 G7: the dim panel behind the UI, as the wearer would see it
|
|
glUniform1i(p.loc("uNSlab"), int(sl.size()));
|
|
if (!sl.empty()) {
|
|
glUniform4fv(p.loc("uSlabRect"), GLsizei(sl.size()), rect);
|
|
glUniform4fv(p.loc("uSlabCell"), GLsizei(sl.size()), cell);
|
|
glUniform1fv(p.loc("uSlabDz"), GLsizei(sl.size()), dz);
|
|
glUniform4fv(p.loc("uPopHole"), GLsizei(sl.size()), hole);
|
|
glUniform4fv(p.loc("uPopInfo"), GLsizei(sl.size()), info);
|
|
glUniform4fv(p.loc("uPopCol"), GLsizei(sl.size()), col);
|
|
}
|
|
glDrawArrays(GL_TRIANGLES, 0, 3);
|
|
std::vector<uint8_t> o = readPixels(out.fbo, ow, oh), rgb(size_t(ow) * oh * 3);
|
|
for (size_t i = 0, n = size_t(ow) * oh; i < n; i++)
|
|
for (int c = 0; c < 3; c++) rgb[i * 3 + c] = o[i * 4 + c];
|
|
stbi_write_png(path.c_str(), ow, oh, 3, rgb.data(), ow * 3);
|
|
std::printf("dumped %s (%dx%d: the surface plus %.0f px around, eye %.2f %.2f %.2f)\n", path.c_str(), ow, oh, double(m),
|
|
double(lastRenderHead.t.x), double(lastRenderHead.t.y), double(lastRenderHead.t.z));
|
|
glActiveTexture(GL_TEXTURE0);
|
|
out.destroy();
|
|
glDeleteTextures(1, &tex);
|
|
}
|
|
void dumpRoom(const std::string &path) {
|
|
mkdirs(dirname(path));
|
|
std::vector<uint8_t> px = readPixels(room.filled.fbo, room.W, room.H);
|
|
std::vector<uint8_t> rgb(size_t(room.W) * room.H * 3), known(size_t(room.W) * room.H);
|
|
for (size_t i = 0, n = size_t(room.W) * room.H; i < n; i++)
|
|
for (int c = 0; c < 3; c++) rgb[i * 3 + c] = px[i * 4 + c];
|
|
stbi_write_png(path.c_str(), room.W, room.H, 3, rgb.data(), room.W * 3);
|
|
std::vector<uint8_t> mp = readPixels(room.map[room.cur].fbo, room.W, room.H);
|
|
size_t seen = 0;
|
|
for (size_t i = 0, n = size_t(room.W) * room.H; i < n; i++) {
|
|
known[i] = mp[i * 4 + 3];
|
|
seen += known[i] > 127;
|
|
}
|
|
std::string kp = path;
|
|
size_t dot = kp.rfind(".png");
|
|
kp = (dot == std::string::npos ? kp : kp.substr(0, dot)) + "-known.png";
|
|
stbi_write_png(kp.c_str(), room.W, room.H, 1, known.data(), room.W);
|
|
std::printf("dumped room map %s and %s (%.1f%% of texels seen, %llu updates)\n", path.c_str(), kp.c_str(),
|
|
100.0 * double(seen) / double(size_t(room.W) * room.H), (unsigned long long)room.updates);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- loop
|
|
void status(uint64_t now, bool dash) {
|
|
const double dt = double(now - lastStatusNs) / 1e9;
|
|
float known = 0, lum = 0;
|
|
room.stats(known, lum);
|
|
const uint64_t seen = feed.framesSeen.load(), att = feed.attachedTotalNs();
|
|
std::string geo;
|
|
int vis = 0;
|
|
size_t slabs = 0, ghosts = 0, dropped = 0, plates = 0;
|
|
for (auto &s : surfaces) {
|
|
geo += (geo.empty() ? "" : ",") + s->spec.name + ":" + s->geoNote;
|
|
vis += s->spec.visible;
|
|
plates += s->plates.size();
|
|
slabs += s->slots.size();
|
|
ghosts += s->ghosts.size();
|
|
dropped += s->dropped.size();
|
|
}
|
|
std::printf("dash=%d surfaces=%d/%zu fps=%.1f gpu=%.2fms%s cpu=%.2fms room=%.1f/s (%.2fms, %llu skipped) feed=%s %.0f/s attached=%.0f%% "
|
|
"known=%.0f%% lum=%.2f env=%.2f masks=%zu%s plates=%zu slabs=%zu+%zu fading, %zu dropped geo=%s\n",
|
|
dash, vis, surfaces.size(), double(framesWindow) / dt, double(gpuMs()), renderTimer.have ? "" : "(cpu+finish)",
|
|
double(cpuMs), double(roomWindow) / dt, double(roomTimer.have ? roomTimer.ms : roomCpuMs),
|
|
(unsigned long long)roomSkipped, feedState().c_str(), double(seen - feedSeenAtStatus) / dt,
|
|
100.0 * double(att - feedAttachedAtStatus) / 1e9 / dt, double(known * 100), double(lum), double(envLuma), masks.size(),
|
|
masksComplete ? "" : "(incomplete)", plates, slabs, ghosts, dropped, geo.empty() ? "-" : geo.c_str());
|
|
feedSeenAtStatus = seen;
|
|
feedAttachedAtStatus = att;
|
|
framesWindow = 0;
|
|
roomWindow = 0;
|
|
lastStatusNs = now;
|
|
}
|
|
|
|
// --bench: GPU clock samples (MHz) from devfreq, for the summary
|
|
std::vector<float> benchClock;
|
|
uint64_t benchClockNs = 0;
|
|
void benchSample(uint64_t now) {
|
|
if (!opt.bench || now - benchClockNs < 100 * kMs) return;
|
|
benchClockNs = now;
|
|
if (FILE *f = std::fopen("/sys/class/devfreq/3d00000.gpu/cur_freq", "r")) {
|
|
long long hz = 0;
|
|
if (std::fscanf(f, "%lld", &hz) == 1 && hz > 0) benchClock.push_back(float(hz / 1e6));
|
|
std::fclose(f);
|
|
}
|
|
}
|
|
void benchSummary() {
|
|
std::vector<float> v = renderTimer.samples;
|
|
if (v.size() < 10) return;
|
|
v.erase(v.begin(), v.begin() + long(v.size() / 10)); // skip the warm-up tenth
|
|
std::sort(v.begin(), v.end());
|
|
std::vector<float> c = benchClock;
|
|
std::sort(c.begin(), c.end());
|
|
const float med = v[v.size() / 2], p90 = v[v.size() * 9 / 10], mhz = c.empty() ? 0.f : c[c.size() / 2];
|
|
std::printf("bench: %zu frames, gpu median %.2f ms, p90 %.2f ms, min %.2f ms; GPU clock median %.0f MHz (%.2f ms at 903 MHz)\n",
|
|
v.size(), double(med), double(p90), double(v.front()), double(mhz), mhz > 0 ? double(med * mhz / 903.f) : 0.0);
|
|
// Frames that overlap the compositor's own GPU work read longer (the
|
|
// timer counts the time the GPU spends on it in between): a second mode.
|
|
int hist[8] = {};
|
|
for (float x : v) hist[std::min(7, int(x / 0.5f))]++;
|
|
std::printf("bench: frames per 0.5 ms bin:");
|
|
for (int i = 0; i < 8; i++) std::printf(" %s%.1f:%d", i == 7 ? ">=" : "<", double((i + 1) * 0.5f - (i == 7 ? 0.5f : 0.f)), hist[i]);
|
|
std::printf("\n");
|
|
}
|
|
|
|
void loop() {
|
|
lastStatusNs = lastOutNs = lastLoopNs = lastHealthNs = monoNowNs();
|
|
uint64_t fpsWindowNs = lastStatusNs, fpsFrames = 0, lastSpecCheckNs = 0;
|
|
const bool wantDump = !opt.dumpDir.empty() || !opt.dumpRoom.empty() || opt.once;
|
|
while (!g_quit) {
|
|
const uint64_t now = monoNowNs();
|
|
const float dt = float(double(now - lastLoopNs) / 1e9);
|
|
lastLoopNs = now;
|
|
if (opt.timeout > 0 && now - startNs > uint64_t(opt.timeout * 1e9)) break;
|
|
if (!opt.orphanOk && origPpid > 1 && getppid() != origPpid) {
|
|
std::printf("parent %d exited; stopping\n", origPpid);
|
|
break;
|
|
}
|
|
|
|
vr::VREvent_t ev;
|
|
while (vr::VRSystem()->PollNextEvent(&ev, sizeof ev)) {
|
|
if (ev.eventType == vr::VREvent_Quit) {
|
|
std::printf("SteamVR is quitting\n");
|
|
vr::VRSystem()->AcknowledgeQuit_Exiting();
|
|
quitWith(kExitTemp);
|
|
}
|
|
}
|
|
if (g_quit) break;
|
|
if (g_loseRequest.exchange(false))
|
|
for (auto &s : surfaces)
|
|
if (s->ov != vr::k_ulOverlayHandleInvalid) loseOverlay(*s, "SIGUSR2 test");
|
|
healthCheck(now);
|
|
if (g_quit) break;
|
|
benchSample(now);
|
|
|
|
Pose hmd;
|
|
const bool test = opt.testPattern >= 0;
|
|
if (test) hmd.t = kTestEye; // fixed test head, looking down -z
|
|
if (test || samplePose(0.f, hmd)) {
|
|
hist.emplace_back(now, hmd);
|
|
while (hist.size() > 2 && now - hist.front().first > 1500000000ull) hist.pop_front();
|
|
room.follow(hmd.t, dt);
|
|
}
|
|
// the spec: at once on an inotify event, else polled at 10 Hz
|
|
if (!opt.demo) {
|
|
if (inoWd < 0 && now >= inoRetryNs) watchSpec();
|
|
const bool touched = drainSpecEvents();
|
|
if (touched || now - lastSpecCheckNs > 100000000ull) {
|
|
lastSpecCheckNs = now;
|
|
checkSpec();
|
|
}
|
|
}
|
|
for (size_t i = 0; i < retired.size();) {
|
|
if (now >= retired[i].freeAtNs) {
|
|
releaseBuffer(retired[i].buf);
|
|
retired.erase(retired.begin() + long(i));
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
expireGhosts(now);
|
|
|
|
const bool dash = opt.dashOverride >= 0 ? opt.dashOverride == 1 : test || vr::VROverlay()->IsDashboardVisible();
|
|
if (dash != lastDash) {
|
|
std::printf("dashboard %s\n", dash ? "visible" : "hidden");
|
|
lastDash = dash;
|
|
dashChangeNs = now;
|
|
graceDone = false;
|
|
masksNs = 0;
|
|
// Right after the dashboard hides, the wearer still faces where
|
|
// the window was: two shots then see the room it hid.
|
|
hideShotNs[0] = dash ? 0 : now + 600 * kMs;
|
|
hideShotNs[1] = dash ? 0 : now + 2000 * kMs;
|
|
}
|
|
if (!graceDone && now - dashChangeNs >= kGraceNs) {
|
|
graceDone = true;
|
|
masksNs = 0; // masks from geometry fetched after the window settled
|
|
}
|
|
const bool warm = opt.noFeed || feed.state.load() == -1 ? now - startNs > uint64_t((opt.warmup + 1) * 1e9)
|
|
: firstRoomNs && now - firstRoomNs > uint64_t(opt.warmup * 1e9);
|
|
const bool dumpDue = (wantDump && !dumpedOnce && warm) || g_dumpRequest.load();
|
|
const bool active = dash || opt.force || opt.once;
|
|
bool anyVisible = false;
|
|
for (auto &s : surfaces) anyVisible |= s->spec.visible;
|
|
|
|
// The feed streams only while glass is on screen; otherwise it is
|
|
// attached for one frame every 1/idle-hz s (v4l2cam works only
|
|
// while a reader is attached).
|
|
if (!opt.noFeed) {
|
|
const bool stream = active && (anyVisible || opt.force || opt.once);
|
|
feed.setStreaming(stream);
|
|
feed.minIntervalNs = uint64_t(1e9 / opt.feedHz);
|
|
if (!stream && opt.idleHz > 0 && (!lastShotNs || now - lastShotNs >= uint64_t(1e9 / opt.idleHz))) {
|
|
feed.requestShot();
|
|
lastShotNs = now;
|
|
}
|
|
for (uint64_t &t : hideShotNs)
|
|
if (t && now >= t) {
|
|
if (!stream && opt.idleHz > 0) feed.requestShot();
|
|
t = 0;
|
|
}
|
|
}
|
|
|
|
// Head pose for rendering; while tracking is lost keep the last one
|
|
// (or a standing head at the origin) so dumps and priming still work.
|
|
Pose head;
|
|
if (test) {
|
|
head.t = kTestEye + v3{opt.testHead[0], opt.testHead[1], opt.testHead[2]};
|
|
lastHead = head;
|
|
haveLastHead = true;
|
|
} else if (samplePose(opt.predictMs / 1000.f, head)) {
|
|
lastHead = head;
|
|
haveLastHead = true;
|
|
} else if (haveLastHead) {
|
|
head = lastHead;
|
|
} else {
|
|
head.t = {0, 1.6f, 0};
|
|
}
|
|
if (test) {
|
|
if (!testRoomDone && !surfaces.empty()) makeTestRoom(head, now);
|
|
} else {
|
|
updateMasks(dash, head, now);
|
|
updateRoom(dash, now);
|
|
}
|
|
roomTimer.poll();
|
|
|
|
// 0.08 deg / 1 mm: well under a frosted texel, above tracking jitter
|
|
const bool moved = poseAngle(head, lastRenderHead) > 0.0014f || length(head.t - lastRenderHead.t) > 0.001f;
|
|
const bool due = now >= nextRenderNs;
|
|
const bool animating = stepPhases(now); // materialize ramps render at full rate
|
|
lastPhaseNs = now;
|
|
// What is behind the glass moves (hands, people, the room's light): every room update is shown, at the
|
|
// feed's rate (within the fps cap), so the background behind the glass moves fluidly. At ~6 Hz it stepped.
|
|
const bool roomDue = roomDirty;
|
|
// Continuous rendering only while the dashboard is visible; one priming
|
|
// frame after a layout change so new buffers never sit empty.
|
|
const bool renderNow = !surfaces.empty() &&
|
|
((active && (anyVisible || opt.force || opt.once) && due && (moved || roomDue || layoutDirty || animating || opt.bench)) ||
|
|
(layoutDirty && due) || dumpDue);
|
|
if (renderNow) {
|
|
const bool forced = opt.force || opt.once || dumpDue || !active;
|
|
// priming renders while hidden don't ask SteamVR for quads
|
|
const bool fetchGeo = dash || opt.force || opt.once || dumpDue;
|
|
const uint64_t before = frames;
|
|
if (renderAll(head, forced, fetchGeo, now) > 0) {
|
|
lastRenderHead = head;
|
|
lastRenderNs = now;
|
|
roomDirty = layoutDirty = false;
|
|
fpsFrames++;
|
|
}
|
|
nextRenderNs = now + uint64_t(1e9 / opt.fps);
|
|
if (outPending || (before == 0 && frames > 0)) {
|
|
writeOut(false);
|
|
outPending = false;
|
|
}
|
|
}
|
|
if (outPending && now - outPendingSinceNs > 500000000ull) {
|
|
writeOut(false); // nothing could be rendered; announce the layout anyway
|
|
outPending = false;
|
|
}
|
|
if (dumpDue && (renderNow || surfaces.empty())) {
|
|
const bool sig = g_dumpRequest.exchange(false);
|
|
std::string dir = !opt.dumpDir.empty() ? opt.dumpDir : (sig ? "/tmp/lgs/glassd-dump" : "");
|
|
std::string roomPath = !opt.dumpRoom.empty() ? opt.dumpRoom : (sig ? "/tmp/lgs/glassd-dump/room.png" : "");
|
|
renderTimer.poll();
|
|
std::printf("dump frame: gpu %.2f ms%s\n", double(renderTimer.last), renderTimer.have ? "" : " (no timer)");
|
|
if (!roomPath.empty()) dumpRoom(roomPath);
|
|
if (!dir.empty()) dumpSurfaces(dir);
|
|
dumpedOnce = true;
|
|
if (opt.once) {
|
|
writeOut(false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (now - fpsWindowNs >= 1000000000ull) {
|
|
fpsNow = float(double(fpsFrames) * 1e9 / double(now - fpsWindowNs));
|
|
fpsFrames = 0;
|
|
fpsWindowNs = now;
|
|
}
|
|
if (now - lastOutNs >= 2000000000ull) writeOut(false);
|
|
if (now - lastStatusNs >= 5000000000ull) {
|
|
status(now, dash);
|
|
if (now - lastKeepNs >= 10000000000ull) { keepRoom(); lastKeepNs = now; }
|
|
}
|
|
|
|
// ~250 Hz while active (pose history + frame pacing), ~100 Hz idle;
|
|
// a spec change wakes the loop at once
|
|
const int waitMs = active ? 4 : 10;
|
|
if (inoFd >= 0) {
|
|
pollfd p{inoFd, POLLIN, 0};
|
|
poll(&p, 1, waitMs);
|
|
} else {
|
|
usleep(useconds_t(waitMs) * 1000);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char **argv) {
|
|
setvbuf(stdout, nullptr, _IOLBF, 0);
|
|
Options opt;
|
|
if (!parseArgs(argc, argv, opt)) {
|
|
usage();
|
|
return 2;
|
|
}
|
|
struct sigaction sa {};
|
|
sa.sa_handler = onSignal;
|
|
sigaction(SIGTERM, &sa, nullptr);
|
|
sigaction(SIGINT, &sa, nullptr);
|
|
sigaction(SIGHUP, &sa, nullptr);
|
|
sigaction(SIGUSR1, &sa, nullptr);
|
|
sigaction(SIGUSR2, &sa, nullptr);
|
|
signal(SIGPIPE, SIG_IGN);
|
|
if (opt.selftest == "phase") return selftestPhase();
|
|
std::printf("starting (spec %s, out %s, key prefix %s, scale %.2f, pid %d)\n", opt.demo ? "<demo>" : opt.spec.c_str(), opt.out.c_str(),
|
|
opt.keyPrefix.c_str(), double(opt.scale), int(getpid()));
|
|
Glassd g(opt);
|
|
int rc = g.run();
|
|
std::printf("exit %d\n", rc);
|
|
return rc;
|
|
}
|