Skip to content

Commit 7b3bd53

Browse files
Fix: First startup frame latency & Frame Timing (#105)
* First frame paint time from ~37.9ms => 210us, giving far snappier start * Fix frame pacing for stable 60FPS without drift * Rebase pacing anchor on suspend/overrun to avoid catch-up bursts
1 parent 83c00b1 commit 7b3bd53

1 file changed

Lines changed: 56 additions & 23 deletions

File tree

src/core/program.zig

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ pub fn Program(comptime Model: type) type {
5959
/// without gaps on resume.
6060
clock_epoch: std.Io.Clock.Timestamp,
6161
last_frame_time: u64,
62+
/// Anchor for absolute frame pacing. Separate from `clock_epoch` so we can
63+
/// rebase after suspend/resume or a long-overrun frame without disturbing
64+
/// user-visible `context.elapsed` / `context.frame` (which `pending_tick`
65+
/// and `every` depend on).
66+
pacing_epoch: std.Io.Clock.Timestamp,
67+
pacing_frame_offset: u64,
6268
pending_tick: ?u64,
6369
every_interval: ?u64,
6470
last_every_tick: u64,
@@ -104,6 +110,8 @@ pub fn Program(comptime Model: type) type {
104110
.running = false,
105111
.clock_epoch = clock_epoch,
106112
.last_frame_time = 0,
113+
.pacing_epoch = clock_epoch,
114+
.pacing_frame_offset = 0,
107115
.pending_tick = null,
108116
.every_interval = null,
109117
.last_every_tick = 0,
@@ -201,7 +209,9 @@ pub fn Program(comptime Model: type) type {
201209
unicode.setWidthStrategy(effective_width_strategy);
202210

203211
self.clock_epoch = std.Io.Clock.Timestamp.now(self.io, .boot);
204-
self.last_frame_time = 0;
212+
self.last_frame_time = self.elapsedNs();
213+
self.pacing_epoch = self.clock_epoch;
214+
self.pacing_frame_offset = 0;
205215
self.context.elapsed = 0;
206216
self.context.delta = 0;
207217
self.context.frame = 0;
@@ -222,25 +232,12 @@ pub fn Program(comptime Model: type) type {
222232

223233
/// Execute a single frame: poll input, process events, render.
224234
pub fn tick(self: *Self) !void {
225-
const now = self.elapsedNs();
226-
const delta = now - self.last_frame_time;
227-
228-
// Enforce framerate limit
229-
const min_frame_time_ns: u64 = if (self.options.fps > 0)
230-
@divFloor(std.time.ns_per_s, self.options.fps)
231-
else
232-
16_666_666; // ~60fps default
233-
234-
if (delta < min_frame_time_ns) {
235-
sleepNs(self.io, min_frame_time_ns - delta);
236-
}
237-
238-
const frame_time = self.elapsedNs();
239-
const actual_delta = frame_time - self.last_frame_time;
240-
self.last_frame_time = frame_time;
235+
const tick_start = self.elapsedNs();
236+
const actual_delta: u64 = if (self.context.frame == 0) 0 else tick_start - self.last_frame_time;
237+
self.last_frame_time = tick_start;
241238

242239
self.context.delta = actual_delta;
243-
self.context.elapsed = frame_time;
240+
self.context.elapsed = tick_start;
244241
self.context.frame += 1;
245242

246243
self.resetFrameAllocator();
@@ -261,9 +258,9 @@ pub fn Program(comptime Model: type) type {
261258
}
262259
}
263260

264-
// Read input
261+
// Non-blocking drain; input typed during pacing sits in the TTY buffer.
265262
var input_buf: [256]u8 = undefined;
266-
const bytes_read = try self.terminal.?.readInput(&input_buf, 16);
263+
const bytes_read = try self.terminal.?.readInput(&input_buf, 0);
267264

268265
if (bytes_read > 0) {
269266
const events = try keyboard.parseAll(self.context.allocator, input_buf[0..bytes_read]);
@@ -286,7 +283,7 @@ pub fn Program(comptime Model: type) type {
286283
// Deliver tick to user's update if Model.Msg has a tick variant
287284
if (@hasField(UserMsg, "tick")) {
288285
const user_msg = UserMsg{ .tick = .{
289-
.timestamp = @intCast(frame_time),
286+
.timestamp = @intCast(tick_start),
290287
.delta = actual_delta,
291288
} };
292289
const cmd = self.dispatchToModel(user_msg);
@@ -301,7 +298,7 @@ pub fn Program(comptime Model: type) type {
301298
self.last_every_tick = self.context.elapsed;
302299
if (@hasField(UserMsg, "tick")) {
303300
const user_msg = UserMsg{ .tick = .{
304-
.timestamp = @intCast(frame_time),
301+
.timestamp = @intCast(tick_start),
305302
.delta = actual_delta,
306303
} };
307304
const cmd = self.dispatchToModel(user_msg);
@@ -313,6 +310,31 @@ pub fn Program(comptime Model: type) type {
313310
// Render
314311
try self.render();
315312
try self.flushPendingImage();
313+
314+
// Pace at end of tick; first tick skips so initial paint is immediate.
315+
const min_frame_time_ns: u64 = if (self.options.fps > 0)
316+
@divFloor(std.time.ns_per_s, self.options.fps)
317+
else
318+
16_666_666; // ~60fps default
319+
const frames_since_anchor = self.context.frame - self.pacing_frame_offset;
320+
if (frames_since_anchor > 1) {
321+
const deadline_offset_ns: u64 = frames_since_anchor * min_frame_time_ns;
322+
// If we've fallen far behind the schedule (long-overrun frame, or
323+
// boot-clock advanced past the anchor while suspended), rebase the
324+
// anchor instead of burst-rendering frames to "catch up."
325+
const elapsed_since_anchor = self.pacingElapsedNs();
326+
if (elapsed_since_anchor > deadline_offset_ns + 4 * min_frame_time_ns) {
327+
self.pacing_epoch = std.Io.Clock.Timestamp.now(self.io, .boot);
328+
self.pacing_frame_offset = self.context.frame;
329+
} else {
330+
// Absolute deadline so sleep overshoot doesn't compound.
331+
const deadline: std.Io.Clock.Timestamp = self.pacing_epoch.addDuration(.{
332+
.raw = .{ .nanoseconds = @intCast(deadline_offset_ns) },
333+
.clock = .boot,
334+
});
335+
deadline.wait(self.io) catch unreachable;
336+
}
337+
}
316338
}
317339

318340
/// Dispatch a message to the model, applying the filter if set
@@ -415,8 +437,11 @@ pub fn Program(comptime Model: type) type {
415437
term.setup() catch {};
416438
}
417439

418-
// Avoid a large post-resume frame delta.
440+
// Avoid a large post-resume frame delta, and rebase the pacing anchor
441+
// so we don't burst-render to "catch up" the suspended interval.
419442
self.last_frame_time = self.elapsedNs();
443+
self.pacing_epoch = std.Io.Clock.Timestamp.now(self.io, .boot);
444+
self.pacing_frame_offset = self.context.frame;
420445

421446
// Force re-render
422447
self.last_view_hash = 0;
@@ -761,6 +786,14 @@ pub fn Program(comptime Model: type) type {
761786
return @intCast(ns);
762787
}
763788

789+
/// Nanoseconds elapsed on the boot clock since `pacing_epoch`.
790+
fn pacingElapsedNs(self: *const Self) u64 {
791+
const dur = self.pacing_epoch.untilNow(self.io);
792+
const ns = dur.raw.nanoseconds;
793+
if (ns <= 0) return 0;
794+
return @intCast(ns);
795+
}
796+
764797
fn sleepNs(io: std.Io, nanoseconds: u64) void {
765798
if (nanoseconds == 0) return;
766799
std.Io.sleep(io, .fromNanoseconds(nanoseconds), .boot) catch unreachable;

0 commit comments

Comments
 (0)