mtlterm: port the unified draw batch from the OpenGL driver

Glyph quads and solid rectangles now accumulate in one CPU vertex array
and flush as a single draw call, instead of one drawPrimitives (with its
pipeline switch) per glyph and per fill.  The rects sample a 4x4 white
block reserved in the atlas -- coverage 1.0 passes the gamma curve
unchanged -- so backgrounds, underlines, boxes and reliefs share the
glyph pipeline and never split the batch.  Clipping moves from the
encoder scissor to queue time: each quad is clamped against the glyph
string's clip rect with texture coordinates adjusted proportionally,
letting the batch survive clip changes and cross strings; the
non-batched primitives (images, color glyphs, fringe bitmaps) apply the
recorded clip as a real scissor right before their own draw.  Small
batches ride in the command buffer via setVertexBytes, larger ones get
a one-shot shared buffer.  The atlas reset paths are centralized in
mtl_atlas_reset, which re-reserves the white block.

Fringe bitmaps are cached by an FNV-1a hash of their rows instead of
creating a texture per call (the old per-call texture was also never
released under MRC -- this fixes that leak).  scroll_run uses one
direct blit when source and destination are disjoint (page scrolls),
keeping the scratch bounce only for overlapping moves.

Verified byte-identical to the previous renderer: a scene exercising
font-lock, bold+box, underline (plain and wave), overline,
strike-through, inverse video, region and fringe marks captures
exactly the same staticTexture, and scrolling by copy matches a fresh
repaint of the same window start pixel for pixel.
This commit is contained in:
Andros Fenollosa 2026-06-11 17:42:49 +02:00
parent b3e19f2d78
commit 01f7960775
2 changed files with 287 additions and 88 deletions

View file

@ -222,6 +222,13 @@ typedef struct mtl_spring {
the expose substitute -- lives in gfxterm.c, not here.) */
@property (nonatomic, assign) BOOL needsPresent;
/* Clip rect for the current glyph string, recorded in logical pixels.
Batched quads are clamped against it on the CPU at queue time; the
non-batched primitives apply it as a real scissor right before they
draw (applyScissorNow). */
@property (nonatomic, assign) BOOL clipOn;
@property (nonatomic, assign) NSRect clipRect;
/* Present coalescing: a redisplay pass can run several update cycles
back-to-back (buffer window + echo area), and with display sync each
present blocks on a drawable -- two blocking presents per keystroke

View file

@ -254,6 +254,28 @@ static CGFloat g_atlas_scale = 1.0;
static void mtl_color_glyph_cache_clear (void);
static struct gfx_driver mtl_gfx_driver;
/* Unified glyph+rect batch (the glterm.c counterpart). Glyph quads and
solid rects accumulate in one CPU vertex array, in submission order,
and flush as a single draw call; the rects sample a white block
reserved in the atlas (coverage 1.0 passes the gamma curve unchanged),
so no pipeline switch ever splits the batch. Each quad is clipped on
the CPU at queue time against the recording frame's clip rect, so the
batch also survives clip changes and crosses glyph strings. Flushed
by: a different target frame, any non-batched primitive (image,
fringe, color glyph), a blit (scroll/shift), the end of the cycle, a
capture, and an atlas repack. */
@class MtlFrameData;
static MtlGlyphVertex *g_batch = NULL;
static int g_batch_verts = 0;
static int g_batch_cap = 0;
static MtlFrameData *g_batch_fd = nil; /* unretained owner */
static void mtl_flush_batch (void);
static void mtl_atlas_reset (void);
/* Texture coordinates of the white block's center texel. */
#define MTL_WHITE_U (2.0f / MTL_ATLAS_WIDTH)
#define MTL_WHITE_V (2.0f / MTL_ATLAS_HEIGHT)
/* Phase 4: global animation configuration (Lisp-configurable) */
/* Sonicboom is the user's pick as the default for tests and demos
(animations themselves stay opt-in behind g_mtl_animations_enabled). */
@ -366,6 +388,7 @@ mtl_global_setup (void)
mipmapped:NO];
td.usage = MTLTextureUsageShaderRead | MTLTextureUsageShaderWrite;
g_atlas = [g_device newTextureWithDescriptor:td];
mtl_atlas_reset (); /* reserve the white block for rect quads */
/* Linear sampler: for glyph atlas (sub-pixel accuracy) */
MTLSamplerDescriptor *sd = [[MTLSamplerDescriptor alloc] init];
@ -544,6 +567,30 @@ glyph_cache_init (void)
g_atlas_row_h = 0;
}
/* Reset the atlas packing and the glyph table, then re-reserve the 4x4
white block at (0,0) that batched solid rects sample (see g_batch).
Queued quads still reference the old layout, so they are flushed
first. Used at atlas creation, on overflow repack, and on a backing
scale change. */
static void
mtl_atlas_reset (void)
{
mtl_flush_batch ();
glyph_cache_init ();
if (g_atlas)
{
static const uint8_t white[16] = {
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
};
[g_atlas replaceRegion:MTLRegionMake2D (0, 0, 4, 4) mipmapLevel:0
withBytes:white bytesPerRow:4];
g_atlas_next_x = 5;
g_atlas_next_y = 0;
g_atlas_row_h = 4;
}
}
static unsigned int
glyph_cache_slot (uint64_t key)
{
@ -648,9 +695,9 @@ mtl_rasterize_glyph_id (CTFontRef font, CGGlyph cgGlyph, uint64_t key)
}
if (g_atlas_next_y + bh > MTL_ATLAS_HEIGHT)
{
/* Atlas full: reset and clear (simple strategy) */
g_atlas_next_x = g_atlas_next_y = g_atlas_row_h = 0;
glyph_cache_init ();
/* Atlas full: repack from the top (mtl_atlas_reset flushes the
queued quads that still reference the old layout). */
mtl_atlas_reset ();
entry = glyph_cache_insert (key);
}
@ -1251,24 +1298,49 @@ mtl_log_seq_p (void)
atX:(int)x y:(int)y color:(unsigned long)color;
- (void)applyClipRect:(NSRect)r;
- (void)clearClipRect;
- (void)applyScissorNow;
@end
@implementation MtlFrameData
/* Clip subsequent draws on the current encoder to R (logical pixels), like the
NS backend's ns_focus clipping with get_glyph_string_clip_rect. This is what
keeps a filled-box cursor on a tall row (e.g. an image line) at the size of
/* Clip subsequent draws to R (logical pixels), like the NS backend's
ns_focus clipping with get_glyph_string_clip_rect. This is what keeps
a filled-box cursor on a tall row (e.g. an image line) at the size of
the character cell instead of the whole row, and stops overhangs from
bleeding outside the window area. */
bleeding outside the window area. Only records state: batched quads
are clamped against it on the CPU at queue time (mtl_batch_append),
and the non-batched primitives apply it as a real scissor right
before their draw (applyScissorNow). */
- (void)applyClipRect:(NSRect)r
{
self.clipOn = YES;
self.clipRect = r;
}
- (void)clearClipRect
{
self.clipOn = NO;
}
/* Apply the recorded clip as the encoder's scissor, in physical pixels.
Called by the non-batched primitives (images, fringe bitmaps, color
glyphs) right before they draw. */
- (void)applyScissorNow
{
if (!self.encoder || !self.staticTexture) return;
long tw = (long) self.staticTexture.width;
long th = (long) self.staticTexture.height;
if (!self.clipOn)
{
MTLScissorRect sc = { 0, 0, (NSUInteger) tw, (NSUInteger) th };
[self.encoder setScissorRect:sc];
return;
}
NSRect r = self.clipRect;
CGSize dsz = self.metalLayer.drawableSize;
NSSize lsz = self.metalLayer.frame.size;
double scx = lsz.width > 0 ? dsz.width / lsz.width : 1.0;
double scy = lsz.height > 0 ? dsz.height / lsz.height : 1.0;
long tw = (long) self.staticTexture.width;
long th = (long) self.staticTexture.height;
long x0 = lround (NSMinX (r) * scx), y0 = lround (NSMinY (r) * scy);
long x1 = lround (NSMaxX (r) * scx), y1 = lround (NSMaxY (r) * scy);
if (x0 < 0) x0 = 0;
@ -1282,14 +1354,6 @@ mtl_log_seq_p (void)
[self.encoder setScissorRect:sc];
}
- (void)clearClipRect
{
if (!self.encoder || !self.staticTexture) return;
MTLScissorRect sc = { 0, 0, self.staticTexture.width,
self.staticTexture.height };
[self.encoder setScissorRect:sc];
}
/* Open a render command encoder targeting the static texture. CLEAR wipes it to
the frame background (only for a fresh/resized texture); otherwise LOAD
preserves the previous content (Emacs redraws just the dirty regions).
@ -1338,25 +1402,40 @@ mtl_log_seq_p (void)
if (pto + ph > tht) ph = tht - pto;
if (pw <= 0 || ph <= 0) return;
/* The copy must run after the draws already recorded this frame. End the
render encoder, do the two blits on the same command buffer (Metal's hazard
tracking orders them after the render writes), then reopen the encoder with
LOAD so subsequent draw_glyph_string calls land on top of the moved pixels. */
/* The copy must run after the draws already recorded this frame. Flush
the queued quads, end the render encoder, do the blit on the same
command buffer (Metal's hazard tracking orders it after the render
writes), then reopen the encoder with LOAD so subsequent
draw_glyph_string calls land on top of the moved pixels. */
mtl_flush_batch ();
BOOL hadEncoder = (self.encoder != nil);
if (self.encoder) { [self.encoder endEncoding]; self.encoder = nil; }
if (!self.cmdBuf) self.cmdBuf = [g_queue commandBuffer];
id<MTLBlitCommandEncoder> blit = [self.cmdBuf blitCommandEncoder];
[blit copyFromTexture:self.staticTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pfrom, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.scratchTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake (0, 0, 0)];
[blit copyFromTexture:self.scratchTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake (0, 0, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.staticTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pto, 0)];
if (pto >= pfrom + ph || pfrom >= pto + ph)
/* Disjoint regions (page scrolls, large jumps): one direct copy is
legal -- only OVERLAPPING copies are undefined -- and halves the
bandwidth. */
[blit copyFromTexture:self.staticTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pfrom, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.staticTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pto, 0)];
else
{
/* Overlapping move (single-line scrolls): bounce through scratch. */
[blit copyFromTexture:self.staticTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pfrom, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.scratchTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake (0, 0, 0)];
[blit copyFromTexture:self.scratchTexture sourceSlice:0 sourceLevel:0
sourceOrigin:MTLOriginMake (0, 0, 0)
sourceSize:MTLSizeMake ((NSUInteger) pw, (NSUInteger) ph, 1)
toTexture:self.staticTexture destinationSlice:0 destinationLevel:0
destinationOrigin:MTLOriginMake ((NSUInteger) px, (NSUInteger) pto, 0)];
}
[blit endEncoding];
if (hadEncoder)
@ -1393,6 +1472,7 @@ mtl_log_seq_p (void)
if (py + ph > tht) ph = tht - py;
if (pw <= 0 || ph <= 0) return;
mtl_flush_batch (); /* queued quads draw before the move */
BOOL hadEncoder = (self.encoder != nil);
if (self.encoder) { [self.encoder endEncoding]; self.encoder = nil; }
if (!self.cmdBuf) self.cmdBuf = [g_queue commandBuffer];
@ -1420,6 +1500,12 @@ mtl_log_seq_p (void)
{
CGSize dsz = self.metalLayer.drawableSize;
/* Start the cycle with an empty batch (endFrame drains it; this is the
defensive reset, mirroring gl_drv_begin_frame). */
g_batch_verts = 0;
g_batch_fd = nil;
self.clipOn = NO;
/* Track the backing scale so the glyph atlas is baked at physical resolution.
If it changes (window moved to a different-DPI monitor), drop the atlas so
glyphs re-rasterize at the new scale. */
@ -1428,8 +1514,7 @@ mtl_log_seq_p (void)
if (scale > 0 && fabs (scale - g_atlas_scale) > 0.01)
{
g_atlas_scale = scale;
g_atlas_next_x = g_atlas_next_y = g_atlas_row_h = 0;
glyph_cache_init ();
mtl_atlas_reset ();
mtl_color_glyph_cache_clear (); /* color glyphs are scale-baked too */
}
@ -1464,24 +1549,103 @@ mtl_log_seq_p (void)
[self openRenderEncoderClear:needsClear];
}
/* Emit the queued quads (see g_batch) as a single draw call. The quads
were clipped on the CPU when queued, so the draw runs under a
full-frame scissor; the non-batched primitives re-apply the recorded
clip themselves (applyScissorNow). Small batches ride in the command
buffer via setVertexBytes; larger ones get a one-shot shared buffer
the encoder keeps alive until execution. */
static void
mtl_flush_batch (void)
{
MtlFrameData *fd = g_batch_fd;
if (g_batch_verts == 0 || !fd) return;
if (!fd.encoder || !g_glyph_pipeline) { g_batch_verts = 0; return; }
MTLScissorRect sc = { 0, 0, fd.staticTexture.width,
fd.staticTexture.height };
[fd.encoder setScissorRect:sc];
[fd.encoder setRenderPipelineState:g_glyph_pipeline];
NSUInteger len = (NSUInteger) g_batch_verts * sizeof (MtlGlyphVertex);
if (len <= 4096) /* Metal's setVertexBytes ceiling */
[fd.encoder setVertexBytes:g_batch length:len atIndex:0];
else
{
id<MTLBuffer> vb =
[g_device newBufferWithBytes:g_batch length:len
options:MTLResourceStorageModeShared];
[fd.encoder setVertexBuffer:vb offset:0 atIndex:0];
[vb release]; /* the encoder retains it until execution */
}
[fd.encoder setVertexBuffer:fd.uniformBuffer offset:0 atIndex:1];
[fd.encoder setFragmentTexture:g_atlas atIndex:0];
[fd.encoder setFragmentSamplerState:g_sampler atIndex:0];
[fd.encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0
vertexCount:(NSUInteger) g_batch_verts];
g_batch_verts = 0;
}
/* Queue one textured quad (logical pixel coords) into the shared batch,
clamping it on the CPU against FD's recorded clip rect (the same
logical bounds the scissor would have used) with the texture
coordinates adjusted proportionally. Clipping at queue time is what
lets the batch survive clip changes and cross glyph strings. */
static void
mtl_batch_append (MtlFrameData *fd, float x0, float y0, float x1, float y1,
float u0, float v0, float u1, float v1,
float r, float g, float b)
{
if (g_batch_fd && g_batch_fd != fd)
mtl_flush_batch ();
g_batch_fd = fd;
if (fd.clipOn)
{
NSRect c = fd.clipRect;
float cx0 = (float) NSMinX (c), cy0 = (float) NSMinY (c);
float cx1 = (float) NSMaxX (c), cy1 = (float) NSMaxY (c);
if (cx1 <= cx0 || cy1 <= cy0)
return; /* clipped out entirely */
if (x0 >= cx1 || x1 <= cx0 || y0 >= cy1 || y1 <= cy0)
return;
float du = (u1 - u0) / (x1 - x0), dv = (v1 - v0) / (y1 - y0);
if (x0 < cx0) { u0 += du * (cx0 - x0); x0 = cx0; }
if (x1 > cx1) { u1 -= du * (x1 - cx1); x1 = cx1; }
if (y0 < cy0) { v0 += dv * (cy0 - y0); y0 = cy0; }
if (y1 > cy1) { v1 -= dv * (y1 - cy1); y1 = cy1; }
}
if (g_batch_verts + 6 > g_batch_cap)
{
int cap = g_batch_cap ? g_batch_cap * 2 : 4096;
MtlGlyphVertex *p =
realloc (g_batch, (size_t) cap * sizeof (MtlGlyphVertex));
if (!p) { mtl_flush_batch (); return; }
g_batch = p;
g_batch_cap = cap;
}
MtlGlyphVertex quad[6] = {
{x0,y0, u0,v0, r,g,b,1}, {x1,y0, u1,v0, r,g,b,1},
{x0,y1, u0,v1, r,g,b,1}, {x1,y0, u1,v0, r,g,b,1},
{x1,y1, u1,v1, r,g,b,1}, {x0,y1, u0,v1, r,g,b,1},
};
memcpy (g_batch + g_batch_verts, quad, sizeof quad);
g_batch_verts += 6;
}
- (void)fillRect:(NSRect)rect color:(unsigned long)color
{
if (!self.encoder || !g_rect_pipeline) return;
if (!self.encoder || !g_glyph_pipeline) return;
/* A solid rect is a quad sampling the atlas' white block: coverage 1.0
passes the gamma curve unchanged, so it joins the glyph batch with
no pipeline switch and no flush. */
float r, g, b;
unpack_color (color, &r, &g, &b);
float x0 = (float)NSMinX(rect), y0 = (float)NSMinY(rect);
float x1 = (float)NSMaxX(rect), y1 = (float)NSMaxY(rect);
MtlRectVertex v[6] = {
{x0,y0,r,g,b,1}, {x1,y0,r,g,b,1}, {x0,y1,r,g,b,1},
{x1,y0,r,g,b,1}, {x1,y1,r,g,b,1}, {x0,y1,r,g,b,1},
};
[self.encoder setRenderPipelineState:g_rect_pipeline];
[self.encoder setVertexBytes:v length:sizeof(v) atIndex:0];
[self.encoder setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[self.encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6];
mtl_batch_append (self,
(float) NSMinX (rect), (float) NSMinY (rect),
(float) NSMaxX (rect), (float) NSMaxY (rect),
MTL_WHITE_U, MTL_WHITE_V, MTL_WHITE_U, MTL_WHITE_V,
r, g, b);
}
- (void)drawGlyph:(MtlGlyphCacheEntry *)ge
@ -1500,33 +1664,26 @@ mtl_log_seq_p (void)
CGFloat s = g_atlas_scale;
float x0 = (float)(origin.x - ge->bearing_x / s);
float y0 = (float)(origin.y - ge->bearing_y / s);
float x1 = x0 + ge->width / s;
float y1 = y0 + ge->height / s;
float u0 = (float)ge->atlas_x / MTL_ATLAS_WIDTH;
float v0 = (float)ge->atlas_y / MTL_ATLAS_HEIGHT;
float u1 = (float)(ge->atlas_x + ge->width) / MTL_ATLAS_WIDTH;
float v1 = (float)(ge->atlas_y + ge->height) / MTL_ATLAS_HEIGHT;
MtlGlyphVertex v[6] = {
{x0,y0, u0,v0, fr,fg,fb,1}, {x1,y0, u1,v0, fr,fg,fb,1},
{x0,y1, u0,v1, fr,fg,fb,1}, {x1,y0, u1,v0, fr,fg,fb,1},
{x1,y1, u1,v1, fr,fg,fb,1}, {x0,y1, u0,v1, fr,fg,fb,1},
};
[self.encoder setRenderPipelineState:g_glyph_pipeline];
[self.encoder setVertexBytes:v length:sizeof(v) atIndex:0];
[self.encoder setVertexBuffer:self.uniformBuffer offset:0 atIndex:1];
[self.encoder setFragmentTexture:g_atlas atIndex:0];
[self.encoder setFragmentSamplerState:g_sampler atIndex:0];
[self.encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:6];
mtl_batch_append (self, x0, y0,
(float) (x0 + ge->width / s),
(float) (y0 + ge->height / s),
(float) ge->atlas_x / MTL_ATLAS_WIDTH,
(float) ge->atlas_y / MTL_ATLAS_HEIGHT,
(float) (ge->atlas_x + ge->width) / MTL_ATLAS_WIDTH,
(float) (ge->atlas_y + ge->height) / MTL_ATLAS_HEIGHT,
fr, fg, fb);
}
/* Rasterize a fringe bitmap (rows of bits, MSB-first like the X backend's
XCreatePixmapFromBitmapData) into a one-shot R8 coverage texture and draw it
as a colored quad. Reuses the glyph pipeline (coverage * color). bits[dh+r]
is row r; the visible window is [dh, dh+h). A fresh texture per call avoids
the deferred-sampling hazard of reusing one texture across queued draws; the
command buffer retains it until completion, and fringes are few per frame. */
XCreatePixmapFromBitmapData) into an R8 coverage texture and draw it as
a colored quad. Reuses the glyph pipeline (coverage * color).
bits[dh+r] is row r; the visible window is [dh, dh+h). The textures
are cached by an FNV-1a hash of the visible rows plus the box size: a
fringe indicator redraws with the same handful of patterns over and
over, the coverage is color-independent (the color rides on the
vertices), and a cached texture is immutable after creation so reusing
it across queued draws is safe (only mutation would be a hazard). */
- (void)drawFringeBits:(unsigned short *)bits dh:(int)dh bw:(int)bw
wd:(int)wd h:(int)h
atX:(int)x y:(int)y color:(unsigned long)color
@ -1535,28 +1692,59 @@ mtl_log_seq_p (void)
if (wd > 32) wd = 32;
if (bw < wd) bw = wd;
MTLTextureDescriptor *td =
[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatR8Unorm
width:(NSUInteger) wd
height:(NSUInteger) h mipmapped:NO];
td.usage = MTLTextureUsageShaderRead;
td.storageMode = MTLStorageModeShared;
id<MTLTexture> tex = [g_device newTextureWithDescriptor:td];
mtl_flush_batch (); /* binds its own texture; drain quads first */
[self applyScissorNow]; /* non-batched: needs the real scissor */
uint8_t *buf = (uint8_t *) calloc ((size_t) wd * (size_t) h, 1);
unsigned long long hash = 1469598103934665603ULL;
for (int r = 0; r < h; r++)
{
unsigned short row = bits[dh + r];
for (int c = 0; c < wd; c++)
/* MSB-first within the bitmap's TRUE width: when the fringe is
narrower than the bitmap, this shows its left-aligned part
(the native backends clip the full bitmap the same way). */
if ((row >> (bw - 1 - c)) & 1)
buf[r * wd + c] = 0xFF;
hash ^= (unsigned long long) bits[dh + r];
hash *= 1099511628211ULL;
}
hash ^= ((unsigned long long) bw << 40)
^ ((unsigned long long) wd << 20) ^ (unsigned long long) h;
#define MTL_BITMAP_CAP 64
static struct { unsigned long long hash; id<MTLTexture> tex; }
cache[MTL_BITMAP_CAP];
static int cache_next;
id<MTLTexture> tex = nil;
for (int i = 0; i < MTL_BITMAP_CAP; i++)
if (cache[i].tex && cache[i].hash == hash)
{ tex = cache[i].tex; break; }
if (!tex)
{
MTLTextureDescriptor *td =
[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatR8Unorm
width:(NSUInteger) wd
height:(NSUInteger) h mipmapped:NO];
td.usage = MTLTextureUsageShaderRead;
td.storageMode = MTLStorageModeShared;
tex = [g_device newTextureWithDescriptor:td];
uint8_t *buf = (uint8_t *) calloc ((size_t) wd * (size_t) h, 1);
for (int r = 0; r < h; r++)
{
unsigned short row = bits[dh + r];
for (int c = 0; c < wd; c++)
/* MSB-first within the bitmap's TRUE width: when the fringe is
narrower than the bitmap, this shows its left-aligned part
(the native backends clip the full bitmap the same way). */
if ((row >> (bw - 1 - c)) & 1)
buf[r * wd + c] = 0xFF;
}
[tex replaceRegion:MTLRegionMake2D (0, 0, (NSUInteger) wd, (NSUInteger) h)
mipmapLevel:0 withBytes:buf bytesPerRow:(NSUInteger) wd];
free (buf);
/* MRC: the cache owns one reference (from newTexture...); evicting
releases it (any in-flight command buffer holds its own). */
if (cache[cache_next].tex)
[cache[cache_next].tex release];
cache[cache_next].hash = hash;
cache[cache_next].tex = tex;
cache_next = (cache_next + 1) % MTL_BITMAP_CAP;
}
[tex replaceRegion:MTLRegionMake2D (0, 0, (NSUInteger) wd, (NSUInteger) h)
mipmapLevel:0 withBytes:buf bytesPerRow:(NSUInteger) wd];
free (buf);
float fr, fg, fb;
unpack_color (color, &fr, &fg, &fb);
@ -1621,6 +1809,8 @@ mtl_log_seq_p (void)
- (void)endFramePresent:(BOOL)present
{
if (!self.encoder) return;
mtl_flush_batch (); /* drain any quads left from the last string */
g_batch_fd = nil; /* the batch no longer targets this frame */
[self.encoder endEncoding];
self.encoder = nil;
self.drawable = nil;
@ -2351,6 +2541,8 @@ mtl_draw_image_texture_uv (MtlFrameData *fd, id<MTLTexture> tex,
float u0, float v0, float u1, float v1, float alpha)
{
if (!fd.encoder || !g_image_pipeline || !tex) return;
mtl_flush_batch (); /* keep submission order vs queued quads */
[fd applyScissorNow]; /* non-batched: needs the real scissor */
typedef struct { float x, y, u, v, a; } ImgVert;
float x1 = x+w, y1 = y+h;