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();

View File

@@ -329,6 +329,15 @@ print :: ($fmt: string, args: ..Any) {
#insert "out(result);";
}
// User-space `xx` extension. `xx val : T` where the built-in conversion
// ladder makes no progress falls through to an `impl Into(T) for Source`
// lookup; the compiler monomorphises `convert` for the (Source, T) pair
// and emits a direct call. Compile-time only — no vtable, no runtime
// dispatch.
Into :: protocol(Target: Type) {
convert :: () -> Target;
}
List :: struct ($T: Type) {
items: [*]T = null;
len: s64 = 0;

View File

@@ -0,0 +1,99 @@
// Obj-C blocks bridged to sx closures.
//
// Apple's block ABI (clang's "Block Implementation Specification"): a block
// pointer is a struct whose first five fields are { isa, flags, reserved,
// invoke, descriptor } followed by per-block captured state. When an API
// like `[UIView animateWithDuration:animations:]` receives a block, the
// runtime reads `invoke` and calls it with the block pointer as the first
// argument. UIKit / Foundation callers always `_Block_copy` synchronously
// before returning, so a stack-allocated block is safe to pass directly.
//
// We layer the sx closure onto Apple's layout by appending two pointer
// fields to the standard 32-byte header: `sx_env` (the closure's captured
// environment pointer) and `sx_fn` (the closure trampoline). The per-
// signature `__block_invoke_*` C-ABI fn knows the offsets and calls
// through to `sx_fn(sx_env, args...)`.
//
// ── Lifetime contract ───────────────────────────────────────────────────
// `xx <closure> : *Block` returns a pointer into the surrounding sx
// function's stack frame. Same rule as `&local_var`: pass it directly to
// a callee that consumes it immediately or `_Block_copy`s internally
// (UIKit/Foundation always do). Don't store the pointer to use after the
// caller returns. If you need that, ship a `Block_copy`-backed sibling
// API and use it instead.
// Standard 32-byte block header plus two trailing slots for the sx closure
// it wraps. Total = 48 bytes.
Block :: struct {
isa: *void;
flags: s32;
reserved: s32;
invoke: *void;
descriptor: *void;
sx_env: *void;
sx_fn: *void;
}
// Per-block-shape metadata. The runtime reads `size` when copying the
// block to the heap, so it must equal the actual instance size.
BlockDescriptor :: struct {
reserved: u64;
size: u64;
}
// libSystem isa pointer for stack-allocated blocks. Resolved at link time
// (auto-linked on every Apple target via libSystem).
_NSConcreteStackBlock : *void #foreign;
// Shared descriptor for the 48-byte sx-block layout. All Into impls below
// point their `descriptor` field at this.
__sx_block_descriptor : BlockDescriptor = .{
reserved = 0,
size = 48,
};
// Per-signature invoke trampolines. Each one reads sx_env + sx_fn from
// its block_self argument and tail-calls the closure through a typed
// fn-ptr cast. One per Apple block signature we support.
//
// Signature: `void (^)(void)` — no args, no return. The single most
// common Apple block shape (UIView animation bodies, dispatch_async, etc).
__block_invoke_void :: (block_self: *Block) callconv(.c) {
typed_fn : (*void) -> void = xx block_self.sx_fn;
typed_fn(block_self.sx_env);
}
impl Into(Block) for Closure() -> void {
convert :: (self: Closure() -> void) -> Block {
.{
isa = @_NSConcreteStackBlock,
flags = 0,
reserved = 0,
invoke = xx @__block_invoke_void,
descriptor = xx @__sx_block_descriptor,
sx_env = self.env,
sx_fn = self.fn_ptr,
};
}
}
// Signature: `void (^)(BOOL)` — UIView animation completion handlers and
// similar one-arg-bool callbacks.
__block_invoke_bool :: (block_self: *Block, arg0: bool) callconv(.c) {
typed_fn : (*void, bool) -> void = xx block_self.sx_fn;
typed_fn(block_self.sx_env, arg0);
}
impl Into(Block) for Closure(bool) -> void {
convert :: (self: Closure(bool) -> void) -> Block {
.{
isa = @_NSConcreteStackBlock,
flags = 0,
reserved = 0,
invoke = xx @__block_invoke_bool,
descriptor = xx @__sx_block_descriptor,
sx_env = self.env,
sx_fn = self.fn_ptr,
};
}
}

View File

@@ -3,6 +3,7 @@
#import "modules/gpu/types.sx";
#import "modules/gpu/api.sx";
#import "modules/stb_truetype.sx";
#import "modules/stb.sx";
#import "modules/ui/types.sx";
// Cached glyph data with UV coordinates into the atlas texture
@@ -426,17 +427,26 @@ GlyphCache :: struct {
context.allocator.dealloc(old_vals);
}
// Upload dirty atlas to GPU
// Upload dirty atlas to GPU. On the Metal path, defer the upload to
// end-of-frame (`upload_atlas_to_gpu`) — calling `replaceRegion:` against
// the same R8 MTLTexture multiple times within one frame garbles the
// contents on iOS-sim Metal. The dirty flag carries over so the final
// end-of-frame upload picks up every rasterization that happened during
// the frame's render pass.
flush :: (self: *GlyphCache) {
if self.dirty == false { return; }
if self.has_gpu {
self.gpu.update_texture_region(self.texture_id, 0, 0,
self.atlas_width, self.atlas_height, xx self.bitmap);
} else {
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
}
if self.has_gpu { return; }
glBindTexture(GL_TEXTURE_2D, self.texture_id);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, self.atlas_width, self.atlas_height, GL_RED, GL_UNSIGNED_BYTE, self.bitmap);
self.dirty = false;
}
upload_atlas_to_gpu :: (self: *GlyphCache) {
if self.has_gpu == false { return; }
if self.dirty == false { return; }
self.gpu.update_texture_region(self.texture_id, 0, 0,
self.atlas_width, self.atlas_height, xx self.bitmap);
self.dirty = false;
}

View File

@@ -175,6 +175,11 @@ UIPipeline :: struct {
self.renderer.begin(self.screen_width, self.screen_height, self.font.texture_id);
self.renderer.process(@self.render_tree);
// Push any glyphs rasterized during process() to the GPU atlas BEFORE
// the final draw is recorded. On Metal we deferred per-render_text
// uploads so this is the single point where the atlas reaches the
// GPU. On the GL path it's a no-op (uploads already happened inline).
self.font.upload_atlas_to_gpu();
self.renderer.flush();
if !self.has_gpu {

View File

@@ -375,6 +375,7 @@ UIRenderer :: struct {
u1 := cached.uv_x + cached.uv_w;
v1 := cached.uv_y + cached.uv_h;
if self.vertex_count + 6 > MAX_UI_VERTICES {
self.flush();
}
@@ -619,12 +620,7 @@ fragment float4 fmain(VOut in [[stage_in]],
// Image mode (mode == -2.0): sample texture
return tex.sample(s, in.uv) * in.color;
} else if (mode < 0.0) {
// Text mode (mode == -1.0): the glyph atlas stores R8 alpha
// coverage from stbtt_MakeGlyphBitmap. Use the sampled value
// directly as alpha (no smoothstep — those were for SDFs and
// thinned anti-aliased coverage strokes). Small-size text renders
// dim on dark backgrounds because most glyph pixels sit in 0.1-0.5
// coverage; tracked as the "faint text" follow-up.
// Text mode (mode == -1.0): the glyph atlas stores R8 alpha coverage.
float alpha = tex.sample(s, in.uv).r;
return float4(in.color.rgb, in.color.a * alpha);
} else if (mode > 0.0 || border > 0.0) {