Skip to content

Instantly share code, notes, and snippets.

@jmiskovic
Last active October 9, 2025 14:40
Show Gist options
  • Select an option

  • Save jmiskovic/533f75fa4b57c0cb04dde6866d3a6309 to your computer and use it in GitHub Desktop.

Select an option

Save jmiskovic/533f75fa4b57c0cb04dde6866d3a6309 to your computer and use it in GitHub Desktop.
Measure input lag
#!/bin/bash
# Measures input lag of an application that draws a blue circle on the last known cursor location
# automates cursor motion for consistency; takes a screenshot and measures distance; averages over N runs
CURSOR_COLOR="#7c7c7c" # set this to your system cursor primary color
CIRCLE_COLOR="#0000ff" # the measured app should draw a circle of this color
TESTS_N=50
get_window_info() {
local xwininfo_output=$(xwininfo)
WINDOW_ID=$(echo "$xwininfo_output" | grep -m 1 "Window id:" | cut -d ' ' -f 4)
WIDTH=$(echo "$xwininfo_output" | grep "Width" | cut -d ":" -f 2 | tr -d " ")
HEIGHT=$(echo "$xwininfo_output" | grep "Height" | cut -d ":" -f 2 | tr -d " ")
TOP_X=$(echo "$xwininfo_output" | grep "Absolute upper-left X" | cut -d ":" -f 2 | tr -d " ")
TOP_Y=$(echo "$xwininfo_output" | grep "Absolute upper-left Y" | cut -d ":" -f 2 | tr -d " ")
echo "Dimensions: $WIDTH x $HEIGHT"
echo "Window position: $TOP_X, $TOP_Y"
wmctrl -ia $WINDOW_ID
}
move_mouse_to_left_edge() {
xdotool mousemove --window $WINDOW_ID -- 0 $((HEIGHT / 2))
}
smooth_move_mouse_to_right_edge() {
local step=1
local measure_point=$((2 * WIDTH / 3))
local start_time=$(date +%s%N)
local distance=0
while [ $distance -le $measure_point ]; do
xdotool mousemove_relative -- $step 0
distance=$((distance + step))
done
local end_time=$(date +%s%N)
local elapsed_time=$((end_time - start_time))
local actual_speed=$(echo "scale=2; $distance / ($elapsed_time / 1000000000)" | bc)
capture_screenshot
measure_distance $actual_speed
}
capture_screenshot() {
xfce4-screenshooter -m --no-border -w -c
screenshot_base64=$(xclip -selection clipboard -t image/png -o | base64)
}
find_entity_position() {
local color=$1
bounding_box=$(echo "$screenshot_base64" | base64 -d |
magick - -fuzz 10% -fill white -opaque "$color" -fill black +opaque white -format "%@" info:-)
if [[ -z "$bounding_box" ]]; then
echo ""
return
fi
local width=$(echo $bounding_box | cut -d'x' -f1)
local height=$(echo $bounding_box | cut -d'x' -f2 | cut -d'+' -f1)
local x=$(echo $bounding_box | cut -d'+' -f2)
local y=$(echo $bounding_box | cut -d'+' -f3)
local center_x=$(echo "$x + $width / 2" | bc)
local center_y=$(echo "$y + $height / 2" | bc)
echo "$center_x $center_y"
}
measure_distance() {
local actual_speed=$1
cursor_position=$(find_entity_position $CURSOR_COLOR)
circle_position=$(find_entity_position $CIRCLE_COLOR)
if [[ -z "$cursor_position" ]]; then
echo "Cursor not found in screenshot."
return
fi
if [[ -z "$circle_position" ]]; then
echo "Circle not found in screenshot."
return
fi
cursor_x=$(echo $cursor_position | cut -d' ' -f1)
cursor_y=$(echo $cursor_position | cut -d' ' -f2)
circle_x=$(echo $circle_position | cut -d' ' -f1)
circle_y=$(echo $circle_position | cut -d' ' -f2)
distance=$(echo "scale=2; sqrt(($cursor_x - $circle_x)^2 + ($cursor_y - $circle_y)^2)" | bc)
# Convert actual_speed from pixels per second to pixels per millisecond
actual_speed_ms=$(echo "scale=2; $actual_speed / 1000" | bc)
lag=$(echo "scale=2; $distance / $actual_speed_ms" | bc)
echo -n "$lag, "
lags+=($lag)
}
echo "Click on app window to measure its input lag"
get_window_info
echo "Taking control over mouse, please wait until $TESTS_N tests are captured"
sleep 0.2
lags=()
for ((test_i = 1; test_i <= $TESTS_N; test_i++)); do
move_mouse_to_left_edge
smooth_move_mouse_to_right_edge
done
echo
if [ ${#lags[@]} -gt 1 ]; then
total=0
for ((i = 1; i < ${#lags[@]}; i++)); do
total=$(echo "$total + ${lags[$i]}" | bc)
done
average=$(echo "scale=1; $total / (${#lags[@]} - 1)" | bc)
sum_sq_diff=0
for ((i = 1; i < ${#lags[@]}; i++)); do
diff=$(echo "${lags[$i]} - $average" | bc)
sq_diff=$(echo "$diff * $diff" | bc)
sum_sq_diff=$(echo "$sum_sq_diff + $sq_diff" | bc)
done
variance=$(echo "scale=1; $sum_sq_diff / (${#lags[@]} - 1)" | bc)
stddev=$(echo "scale=1; sqrt($variance)" | bc)
echo "Average input lag (excluding first measurement): $average ms"
echo "Standard deviation (excluding first measurement): $stddev ms"
else
echo "Not enough valid measurements were taken."
fi
@jmiskovic
Copy link
Author

jmiskovic commented Feb 13, 2025

The measuring process:

measure_lag_conv.mp4

The tested LÖVR app simply draws a sphere at cursor position:

local function getWorldFromScreen(pass)
  local w, h = pass:getDimensions()
  local clip_from_screen = mat4(-1, -1, 0):scale(2 / w, 2 / h, 1)
  local view_pose = mat4(pass:getViewPose(1))
  local view_proj = pass:getProjection(1, mat4())
  local world_from_screen = view_pose:mul(view_proj:invert()):mul(clip_from_screen)
  return world_from_screen
end

function lovr.draw(pass)
  local x, y = lovr.system.getMousePosition()
  local wfs = getWorldFromScreen(pass)
  local pos = vec3(wfs:mul(x, y, 0.005))
  --local pos = mat4(lovr.headset.getPose('left')):mul(0, 0, -10)
  pass:setColor(0,0,1)
  pass:sphere(pos, 0.15 * 2)
end

@jmiskovic
Copy link
Author

jmiskovic commented Feb 13, 2025

The standard deviation across 50 samples is around ~7ms, so these results are a bit spread.
Captured on Fedora with X11 and 50Hz monitor.

vsync=off

lövE           20 ms
lövR           22 ms   same, one frame behind for 50Hz

vsync=on

godot          81 ms
lövE           55 ms
rest are lövR:
headset=false  77 ms   
with vr sim    77 ms   the headset sim pose reading doesn't introduce additional lag
early getPass  57 ms   call getWindowPass() earlier in run(), before pollEvents()
^same; vr sim  60 ms   again, lag is within standard deviation of non-vrsim

@jmiskovic
Copy link
Author

The recent change on dev decreased the lag, here's the repeated measuring of lag:

vsync=off      27 ms
headset=false  57 ms   
with vr sim    56 ms

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment