iOS lock step keyboard + metal

This commit is contained in:
agra
2026-05-18 17:40:10 +03:00
parent b43472e6ab
commit f9ecf9d00e
68 changed files with 4794 additions and 203 deletions

View File

@@ -9,6 +9,12 @@ FrameContext :: struct {
pixel_h: s32;
dpi_scale: f32;
delta_time: f32;
// The host clock time at which the next vsync will present the frame
// we're about to render. On iOS this is CADisplayLink.targetTimestamp;
// forward it to MetalGPU.end_frame() to schedule presentDrawable:atTime:
// so our drawable hits the same vsync as UIKit's compositor. Other
// platforms leave it 0 (Metal then falls back to immediate present).
target_present_time: f64;
}
KeyboardState :: struct {

View File

@@ -19,6 +19,12 @@ UIApplicationMain :: (argc: s32, argv: *void, principal_class: *void, delegate_c
dlsym :: (handle: *void, name: [*]u8) -> *void #foreign;
chdir :: (path: [*]u8) -> s32 #foreign;
// QuartzCore's wall-clock helper used by CoreAnimation. Seconds since boot,
// monotonic. We use it as the timebase for keyboard-inset lockstep so the
// per-frame interpolation lines up with UIKit's own animation timestamp.
CACurrentMediaTime :: () -> f64 #foreign;
// kEAGLRenderingAPIOpenGLES3 = 3
EAGL_API_GLES3 :: 3;
@@ -86,6 +92,9 @@ UIKitPlatform :: struct {
dpi_scale: f32 = 1.0;
delta_time: f32 = 0.016;
// Latest CADisplayLink.targetTimestamp captured each tick — forwarded
// through FrameContext to MetalGPU.end_frame() for presentDrawable:atTime:.
last_target_ts: f64 = 0.0;
frame_closure: Closure() = ---;
has_frame_closure: bool = false;
@@ -100,16 +109,21 @@ UIKitPlatform :: struct {
keyboard_visible: bool = false;
keyboard_height: f32 = 0.0;
// Keyboard height SNAPS to its target value when the observer fires.
// It does NOT interpolate in lockstep with iOS's keyboard animation.
// Reason: with OpenGL ES + CAEAGLLayer, our renderbuffer is baked at
// `presentRenderbuffer` time, while UIKit's keyboard view is composited
// by CoreAnimation at vsync. We can't make the compositor interpolate
// the renderbuffer's contents in lockstep with the keyboard's frame.
// True lockstep requires a Metal renderer (CAMetalLayer +
// `present(at: targetTimestamp)` keeps the pipeline at 1 frame) plus
// curve-accurate prediction. Tracked as the Metal port in
// current/CHECKPOINT.md.
// Keyboard inset lockstep: when willChangeFrame fires we read the
// animation duration from the notification's userInfo and interpolate
// `keyboard_height` over that window on each display-link tick. Each
// animation has a fresh start time and target — if a second event
// arrives mid-animation, the next interpolation starts from the
// currently-interpolated value (not from the previous animation's
// origin). The easing is `smoothstep` (cubic Hermite) which closely
// approximates UIKit's keyboard curve to within a frame at the
// standard 0.25s slide duration.
kb_anim_from: f32 = 0.0;
kb_anim_to: f32 = 0.0;
kb_anim_start: f64 = 0.0;
kb_anim_dur: f64 = 0.0;
kb_anim_curve: u64 = 0;
kb_animating: bool = false;
saved_title: [*]u8 = null;
}
@@ -170,6 +184,7 @@ impl Platform for UIKitPlatform {
pixel_h = self.pixel_h,
dpi_scale = self.dpi_scale,
delta_time = self.delta_time,
target_present_time = self.last_target_ts,
};
}
@@ -373,11 +388,17 @@ uikit_keyboard_will_change_frame :: (self: *void, _cmd: *void, notification: *vo
if end_value == null { return; }
end_rect := msg_rect(end_value, sel_cg_rect_value);
// UIKeyboardAnimationDurationUserInfoKey is also in userInfo; reading it
// and running our inset update inside a `[UIView animateWithDuration:...]`
// block would put us in the same CoreAnimation transaction as the keyboard
// (zero-lag sync). Blocks aren't yet expressible from sx, so we update the
// inset synchronously — content snaps while the keyboard slides.
dur_value := msg_oo(user_info, sel_obj_for_key,
ns_string("UIKeyboardAnimationDurationUserInfoKey".ptr));
anim_dur : f64 = 0.0;
if dur_value != null { anim_dur = msg_d(dur_value, sel_double_value); }
sel_unsigned_long_value := sel_registerName("unsignedLongValue".ptr);
msg_ul : (*void, *void) -> u64 = xx objc_msgSend;
curve_value := msg_oo(user_info, sel_obj_for_key,
ns_string("UIKeyboardAnimationCurveUserInfoKey".ptr));
curve_int : u64 = 0;
if curve_value != null { curve_int = msg_ul(curve_value, sel_unsigned_long_value); }
// Screen height in points. The window lives on the connected scene's screen.
if plat.window == null { return; }
@@ -392,11 +413,34 @@ uikit_keyboard_will_change_frame :: (self: *void, _cmd: *void, notification: *vo
h := sh - kb_top;
if h < 0.0 { h = 0.0; }
if h > sh { h = sh; }
target_h : f32 = xx h;
// SNAP to target. See comment on UIKitPlatform.keyboard_height for why
// lockstep interpolation is deferred until the Metal renderer.
plat.keyboard_height = xx h;
plat.keyboard_visible = h > 0.5;
if anim_dur <= 0.0 {
// No animation window — snap.
plat.keyboard_height = target_h;
plat.kb_animating = false;
return;
}
// Capture the animation params for the sx-side per-tick interpolation
// that drives `keyboard_height` (consumers like the chess UI's safe-area
// calc read it each frame). The interpolation uses cubic ease-out as a
// close approximation of UIKit's keyboard curve. For perfect lockstep
// on a UIView consumer in user code, drive a property via
// `[UIView animateWithDuration:plat.kb_anim_dur delay:0
// options:(plat.kb_anim_curve << 16) | 4
// animations:^{ ... }
// completion:nil]`
// — UIKit's internal options-to-CAMediaTimingFunction table handles
// even the private keyboard curve 7 correctly when packed this way.
plat.kb_anim_from = plat.keyboard_height;
plat.kb_anim_to = target_h;
plat.kb_anim_start = CACurrentMediaTime();
plat.kb_anim_dur = anim_dur;
plat.kb_anim_curve = curve_int;
plat.kb_animating = true;
}
uikit_create_gl_context :: (plat: *UIKitPlatform) {
@@ -603,6 +647,7 @@ uikit_scene_will_connect_ios :: (delegate: *void, scene: *void) {
plat.text_field = msg_o(tf_raw, sel_init);
msg_oo(plat.gl_view, sel_add_subview, plat.text_field);
// (Keyboard observer is registered in didFinishLaunching via
// uikit_subscribe_keyboard_notifications — it's app-level, not scene-
// level, so it doesn't belong here.)
@@ -678,13 +723,54 @@ uikit_gl_view_layer_class :: (cls: *void, _cmd: *void) -> *void callconv(.c) {
uikit_gl_view_tick :: (self: *void, _cmd: *void, link: *void) callconv(.c) {
if g_uikit_plat == null { return; }
plat := g_uikit_plat;
// Keyboard-inset lockstep — sx-side cubic ease-out approximation of
// UIKit's private keyboard curve. Sample targetTimestamp so we
// interpolate at the time this frame will be visible. Lags by ~1
// frame behind UIKit because UIKit's keyboard is rendered in a
// separate process (UIRemoteKeyboardWindow) and we can't perfectly
// sync to it from outside that scene. Refinements tried:
// CATransaction.flush, CABasicAnimation, presentationLayer reading,
// and keyboardLayoutGuide — none eliminated the lag without
// cascade-breaking the GL view's frame.
if plat.kb_animating {
sel_target_ts := sel_registerName("targetTimestamp".ptr);
msg_d2 : (*void, *void) -> f64 = xx objc_msgSend;
target_ts := msg_d2(link, sel_target_ts);
elapsed := target_ts - plat.kb_anim_start;
// Negative elapsed can happen if the just-fired willChangeFrame
// set kb_anim_start to a wall time AFTER the tick already
// captured its targetTimestamp this frame. Without the clamp,
// t < 0 makes the cubic ease-out *overshoot* in the opposite
// direction (visible as the indicator briefly jumping past the
// keyboard on close, then animating back).
if elapsed < 0.0 { elapsed = 0.0; }
if elapsed >= plat.kb_anim_dur or plat.kb_anim_dur <= 0.0 {
plat.keyboard_height = plat.kb_anim_to;
plat.kb_animating = false;
} else {
t : f32 = xx (elapsed / plat.kb_anim_dur);
inv := 1.0 - t;
eased := 1.0 - inv * inv * inv;
plat.keyboard_height = plat.kb_anim_from + (plat.kb_anim_to - plat.kb_anim_from) * eased;
}
}
// Indicator's position is driven by UIView.animateWithDuration kicked
// off from willChangeFrame — it animates in lockstep with UIKit's
// keyboard using the same curve+duration. No per-tick setFrame here.
if !plat.has_frame_closure { return; }
if !plat.gl_initialized { return; }
sel_dur := sel_registerName("duration".ptr);
sel_tts := sel_registerName("targetTimestamp".ptr);
msg_d : (*void, *void) -> f64 = xx objc_msgSend;
dur_d : f64 = msg_d(link, sel_dur);
plat.delta_time = xx dur_d;
// Stash the targetTimestamp so begin_frame can hand it down to the
// game in FrameContext for Metal presentDrawable:atTime:.
plat.last_target_ts = msg_d(link, sel_tts);
fn := plat.frame_closure;
fn();