Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ This file tracks user-facing, integration-facing, and runtime-relevant changes f

### Added

- Added gaze-driven foveated encoding. Clients that advertise
`CLIENT_CAPABILITY_FOVEATION_CENTER` may report an eye-gaze direction in the tracking packet
(`TrackingPacket.gazeDirection`, gated by `TRACKING_FLAG_EYE_GAZE_ACTIVE`), and the runtime
steers the foveated-encode centre to follow it instead of holding a fixed centre.
The centre the encoder warped with is carried back per frame, quantized into the two spare bytes
of `VideoPacketHeader`/`TcpVideoNalHeader` and of `TcpRenderPose`, flagged by
`VIDEO_FLAG_FOVEATION_CENTER`, so a client reconstructs with exactly the parameters the server
used. Wire sizes are unchanged except `TrackingPacket`, which grows by an appended field that
older clients simply omit. Moving the centre never changes the encoded size, so it needs no
encoder reconfigure; clients without the capability keep the previous fixed-centre behaviour.

- Added first-class macOS `arm64` and `x86_64` CI lanes plus universal release packaging with
architecture validation for the runtime and OXRSys Home.
- Added a macOS/iOS simulator build lane covering the Cardboard-style stereo viewer and ARKit
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ add_executable(oxrsys_runtime_tests
tests/TestBoundedDrain.cpp
tests/TestClientLiveness.cpp
tests/TestConfig.cpp
tests/TestGazeFoveation.cpp
tests/TestInputManager.cpp
tests/TestRuntimePlatform.cpp
tests/TestStreamingAbr.cpp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ public struct TcpVideoNalHeader: Sendable {
public var payloadSize: UInt32 = 0
public var flags: UInt8 = 0
public var codec: UInt8 = 0
public var reserved: UInt16 = 0
// Mirrors VideoPacketHeader: gaze-driven foveation centre for this frame, meaningful only
// when VideoFlags.foveationCenter is set. Reuses the two bytes formerly named `reserved`.
public var foveationCenterX: Int8 = 0
public var foveationCenterY: Int8 = 0
public var reserved2: UInt32 = 0

public init() {}
Expand All @@ -67,7 +70,12 @@ public struct TcpVideoNalHeader: Sendable {
public struct TcpRenderPose: Sendable {
public var presentationTimeNs: Int64 = 0
public var frameIndex: UInt32 = 0
public var reserved: UInt32 = 0
// Gaze-driven foveation centre for this frame, carried alongside the render pose because
// the client needs both to present it correctly. Reuses the bytes formerly named `reserved`.
public var foveationCenterX: Int8 = 0
public var foveationCenterY: Int8 = 0
public var hasFoveationCenter: UInt8 = 0
public var reservedPad: UInt8 = 0
public var position: (Float, Float, Float) = (0, 0, 0)
public var orientation: (Float, Float, Float, Float) = (0, 0, 0, 1)
public var reserved2: UInt32 = 0
Expand Down Expand Up @@ -121,6 +129,9 @@ public struct ClientCapabilityFlags {
public static let spatialEntity: UInt32 = 0x00000100
public static let sceneCapture: UInt32 = 0x00000200
public static let tenBitEncoding: UInt32 = 0x00000400
// Client un-warps every frame with the centre carried in VideoFlags.foveationCenter /
// TcpRenderPose. Without this bit the server keeps the centre static.
public static let foveationCenter: UInt32 = 0x00000800
}

public enum FoveationPreset: UInt32, Sendable {
Expand Down Expand Up @@ -271,7 +282,11 @@ public struct VideoPacketHeader: Sendable {
public var flags: UInt8 = 0
public var codec: UInt8 = 0
public var fecGroupLastPacketPayloadSize: UInt16 = 0
public var reserved: UInt16 = 0
// Gaze-driven foveated-encoding centre for this frame, quantized to [-127, 127]. Meaningful
// only when VideoFlags.foveationCenter is set; zero otherwise, which is the fixed-centre
// behaviour older clients already assume. Reuses the bytes formerly named `reserved`.
public var foveationCenterX: Int8 = 0
public var foveationCenterY: Int8 = 0
public var presentationTimeNs: Int64 = 0

public init() {}
Expand All @@ -286,6 +301,7 @@ public struct VideoFlags {
public static let fec: UInt8 = 0x10
public static let renderPose: UInt8 = 0x20
public static let alphaBlend: UInt8 = 0x40
public static let foveationCenter: UInt8 = 0x80
}

public struct AudioPacketHeader: Sendable {
Expand Down Expand Up @@ -355,6 +371,11 @@ public struct TrackingPacket: Sendable {
public var rightControllerAimPos: (Float, Float, Float) = (0, 0, 0)
public var rightControllerAimRot: (Float, Float, Float, Float) = (0, 0, 0, 1)

// Eye gaze direction in head space as a unit vector (-Z forward). Valid only when
// TrackingFlagsValues.eyeGazeActive is set; visionOS never exposes a raw gaze ray to
// applications, so Swift clients leave it zero. Appended so the C++ struct stays in sync.
public var gazeDirection: (Float, Float, Float) = (0, 0, 0)

public init() {}
}

Expand Down Expand Up @@ -404,6 +425,7 @@ public struct TrackingFlagsValues {
public static let rightHandActive: UInt32 = 0x0002
public static let leftControllerActive: UInt32 = 0x0004
public static let rightControllerActive: UInt32 = 0x0008
public static let eyeGazeActive: UInt32 = 0x0010
}

public struct ButtonFlags {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ final class ProtocolLayoutTests: XCTestCase {
XCTAssertEqual(ServerFeatureFlags.streamReconfigure, 0x00000010)
XCTAssertEqual(ClientCapabilityFlags.streamReconfigure, 0x00000010)
XCTAssertEqual(ClientCapabilityFlags.tenBitEncoding, 0x00000400)
XCTAssertEqual(ClientCapabilityFlags.foveationCenter, 0x00000800)
XCTAssertEqual(VideoCodec.h264.rawValue, 1)
XCTAssertEqual(ClientCodecCapability.h265, 0x00000001)
XCTAssertEqual(ClientCodecCapability.h264, 0x00000002)
Expand Down Expand Up @@ -111,11 +112,20 @@ final class ProtocolLayoutTests: XCTestCase {
func testVideoAndControlLayoutsMatchCppWireFormat() {
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.size, 24)
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.offset(of: \.fecGroupLastPacketPayloadSize), 12)
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.offset(of: \.reserved), 14)
// foveationCenterX/Y reuse bytes formerly named `reserved`: same offsets, same sizes.
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.offset(of: \.foveationCenterX), 14)
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.offset(of: \.foveationCenterY), 15)
XCTAssertEqual(MemoryLayout<VideoPacketHeader>.offset(of: \.presentationTimeNs), 16)
XCTAssertEqual(VideoFlags.foveationCenter, 0x80)
XCTAssertEqual(MemoryLayout<TcpRecordHeader>.size, 12)
XCTAssertEqual(MemoryLayout<TcpVideoNalHeader>.size, 24)
XCTAssertEqual(MemoryLayout<TcpVideoNalHeader>.offset(of: \.foveationCenterX), 18)
XCTAssertEqual(MemoryLayout<TcpVideoNalHeader>.offset(of: \.foveationCenterY), 19)
XCTAssertEqual(MemoryLayout<TcpRenderPose>.size, 48)
XCTAssertEqual(MemoryLayout<TcpRenderPose>.offset(of: \.foveationCenterX), 12)
XCTAssertEqual(MemoryLayout<TcpRenderPose>.offset(of: \.foveationCenterY), 13)
XCTAssertEqual(MemoryLayout<TcpRenderPose>.offset(of: \.hasFoveationCenter), 14)
XCTAssertEqual(MemoryLayout<TcpRenderPose>.offset(of: \.position), 16)
XCTAssertEqual(MemoryLayout<TcpAudioHeader>.size, 24)
XCTAssertEqual(OXRProtocol.tcpRecordMagic, 0x4f585255)
XCTAssertEqual(MemoryLayout<AudioPacketHeader>.size, 32)
Expand All @@ -128,7 +138,10 @@ final class ProtocolLayoutTests: XCTestCase {
}

func testTrackingLayoutMatchesCppWireFormat() {
XCTAssertEqual(MemoryLayout<TrackingPacket>.size, 1064)
// The C++ struct is 1080 (gazeDirection ends at 1076, int64 alignment pads to 8);
// Swift's `size` excludes that trailing padding, `stride` matches sizeof.
XCTAssertEqual(MemoryLayout<TrackingPacket>.size, 1076)
XCTAssertEqual(MemoryLayout<TrackingPacket>.stride, 1080)
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.headLinearVelocity), 152)
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.headAngularVelocity), 164)
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.leftHandJoints), 176)
Expand All @@ -138,7 +151,11 @@ final class ProtocolLayoutTests: XCTestCase {
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.leftControllerAimRot), 1020)
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.rightControllerAimPos), 1036)
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.rightControllerAimRot), 1048)
// Eye gaze appended after the aim poses; Swift clients send it zeroed with the flag
// clear, which the server treats as no gaze.
XCTAssertEqual(MemoryLayout<TrackingPacket>.offset(of: \.gazeDirection), 1064)
XCTAssertEqual(TrackingFlagsValues.leftControllerActive, 0x0004)
XCTAssertEqual(TrackingFlagsValues.rightControllerActive, 0x0008)
XCTAssertEqual(TrackingFlagsValues.eyeGazeActive, 0x0010)
}
}
115 changes: 98 additions & 17 deletions common/protocol/include/oxrsys/protocol/Foveation.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,58 @@ inline uint32_t AlignUp(uint32_t value, uint32_t alignment)
return alignment == 0 ? value : ((value + alignment - 1u) / alignment) * alignment;
}

// Snap a requested center shift onto the same grid the edge compression uses. Server and client
// must call this with identical inputs or the client's un-warp will not match the server's warp.
inline float AlignCenterShift(float requestedShift,
float centerSizeAligned,
float targetSize,
float edgeRatio)
{
const float edgeSizeAligned = targetSize - centerSizeAligned * targetSize;
if (edgeSizeAligned <= 0.0f)
{
return 0.0f;
}
// Round to nearest: std::ceil would bias every off-grid value toward +1, drifting the foveal
// region right/down by up to one grid cell. Harmless while the shift was always zero; real
// once gaze drives it. edgeSizeAligned is a multiple of 2 * edgeRatio up to float error, so
// +-1.0 land within an ulp of exact and the clamp holds the shader's |shift| <= 1 invariant.
const float aligned = std::round(requestedShift * edgeSizeAligned / (edgeRatio * 2.0f)) *
(edgeRatio * 2.0f) / edgeSizeAligned;
return std::clamp(aligned, -1.0f, 1.0f);
}

// Wire representation of a gaze-driven center shift. Quantized so the value the server warps with
// is exactly the value the client un-warps with: quantize first, then use the dequantized result
// on both sides. Two int8s ride in VideoPacketHeader's spare bytes, so this costs no bandwidth.
inline int8_t QuantizeCenterShift(float shift)
{
// NaN falls through std::clamp unchanged and std::lround on it is unspecified; the caller
// guards its inputs, but a poisoned value must never reach the wire.
if (std::isnan(shift))
{
return 0;
}
const float clamped = std::clamp(shift, -1.0f, 1.0f);
return static_cast<int8_t>(std::lround(clamped * 127.0f));
}

inline float DequantizeCenterShift(int8_t quantized)
{
return static_cast<float>(quantized) / 127.0f;
}

// The one decode both ends must share: the server's warp and the client's un-warp each turn the
// transmitted byte into a shift through this exact call, so the reconstruction is bit-identical.
inline float DecodeCenterShift(int8_t quantized,
float centerSizeAligned,
float targetSize,
float edgeRatio)
{
return AlignCenterShift(
DequantizeCenterShift(quantized), centerSizeAligned, targetSize, edgeRatio);
}

inline FoveationLayout CalculateFoveationLayout(uint32_t targetEyeWidth,
uint32_t targetEyeHeight,
FoveationPreset preset,
Expand Down Expand Up @@ -93,23 +145,10 @@ inline FoveationLayout CalculateFoveationLayout(uint32_t targetEyeWidth,
1.0f - std::ceil(edgeSizeY / (params.edgeRatioY * 2.0f)) *
(params.edgeRatioY * 2.0f) / targetH;

const float edgeSizeXAligned = targetW - centerSizeXAligned * targetW;
const float edgeSizeYAligned = targetH - centerSizeYAligned * targetH;

float centerShiftXAligned = 0.0f;
float centerShiftYAligned = 0.0f;
if (edgeSizeXAligned > 0.0f)
{
centerShiftXAligned =
std::ceil(params.centerShiftX * edgeSizeXAligned / (params.edgeRatioX * 2.0f)) *
(params.edgeRatioX * 2.0f) / edgeSizeXAligned;
}
if (edgeSizeYAligned > 0.0f)
{
centerShiftYAligned =
std::ceil(params.centerShiftY * edgeSizeYAligned / (params.edgeRatioY * 2.0f)) *
(params.edgeRatioY * 2.0f) / edgeSizeYAligned;
}
const float centerShiftXAligned =
AlignCenterShift(params.centerShiftX, centerSizeXAligned, targetW, params.edgeRatioX);
const float centerShiftYAligned =
AlignCenterShift(params.centerShiftY, centerSizeYAligned, targetH, params.edgeRatioY);

params.centerSizeX = std::clamp(centerSizeXAligned, 0.0f, 1.0f);
params.centerSizeY = std::clamp(centerSizeYAligned, 0.0f, 1.0f);
Expand All @@ -131,6 +170,48 @@ inline FoveationLayout CalculateFoveationLayout(uint32_t targetEyeWidth,
return layout;
}

// The receiving half of the above: reconstruct the exact layout the server warped with, from the
// two quantized bytes carried in VideoPacketHeader.
inline void ApplyQuantizedCenterShift(FoveationLayout& layout, int8_t quantX, int8_t quantY)
{
if (layout.preset == FoveationPreset::Off || layout.targetEyeWidth == 0 ||
layout.targetEyeHeight == 0)
{
return;
}

const float targetW = static_cast<float>(layout.targetEyeWidth);
const float targetH = static_cast<float>(layout.targetEyeHeight);
layout.parameters.centerShiftX = DecodeCenterShift(quantX,
layout.parameters.centerSizeX,
targetW,
layout.parameters.edgeRatioX);
layout.parameters.centerShiftY = DecodeCenterShift(quantY,
layout.parameters.centerSizeY,
targetH,
layout.parameters.edgeRatioY);
}

// Move the foveal region to follow gaze. Safe to call per frame: the optimized (encoded) size
// depends only on centerSize and edgeRatio, so shifting the center never resizes the stream and
// never needs an encoder reconfigure.
//
// shiftX/shiftY are normalized gaze offsets from the eye's view centre in [-1, 1]. They are
// quantized to the wire representation before use so that a client decoding the quantized value
// from the packet header reproduces this layout bit-for-bit.
inline void ApplyGazeCenterShift(FoveationLayout& layout, float shiftX, float shiftY)
{
if (layout.preset == FoveationPreset::Off || layout.targetEyeWidth == 0 ||
layout.targetEyeHeight == 0)
{
return;
}

ApplyQuantizedCenterShift(layout,
QuantizeCenterShift(shiftX),
QuantizeCenterShift(shiftY));
}

inline bool IsFoveatedEncodingLayoutUsable(const FoveationLayout& layout,
uint32_t sourceEyeWidth,
uint32_t sourceEyeHeight)
Expand Down
32 changes: 29 additions & 3 deletions common/protocol/include/oxrsys/protocol/Protocol.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,23 @@ struct TcpVideoNalHeader
uint32_t payloadSize = 0;
uint8_t flags = 0;
uint8_t codec = 0;
uint16_t reserved = 0;
// Mirrors VideoPacketHeader: gaze-driven foveation centre for this frame, meaningful only
// when VIDEO_FLAG_FOVEATION_CENTER is set. Reuses the two bytes formerly named `reserved`.
int8_t foveationCenterX = 0;
int8_t foveationCenterY = 0;
uint32_t reserved2 = 0;
};

struct TcpRenderPose
{
int64_t presentationTimeNs = 0;
uint32_t frameIndex = 0;
uint32_t reserved = 0;
// Gaze-driven foveation centre for this frame, carried alongside the render pose because the
// client needs both to present it correctly. Reuses the four bytes formerly named `reserved`.
int8_t foveationCenterX = 0;
int8_t foveationCenterY = 0;
uint8_t hasFoveationCenter = 0;
uint8_t reservedPad = 0;
float position[3] = {};
float orientation[4] = {0, 0, 0, 1};
uint32_t reserved2 = 0;
Expand Down Expand Up @@ -128,6 +136,11 @@ enum ClientCapabilityFlags : uint32_t
CLIENT_CAPABILITY_SPATIAL_ENTITY = 0x00000100,
CLIENT_CAPABILITY_SCENE_CAPTURE = 0x00000200,
CLIENT_CAPABILITY_TEN_BIT_ENCODING = 0x00000400, // client can decode HEVC Main10
// Client un-warps every frame with the centre carried in VIDEO_FLAG_FOVEATION_CENTER /
// TcpRenderPose. Without this bit the server must keep the centre static: a client that
// un-warps with a different centre than the server warped with reconstructs a geometrically
// wrong image, not merely a stale one.
CLIENT_CAPABILITY_FOVEATION_CENTER = 0x00000800,
};

enum ClientCodecCapabilityFlags : uint32_t
Expand Down Expand Up @@ -244,7 +257,12 @@ struct VideoPacketHeader
uint8_t flags; // See VideoFlags
uint8_t codec; // VideoCodec cast to u8
uint16_t fecGroupLastPacketPayloadSize; // FEC packets: payload size of this group's last data packet
uint16_t reserved = 0;
// Gaze-driven foveated-encoding centre for this frame, quantized to [-127, 127] by
// protocol::QuantizeCenterShift. Meaningful only when VIDEO_FLAG_FOVEATION_CENTER is set;
// zero otherwise, which is the fixed-centre behaviour older clients already assume. Occupies
// the two bytes previously named `reserved`, so the wire layout is unchanged.
int8_t foveationCenterX = 0;
int8_t foveationCenterY = 0;
int64_t presentationTimeNs; // Server-side timestamp
};

Expand All @@ -258,6 +276,7 @@ enum VideoFlags : uint8_t
VIDEO_FLAG_FEC = 0x10, // Forward Error Correction parity packet
VIDEO_FLAG_RENDER_POSE = 0x20, // Payload contains the server's render pose for this frame
VIDEO_FLAG_ALPHA_BLEND = 0x40, // App submitted alpha-blend environment or source-alpha projection layer
VIDEO_FLAG_FOVEATION_CENTER = 0x80, // foveationCenterX/Y carry this frame's gaze-driven centre
};

struct AudioPacketHeader
Expand Down Expand Up @@ -330,6 +349,12 @@ struct TrackingPacket
float leftControllerAimRot[4];
float rightControllerAimPos[3];
float rightControllerAimRot[4];

// Eye gaze direction in head space as a unit vector (-Z forward), from
// XR_EXT_eye_gaze_interaction. Drives the server's foveated-encoding centre. Valid only when
// TRACKING_FLAG_EYE_GAZE_ACTIVE is set. Appended at the end so shorter packets from clients
// that predate this field stay readable.
float gazeDirection[3];
};

enum ButtonFlags : uint32_t
Expand All @@ -353,6 +378,7 @@ enum TrackingFlags : uint32_t
TRACKING_FLAG_RIGHT_HAND_ACTIVE = 0x0002,
TRACKING_FLAG_LEFT_CONTROLLER_ACTIVE = 0x0004,
TRACKING_FLAG_RIGHT_CONTROLLER_ACTIVE = 0x0008,
TRACKING_FLAG_EYE_GAZE_ACTIVE = 0x0010,
};

// ─── Control Channel (bidirectional, UDP on CONTROL_PORT) ───────────────────
Expand Down
Loading
Loading