# Spike S3 — FINDINGS: distortion ground truth

**Status: PASS, 2026-06-09.** Standalone evaluator matches OpenVR `ComputeDistortion`
to **0.0002 px max error** (float32 noise floor) across 65×65 grid × 3 channels × 2 eyes
on unit LHR-599F3B91 (enlyzeam rig). Criterion was sub-pixel; beaten by ~4 orders.

## The verified formula (per eye, per color channel)

Input: panel/viewport UV `(u,v)`, origin top-left of the eye's viewport (OpenVR
`ComputeDistortion` convention). Output: source UV into the eye's render target.

```
t   = (2u−1, 2v−1)                      # per-eye NDC, y down
t  -= (center_x, center_y)              # shared across channels within an eye
r²  = t·t                               # NO clamp (see below)
d   = 1 + k1·r² + k2·r⁴ + k3·r⁶         # DIRECT polynomial — NOT reciprocal
uv_src = 0.5 + (t·d + center) · 0.5/(1 + grow_for_undistort)
```

`(k1,k2,k3)` = the `coeffs` of `distortion_red` / `distortion` / `distortion_blue`
verbatim from the device config JSON. Implementation: `poly3.py` (hypothesis
`poly+grow` in `verify.py`).

### Answers to the open questions

1. **POLY3 direction:** production `DISTORT_POLY3` (positive leading coeffs) is the
   **direct** polynomial `d = 1 + k1·r² + …` applied panel→source. The Monado/OpenHMD
   Vive evaluator's reciprocal form `d = 1/(1+…)` is **wrong for POLY3** — it loses by
   2578 px max. The historical EVT/DVT `DISTORT_DPOLY3` negative coeffs are explained:
   a reciprocal re-fit of the dump lands near −0.21 leading (vs +0.209 config), i.e.
   DPOLY3(−k) ≈ 1/POLY3(+k) — same optics, opposite fit direction. Only POLY3 is
   validated here; all production Beyonds are POLY3, so DPOLY3 handling is moot.
2. **`grow_for_undistort` normalization:** exactly `0.5/(1+grow)` on the output, exactly
   as Monado does it. Confirmed numerically: fitted scale/offset reproduce
   `0.31250 = 0.5/1.6` and `0.5 + center·0.3125` to 5 decimals.
3. **`undistort_r2_cutoff` is NOT applied** by `ComputeDistortion`: panel corners reach
   r² ≈ 2.1 > cutoff 1.0 and still follow the polynomial exactly. Clamping at the cutoff
   was the sole reason the otherwise-correct hypothesis initially failed (1395 px corner
   error). The cutoff presumably gates something else (mesh/hidden-area). Our warp must
   not clamp within the panel.

## Bonus ground truth captured (load-bearing for M2)

- **`GetEyeToHeadTransform` = identity rotation + (∓31 mm, 0, 0)** — the lighthouse
  driver does NOT expose the config `eye_to_head` canting rotation. Config has
  `parallel_render_cameras: true`: SteamVR renders parallel cameras and folds the
  ±6.17° yaw / ~−5° pitch cant into the **asymmetric projection** instead.
- **`GetProjectionRaw` ≠ intrinsics frustum.** Dump (left eye): L −1.400 R +0.913
  T −1.380 B +0.923 vs intrinsics-derived ±0.70. The raw projection = grow-expanded,
  cant-folded window; the `uv_src` formula above lands in exactly this window
  (`tan = L + u_src·(R−L)`, y-down, v=0 ↦ T). M2 must either replicate this fold
  (derive closed form config → frustum; the dump + fitted per-eye offset/scale
  `a=(−0.2505,−0.2090)L /(+0.2492,−0.1866)R`, `s=(0.7228,0.7197)L /(0.7208,0.7132)R`
  are the ground truth to derive against) or render genuinely canted cameras with its
  own consistent warp math — NOT mix the two.
- **IPD translation comes from a user setting, not the config:** dump says 62.0 mm,
  config `ipd.default_mm` = 69. Source: `steamvr.vrsettings →
  driver_BeyondSteamVR.ipd_mm = 62.000003814697266` (Bigscreen's SteamVR driver
  utility setting; `steamvr.ipdOffset` ≈ 0 here). v1: default to config
  `ipd.default_mm`, expose a user override exactly like Bigscreen does.
- **Intrinsics sign convention settled:** distortion `center_x = −K[0][2]`,
  `center_y = +K[1][2]` (confirmed on both eyes of this unit; matches the B1
  production template pattern).
- `steamvr.vrsettings` also confirms the direct-mode EDID whitelist entry
  (`direct_mode: edidVid3 9993 = 0x2709, edidPid3 4660 = 0x1234`) and that users can
  carry per-channel color-gain overrides (`hmdDisplayColorGain* = 0.8/0.85/1.2` on this
  rig — remember when judging eyes-in color).

## Method

1. `dump_openvr.py` — pyopenvr `VRApplication_Background` against a running SteamVR
   with the Beyond connected (the one sanctioned dev-time SteamVR run). 65×65 UV grid
   per eye, all 4225 points ok both eyes; plus `GetProjectionRaw`,
   `GetEyeToHeadTransform`, recommended RT (3104×3104 @ supersampleScale 0.76).
2. `verify.py` — scores 4 closed-form hypotheses {poly, recip} × {grow rescale on/off}
   in panel pixels (2544/eye). Result: poly+grow 0.0002 px; next best 1795 px. PASS.
3. `analyze.py` — the step that found the formula after all 4 original hypotheses
   failed: 9-param free fit (center, offset, scale, k1..3) per eye/channel in
   `GetProjectionRaw` tangent space. Direct-poly fit converged to **0.000 px with
   fitted center and coeffs equal to the config values to 5 decimals** — proving the
   config values are used verbatim and isolating the normalization algebra, which then
   reduced exactly to the formula above.
4. Ops note: OpenVR IPC refuses connections from SSH logon sessions. Workaround that
   works: `schtasks /create … /it` + `schtasks /run` to execute the dump inside the
   interactive session (see git history for the exact incantation).

Artifacts in this directory: `distortion_dump_lhr_599f3b91.json` (ground truth, 1.4 MB),
`config_lhr_599f3b91.json` (unit config snapshot), `poly3.py` (the evaluator to port to
the compositor shader in M2), `verify.py`, `analyze.py`, `dump_openvr.py`.

## M2 stereo resolution (2026-06-10, LHR-1F8E25F1, eyes-in) — CANTED branch wins

The "replicate the fold OR render genuinely canted cameras" question is now
settled **in favor of canted cameras**. Parallel cameras + the fold contract
(exact replication of GetProjectionRaw + ComputeDistortion, verified to
0.0002 px / 0.000000 tan) produced broken stereo convergence off-center
("lines outside the central convergence point quickly diverge" — a
5-year-headset-QC eyeball). Rendering with the full per-eye `eye_to_head`
ROTATION in the view and the UNFOLDED grow-expanded intrinsics window
(`(±G−c)/f`, same q-space, warp unchanged) fixed fusion instantly.

Interpretation: the calibration polynomials/intrinsics live in the
LENS-AXIS (canted) frame; Valve's parallel+fold contract is an affine
approximation whose error is a per-eye homography — invisible to monocular
grid tests (lines stay straight under any homography), mirrored between
eyes, so it surfaces only as stereo divergence. The 65×65 dump remains
ground truth for the WARP; the fold-derived window is only the contract's
approximation of the camera, not the optically correct camera.

Also eyes-in falsified: clamping r² at `undistort_r2_cutoff` in the warp
(Monado behavior) creates a jarring distortion boundary near the FOV edge —
the unclamped extrapolated polynomial (= ComputeDistortion behavior, the S3
measurement above) is what the optics actually want.

## Eyes-in validation — PASS, 2026-06-09 (enlyzeam, user eyeball)

`make_warped_grid.py` renders the verified warp into a static pre-distorted grid
(per-channel, straight in source/tangent space) for this unit's config; presented at
native 5088×2544@75 via `s2_nvapi present --image` (new static-image path,
CopyTextureRegion into the DM surfaces, no shaders). Two 120 s runs, 74.99 fps:

1. **Grid rectilinear through the lens, both eyes** — warp direction + coeffs correct.
2. **Chromatic aberration stable.** Residual fringing only where lines alias against
   the pixel grid — the generator is point-sampled with no AA, not a warp error.
   (M2 warp shader gets AA/filtering for free by sampling a rendered texture.)
3. **Up-marker triangle points up** — v orientation confirmed (v=0 = top, no flip).
4. **Left-eye marker in left eye only** — eye assignment confirmed (left half of the
   5088-wide surface = left eye).
5. Stereo convergence not achievable — expected: the test image carries no IPD
   translation and no canting compensation (both M2; the SteamVR dump quantifies
   exactly what's missing: cant folded into projection + 62 mm eye separation).

Incidental observation for M1: ~105 s into run 1 the present loop went free-run
(75 → 5000+ "fps", waitable instantly signaled, presentErrors 0) — headset doffed,
proximity powered panels down, vsync source vanished. The compositor needs prox/power
state handling and free-run detection; a present loop that "succeeds" at 5000 fps is
scanning out to nothing.
