Skip to main content

Timing

Everything LABO writes to disk carries a time value. Analysis accuracy depends on knowing which clock that value came from, when it was sampled, and how it relates to what the participant actually saw. This page is the reference every stream page and every recipe links back to.

The idea

Three clocks run together every frame, separated by known offsets:

  • CPU clockTime.time, Time.frameCount. This is what every CSV row records.
  • GPU clock — when the frame is finished rendering. Lags the CPU by 0 to Max Queued Frames.
  • Display clock — when photons leave the display. Lags the GPU by up to one V Sync interval plus display response time.

Every row LABO writes is a CPU-clock sample. The gap between the CPU clock and the display clock is the display offset — this is what analyses subtract to translate the time a row records into the time the participant perceived it.

The runtime loop in one picture

Every frame, Unity runs the same sequence on the CPU:

Input → FixedUpdate × N → Update → LateUpdate → Render → Present
  • FixedUpdate runs on a fixed clock (Time.fixedDeltaTime). If the last frame took longer than one fixed delta, FixedUpdate runs multiple times in a row to catch up — capped by Time.maximumDeltaTime.
  • Update and LateUpdate run once per render frame and use Time.deltaTime (variable).
  • Render builds the frame on the CPU and hands commands to the GPU.
  • Present is when the finished frame is shown on screen. V Sync and Max Queued Frames both live here — they control when, and how far ahead of the display, the CPU is allowed to run.
In action

At 60 Hz with Max Queued Frames = 2, the CPU is computing frame N while the GPU renders frame N-1 and the display is still showing frame N-2 — roughly 33 ms of hidden offset between the CPU state a row records and what the participant is seeing.

MonotonicExecutionTime — the authoritative clock

MonotonicExecutionTime is derived from System.Diagnostics.Stopwatch and measures seconds since the first sample. It is:

  • Monotonic — only ever increases.
  • Drift-free — not affected by NTP or system clock adjustments.
  • Immune to Time.timeScale, Time.maximumDeltaTime, and float precision decay in long sessions.

It is the reference clock for correlating LABO data with external devices (EEG, biopac, standalone eye trackers) and across sessions. When in doubt, use MonotonicExecutionTime, not ObservationTime or FrameTime.

When is MonotonicExecutionTime captured?

The answer depends on whether the stream is regular or irregular.

Regular streams

Cached once per Time.frameCount at the start of the sampling loop. All regular rows emitted in the same frame share an identical MonotonicExecutionTime, down to the last digit.

This means: if ExperienceState, ParticipantTracking, EyeTracking, and BodyTracking all write a row during the same LateUpdate, their MonotonicExecutionTime values are byte-identical — they came from one Stopwatch.GetTimestamp() call cached at the top of the frame.

Irregular streams

Captured inside LaboObservation.Stamp() at the moment the observation is created — which is the moment the trigger actually fires. The value is stored on the observation and serialised later when the writer emits, but the written value represents the trigger moment, not the write moment.

An irregular row's MonotonicExecutionTime is therefore accurate to within microseconds of the real event, and the ExperienceState row for that same frame (written during LateUpdate) lands a millisecond or two later — both carry the same FrameNumber for joining.

In action

A participant presses the spacebar at CPU time 12.3456 s during frame 745.

  • InputCapture.csv — one row stamped at 12.3456 s, FrameNumber = 745.
  • ExperienceState.csv — the row for frame 745 gets written at LateUpdate, perhaps at CPU time 12.3462 s, but carries FrameNumber = 745 and its own MonotonicExecutionTime cached at the top of the frame.

Both rows share the same FrameNumber, so joining is trivial. The times will differ by a millisecond or two — that gap is real, not noise.

The shared time columns

Every CSV begins with the same time-related columns. They mean slightly different things; don't conflate them.

ColumnSourceWhat it is
ObservationTimeTime.realtimeSinceStartupAsDoubleWall-like time Unity reports at the moment the row is written. May differ slightly between writers in the same frame unless frame-cached.
MonotonicExecutionTimeStopwatch.GetTimestamp()Drift-free authoritative clock. Regular streams: frame-cached. Irregular streams: stamped at trigger time. Prefer this for all analysis.
FrameTimeTime.unscaledTimeAsDouble at frame startIdentical across all rows written in the same render frame. Useful for grouping "same frame" rows.
FixedIntervalTimeTime.fixedUnscaledTimeAsDoublePhysics-tick time. Identical across all rows written in the same FixedUpdate.
FrameNumberTime.frameCountMonotonic render-frame counter. The primary join key.
FixedIntervalNumberPhysics-tick counterMonotonic physics-tick counter. Counts independently of FrameNumber.

When USING_LSL is compiled in, three additional columns appear — FrameStartTimeLSL, FrameEndTimeLSL, FixedIntervalTimeLSL — sampled from liblsl for cross-process stream alignment.

Do not conflate

  • FrameNumber vs FixedIntervalNumber — the former counts render frames, the latter counts physics ticks. They advance independently.
  • FrameTime vs ObservationTimeFrameTime is identical across all rows in the same frame. ObservationTime may differ slightly between writers in the same frame unless frame-cached.
  • ObservationTime vs MonotonicExecutionTime — both are wall-like, but ObservationTime comes from Unity (can drift under Time.timeScale changes or frame hitches), while MonotonicExecutionTime comes from Stopwatch (cannot).

CPU time vs display time

The CPU time LABO writes is not the display time. Between "CPU sampled the state" and "display showed the frame" there is a constant offset — the display offset — made up of:

  1. The GPU's render queue (Max Queued Frames).
  2. One V Sync interval (if V Sync is on).
  3. Display response time (hardware-specific, usually a few ms).

The first two are controllable and documented. For a session where V Sync and Max Queued Frames do not change, the display offset is a constant you can subtract.

Display-time correction formula

display_time ≈ cpu_time + maxQueuedFrames × (1 / refresh)

The offset is constant per session (as long as V Sync and Max Queued do not change). Subtract the CPU-side timestamp from the display-side one to derive intervals; add the offset to translate a simulation moment into a visual moment.

In action

Desktop preset, 60 Hz monitor, Max Queued Frames = 1: display_time ≈ cpu_time + 16.7 ms. A row at cpu_time = 10.000 s corresponds to display_time ≈ 10.017 s.

V Sync

  • ON locks buffer swaps to the display refresh. The frame appears on a refresh boundary, and the CSV row → display offset is exactly one refresh interval.
  • OFF lets the GPU present as soon as each frame is ready. Tearing is possible and row → display offset becomes variable.
  • Count multiplies latency: higher count halves the effective presentation rate.

Max Queued Frames

How many frames the CPU may queue ahead of the GPU. Each queued frame adds (1 / refresh) seconds of offset between the CSV timestamp and the display time for that frame. This offset is constant and subtractable in analysis.

In action

60 Hz, Max Queued Frames = 3: a row at t = 5.000 s corresponds to a display time of roughly t = 5.050 s. Dropping to Max Queued = 1 reduces the offset to ~17 ms.

On-Demand Rendering

Renders every Nth frame while Update / LateUpdate still run every frame. The CSV row rate and the display update rate decouple: logic rate stays high, display rate drops by the interval factor. A row at 72 Hz logic rate with Interval = 2 describes a frame the display did not newly present.

Run In Background

  • OFF: when the application loses focus, Time.time stops advancing and capture halts. The CSV shows a gap until focus returns.
  • ON: the application keeps ticking under reduced OS priority. Frame pacing becomes irregular; rows continue to appear with valid CPU timestamps, but the display may not be updating during that interval.

Physics timing

Time.fixedDeltaTime sets the FixedUpdate tick interval. Smaller values give finer physics sampling and more FixedUpdate invocations per frame. Values that evenly divide the frame budget (e.g. match the display refresh) align physics ticks with render frames.

Time.maximumDeltaTime is the upper bound on the delta fed to physics and updates after a slow frame. A large cap absorbs the full duration of a hitch into one step; a small cap distributes it across several steps.

In action

fixedDeltaTime = 0.02 s (50 Hz) on a 90 Hz display means one physics step every ~1.8 render frames. Render frames alternate between "physics advanced" and "physics held"; any row driven by physics updates every 20 ms, not every 11.1 ms.

PresetV SyncFixed DeltaMax QueuedBackgroundTypical display offset
DesktopOn, count 1Match 60 Hz1Off~16.7 ms
PC VROff (HMD compositor owns sync)Match 90 Hz1On~11.1 ms
Mobile / XROnMatch 72 Hz1On~13.9 ms

Same project on two targets: Desktop on a 60 Hz monitor produces rows every 16.7 ms with a ~16.7 ms display offset; Mobile / XR on a Quest produces rows every 13.9 ms with a similar one-frame offset.

Further reading