Skip to main content

ExperienceState (per-frame timeline)

Regular • Files: UpdateLoop.csv and/or FixedInterval.csv • Writer: LaboExperienceStateDataWriter.cs • Configs: UpdateLoopDataCapture + FixedIntervalDataCapture

At a glance

The authoritative per-frame timeline for the session — one row per sampled frame, containing the shared LaboExperienceState columns plus a few frame-timing extras. Every other CSV from the same run can be aligned to it on FrameNumber or MonotonicExecutionTime. If you're doing any analysis that spans multiple streams, this is where you start.

The same writer class produces two files via two configs:

  • UpdateLoop.csv — one row per LateUpdate (render frame). Driven by UpdateLoopDataCapture.
  • FixedInterval.csv — one row per FixedUpdate (physics tick). Driven by FixedIntervalDataCapture.

Each config is independent. You can enable one, the other, or both. Both files have the same column shape.

When it writes

Each writer instance samples its loop:

  • UpdateLoop.csv writer samples in LateUpdate.
  • FixedInterval.csv writer samples in FixedUpdate.

Sampling honors:

  • skip — record every (skip + 1)th sample. 0 writes every sample.
  • captureHitches — emit an extra row whenever the frame delta exceeds hitchThresholdSeconds.

File & location

  • UpdateLoop.csv — default name; override via UpdateLoopDataCapture.fileName.
  • FixedInterval.csv — default name; override via FixedIntervalDataCapture.fileName.
  • Both files are created on first row write — see the lifecycle note in File layout.
  • Both configs are auto-created on experience open if missing — see Configuration below.

Configuration

UpdateLoopDataCapture and FixedIntervalDataCapture are both subclasses of LoopDataCapture, which inherits from DataCaptureConfig. They share the same field set:

FieldWhere it livesWhat it does
enabledLoggingDataCaptureConfigMaster enable/disable. If false, no file is created.
fileNameDataCaptureConfigOverride the default filename. Blank → use the default.
flushEveryNRowsDataCaptureConfigBatched flush cadence. Default 30.
writeHeaderDataCaptureConfigWrite the header row on first emission. Default true.
skipLoopDataCaptureRecord every (skip + 1)th sample. 0 = every sample.
captureHitchesLoopDataCaptureEmit an extra row on slow frames. Default false.
hitchThresholdSecondsLoopDataCaptureSlow-frame threshold. Default 0.033 (≈30 fps).

Auto-create: not for these two — they're created when the experience is set up, not on demand. Create them manually in the editor if missing.

Columns

Shared prefix

See Column conventions — The shared prefix. In order: ObservationTime, MonotonicExecutionTime, FixedIntervalNumber, FrameNumber, EventNumber, EpochNumber, EpochName, FrameTime, FixedIntervalTime (plus the three *LSL columns when USING_LSL is defined).

Frame-timing columns

Appended after the shared prefix:

ColumnTypeUnitsDescription
deltaTimefloatsecondsTime.deltaTime — scaled delta for this frame. Affected by Time.timeScale.
unscaledDeltaTimefloatsecondsTime.unscaledDeltaTime — real wall-clock delta. Use for true frame timing.
fpsfloatHz1.0 / unscaledDeltaTime. Convenience column.
smoothDeltaTimefloatsecondsTime.smoothDeltaTime — Unity's smoothed estimate.

Sample rows

ObservationTime,MonotonicExecutionTime,FixedIntervalNumber,FrameNumber,EventNumber,EpochNumber,EpochName,FrameTime,FixedIntervalTime,deltaTime,unscaledDeltaTime,fps,smoothDeltaTime,
12.3451,12.3451,612,745,0,1,Baseline,12.3450,12.3400,0.01111,0.01111,90.0,0.01111,
12.3562,12.3562,613,746,0,1,Baseline,12.3561,12.3512,0.01111,0.01111,90.0,0.01111,
12.3673,12.3673,614,747,1,1,Baseline,12.3672,12.3623,0.01111,0.01111,90.0,0.01111,

Note the jump in EventNumber between frames 746 and 747 — an event fired between those LateUpdates. Every irregular-stream row with EventNumber = 1 and matching FrameNumber = 747 aligns to this row.

Join with other streams

UpdateLoop.csv is the join target for any other regular or irregular stream sampled in the render-frame world. Join on FrameNumber:

import pandas as pd

exp = pd.read_csv("UpdateLoop.csv")
eye = pd.read_csv("EyeTracking.csv")

joined = exp.merge(eye, on="FrameNumber", how="inner", suffixes=("_exp", "_eye"))

# Now every eye-tracking sample has the session's epoch context attached:
joined.groupby("EpochName")["Eye_Center_DirZ"].mean()

For irregular streams, left-join them to UpdateLoop.csv to attach per-frame context (fps, deltaTime) to each trigger event:

events = pd.read_csv("EventPoints.csv")
enriched = events.merge(exp, on="FrameNumber", how="left", suffixes=("_event", "_frame"))
slow_events = enriched[enriched["deltaTime"] > 0.025] # events that fired during slow frames

For physics-loop analyses, use FixedInterval.csv and join on FixedIntervalNumber instead. See Join keys for the full pattern library.

Gotchas

  • Two files, two clocks. UpdateLoop.csv advances on render frames; FixedInterval.csv on physics ticks. Don't merge them on each other — merge each to the irregular stream they share a clock with.
  • skip > 0 means gaps. With skip = 1 you only get every other sample, so consecutive rows may have FrameNumber differing by 2, 3, or more. Joins still work, but FrameNumber diffs in your analysis can't be assumed to be 1.
  • deltaTime vs. unscaledDeltaTime. If your experience uses Time.timeScale (for slow-motion or pauses), deltaTime is scaled and unscaledDeltaTime is real. Use unscaledDeltaTime for true frame timing and MonotonicExecutionTime for cross-row temporal math.
  • Hitch rows are interleaved, not separate. Extra rows emitted by captureHitches go into the same file, in order. To find them in analysis, filter on unscaledDeltaTime > hitchThresholdSeconds.
  • fps is derived, not measured independently. It's 1.0 / unscaledDeltaTime for that frame. For a smoother rate estimate, roll your own from unscaledDeltaTime over a window.
  • Files only appear after the first row. SessionBoundCsvWriterBase is lazy — if a writer is enabled but never produces a row, no CSV is created. See File layout.

Analysis recipes