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 clock —
Time.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 byTime.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.
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.
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 carriesFrameNumber = 745and its ownMonotonicExecutionTimecached 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.
| Column | Source | What it is |
|---|---|---|
ObservationTime | Time.realtimeSinceStartupAsDouble | Wall-like time Unity reports at the moment the row is written. May differ slightly between writers in the same frame unless frame-cached. |
MonotonicExecutionTime | Stopwatch.GetTimestamp() | Drift-free authoritative clock. Regular streams: frame-cached. Irregular streams: stamped at trigger time. Prefer this for all analysis. |
FrameTime | Time.unscaledTimeAsDouble at frame start | Identical across all rows written in the same render frame. Useful for grouping "same frame" rows. |
FixedIntervalTime | Time.fixedUnscaledTimeAsDouble | Physics-tick time. Identical across all rows written in the same FixedUpdate. |
FrameNumber | Time.frameCount | Monotonic render-frame counter. The primary join key. |
FixedIntervalNumber | Physics-tick counter | Monotonic 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
FrameNumbervsFixedIntervalNumber— the former counts render frames, the latter counts physics ticks. They advance independently.FrameTimevsObservationTime—FrameTimeis identical across all rows in the same frame.ObservationTimemay differ slightly between writers in the same frame unless frame-cached.ObservationTimevsMonotonicExecutionTime— both are wall-like, butObservationTimecomes from Unity (can drift underTime.timeScalechanges or frame hitches), whileMonotonicExecutionTimecomes fromStopwatch(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:
- The GPU's render queue (
Max Queued Frames). - One V Sync interval (if V Sync is on).
- 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.
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.
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.timestops 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.
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.
Recommended presets
| Preset | V Sync | Fixed Delta | Max Queued | Background | Typical display offset |
|---|---|---|---|---|---|
| Desktop | On, count 1 | Match 60 Hz | 1 | Off | ~16.7 ms |
| PC VR | Off (HMD compositor owns sync) | Match 90 Hz | 1 | On | ~11.1 ms |
| Mobile / XR | On | Match 72 Hz | 1 | On | ~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
- Regular vs Irregular — the framework this page builds on.
- Join keys — how to align rows across CSVs.
- Column conventions — the rest of the shared prefix.
- Cross-device sync recipe — aligning LABO to EEG / biopac.