65.9K
CodeProject 正在变化。 阅读更多。
Home

GFX 第一部分:独立于设备的像素

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2021年4月11日

MIT

15分钟阅读

viewsIcon

5486

downloadIcon

135

一个适用于物联网设备的设备无关图形库。系列文章的第一部分。

pixel

引言

更新:今天,此代码的新版本已作为本系列下一篇文章的一部分提供。建议您使用该代码,因为它包含了错误修复。

关于物联网,其中一件有趣或可怕的事情(取决于您的观点和心情)是所有东西都是裸机(bare metal)。

这使得实现图形库变得相当复杂,因为每个轮子都必须基本上重新发明,而且我们必须能够处理无数种不同的屏幕模式配置。

你以为我们会用 DirectX 吗?没那么幸运。你所拥有的是一个通过 SPI 或 I2C 总线连接到显示器的帧缓冲区,其显示模式完全是任意的。这个设备可能支持像素块传输(blting)以及像素的读写。这基本上就是你能指望的全部了,尽管有些设备可能支持其他操作、DMA 传输和其他高级功能。请记住,这些屏幕模式的二进制格式完全是任意的,并且依赖于具体设备。

在本文中,我们将只讨论小小的像素。原因是图形库中的一切都建立在此之上,而且仅仅讨论像素就是一个冗长的话题,因为它是设备无关图形库中较为复杂的部分之一。

构建这个大杂烩

此代码已使用 clang 11 和 gcc 10 并带有 -std=c++17 标志进行了测试。

最新版本的 MSVC 无法编译此代码。坦白说,我不在乎,因为我遇到的任何东西都无法与 MSVC 交叉编译,而且此代码的主要目的是用于那些无法轻易用 MSVC 作为目标的物联网设备。代码中没有任何特定于 Linux 的内容,我相信它只是符合标准的 C++,但我可能在不经意间使用了一些 gcc 和 clang 特有的功能。

通过对源代码进行一些修改,也许可以将其适配到 C++14 标准,因为其中大部分内容都与 C++14 兼容。我只是没有排查所有的错误/C++17 特定功能。

概念化这个混乱的局面

在引言中,我说过显示模式是任意的。这里我不仅指分辨率和颜色深度,还指帧缓冲区在内存中的二进制布局。例如,一些显示模式可能期望一个 8 位的值代表蓝色通道,然后是绿色通道,最后是红色通道。另一种模式可能期望一个 5 位的值代表红色通道,一个 6 位的值代表绿色通道,还有一个 5 位的值代表蓝色通道。还有的可能是灰度模式,甚至单色模式。此外,可能还有其他模式,特别是当您处理图像文件以及帧缓冲区时。例如,JPEG 格式使用的颜色模型甚至不是 RGB。

你可以看到这可能会变得多么复杂。我们需要的是能够像这样声明东西:

// the first described example above
// 24-bit color in BGR
using bgr888 = pixel<
    channel_traits<channel_name::B,8>,
    channel_traits<channel_name::G,8>,
    channel_traits<channel_name::R,8>
>;    
// the second example above
// 16-bit color in RGB
using rgb565 = pixel<
    channel_traits<channel_name::R,5>,
    channel_traits<channel_name::G,6>,
    channel_traits<channel_name::B,5>
>;
// 8-bit grayscale
using gsc8 = pixel<
    channel_traits<channel_name::L,8>
>;
// 1-bit mono
using mono1 = pixel<
    channel_traits<channel_name::L,1>
>;

在这里,我们声明了四种不同的 pixel<> 格式,每种格式都有一个或多个 channel_traits<>,每个通道都有一个 channel_name 和位深度。

之后,我们需要一种方法来初始化它们,访问单个通道的值,并且最好能在不同格式之间进行转换。

由于我们的目标设备 CPU 和 RAM 极其有限,我们应该尽可能地将计算任务推到编译时完成。因此,代码有时可能需要费尽周折来避免运行时计算,而使用编译时计算。此外,我们需要确保库的设计能够公开丰富的编译时 API,以便于在编译时创建复杂的像素操作。

要做到这一切,需要进行一些元编程。代码“哄骗”编译器进行相当复杂的计算来实现这一点,但换来的是高效和灵活的代码,特别是在值在编译时已知的情况下。

我们想要的是能够初始化像素、查询其通道数据、设置通道数据,甚至在像素格式之间进行转换,使得如果所有值在编译时都已知,则不会为这些操作生成任何代码。相反,对这些操作的请求会被一个代表像素内在字面值的常量所取代,就好像像素本身只是一个 C++ 的内在整型值一样。有了这样的东西,我们可以将紫色颜色的 RGB 模型定义转换成另一种颜色模型——甚至是单色或灰度,而无需在运行时进行转换。编译器会为我们计算出最终值。我们也可以转换成不同的二进制布局和位深度。

我们还需要运行时代码表现良好。我们需要能够在并非所有值都在编译时已知的情况下完成上述操作,并为其生成高效的代码。在许多情况下,我们可以通过在编译时尽可能多地完成工作,然后在运行时完成剩余的任务来实现这一点。

提供一组丰富的功能,用于在编译时查询像素的各种属性也会很有帮助。例如,我们应该能够通过名称或索引检索通道,并获取它们的位深度、最小值和最大值、通道掩码,以及其他一些信息。

最后,它应该能在主流平台上运行。为实现这一点,像素内部以本地字节序(native endian)格式存储,但也可以作为大端字节序(big endian)访问,因为这对于表示帧缓冲区中的二进制像素很有用,并且在获取和设置通道值时,它会在必要时进行字节序转换。需要注意的是,对于 ARM 处理器,虽然可以在运行时切换字节序模式,但本库不支持该功能,也不应与本代码一起使用。

免责声明:我没有在大端字节序处理器上测试过此代码,因为我目前没有这样的设备。我正在等待一台运送给我。尽管如此,我相当有信心这代码能在大端字节序机器上正常工作。

使用这个烂摊子

使用像素可以做的主要事情包括定义它们、初始化它们、获取或设置单个通道的值、将它们转换为其他像素格式,以及查询关于像素定义的元数据。

对于下面的所有示例,我们将使用上面定义的像素。

初始化

有三种方法可以初始化一个像素:

rgb565 pixel;                // initializes to 0
// note our green channel is 0-63 not 0-31
rgb565 pixel2(0,31,15);      // dark cyan - one int per channel
rgb565 pixel3(true,0,.5,.5); // same as above, but reals

请注意,没有办法通用地初始化任意配置的像素,因为一个像素可能只有 1 个通道,也可能多达 64 个通道。但是,你可以做一些事情,比如将其设置为一个已知的颜色,如 color<rgb565>::purple,这适用于任何可以从 RGB 颜色模型转换的像素格式。否则,你的构造函数调用将特定于你实例化的像素类型,具体取决于通道数量以及每个通道的 int_typereal_type

访问通道值

有几种方法可以访问通道值。你可以将它们作为实数或整数,通过通道索引或通道名称来检索和设置。如果可能的话,获取和设置操作会在编译时进行。索引或名称必须在编译时已知,因为它是一个模板参数。

auto r = pixel.channel<0>();                  // get the red value
auto rf = pixel.channelf<0>();                // get the red value
auto r2 = pixel.channel<channel_name::R>();   // get the red value
auto rf2 = pixel.channelf<channel_name::R>(); // get the red value

设置它们的方式类似:

pixel.channel<0>(16);               // set red to 16
pixel.channelf<0>(.5);              // set red to .5 (~16)
pixel.channel<color_name::R>(16);   // set red to 16
pixel.channelf<color_name::R>(.5);  // set red to .5 (~16);

未检查版本

还有 channel_unchecked<>() 模板方法,它们不会检查索引的有效性。这允许你绕过编译器对传入索引的检查。有时这是必要的,因为编译器无法确定你传递的索引保证是有效的,即使它确实是。在这些情况下,使用标准的通道访问方法会导致编译器错误。当这种情况发生时,请使用未检查的方法,但要注意,传入无效索引会产生一个“空通道”,其中获取和设置操作什么也不做,并且所有通道元数据都为零。因此,你真的应该事先检查你传入的索引的有效性。

在像素格式之间转换

pixel<> 提供了一个名为 convert<>() 的模板方法,它允许你从一种像素格式转换为另一种。目前,它可以将灰度或单色转换为 RGB 颜色模型,反之亦然;它可以在不同位深度之间转换;它还会根据需要重新排序通道数据。如果你想添加对其他颜色模型(如 HUV)的支持,你需要向这个例程中添加代码。

这个方法有两个版本。一个通过输出参数传递结果并返回一个 `bool` 值。另一个版本直接返回转换后的值。前者更安全,因为它会报告转换是否无法执行,而后者在失败时只会返回一个值为零的像素。如果你确定转换会成功,你可以直接使用后者来简化代码。它唯一会失败的情况是不支持特定的颜色模型。

// safer convert call
gsc8 pixel;
// todo: check the return value for success
rgb888(true,1,0,.3333).convert(&pixel);

// easier call
pixel = rgb888(true,1,0,.3333).convert<gsc8>();

如果被转换的像素是用编译时已知的值初始化的,那么这个方法就没有运行时开销。该调用会从代码中被消除。

预定义颜色

在 `color` “枚举”(实际上是一个 `template struct`)下,有几个预定义的颜色,如 `black`、`white`、`dark_green`、`cyan` 等。你可以初始化任何可从 RGB 转换的像素类型。

mono1 m = color<mono1>::green;    // will resolve to white/1
rgb565 c = color<rgb565>::yellow; 

访问原始像素数据

也可以将像素数据作为整数来读取和写入,但这些数据在内部以本地字节序格式存储。如果是大端字节序,它将按照通道顺序从左到右排列,未使用的位会填充在右侧。例如,一个 24 位像素的机器字长将是 32 位,在使用大端字节序时,剩余的 8 位会在像素数据的右侧,或者在使用小端字节序时在左侧。这种表示可以通过大端字节序这样访问:

pixel.value(0xFFFFAA00);    // set the pixel to pale yellow

你可以使用 native_value 字段来获取机器本地字节序模式下的像素数据。

查询像素和通道的元数据

像素提供了丰富的元数据集合,描述了从二进制布局到每个通道的有效值范围和缩放比例,再到每个通道的名称等所有信息。此外,它们提供了强大的比较机制来评估两种像素类型的相似性和差异。所有这些都不会生成代码或在运行时导致任何代码执行。

像素数据

  • <别名> type - 像素本身的声明类型
  • <别名> int_type - 保存像素数据的整数类型
  • size_t channels - 声明的通道数量
  • size_t bit_depth - 每个声明通道的位深度之和
  • int_type mask - 像素值的掩码
  • size_t packed_size - 容纳像素数据所需的最小字节数
  • bool byte_aligned - 如果像素是整数个字节,则为 true
  • size_t total_size_bits - 像素的总位数。基于 int_type
  • size_t packed_size_bits - 以位为单位的 packed_size
  • size_t pad_right_bits - 右侧未使用的位数

确定颜色模型

了解你正在处理的像素的颜色模型可能很有用。也就是说,这个像素是 RGB/BGR 类型的吗?是灰度还是单色?还是其他类型,比如 HUV 或 YCbCr?

因为你可以定义自己的任意通道类型,你可能会认为确定一个像素是否属于任意颜色模型会很有挑战性,通常你是对的。然而,通过类型列表和元编程的魔力,我提供了一个名为 has_channel_names<> 的辅助“方法”,使之变得简单:

// check for an RGB color model
bool isRgb = bgr888::has_channel_names<
        channel_name::R,
        channel_name::G,
        channel_name::B>::value;

有时,确定颜色模型可能会稍微复杂一些。一个例子是单色或灰度。在这种情况下,我们使用 channel_name::L 来表示亮度(luminosity)。然而,我们还希望确保只有一个通道。我们可以通过检查 channels 的数量来确定这一点。

bool isBW = mono1::has_channel_names<
        channel_name::L>::value &&
        mono1::channels==1;

相等性、超集和子集

在某些情况下,你可能需要确定一种像素类型是否与另一种像素类型具有相同的通道(通过名称识别),或者一种像素在通道方面是否是另一种的子集或超集。

// see if the two pixel types have the same
// channel names in the same order
printf("bgr888::equals<rgb565> = %s\r\n\r\n",
    bgr888::equals<rgb565>::value?"true":"false");

// see if the two pixel types have the same
// channel names in any order
printf("bgr888::unordered_equals<rgb565> = %s\r\n\r\n",
    bgr888::unordered_equals<rgb565>::value?"true":"false");

上面第一行是 false,第二行计算结果为 true。

is_superset_of<>is_subset_of<> 的工作方式类似。在(无序)相等的情况下,它们也会返回 true。

检索通道数据

你可以分别使用 channel_by_index<>channel_by_name<> 按索引或名称检索通道的元数据。你还可以使用 channel_index_by_name<> 将名称转换为索引。channel_by_index_unchecked<> 允许你绕过编译器错误,这些错误可能在编译器无法确定传入的索引始终是有效通道时出现。如果索引无效,将返回一个“空通道”。这种通道的元数据为零。最好事先确保索引是有效的。

一旦检索到一个通道,你就可以获取它的元数据。

channel<> 有许多静态字段和类型别名,它们返回各种信息:

  • <别名> type - 该通道本身声明类型的别名
  • <别名> pixel_type - 声明该通道的像素类型
  • <别名> name_type - 表示通道名称的类型
  • <别名> int_type - 保存通道值的整数类型
  • <别名> real_type - 保存通道值的实数类型。
  • <别名> pixel_int_type - pixel_type::int_type 的快捷方式
  • size_t bits_to_left - 此通道数据左侧的位数
  • size_t total_bits_to_right - 右侧的总位数,包括填充位
  • size_t bits_to_right - 右侧的位数,不包括填充位
  • char* name() - 通道的名称
  • size_t bit_depth - 通道的位深度
  • int_type value_mask - 通道值的掩码
  • pixel_int_type channel_mask - 作为像素数据一部分的通道掩码
  • int_type min - 最小值。可以在 channel_traits<> 中设置
  • int_type max - 最大值。可以在 channel_traits<> 中设置
  • int_type scale - 整数缩放比例 - 分母
  • real_type scalef - 实数缩放比例 - scale 的倒数。

你不需要担心这些,除非你以后需要它。它特意加载了大量信息,因为在几乎所有情况下,拥有它而不需要它比需要它而没有它要好,因为这没有任何运行时开销。最坏的情况下,检索名称会在你的可执行文件的 .text 段中放入一个字符串。其他所有东西都是零影响。

扩展这个复杂的系统

扩展可用的颜色模型数量可能是可取的。大多数时候,你只需声明一个带有你想要通道的像素类型即可,但仍然存在与 RGB 等格式相互转换的问题,以及你可能需要更多 `channel_name` 条目的可能性。对像素的修改在 *gfx_pixel.hpp* 中完成。

添加通道名称

你可以使用 `GFX_CHANNEL_NAME(x)` 宏来声明一个新的通道名称。该名称必须是有效的 C 标识符。你不必将新的通道名称放在 `channel_name` 下,但你可以这样做,并且你可能会发现将它们都放在一个地方更可取。

添加与其他颜色模型的转换

扩展此系统的第二种方法是修改 `pixel<>` 的主 `convert<>()` 例程,使其能够支持你的新颜色模型。这样做一开始可能看起来很复杂,但大部分代码都是样板。查找 `// TODO:` 注释,以了解在哪里为其他源格式和目标格式添加额外的 `if`/`else` 条件。进行链式转换可能是可取的。例如,与其编写代码处理从 Y'UV 到/从 RGB 以及从 Y'UV 到/从灰度的转换,你可以通过将你的值递归转换为 RGB,然后再从 RGB 转换为灰度来实现后一种情况。记住如何确定颜色模型。你需要在例程的顶部为此添加代码。目前,你会看到 RGB 和 BW(黑白)。记住总是按名称查找通道,因为像素可能具有相同的通道但顺序不同。另外,使用 `helpers::convert_channel_depth()` 来转换为目标位深度。请参考现有代码。

优化说明

此代码尚未针对编译时间进行优化。如果你发现编译时间过长,有办法可以加快编译速度。大部分时间将花费在诸如测试相等性或按通道名称或索引检索之类的操作上,但你基本上可以假设所有模板调用都是编译器密集型的。特别是在 `gfx::helpers` 下,你会发现有很多优化的机会。

你可能已经注意到此代码被积极地内联了。这样做有两个原因:

首先,可以通过用另一个方法包装一个方法来“取消内联”,但你不能内联一个非内联方法。因此,我为了灵活性而进行了内联。

其次,我注意到当你使用 **"gcc -Os"**(我推荐)为大小进行优化时,如果代码被重复,内联的方法会被取消内联。换句话说,如果你调用一个内联方法两次,它会取消内联,这意味着没有额外的代码膨胀,同时如果你只调用它一次,你又能获得内联方法的优势。

下一步

目前我们还不能用像素做很多事情。我们需要能够将它们绘制到某个地方,以及类似的事情。在下一部分中,我们将使用像素来实现高效的位图操作。

历史

  • 2021年4月11日 - 初次提交
© . All rights reserved.