DirectX12(D3D12)基础教程(十四)——使用WIC、Computer Shader显示GIF动画纹理(下)

5、用DirectComputer完成GIF帧预处理

  当看到这一小节的标题时,可能会有点蒙,为什么不把解析出来的帧直接当成纹理用?还需要“处理”什么?其实仔细想想这不是那么简单的。前面已经啰嗦了很多关于GIF以及帧的属性读取的内容,并在其中介绍了不少关于GIF优化存储策略方面的知识,从这些知识就可以想明白为什么不能直接把帧画面当成纹理用了。因为根据原理,GIF中的帧很可能是不完整的,也即GIF帧往往是只存储了变换部分图像内容的一个子画面而已,最终要呈现一幅完整的帧,至少还需要与背景或前一帧进行Alpha混色操作(也就是判断最终画面里前一帧中某个像素要不要被当前帧覆盖,或者说当前帧的某个像素是不是透明的,用不用显示),这个过程才是真正的完成了对GIF完整的一帧画面的“解码”。而“解码”运算后的最终图片,才是完整的一副图像,这样显示之后整个GIF动画才是正确的。

  如果明白了这个过程,这时来思考一个重要的问题,就是GIF帧究竟怎样绘制到纹理上去,注意本章示例的终极目标就是把GIF用作纹理,而不是最终在屏幕(或渲染目标)上渲染GIF就完事。默默想两分钟这个问题,思考清楚问题的症结,尤其是与一般的图片甚至法线图、Cube Map等等用作纹理,这里究竟有什么不同?

  其实重要的区别就是,一般的纹理无论内容究竟存储的是什么,也不论是不是有Mipmap或者Image Array等,基本都是“静态的”,使用方法就是简单的解码之后通过“两次Copy”操作,上传到显存中的默认堆上即可。而GIF帧作为纹理时,它是“动态”的,主要体现在两个方面,一方面就是刚才描述的必须还要用帧的画面绘制到背景(或者前一帧完整画面)上形成一副完整的画面然后才能用于纹理;另一方面就是必须要按照GIF帧要求的延时值顺序显示每一帧,从而形成GIF动画效果。第二个方面,不是很复杂,无非就是反复加载不同的纹理并渲染即可,在示例中也就是两个Texture的不断创建、销毁以及对应的两次Copy上传操作而已,这个大家已经轻车熟路了,也就不再啰嗦。而第一个方面的问题,就是本章的重点了,也就是动态纹理的预计算问题,或者叫纹理预处理问题,这是一个很重要的基础技术。

  首先可以想象一下,因为在渲染管线中有一个Blend混合阶段,貌似可以用来处理GIF帧绘制的问题,最多就是再结合下之前已经掌握的渲染到纹理的技术以及一般的基本UI渲染技术(偏移矩形框)即可。其实这样的做法也可以,但是仔细一想会发现这样做有点杀鸡用牛刀的嫌疑,因为渲染管线实在是太笨重了,只是解算一帧的画面就需要动用整个管线,或者说至少要动用基本的VS、PS两个Shader阶段,还要根据需要打开Blend State,然后还有一堆的资源状态要来回切换,所以这貌似不是最“简洁”的方式。

  此时,不要忘了在D3D12中,还有一个DirectComputer管线可以加以利用,相较于渲染管线来说,它就简单的多了,说白了它里面纯粹就是按照Computer Shader计算数据而已,没有复杂的状态需要管理或切换,也不用管什么渲染目标之类的资源。而其实前述的关于GIF帧作为”动态“纹理的预处理过程,表面上看是一个小矩形中的图片绘制到一个大的背景上的问题,而实质上其实就是两个二维数组的组合运算(一般是Alpha混色)操作而已!而这个计算过程完全可以放到DirectComputer管线中去处理。(注意前面讲DirectComputer时提出而没有回答的问题。)

  另外根据之前教程中所一再强调的那样,现代的GPU架构一般都是多引擎架构,而且这些引擎间是可以完全并行执行的!这当中,除了重要的3D引擎外,就数计算引擎最重要了,而要使用计算引擎,就需要通过DirectComputer来操控计算引擎。

5.1、Computer Shader中以数组方式访问纹理完成帧预处理

  在本章核心示例的Computer Shader中,为了完成GIF帧画面的预处理,就需要在Compter Shader中操作相应图片画面中的每个像素。

  而在一般的Shader中,要操作图片,都是加载成纹理,然后使用采样器来操作,也就是常说的纹理采样操作,纹理坐标都是归一化的在[0-1]之间,这对于纹理的采样以及显示等操作来说是非常合适的选择,这样不但不用区别大小不同的纹理,同时正好方便线性插值等操作。

  但是对于本例中需要精确访问每个像素并进行计算的GIF帧预处理操作来说,则是个很麻烦的事情。幸运的是,在Computer Shader中就可以以整数下标并以二维数组的方式来访问纹理。具体的可以先像下面这样来定义被应用的纹理:

// 帧纹理,尺寸就是当前帧画面的大小,注意这个往往不是整个GIF的大小
Texture2D			g_tGIFFrame : register(t0);
// 最终代表整个GIF画板纹理,大小就是整个GIF的大小
RWTexture2D<float4> g_tPaint	: register(u0);

  首先,在代码中要先声明从GIF中解析出来的一帧画面对应的纹理,从代码中可以看出,这与在其他的Shader中声明一副纹理没什么区别。都是使用Texture2D数据类型,然后寄存器都是texture(t0)。后面会看到对它的访问就与其它Shader中不一样了。

  其次,第二行代码中出现了一个新的数据类型RWTexture2D。如果之前的例子看明白了的话,这个数据类型就好理解了,它就是一副可以读写访问的2D纹理。那么它跟普通的只读纹理唯一的区别其实就是可以写入。并且要注意它放在了unorder access buffer(u0)类型的寄存器中,即无序访问的缓冲。当然这里说成是纹理更合适,以体现它是2D形式的缓冲的本质。这句代码实质上就等于是定义了GIF的背景,像素大小就是整个GIF的大小,然后GIF的帧不断的绘制在其上就形成了最终完整的GIF帧画面。

  定义好了“画板”(g_tPaint),以及将要被“复制”的GIF帧画面(g_tGIFFrame),接着还需要将GIF中定义的怎么“复制”画面的操作(Offset、SubHW、Disposal)等参数传入到Computer Shader中,这时只需要像在其他Shader中那样定义一个常量缓冲区来接收这些参数即可,在本例中,定义了像下面这样的常量缓冲:

cbuffer ST_GRS_GIF_FRAME_PARAM : register(b0)
{
	float4	m_c4BkColor;		
	uint2	m_nLeftTop;
	uint2	m_nFrameWH;
	uint	m_nFrame;
	uint	m_nDisposal;
};

  这个定义方式与在其它的Shader中定义常量缓冲是一样的。其中m_c4BkColor变量中存储的就是从GIF调色板中解析出的全局背景色。m_nLeftTop相当于一个有两个元素的UINT类型的数组,其中存放的就是GIF子帧画面相对于整个GIF左上角的偏移位置,相当于将GIF左上角坐标看成原点时,子帧画面左上角的偏移坐标,示意图如下:
GIF画面与子帧画面坐标原理
  上图中几个关于GIF及GIF帧的坐标变量的含义就一目了然了。接着m_nFrame参数就是GIF帧的序号,这个序号是从0开始的,因此第n帧的序号就是n-1。最后m_nDisposal就是从GIF中解析出的帧的处理手法,也就是当前帧要怎么处理GIF的背景,这在用WIC解析GIF部分已经有说明了。这里直接上Shader代码来看具体含义是指什么:

[numthreads(1, 1, 1)]
void ShowGIFCS( uint3 n3ThdID : SV_DispatchThreadID)
{
	if ( 0 == m_nFrame )
	{// 第一帧时用背景色整个绘制一下先,相当于一个Clear操作
		g_tPaint[n3ThdID.xy] = m_c4BkColor;
	}

	// 注意画板的像素坐标需要偏移,画板坐标是:m_nLeftTop.xy + n3ThdID.xy
	// m_nLeftTop.xy就是当前帧相对于整个画板左上角的偏移值
	// n3ThdID.xy就是当前帧中对应要绘制的像素点坐标

	if ( 0 == m_nDisposal )
	{//DM_NONE 不清理背景
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy]
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
	else if (1 == m_nDisposal)
	{//DM_UNDEFINED 直接在原来画面基础上进行Alpha混色绘制
		// 注意g_tGIFFrame[n3ThdID.xy].w只是简单的0或1值,所以没必要进行真正的Alpha混色计算
		// 但这里也没有使用IF Else判断,而是用了开销更小的?:三元表达式来进行计算
		g_tPaint[ m_nLeftTop.xy + n3ThdID.xy ] 
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
	else if ( 2 == m_nDisposal )
	{// DM_BACKGROUND 用背景色填充画板,然后绘制,相当于用背景色进行Alpha混色,过程同上
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy] = g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : m_c4BkColor;
		//g_tPaint[m_nLeftTop.xy + n3ThdID.xy] = m_c4BkColor;
	}
	else if( 3 == m_nDisposal )
	{// DM_PREVIOUS 保留前一帧
		g_tPaint[ m_nLeftTop.xy + n3ThdID.xy ] = g_tPaint[n3ThdID.xy + m_nLeftTop.xy];
	}
	else
	{// Disposal 是其它任何值时,都采用与背景Alpha混合的操作
		g_tPaint[m_nLeftTop.xy + n3ThdID.xy]
			= g_tGIFFrame[n3ThdID.xy].w ? g_tGIFFrame[n3ThdID.xy] : g_tPaint[m_nLeftTop.xy + n3ThdID.xy];
	}
}

  第一、Shader代码中numthreads语义说明的所有参数都是1,根据前面的知识,立刻就可以知道,这里的线程盒中只有一个线程项。这主要是因为就当前的绘制GIF帧这个问题来说,细粒度的数据项其实就是像素点而已,而像素点在这里就是一个4D向量,而之前我们说过,在GPU中ALU都是被设计成了能够同时处理4个分量的向量计算器,所以就可以在一个线程项中并行的处理4D向量的所有分量,因此我们不必像之前那个为了解释线程盒、线程组等概念时举得例子,也即不需要将4个分量放到4个线程里去处理。最终我们只需要一个线程项来处理一个像素即可。

  第二、Computer Shader的主函数的参数列表中只引用了SV_DispatchThreadID语义参数,也是因为当前的线程盒中只有一个线程项的原因,这时其实SV_DispatchThreadID和SV_GroupID语义参数是一样的。(想想为什么?)而具体的此处的SV_DispatchThreadID语义参数也只有前两个维度是有意义的,它的上限即GIF子帧画面的大小,因为在这里,实质上只需要处理GIF子帧画面中的元素即可,而不需要处理整个GIF画面。

  第三、代码中第一个if判断目的就是把整个GIF画面用背景色填充一下。当在第一帧时,一般帧画面大小就是整个GIF画面的大小,所以直接使用n3ThdID.xy来索引像素点,并为像素点赋值为背景色即可。这里需要注意的就是在Computer Shader中索引一个2D的数组或纹理时,我们可以直接写成g_tPaint[n3ThdID.xy]这种形式,而不必像c++中需要两个中括号[]的表达式。

  第四、代码中后续的大的if…else if…else判断分支语句就是根据GIF帧的Disposal(处理手法)属性进行与背景的混合。而之前已经说过虽然GIF帧已经被转换成了R8G8B8A8的形式,但其Alpha分量其实只有0或1两个值,即表示当前背景像素点要不要被覆盖而已,所以代码中关于混色操作就用了更高效的?问号三元表达式,而没有写成经典的Alpha混色表达式。

  第五、要注意代码中关于像素点偏移坐标的计算。因为GIF子帧的偏移对于一帧画面的显示来说都是固定的所以将子帧偏移值用常量传递了进来。最后用它与SV_DispatchThreadID语义参数相加,即m_nLeftTop.xy + n3ThdID.xy,就得到了子帧像素相对于整个GIF左上角偏移的像素点坐标,这是一个最简单的2D图像偏移计算,坐标系就是类似于屏幕的坐标系,x轴向右,y轴方向垂直向下。这与一般的D3D坐标系是不一样的,与我们之前讲解过的UI坐标系是一致的。也等价于一般的纹理2D坐标系。

  第六、最后大的条件分支中之所以有个else分支,完全是因为我在测试中发现有些GIF中帧的Disposal值居然会大于3,无奈我也没有搜到相关资料和说法,大多数讲GIF显示文章中,关于这个Disposal参数解释也是含糊不清的,但基本都只说了小于等于3的这4种情况而已,所以我就用else来处理这种情况,也按照一般的像素是否透明的方法来显示,目前测试没有发现其它的问题。如果各位在查看运行代码的过程中试出了其它的问题,请将对应的GIF图片也发给我测试下,不胜感激!

5.2、Dispatch启动GIF帧绘制

  处理GIF子帧画面的Computer Shader准备好之后,就是编译然后创建计算管线的根签名和PSO对象,这部分没什么特别不容易理解的,我就不啰嗦多说了。

  最后在代码中就是调用Dispatch发起一帧画面的绘制了,代码如下:

pICSList->Dispatch(stGIFFrame.m_nFrameWH[0], stGIFFrame.m_nFrameWH[1],1);

  代码中m_nFrameWH成员变量中存储的就是GIF子帧画面的宽和高,当然为了方便的向Computer Shader中传递这个数据,就定义成与Computer Shader相同的形式,只是在Shader中数据类型是uint2,而代码中是UINT类型的2维数组,二者在字节大小及顺序上保持一致即可。

  这样我们就根据子帧画面的像素大小启动了同样多的线程盒来处理其中的每个像素。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片