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

在 Windows 中使用汇编语言进行图像混合

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (27投票s)

2017 年 7 月 26 日

CPOL

15分钟阅读

viewsIcon

31189

抛弃缓慢的性能,只需稍加努力,便能在 GDI 图像混合中实现显著的速度提升。

Windows 中的图像混合 – 汇编语言方法

[文章中的代码片段再次无法正确格式化。  在输入文章文本时,会出现滚动条,一切正常。  提交文章时,滚动条消失,所有代码片段都会狂热地贴合狭窄的右边距,不必要的换行符会出现在代码片段的各个地方。  这个问题在我写的其他文章中也出现过,比我更聪明的人也解决不了。  这个问题并非未被注意到,也不是懒惰造成的。]

[更糟糕的是,文章编辑器似乎会在输入 http:// 后强制将图片 URL 引用改为 https://.  不知道为什么会发生这种情况,但作为作者,我不知道有什么方法可以修复它.  图片在编辑器中显示正常;在我提交文章后,URL 会违背我的意愿被自动修改为 https://.  为了尽可能地解决这个问题,每张图片的标题都包含了图片所在位置的完整 URL。]

C 语言,还是非 C 语言?

从 C++ 的角度来看,高质量的代码可能从 CPU 的角度来看是极其浪费和低效的.  如果你不是直接在 CPU 的舞台上进行操作,你就无法接近最大化其潜能.  对于像图像混合这样的高要求任务,你不能依赖 Windows API——尤其是 GDI 函数——来高效地完成工作.  Windows 被设计成满足所有开发者的需求;它将形式置于性能之上.  如果你要有效地消除瓶颈,转向汇编语言是不会错的. 

这里需要注意的是,内在函数并不是一个安全的替代方案.  为了从 CPU 中榨取每一丝性能,你不能编写通用的代码,而这些代码不知道或不在乎它是在运行在光速的第九代 i9 处理器上还是笨重的 ARM 处理器上.  简而言之,你不能通过捷径获得高质量.  为了获得最佳性能,有时你需要付出努力:你必须根据硬件来定制你的工作.  最终,这项工作会获得丰厚的回报. 

BitBlt 的陷阱

首先要注意的是,当你使用 **CreateCompatibleBitmap** 创建一个位图时,非常有限的核心内存将被用来存储该图像.  如果微软有记录这一点,那么这些讨论也隐藏在人眼之外.  如果存在这样的文档,我从未见过.  (而且可能确实存在;也许我没有理由从未见过它!)  在动态创建位图时,我曾一次遇到“内存不足”的墙;那次经历促使我开始浏览开发论坛,寻找我哪里出了问题.  那时我了解到哪些位图存储在哪些内存区域,并且在调整之后,那个特定问题再也没有出现. 

**CreateDIBSection** 使用当前进程堆栈外的内存;切换到这个方式一次性解决了我的资源问题.  我开发了自己的本地函数 **CreateLocalBitmap**,它接受与 **CreateCompatibleBitmap** 相同的参数(外加一个额外的参数),但创建的是 DIB section——并且其功能包括返回位图数据的地址.  **在本文讨论的图像混合过程中,这个小数据指针被直接使用,跨所有地方。 ** 如果你从资源加载图像,你也可以使用 **GetDIBits** 来检索位图数据.  ( **GetBitmapBits 的文档说该函数已被弃用;使用 GetDIBits 代替。**)

我本地声明的函数 **CreateLocalBitmap** 始终创建一个 32 位位图.  之所以这样做是因为每像素 4 字节对于通过 XMM 寄存器进行处理非常有效.  对于 24 位格式,你无法做到这一点.  你必须先将 24 位格式转换为 32 位布局——这是一个繁重的过程,完全不必要,只需稍加准备,从一开始就创建 32 位位图. 

[注意: 以下两段在撰写时是准确的.  它们是直接由于我处理的位图从 32 位强制更改为 24 位而插入的.  然而,在为本文创建示例图像的过程中,这种效果似乎已经消失了.  我使用 BitBlt 从 **LoadImage** 的 24 位格式复制到 **CreateDIBSection** 的 32 位格式,并且 32 位格式保持不变.  所以请对以下两段持保留态度——在真正的 Windows 传统中,它*可能*会发生,但并不总是发生.  让你的经验来指导你。]

**BitBlt** 喜欢 24 位格式,并且它非常积极地将它的观点强加给所有经过它的位图.  即使你将数据块传输到一个或 DIB section,它被明确创建为一个 32 位图像,**BitBlt** 也会用 24 位值覆盖 32 位数据.  分配的位图内存量从其初始大小不变,但预期的 32 位数据格式在调用 **BitBlt** 后无法幸免.  该函数完全不知道 Alpha 通道,并且会毫不犹豫地破坏关于 Alpha 通道的每一字节位图数据. 

相反,**AlphaBlend** 设计用于处理 Alpha 通道,它尊重 Alpha 通道字节的主权,并保持位图格式为 32 位实体.  理想情况下,如果你能在 Windows 10 下让该函数正常工作,它会以 255 的不透明度值(完全不透明)被调用,以模拟 **BitBlt** 而不破坏位图数据的格式.  当一个 24 位图像被 **AlphaBlend** 到一个 32 位目标时会发生什么,我无法回答——我最后一次尝试使用该函数导致了一个不应发生且不应存在的错误,然后我就停止了对它的尝试.  在 Windows 上开发了 22 年(几乎完全使用汇编语言)之后,我对 Windows API 故障的容忍度已经提高到我不再研究大多数 bug 和不可原谅的顽固函数——我直接寻找解决方法.  例如,在用纯色填充位图的情况下,创建画笔、调用 **FillRect**、删除画笔的这个愚蠢且非常愚蠢(我的观点)的过程,更不用说创建一个内存 DC 来保存位图,将位图选入其中,然后取消选择位图并最终删除 DC——这一切都远远超出了纯粹疯狂的界限,我甚至无法开始解释其背后的想法.  相反,七条简单的汇编语言指令就可以填充位图.  不需要 DC,不需要画笔,不需要选择、取消选择或删除对象.  只要你有那个位图数据指针,一切都好. 

lea     rdi, BitmapData
mov     rax, BitmapWidth
mov     rcx, BitmapHeight
mul     rcx
mov     rcx, rax
mov     rax, FillColor
rep     stosd

完成……然后……完成.  就是这么简单.  没有理由让它更复杂. 

混合

本文概述了一个混合两个 32 位格式位图的过程.  不使用 Alpha 值(预乘或其他);混合是合并两个图像的最终结果.  源不透明度以整数百分比值乘以一百传递,在混合函数内部转换为单精度浮点数;目标不透明度假定为 1 减去该值.  例如,为源位图声明 20% 的不透明度意味着目标不透明度为 80%.  我没有找到用 GDI 实现这一点的方法(一切似乎都是加法的),而且我不会超越它来完成工作.  DirectX 需要相对巨大的设置时间,这在我创建的大多数应用程序中都难以证明其合理性.  进一步发展到 DirectComposition 或 Direct2D对我来说是不可思议的——我将其比作为了购买杂货而把一艘全尺寸的航空母舰开到杂货店,而一辆小红车就能做到.  这并不意味着那些 API 不那么强大——它们只是不必要地复杂(而且令人尴尬地臃肿)。 

对于本文概述的过程,重点是速度,并且为了最大化速度,必须遵循两条主要规则

  1.  避免内存访问。 
  2. 使用 XMM 寄存器并行处理所有颜色分量值——这样每个像素只需一次乘法,而不是三次. 

YMM 寄存器在这种应用中没有意义,因为它们使用 64 位值.  对于混合图像的过程,32 位值最终会向下转换为每个颜色通道的 8 位,而 XMM 寄存器处理的四个 32 位值是 SSE/AVX 粒度的低限. 

思考过程

预见是性能的前兆.  为要运行的任务定制处理器细节的影响不容低估.  稍加分析和计划就可以在实现前所未有的速度提升方面发挥巨大作用;为硬件定制代码的优点是无与伦比的. 

在实际的混合循环中,为了获得最佳性能,内存访问应该只发生三次:读取图像 1 的每个像素,读取图像 2 的每个像素,以及写入最终混合的结果.  使过程复杂化的是在执行混合之前需要将每个像素分解为颜色通道的固有需求.  混合完成后,数据需要再次从浮点值转换回来,重新组合成一个单一的 32 位值,并存储在其目标位置. 

从逻辑上讲,这个过程非常简单,以至于大多数开发人员(如果他们不把任务交给 Windows)都会运行一个循环来执行它.  问题在于,在 C++ 或任何其他语言中,算法的简单性通常被误认为等同于执行速度,而实际实现的细节并不那么重要. 

本文创建的函数将接受两个传入的位图——位图一和位图二——并将最终的混合数据覆盖到位图一的传入数据上.  由于所有操作都在 CPU 寄存器中进行,因此无需分配(或后续释放)内存即可实现混合. 

该过程的关键在于将像素数据分解为颜色通道,然后将这些数据加载到 XMM 寄存器中,以便执行所需的乘法. 

乘数的值在循环生命周期内不会改变,因此它们应该直接从传递给混合函数的寄存器放入 XMM 寄存器,然后保持不变. 

进入函数时

RCX      > bitmap one bits
RDX      > bitmap two bits
R8       = bitmap one blend %, multiplied by one hundred to allow an integer value to be passed
R9       = bitmap width
[rsp+40] = bitmap height

将混合百分比作为整数传递的原因是 64 位调用约定在尝试传递浮点数时会变得混乱和复杂.  当然可以做到(大多数语言都会这样做),但作为唯一的浮点值,混合因子将进入函数中的 XMM0.  你可以这样做,如果你愿意的话;本文中的代码不会.  一般来说,我发现通过以某种合理的顺序利用 XMM 寄存器,可以节省大量错误和调试时间,因为它们不容易丢失.  有十六个这样的寄存器,如果你随机使用它们,很容易忘记谁在做什么以及为什么.  最终,整个问题*完全是美学问题*,但我个人认为比其他任何方式都好:参数 3, R8, 保存源位图混合百分比乘以一百——允许将整数值传递到通用寄存器. 

由于稍后讨论的原因,在混合过程中,任何 8 位颜色通道都不会有溢出的危险. 

进入函数后,立即将 RDI 指向第一个位图的数据,将 RSI 指向第二个.  汇编指令 **STOSD** 写入 RDI 所指向的位置,位图一就是混合输出的写入位置.  所以最好将其设置为首要任务. 

mov rdi, rcx ; On entry, RCX points to bitmap one data – copy that pointer to RDI, the output
mov rsi, rdx ; On entry, RDX points to bitmap two data

完成此操作后,该决定 XMM 寄存器的使用.  XMM 0 将获取位图一的数据;XMM1 将保存位图一的混合因子.   XMM2 将获取位图二的数据,XMM3 将保存位图二的混合因子. 

在深入处理循环之前,除了将混合因子值设置到 XMM1 和 XMM3 之外,几乎没有什么要做了.  一个最后的注意事项至关重要:将每个输出像素除以 100.0 是非常愚蠢的.  在将传入的 R8 移动到 XMM0 后,立即除以 100.0,这样除法——地球上最慢的指令——只需要执行一次. 

以下变量声明为数据

align       16
one_hundred real4 4 dup ( 100.0 )

然后是函数的入口代码

mov       rdi, rcx                    ; Set write pointer @ bitmap 1 
mov       rsi, rdx                    ; Set read pointer @ bitmap 2
movd      xmm1, r8                    ; Move the incoming bitmap 1 blend factor 
cvtdq2ps  xmm1, xmm1                  ; Convert value to floating point 
divss     xmm1, real4 ptr one_hundred ; Divide the blend factor by 100 
shufps    xmm1, xmm1, 0               ; Copy the low dword of XMM0 into all four XMM0 dwords 

将 XMM3 设置为 (1 - XMM1) 需要一点创意.  没有指令可以将立即数据(嵌入在指令中的数据)移入 XMM 寄存器,从内存加载 1.0 是最后的选择. 

**CMPPS** 指令将 XMM3 中的任何随机数据与自身进行比较,检查 XMM3 = XMM3.  结果必须为真,无论寄存器中有什么.  然后 XMM3 中的所有位都将被设置为 1,正如比较为真时那样.  将这些位向右移动 31 位将在 XMM3 的每个 32 位分区中留下 1.0 的值. 

cmpps     xmm3, xmm3, 0               ; Compare XMM3 = XMM3 to set all bits of register
psrld     xmm3, 31                    ; Right align bit 31, shift out all other bits
cvtdq2ps  xmm3, xmm3                  ; Convert to floating point value
subps     xmm3, xmm1                  ; Subtract bitmap one blend factor from 1.0

混合因子现在正确地设置为打包的(存在并重复在所有四个 32 位部分中)浮点值:XMM1 用于图像一,XMM3 用于图像二. 

分解与重组

需要特别注意的下一个部分是分离和重新组合颜色通道值.  幸运的是,SSE 会为你处理大部分问题,至少在上传时.  奇怪的是,在写每个最终混合像素时,没有这样的便利. 

如果混合因子不起作用,事情会简单得多.  所有操作都可以使用 SSE 整数指令完成,并且永远不需要将值转换为浮点数或从浮点数转换回来.  但混合因子是函数存在的整个原因,并且它必须存储为浮点值,因为在函数内部它总是 <= 1.0.  所以,在相乘之前将颜色数据转换为浮点数几乎是唯一的选择. 

以下指令将源数据加载到 XMM0(位图一)和 XMM2(位图二)

pmovzxbd       xmm0, dword ptr [ rdi ]      ; Load the three color channels & the alpha channel: bitmap one
cvtdq2ps       xmm0, xmm0                   ; Convert values to floating point
pmovzxbd       xmm2, dword ptr [ rsi ]      ; Load the three color channels & the alpha channel: bitmap two
cvtdq2ps       xmm2, xmm2                   ; Convert values to floating point

上述指令将从 dword 值中提取的每个字节分离开,并将数据方便地放置在 XMM 寄存器内的连续 dword 位置.  这比手动执行节省了大量执行时间. 

接下来,进行乘法

mulps          xmm0, xmm1                   ; Multiply color data by bitmap one blend factor
mulps          xmm2, xmm3                   ; Multiply color data by bitmap two blend factor

将两个值相加

addps          xmm0, xmm2                   ; Add the two values

最后,构建输出 dword,存储它,并向前移动两个位图数据指针. 

cvtps2dq       xmm0, xmm0                   ; Convert result back to integer values
shufps         xmm0, xmm0, 4Eh              ; Shift result 2 slots or 64 bits (either direction, same result)
movd           ebx, xmm0                    ; Get the red channel value
shufps         xmm0, xmm0, 93h              ; Shift result 1 slot left
movd           eax, xmm0                    ; Get the green channel value
shl            ebx, 8                       ; Shift the accumulator left 1 byte
mov            bl, al                       ; Set green in BL
shufps         xmm0, xmm0, 93h              ; Shift result 1 slot left
movd           eax, xmm0                    ; Get the blue channel value
shl            ebx, 8                       ; Shift the accumulator left 1 byte
or             eax, ebx                     ; OR the red and green with the blue currently in AL

stosd                                       ; Store the final result and advance write pointer (bitmap 1) 4 bytes

以上代码构成了合并过程的全部内容. 

循环内部没有发生内存访问,除了加载源值和存储合并结果.  执行的唯一数学运算是将每个颜色通道乘以混合因子,即使是这个操作也通过 XMM 寄存器同时作用于所有颜色通道. 

完整的函数如下.  请注意,传递给此函数的图像假定大小相同,并且会完全合并.  一个更复杂的函数,允许选择图像的指定区域,在循环控制方面会复杂得多——需要逐行循环,然后在每行内逐列循环,同时正确跟踪两个循环中的数据指针.  为简单起见,本文合并了传入的全部位图数据——其目的是专注于利用汇编语言,特别是 XMM 寄存器,来执行实际的混合. 

函数所需数据

                   align          16                           ; Required for XMM access
one_hundred        real4          4 dup ( 100.0 )              ; Divisor of 100

最后,整个函数

;**********************************************************************************************************************
; BlendImages
;
; In: 1 RCX           > bitmap one bits
;     2 RDX           > bitmap two bits
;     3 R8            = bitmap one blend factor (integer, % * 100; bitmap 2 is 1 minus this value)
;     4 R9            = bitmap width (same for both bitmaps)
;     5 [ RSP + 20h ] = bitmap height

BlendImages        proc                                        ; Declare the function

                   mov            rdi, rcx                     ; Set write pointer @ bitmap 1
                   mov            rsi, rdx                     ; Set read pointer @ bitmap 2

                   movd           xmm1, r8                     ; Move the incoming bitmap 1 blend factor
                   cvtdq2ps       xmm1, xmm1                   ; Convert value to floating point
                   divss          xmm1, real4 ptr one_hundred  ; Divide the blend factor by 100
                   shufps         xmm1, xmm1, 0                ; Copy the low dword of XMM0 into all four XMM0 dwords

                   cmpps          xmm3, xmm3, 0                ; Compare XMM3 = XMM3 to set all bits of register
                   psrld          xmm3, 31                     ; Right align bit 31, shift out all other bits
                   cvtdq2ps       xmm3, xmm3                   ; Convert to floating point value
                   subps          xmm3, xmm1                   ; Subtract bitmap one blend factor from 1.0

                   mov            rax, [ rsp + 40 ]            ; Get the bitmap width
                   mul            r9                           ; Multiply by height for pixel count
                   mov            rcx, rax                     ; Set the count through the loop

BlendLoop:         pmovzxbd       xmm0, dword ptr [ rdi ]      ; Load the three color channels & the alpha channel: bitmap one
                   cvtdq2ps       xmm0, xmm0                   ; Convert values to floating point
                   pmovzxbd       xmm2, dword ptr [ rsi ]      ; Load the three color channels & the alpha channel: bitmap two
                   cvtdq2ps       xmm2, xmm2                   ; Convert values to floating point

                   mulps          xmm0, xmm1                   ; Multiply color data by bitmap one blend factor
                   mulps          xmm2, xmm3                   ; Multiply color data by bitmap two blend factor

                   addps          xmm0, xmm2                   ; Add the two values

                   ; Build and store the final output dword

                   cvtps2dq       xmm0, xmm0                   ; Convert result back to integer values
                   shufps         xmm0, xmm0, 4Eh              ; Shift result 2 slots or 64 bits (either direction, same result)
                   movd           ebx, xmm0                    ; Get the red channel value
                   shufps         xmm0, xmm0, 93h              ; Shift result 1 slot left
                   movd           eax, xmm0                    ; Get the green channel value
                   shl            ebx, 8                       ; Shift the accumulator left 1 byte
                   mov            bl, al                       ; Set green in BL
                   shufps         xmm0, xmm0, 93h              ; Shift result 1 slot left
                   movd           eax, xmm0                    ; Get the blue channel value
                   shl            ebx, 8                       ; Shift the accumulator left 1 byte
                   or             eax, ebx                     ; OR the red and green with the blue currently in AL

                   stosd                                       ; Store the final result and advance write pointer (bitmap 1) 4 bytes
                   add            rsi, 4                       ; Advance the source pointer (bitmap two)

                   loop           BlendLoop                    ; Return to top of loop

                   xor            rax, rax                     ; Zero the return value

                   ret                                         ; Return from function

BlendImages        endp                                        ; End function declaration

该函数符合 64 位调用约定,允许将其从 DLL 导出并从任何允许调用外部函数的语言调用. 

无需进行限制检查,因为混合过程的性质使得溢出不可能——对于 8 位颜色通道,任何像素的值都不可能大于 255.  即使传递了混合因子 > 1,例如 1.25,位图二的混合因子也将是 1 - 1.25 或 -0.25.  最坏情况下,两个混合的像素都是 255.  位图 1 的像素将计入 255 * 1.25 或 319(向上取整);位图 2 的像素将为 255 * -0.25 或 -64(向上取整)。  两者相加将得到 255.  因此,在所示的实现中不需要限制检查. 

可认证运行

本文中的代码已经过实际编译和执行,以 45% 的图像 1(日落)和 55% 的图像 2(森林)混合了以下图像;第三张(混合)图像是从本文源代码生成的实际输出中捕获的. 

Image 1: http://www.starjourneygames.com/images/sunset.bmp
Image 2: http://www.starjourneygames.com/images/forest.bmp
Image 3: http://www.starjourneygames.com/images/blend.bmp

结论

本文展示了为运行硬件定制代码可以多么大地加快操作速度.  垃圾进,垃圾出;捷径是通往下一个瓶颈的快车道. 

此处概述的函数 hardly 由数千行代码组成,并且几乎完全自给自足.  在大多数(如果不是全部)现代语言中,伴随的声明、包含文件和无数的编译器指令远远超过了编码过程中的任何时间节省.  打个比方,每天,越来越多的工人被冗余的经理取代.  工人(实际功能)可能做得快,也可能做得慢,但冗余管理的爆炸式增长使得公司整体一年比一年庞大,而产出却越来越少——产出的执行速度也越来越慢. 

一篇即将发表的文章将涵盖一个更复杂的主题:用汇编语言实现高斯模糊.  该文章的代码将使用一个 11x11 的卷积核,并运行单遍.  该代码的速度提升是惊人的,这是因为应用了与本文相同的逻辑:定制代码以最大化 CPU 的能力. 

© . All rights reserved.