要栅格化应用中的 NSImageView,请使用 AppKit 配套的位图缓存方法,然后保留生成结果的像素尺寸。如果 Metal 需要可预测的字节数据,请将该位图重新绘制到明确定义的 Core Graphics 缓冲区中。
本指南介绍完全在内存中生成的合成图像。任务是获取其在 NSImageView 中呈现效果的可复用栅格图,诊断像素错位或重复的问题,并可选择将栅格图传输到 Metal 纹理。
确定你需要视图的呈现效果,还是精确尺寸的图像
图像和显示图像的视图承担着不同的职责。视图会应用其缩放模式,将图像适配到自己的框架中。其默认值为 scaleProportionallyDown,因此提取原始图像不一定能复现视图的呈现效果。Apple 的 imageScaling 文档介绍了此行为。
选择 API 之前,先确定输出:
| 期望的输出 | 合适的起点 | 尺寸约定 |
|---|---|---|
| 图像视图绘制出的图像 | AppKit 的视图位图缓存方法 | 检查返回位图的像素尺寸 |
| 严格按指定尺寸绘制的合成图像 | 直接绘制到位图 CGContext 中 |
明确指定整数像素宽度和高度 |
| 可供 Metal 纹理使用的可预测字节数据 | 将栅格图重新绘制到已定义的位图上下文中 | 明确指定像素格式、行跨度和所有权 |
Apple 将 bitmapImageRepForCachingDisplay(in:) 描述为创建适用于所请求视图区域的表示形式。将该表示形式传给 cacheDisplay(in:to:);Apple 特别推荐这种配套用法,以确保像素格式兼容。第二个方法会绘制视图及其后代视图。位图分配文档、视图缓存文档。
这些方法无法指定任意的输出分辨率。如果你的要求是精确的 192 × 128 像素,请直接按该尺寸绘制合成图像。事后放大较小的视图栅格图,无法恢复从未栅格化过的细节。这是两种不同输出约定导致的结果,并非视图缓存的另一项功能。
区分点、像素和矩形
请求完整的局部绘制区域时,请使用 view.bounds。视图的 bounds 描述其自身的坐标系;frame 描述它在父视图坐标系中的位置和尺寸。因此,将 frame 传给要求局部区域的方法可能会引入意外偏移。Apple 的 bounds 文档。
作为尺寸约定的示例,192 × 128 的源位图可以呈现为 96 × 64 的逻辑图像。请保留这两个值:前者表示采样点,后者表示呈现尺寸。NSImage 初始化器接受显式尺寸;如果提供零,则会采用源图像的像素尺寸。Apple 的图像初始化器文档。
诊断现有视图的 backing 几何信息时,请使用 convertToBacking(_:) 将其局部矩形转换为 backing store 坐标。对于实际返回的栅格图,请检查 pixelsWide 和 pixelsHigh,或检查其 CGImage 尺寸。Apple 文档指出,NSBitmapImageRep 的 cgImage 与该表示形式具有相同的像素尺寸。Backing 转换文档、位图 CGImage 文档。
不要用臆测的 2× 倍数代替这些返回尺寸。请同时记录所请求的局部矩形、返回的像素宽度和高度,以及预期的逻辑尺寸。这样,当结果看起来被放大、裁剪或偏移时,就有具体数据可供比较。
构建完整的合成栅格化示例
下面的 Objective-C 程序会生成一张非对称测试图,将其赋给内存中的图像视图,对视图进行栅格化,然后将结果规范化为 Core Graphics 位图。它不会创建任何文件。在安装了命令行开发者工具的 Mac 上,将代码保存为 RasterDemo.m。
clang -fobjc-arc RasterDemo.m -framework Cocoa -o RasterDemo
./RasterDemo
规范化上下文使用 8 位分量、最高有效位优先的预乘 alpha,以及小端 32 位存储。这些设置组合起来会产生 BGRA 字节顺序:蓝色分量位于内存中的最低有效位端,因此会最先出现。这符合 Apple 对 premultipliedFirst 和 byteOrder32Little 的定义。
#import <Cocoa/Cocoa.h>
static CGContextRef NewBitmap(size_t w, size_t h) {
// Illustrative allocation budget, not an API dimension limit.
if (w == 0 || h == 0 || w > 4096 || h > 4096) return NULL;
size_t rowBytes = (w * 4 + 63) & ~(size_t)63;
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
if (!space) return NULL;
CGContextRef c = CGBitmapContextCreate(
NULL, w, h, 8, rowBytes, space,
kCGBitmapByteOrder32Little | kCGImageAlphaPremultipliedFirst);
CGColorSpaceRelease(space);
return c;
}
static CGImageRef NewTestImage(void) {
CGContextRef c = NewBitmap(192, 128);
if (!c) return NULL;
CGContextSetRGBFillColor(c, 1, 1, 1, 1);
CGContextFillRect(c, CGRectMake(0, 0, 192, 128));
CGContextSetRGBFillColor(c, 0, 0, 0, 1);
for (size_t x = 0; x < 192; x += 16) {
CGContextFillRect(c, CGRectMake(x, 0, 1, 128));
}
CGContextSetRGBFillColor(c, 0, 0.7, 0.8, 1);
CGContextFillRect(c, CGRectMake(8, 92, 24, 20));
CGImageRef image = CGBitmapContextCreateImage(c);
CGContextRelease(c);
return image;
}
static CGImageRef NewNormalizedImage(CGImageRef input) {
size_t w = CGImageGetWidth(input);
size_t h = CGImageGetHeight(input);
CGContextRef c = NewBitmap(w, h);
if (!c) return NULL;
CGContextClearRect(c, CGRectMake(0, 0, w, h));
CGContextSetBlendMode(c, kCGBlendModeCopy);
CGContextSetInterpolationQuality(c, kCGInterpolationNone);
CGContextDrawImage(c, CGRectMake(0, 0, w, h), input);
NSLog(@"Normalized: %zu x %zu pixels; stride %zu bytes",
w, h, CGBitmapContextGetBytesPerRow(c));
CGImageRef result = CGBitmapContextCreateImage(c);
CGContextRelease(c);
return result;
}
int main(void) {
@autoreleasepool {
[NSApplication sharedApplication];
CGImageRef source = NewTestImage();
if (!source) { NSLog(@"Source allocation failed"); return 1; }
NSImage *image = [[NSImage alloc] initWithCGImage:source
size:NSMakeSize(96, 64)];
CGImageRelease(source);
NSImageView *view = [[NSImageView alloc]
initWithFrame:NSMakeRect(0, 0, 96, 64)];
view.image = image;
view.imageScaling = NSImageScaleNone;
view.imageAlignment = NSImageAlignCenter;
view.imageFrameStyle = NSImageFrameNone;
[view layoutSubtreeIfNeeded];
NSRect region = view.bounds;
NSBitmapImageRep *rep =
[view bitmapImageRepForCachingDisplayInRect:region];
if (!rep) { NSLog(@"View bitmap allocation failed"); return 1; }
[view cacheDisplayInRect:region toBitmapImageRep:rep];
CGImageRef cached = rep.CGImage;
if (!cached) { NSLog(@"View bitmap has no CGImage"); return 1; }
CGImageRef raster = NewNormalizedImage(cached);
if (!raster) { NSLog(@"Normalization failed"); return 1; }
NSImage *result = [[NSImage alloc] initWithCGImage:raster
size:region.size];
NSLog(@"Result logical size: %@", NSStringFromSize(result.size));
CGImageRelease(raster);
}
return 0;
}
程序会有意报告实际输出尺寸,而不会断言视图缓存与源图像的 192 × 128 像素尺寸一致。青色标记可以帮助你将方向变化与重复的垂直测试线区分开来。如果你的要求是精确的源分辨率,请直接使用 NewTestImage() 返回的 CGImage,不要经过视图。
显式清除操作会在规范化之前先将目标像素设为透明。单图绘制会填满目标矩形,不会平铺。Apple 文档指出,相应的 Core Graphics 绘制操作会缩放以适配目标矩形,并绘制单个副本,除非启用了平铺。Apple 的图像绘制文档。
我检查了此程序,但无法在缺少 AppKit 和 C 编译器的 Linux 研究环境中编译。下文使用的行跨度计算已通过一段小型确定性 Python 计算进行核对。
按固定顺序诊断像素偏移或重复
以下步骤是根据上述几何和内存约定整理的诊断指南。这些症状可以提示你优先检查的位置,但无法单独确定唯一原因。
- 检查所请求的矩形。 对于整个图像视图,请将缓存请求与
bounds进行比较。如果导出的是子区域,请将其局部原点作为输入约定的一部分。 - 先检查尺寸,再检查原始字节。 将缓存图像的宽度和高度与传给后续每个使用方的尺寸进行比较。以点宽代替像素宽会改变缓冲区的解释方式。
- 检查行跨度。 如果每一行的起始位置逐渐偏离预期,请先检查行起始位置之间的距离,再调整变换。
- 使用非对称标记检查方向。 垂直翻转时应检查坐标约定。出现恒定偏移时应检查原点和平移量。
- 检查重复是否为有意结果。 检查是否有绘制调用启用了平铺。调整行跨度无法纠正显式启用的平铺。
- 最后检查呈现缩放。 如果存储的栅格图正确,但显示结果不同,请比较输出图像的逻辑尺寸和接收图像视图的缩放设置。
对于每像素 4 字节的格式,示例中的 192 × 128 缓冲区每行占用 768 字节,总共需要 98,304 字节。但采用示例中的 64 字节行对齐时,191 × 128 缓冲区的行跨度同样为 768 字节:每行包含 4 字节填充。如果按 191 × 4,也就是 764 字节推进,后续每行的起点都会提前 4 字节。Apple 通过上下文的 bytesPerRow 属性提供实际行跨度。
只有对本文确定的四字节格式,才使用地址计算 base + y * bytesPerRow + x * 4。任意 AppKit 位图都有自己的格式约定;不要仅凭外观推断其分量布局。
同样,应有意进行坐标变换。Core Graphics 提供了分别改变用户空间原点和缩放比例的操作。如果要进行垂直翻转,请记录由哪个阶段负责该转换,并对照标记核验后再添加其他变换。平移文档、缩放文档。
返回图像或上传到 Metal 时保留所有权
示例在绘制其 CGImage 时会保持 AppKit 表示形式处于有效状态。随后,它会从规范化上下文创建快照并返回。Apple 文档指出,makeImage() 会创建副本,内部可能采用写时复制;后续对上下文的绘制不会改变已返回的图像。因此,创建快照后可以释放上下文。Apple 的位图快照文档。
原始位图指针的生命周期不同。Apple 提供对上下文所拥有位图数据的访问,即使上下文是以空数据参数创建的也一样。请将该指针视为属于仍有效的上下文;如果稍后还要使用数据,请将字节复制到你自己拥有的存储中,或在使用完成前保持上下文有效。Apple 的位图数据文档。
要上传到 Metal,请在规范化上下文仍有效时执行以下步骤:
- 创建一个二维纹理,其宽度和高度与上下文的像素宽度和高度相同。示例使用
bgra8Unorm表示 BGRA 分量布局;Apple 将其定义为按 BGRA 顺序排列的 4 个 8 位归一化无符号分量。 - 对于 CPU 上传,保留系统的默认存储模式,不要强制使用特定硬件的共享模式。Apple 文档指出,Apple 芯片上的默认模式为 shared,基于 Intel 的 Mac 上纹理的默认模式为 managed。
- 对完整纹理区域调用
replace(region:mipmapLevel:withBytes:bytesPerRow:),mipmap 级别为0,并使用上下文的数据指针和实际行跨度。 - 释放上下文之前完成上传。若要复用纹理,还要确保此前使用该纹理的 GPU 工作已经完成,再由 CPU 覆写纹理内容。
这些格式和存储设置依据 Apple 的 bgra8Unorm 文档和资源存储指南。Apple 指出,替换方法会立即复制所提供的像素,但不会与 GPU 操作同步。对于 private 纹理,文档建议先使用非 private 临时纹理,再通过 blit 进行传输。Apple 的纹理替换文档。
纹理上传会保留提供的字节;仅匹配分量布局无法定义渲染管线的其余部分。请在使用方的设计中沿用示例的预乘 alpha 和 sRGB 设置。规范化是将图像重新绘制到选定格式中的过程,因此不应将其描述为能够保证将任何 AppKit 表示形式转换成逐字节完全相同的结果。
对于此任务,可靠的交接内容包括栅格图及其像素尺寸、逻辑呈现尺寸、行跨度、分量格式、方向约定和所有者。将这些值放在一起,用非对称合成图进行测试,并修正它们首次出现偏差的阶段。这样,像素偏移或重复就能作为可追踪的约定问题进行排查。
来源
cacheDisplay(in:to:) — Apple
imageScaling — Apple
bounds — Apple
init(cgImage:size:) — Apple
convertToBacking(_:) — Apple
cgImage — Apple
byteOrder32Little — Apple
draw(_:in:byTiling:) — Apple
bytesPerRow — Apple
translateBy(x:y:) — Apple
scaleBy(x:y:) — Apple
makeImage() — Apple
data — Apple
MTLPixelFormat.bgra8Unorm — Apple
Setting resource storage modes — Apple