unity 适配_DirectX学习笔记:搭建一个支持分辨率适配的GUI框架

前言

这两天基于 DX 搭了个简单的 GUI 框架,代码放在 SimpleEngine 里,暂时只是初步支持了分辨率适配功能,其他地方都很简陋,甚至控件的渲染都是用 2D 图片绘制临时代替的 Orz,这里单纯把实现思路记一下,省得过两天只有上帝看得懂我这块代码了ww


框架结构

首先,整个 GUI 框架的入口是 GUIManager,通过它来管理各个具体界面(比如商店界面、背包界面等),每个界面都继承自 BaseLayout 类。

然后,我们知道,界面是由控件构成的(比如按钮、文本框等等),实际上,每个界面都可以看成是一棵由各个控件节点构成的树,但这一块的代码组织,如果直接把控件作为树的节点,我感觉并不妥当,因为我们很可能碰到这种情况,一个节点只是用来辅助计算它所包含的子控件的尺寸坐标的,这个节点本身并不带有渲染功能。所以我这里把一个控件抽象成两个部分,一个是布局对象 LayoutNode,它专门负责管理控件的尺寸坐标,另一个是控件皮肤 Skin,它专门负责控件的渲染。于是一个完整 BaseLayout 界面的结构就是,由 LayoutNode 节点构成整棵树,然后在 LayoutNode 类里维护了一个指向 Skin 的指针,当这个指针为空时,表示这个布局对象不参与渲染,只有明确地给这个布局对象指定了一个皮肤时,LayoutNode 才会把排版信息传给 Skin,由皮肤完成渲染。


UI渲染基本流程

  1. 关闭深度缓冲,或者说禁用深度测试,在这种状态下,我们之后写入后台缓冲区的像素数据会无脑覆盖掉先前写入的数据,而不管 z 坐标如何,这一是保证了 2D UI 能覆盖在 3D 场景之上,二是使得我们可以通过控制 UI 控件的绘制顺序来调整它们的遮挡关系,简单来说,就是在一个界面对应的树里,子控件应该覆盖父控件,排在后面的兄弟节点应该覆盖排在前面的节点。
  2. 遍历 GUIManager 里的 BaseLayout 列表,调用 BaseLayout::OnRender 开始渲染,在 BaseLayout 里其实只维护了一个根节点,而每个 LayoutNode 节点本身又维护了一个子节点列表,如此只需调用最上层根节点的 LayoutNode::OnRender,递归完成整棵树的渲染,
  3. 等所有 UI 控件渲染完毕,重新开启深度缓冲,防止影响后续渲染。

分辨率适配

  • 坐标计算

其实分辨率适配就是一堆坐标的计算,这里把计算流程简单描述一遍。

我们先在一个局部空间中对整个 UI 界面进行搭建,因为 UI 本身就是一个二维的东西,而我们这边又关闭了深度缓冲,自己控制控件的渲染顺序(按照从根节点到子节点的顺序依次绘制控件),压根用不到 z 坐标,所以我这里是直接将整个局部空间视作一块二维的屏幕,原点就定在屏幕左下角,并以 x 轴向右、y 轴向上为正方向。

在具体放置布局对象时,并不直接以相对于整个屏幕左下角的绝对坐标来定位各个布局对象,而是以各个布局对象相对于父节点的相对坐标来定位,这里我参考了 Unity 的设计,当对一个布局对象定位时,需要调用 SetCoord 函数,这个函数的声明是:

void LayoutNode::SetCoord(Rect<int> anchor_offset, Point<float> anchor_min, Point<float> anchor_max);

可以看到由三个部分组成,先说第二和第三个参数,这其实是相对于父节点的两个锚点,anchor_min 是父节点左下角的锚点,anchor_max 是父节点右上角的锚点,两个锚点的数值都进行了归一化,即取值范围在 [0, 1] 之间,举个例子,当一个锚点值为 (0.5, 0.5),表示这个锚点位于父节点的正中心,当锚点值为 (0, 1),表示这个锚点位于父节点的左上角,我们当前的布局对象就是相对于这两个锚点进行定位,可以看到,第一个参数 anchor_offset 其实就是当前布局对象的四边相对于锚点位置的偏移,anchor_offset.left 和 anchor_offset.bottom 相对于 anchor_min 锚点进行偏移,anchor_offset.right 和 anchor_offset.top 相对于 anchor_max 锚点进行偏移,程序里的关键计算代码如下:

void LayoutNode::OnResize(float resize_scale)
{
	Size<int> parent_size;
	if (node_parent_)
	{
		parent_size = node_parent_->GetSize();
	}
	else
	{
		parent_size = Size<int>(NativePlatform::Instance().GetScreenWidth(),
			NativePlatform::Instance().GetScreenHeight());
	}

	relative_coord_.left = parent_size.width * anchor_min_.x
		+ anchor_offset_.left * resize_scale;

	relative_coord_.bottom = parent_size.height * anchor_min_.y
		+ anchor_offset_.bottom * resize_scale;

	relative_coord_.width = parent_size.width * anchor_max_.x
		+ anchor_offset_.right * resize_scale - relative_coord_.left;

	relative_coord_.height = parent_size.height * anchor_max_.y
		+ anchor_offset_.top * resize_scale - relative_coord_.bottom;

	for (auto widget : node_children_)
	{
		if (widget)
		{
			widget->OnResize(resize_scale);
		}
	}
}

解释一下这几行计算代码 Orz,如果当前布局对象有父节点,就取父节点的大小进行排版,如果当前布局对象没有父节点,就按照屏幕大小来排版,relative_coord_ 就是当前布局对象左下角相对于父节点左下角的尺寸坐标(也就是说,所有坐标计算都是以布局对象的左下角为基准),而 resize_scale 则是整个界面布局对象的缩放比例(指当窗口大小发生改变时,应该按照什么比例对所有布局对象的大小进行缩放),比如有一个界面是按照 1920x1080 的分辨率来设计制作的,而玩家实际运行时的窗口大小是 1366x768,如果我们完全以高度为基准进行适配的话,所以布局对象的尺寸会按照 768/1080 的比例来缩放。说回布局对象的相对定位方式,计算起来很简单,实际使用时主要又分为两种情况,先看这个:

widget_a->SetCoord(Rect<int>(0, 0, 0, 0), Point<float>(0, 0), Point<float>(1.0f, 1.0f));

这是 anchor_min 和 anchor_max 两个锚点不统一的情况,在这种情况下,当前布局对象的大小会随着父节点的大小变化而进行拉伸,可以看到,anchor_min 锚点就定在父节点左下角,anchor_max 锚点就定在父节点右上角,然后当前布局对象 left, top, right, bottom 四个方向相对于锚点的偏移都是 0,也就是说,不管父节点大小怎么变,当前布局对象都会占满整个父节点。再看这个:

widget_b->SetCoord(Rect<int>(-254, 0, 0, -300), Point<float>(1.0f, 1.0f), Point<float>(1.0f, 1.0f));

这是 anchor_min 和 anchor_max 两个锚点统一的情况,在这种情况下,当前布局对象的大小只会跟着 resize_scale 变,而与父节点无关,可以看到,由于 widget_b 的 top 和 right 偏移设为了 0,此布局对象的位置会一直固定在父节点的右上角。

虽然放置布局对象时是按相对坐标来放,但渲染时还是要换算成整个局部空间里的绝对坐标,又因为布局对象的 width 和 height 大小是不变的,需要调整的只有 x 和 y 坐标,关键代码如下:

void LayoutNode::OnRender(Point<int> parent_absolute_position)
{
	Point<int> absolute_position = parent_absolute_position;
	absolute_position += Point<int>(relative_coord_.left, relative_coord_.bottom);

	if (owned_skin_)
	{
		owned_skin_->DoRender(Rect<int>(
			absolute_position.x,
			absolute_position.y + relative_coord_.height,
			absolute_position.x + relative_coord_.width,
			absolute_position.y));
	}

	for (auto node : node_children_)
	{
		node->OnRender(absolute_position);
	}
}

因为每个界面的布局对象都会构成一棵树,渲染时直接从上到下遍历一遍,根控件是相对于屏幕大小进行排版的,父节点的 left 和 bottom 坐标可以直接视为 (0, 0),然后每个布局对象在计算出自己的绝对坐标后,都传给子节点,如此就得到了每个布局对象的绝对坐标。

中间的 owned_skin_ 也就是控件皮肤,如果 owned_skin_ 指针为空, 就表示这个布局对象仅仅是用来辅助调整尺寸位置的, 并不参与渲染,只有指定了皮肤时,布局对象才会把尺寸坐标传给这个皮肤,然后控件皮肤会根据布局对象的尺寸坐标信息构建几何体(比如我们这里是临时用 2D 图片渲染来代替一般控件,所以皮肤就会构建两个三角形拼成一个矩形),填充顶点缓冲区后绑定到渲染流水线完成绘制。

关于分辨率缩放的坐标计算基本就是以上的内容了,这里稍微再提一下顶点缓冲区填充完毕后的一些坐标系细节。

因为控件的顶点缓冲区是基于 UI 的局部空间来创建的,我们需要将坐标从局部空间变换到世界空间,这里我希望在坐标变换后,之前局部空间中屏幕的中心刚好位于世界空间的原点上,所以世界矩阵就是:

D3DXMatrixTranslation(&world_matrix_,
	-static_cast<float>(NativePlatform::Instance().GetScreenWidth()) / 2,
	-static_cast<float>(NativePlatform::Instance().GetScreenHeight()) / 2,
	0);

再就是观察空间的设定,UI 这边单独用一个摄像机进行渲染,将该摄像机放在世界空间坐标 (0, 0, -100) 上并沿 z 轴正方向进行观察,摄像机的向上方向等同于世界空间 y 轴的方向。

最后一个需要注意的就是从 3D 场景投影得到 2D 图像时,因为这里是作用于 2D UI 控件,所以要采用正交投影而不是透视投影。

  • 实现细节

说完计算流程,再来聊聊实现时的一些细节。

分辨率适配的逻辑主要是通过 WM_SIZE 消息来触发,当我们收到一个 WM_SIZE 消息,我们会按以下步骤进行处理:

  1. 调整 Direct3D 本身受窗口大小影响的属性,比如重建深度/模板缓冲区,通过 IDXGISwapChain::ResizeBuffers 调整后台缓冲区的大小,通过 ID3D11DeviceContext::RSSetViewports 重置视口等;
  2. 调整我们自己代码里那些依赖于窗口工作区大小的属性,比如世界矩阵、投影矩阵等都需要重新计算;
  3. 最后才是调用 BaseLayout::OnResize 对界面里各个控件的尺寸坐标进行调整。

后记

最后给张效果图吧,不过是用临时的 2D 图片绘制代替的 :(

63c3da3a86c1a4923e008f4d53ba5c18.gif
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值