Packages¶
go-xrkit is a single Go module, github.com/go-xrkit/xrkit, split into
focused packages. Import only what you need; each is usable on its own and
depends on nothing third-party.
pose¶
Quaternions, the Euler convention head trackers actually report, recentring and smoothing.
q := pose.FromEulerZXY(pose.Euler{Yaw: -30, Pitch: 10})
dir := q.Rotate(pose.Vec3{Z: -1}) // where the viewer is looking
r := pose.NewRecentre()
r.Set(q) // "this is straight ahead now"
rel := r.Apply(next)
s := pose.Smoother{Alpha: 0.35} // a tracker is noisy at rest
smooth := s.Update(rel)
Yaw is applied last. FromEulerZXY composes roll, then pitch, then yaw, so
yaw stays a turn about the global up axis. Compose it first instead and
pitching to 90° no longer looks straight up: there is no gimbal lock, and the
horizon swings as the viewer raises their head. This package shipped that bug
once; every single-axis test passed while it did, because with one non-zero
angle the order cannot matter.
stereo¶
How a frame packs two eyes.
f := stereo.Format{Layout: stereo.SideBySide}
r := f.EyeRect(stereo.Left, 3840, 1080) // {0, 0, 1920, 1080}
Layouts: mono, side-by-side, top-bottom. Swapped is an explicit flag, never a
guess: eye-reversed material is not detectable from the pixels, and getting it
wrong inverts the depth of the whole scene — which viewers report as eye
strain rather than as a wrong picture. An odd frame dimension floors the split
and leaves the middle line unread: losing one column is invisible, a column of
the wrong eye is not.
projection¶
Direction ↔ picture.
vp := projection.Viewport{Width: 1920, Height: 1080, FOVyDeg: 90}
dir := vp.LookRay(headOrientation, x, y)
u, v, ok := projection.Sphere360.Sample(dir)
if !ok {
// outside the content — show background, do not clamp: clamping smears
// the edge pixels across the whole of the missing region
}
Three projections: Flat (a virtual screen), Equirect (360×180, or VR180's
180×180) and Fisheye (equidistant, 180° to 200°). Fisheye is equidistant,
not tangent: radius is proportional to the angle from the axis. A tangent law
agrees at the centre and is wrong everywhere else — the kind of error that
looks plausible in a still.
Rays are taken through pixel centres. Sampling the corner instead biases the whole image by half a pixel: invisible alone, a visible seam where two views meet.
warp¶
The projection precomputed into a lookup table, so reshaping a video frame for one eye costs a copy rather than a computation.
m := warp.Build(vp, projection.Sphere360, q, warp.Source{
Width: srcW, Height: srcH, Stride: srcW,
Eye: f.EyeRect(stereo.Left, srcW, srcH),
})
m.ApplySwapRB(src, dst, dstStride, 0, 0)
The per-pixel trigonometry in projection is fine to reason about and far too
slow for four million pixels sixty times a second. But it only depends on the
viewer's orientation, and when that is fixed — no head tracking, a screen
anchored in front of the viewer — the answer for every output pixel is the
same on every frame. So it is computed once into a table of source offsets,
and each frame becomes a gather. Nearest-neighbour sampling is deliberate: the
output of an immersive viewer is already a magnification of the source, so the
visible artefact is the magnification itself, not the interpolation.
A Map is tied to the geometry it was built for — orientation, output size,
projection, source dimensions — and must be rebuilt if any of those change.
Measured, reshaping both eyes at once: ~3 ms at 4K, ~8 ms at 8K, zero allocations per frame — against a 16.6 ms budget at 60 Hz.
ribbon¶
Screens on a 360° band: where each one sits, and how they composite into one equirectangular panorama by yaw.
The reason this is a package and not a loop in an application is a performance
fact that decides the whole design. Building a warp.Map costs 56.5 ms, and
there are 16.6 ms in a frame at 60 Hz — so a ribbon whose yaw changed the
warp table could never be turned. But on an equirectangular panorama a yaw is
exactly a horizontal shift. So the yaw is applied when the screens are
composited into the panorama, where it costs nothing at all, and the distortion
table is never rebuilt.
ribbon also decides how many screens fit and how wide each one is, from the
headset's own optics rather than from configuration: each screen gets exactly
one eye's resolution and exactly the arc that eye can see, so looking straight
at one shows it edge to edge at one source pixel per output pixel.
glasses¶
Which display is the headset, and how wide is the view.
Every entry in the catalogue records how far it has been checked, because almost everything about a headset is read off a specification sheet, and a field of view taken from a wrong data sheet renders everything in the wrong place while nothing about the picture says so:
| Confidence | Meaning |
|---|---|
Observed |
connected, and its modes seen |
Enumerated |
seen on the bus, but its video was never connected |
Published |
sourced and cited, never plugged in here |
Identification is not one thing — a display name, a USB vendor/product pair and a mode list each answer a different question, and the package keeps them separate rather than pretending one implies another.
The catalogue will always be behind the hardware, so a model can be declared in an HCL file under the platform's config directory, and a user entry wins ties against the built-in one. HCL is the module's one dependency, and it is there for a specific reason: a figure about a headset is only worth something with its provenance attached, and HCL has comments, so what you measured sits next to the number you measured it from. A file that exists and is wrong is an error naming the file, the line and the block — a catalogue line that quietly does nothing is the same invisible failure as a wrong angle.