快速导航
教程代码资源:
dgaf 的 《DirectX 12 快速教程》配套代码 (A Sample of dgaf's DirectX 12 Quick Beginner Tutorial)
我们常常在 Minecraft 中见到物品栏的方块的立体形式的,而很多 UP 主的实现只渲染了方块一个面,其实只差了两个矩阵变换:

原版 (携带版) 物品栏

出处:https://www.bilibili.com/video/BV1qM411C7Wu/
正交投影与等轴侧变换

正交投影 (左图) 与透视投影 (右图) 的区别,一目了然。
正交投影同样保存了深度,但它没有改变 w 值,所以透视除法之后结果还是一样的,也就没有近大远小的透视现象了。
像左图这种情况叫 Isometric View Transformation 等轴测视图变换,iso = 相同,metric = 度量,它是一种特殊的正交投影,核心特征是:三个坐标轴的投影缩放比例完全相等。常用于游戏 UI 或工程制图。

大量应用在制图上的等轴测视图
投影不乘特定的观察矩阵,在 DirectX 坐标系中,相机默认朝着 +Z 轴方向观察,因此要显示方块三个面,我们只需要方块 -Z 后面,+X 右面,+Y 上面就行:
在 Minecraft 中,做等轴测变换,只需要方块先按 +Y 轴顺时针旋转 45°,然后再按 +X 轴逆时针旋转 30°,就能得到物品栏方块的等轴测视图了:


观察者的观察方向,会影响对物体旋转方向的判断:
Direct2D 基于窗口坐标系,左上角为 (0, 0) 且 +Y 轴朝下,而我们是在 +Y 轴朝上的世界坐标系上变换方块的,我们需要通过放缩矩阵放大方块并反转 Y 轴,进行镜像翻转,再通过平移矩阵移动到位图中心:

cpp
// 本次我们要渲染物品栏内的方块,之所以在 2D 平面上也能呈现立体感,是因为它们使用轴侧视图
// 物品栏中的方块以固定的等轴测视角显示,这样能使 正面 (+X),后面 (-Z),上面 (+Y) 同时扁平化呈现在平面上
// 我们需要提供正方体数据,对这个正方体进行等轴侧变换,再往这个扁平化的正方体贴纹理 (D2D 位图)
// 等轴变换矩阵 (模型空间 -> 屏幕空间)
XMMATRIX IsometricMatrix;
// 方块三个面 { 正面 (+X),后面 (-Z),上面 (+Y) } NDC 空间下的顶点位置数据
// 顺序遵循 左上角 -> 右上角 -> 右下角 -> 左下角
std::vector<XMVECTOR> BlockItemsVertex =
{
// 正面 (+X)
XMVectorSet(1, 1, -1, 1),
XMVectorSet(1, 1, 1, 1),
XMVectorSet(1, -1, 1, 1),
XMVectorSet(1, -1, -1, 1),
// 后面 (-Z)
XMVectorSet(-1, 1, -1, 1),
XMVectorSet(1, 1, -1, 1),
XMVectorSet(1, -1, -1, 1),
XMVectorSet(-1, -1, -1, 1),
// 上面 (+Y)
XMVectorSet(-1, 1, -1, 1),
XMVectorSet(-1, 1, 1, 1),
XMVectorSet(1, 1, 1, 1),
XMVectorSet(1, 1, -1, 1)
};
// 计算等轴变换矩阵 (模型空间 -> 屏幕空间),并对方块物品顶点数据进行等轴变换
// 这个等轴变换属于正交投影,是轴侧投影的一个特例,没有"近大远小"的透视效果
// 等轴测投影中,三个坐标轴的缩放因子相等,且两两夹角均为 120°,从而呈现出独特的立体感
void D2D_STEP06_CalcIsometricMatrixAndTransform()
{
// 先绕 y 轴旋转 45°,XM_PIDIV4 = 45°,让正面和后面可见
XMMATRIX RotateY_Matrix = XMMatrixRotationY(XM_PIDIV4);
// 再绕 x 轴旋转 -30°,XM_PIDIV2 = 90°,让顶面可见
XMMATRIX RotateX_Matrix = XMMatrixRotationX(-XM_PIDIV2 / 3.0);
// 构建旋转矩阵
XMMATRIX RotationMatrix = RotateY_Matrix * RotateX_Matrix;
// 构建缩放矩阵 (x,y 轴缩放系数相同,y 轴是负数是因为 D2D 坐标系 y 轴朝下,需要指定负数翻转位图)
// 这个缩放系数 5 的计算方法:
// 模型空间下方块的边长是 2 (详情见上文 BlockItemsVertex),y 轴旋转 45° 后顶面边长 2 * sqrt(2)
// 2 * sqrt(2) * 5 = 10 * sqrt(2) = sqrt(200) < sqrt(225) = 15
// 恰好留了 (sqrt(225) - sqrt(200)) / 2 的空隙,差不多等于 0.5
XMMATRIX ScalingMatrix = XMMatrixScaling(5, -5, 1);
// 构建平移矩阵 (一个物品槽内部空位 15x15,从物品槽左上角移到正中心偏左 0.5 像素)
XMMATRIX TranslationMatrix = XMMatrixTranslation(7, 7, 0);
// 最终构建等轴变换矩阵 (旋转 -> 缩放 -> 平移,顺序不满足乘法交换律)
// 注意!均匀缩放可以和旋转矩阵交换位置,结果不变;非均匀缩放不可以交换!
IsometricMatrix = RotationMatrix * ScalingMatrix * TranslationMatrix;
// 对每个顶点进行等轴变换
for (UINT i = 0; i < BlockItemsVertex.size(); i++)
{
BlockItemsVertex[i] = XMVector3TransformCoord(BlockItemsVertex[i], IsometricMatrix);
}
}
制作预渲染立体方块图标

上面我们只是把顶点数据做了等轴测变换,还没有做真正的方块图标,接下来,我们要给顶点数据围成的 2D 图形贴纹理,制作预渲染图标。
我们需要使用 D2DDeviceContext 的成员方法 CreateCompatibleRenderTarget 创建位图渲染目标,在位图上进行渲染。
cpp
// 位图渲染目标,用于生成并渲染方块图标,位图渲染目标也拥有 m_D2DDeviceContext 相似的能力
ComPtr<ID2D1BitmapRenderTarget> m_BitmapRenderTarget;
// 为每个不同的方块图标,创建一个 D2D 可兼容的新位图渲染目标
// (Compatible 可兼容的,早些时候 D3D10、D3D11 的渲染目标资源绑定,渲染到纹理也是它做的)
m_D2DDeviceContext->CreateCompatibleRenderTarget(
D2D1::SizeF(Slot_InnerSpace_Width, Slot_InnerSpace_Height), // 位图渲染目标的大小 (15x15)
D2D1::SizeU(DPI, DPI), // 位图渲染目标 DPI 大小
m_InventoryBlockBitmaps[0]->GetPixelFormat(), // 位图渲染目标的 D2D 格式,要和原资源一致
&m_BitmapRenderTarget // 要创建的位图渲染目标接口
);
调用 BitmapRenderTarget 的 DrawBitmap 就可以画纹理了,问题是,如何贴纹理?

本质上是做一次纹理映射,DrawBitmap 会帮我们做绘制任务的,所以数学计算的任务落在我们身上,我们需要计算一个新的变换矩阵,映射顶点的位置,让纹理精准贴上立体图标。
3D 有仿射变换矩阵,2D 同样也有:

在 D2D 中,表示 2D 仿射矩阵的数据结构是 D2D1_MATRIX_3X2_F,微软考虑到 2D 开发者用不到最后一列固定的 [0, 0, 1],索性把最后一列直接砍了:

求仿射矩阵六个参数,其实找同一个方块面三个顶点,再把位图那三个对应点弄过来列等式,解方程,就能得到矩阵每个参数的表示了,不难 (第四个顶点可以用平行四边形的性质直接求出来,所以三个点就够了):

利用 BitmapRenderTarget 的成员方法 SetTransform,可以应用矩阵,改变渲染目标的状态,同一渲染目标下后续绘制都会受到这个矩阵的影响,以上这些就是在 D2D 做纹理映射,绘制立体图标的原理。
cpp
// 物品栏立方体面结构体,只有一个长度为 3 的 UINT 数组成员
// 描述一个方块三个面在 m_InventoryBlockBitmaps 的索引
struct ITEMCUBEFACE
{
// 三个立方体面对应的 D2D 位图在 m_InventoryBlockBitmaps 中的位置
// 数组索引 0-2 分别对应右面 (+X),后面 (-Z),上面 (+Y)
UINT FaceBitmapIndex[3];
};
// 物品栏 9 个方块物品的方块类型-位图索引组
std::vector<ITEMCUBEFACE> BlockBitmap_IndexGroup =
{
// 右面 (+X) -> 后面 (-Z) -> 上面 (+Y)
{0, 1, 2}, // 0.熔炉
{3, 4, 5}, // 1.工作台
{6, 6, 7}, // 2.TNT
{8, 8, 9}, // 3.活塞
{10, 10, 11}, // 4.石英块
{13, 12, 12}, // 5.发射器
{15, 15, 14}, // 6.书架
{16, 16, 16}, // 7.钻石原矿
{17, 17, 17} // 8.活塞
};
// 物品栏 9 个方块物品的预渲染等轴立体图标
std::vector<ComPtr<ID2D1Bitmap>> m_InventoryBlockIcons;
// 位图渲染目标,用于生成并渲染方块图标,位图渲染目标也拥有 m_D2DDeviceContext 相似的能力
ComPtr<ID2D1BitmapRenderTarget> m_BitmapRenderTarget;
const float Slot_InnerSpace_Width = 15.0f; // 物品栏空槽的宽度
const float Slot_InnerSpace_Height = 15.0f; // 物品栏空槽的高度
// 物品栏 (位图渲染目标) 的方框大小
D2D1_SIZE_F Slot_InnerSpace_Size = { Slot_InnerSpace_Width , Slot_InnerSpace_Height };
const float BlockBitmap_Width = 16.0f; // 方块位图的宽度
const float BlockBitmap_Height = 16.0f; // 方块位图的宽度
// 方块纹理源图范围
D2D1_RECT_F Source_BlockItems_Rect = { 0, 0, BlockBitmap_Width, BlockBitmap_Height };
// 对每个方块物品的顶点数据进行等轴变换,并绘制相应面的 D2D 位图,生成预渲染立体图标
void D2D_STEP07_GenerateBlockItemIcons()
{
// m_InventoryBlockIcons 重置大小为 9,等会要进行位图创建
m_InventoryBlockIcons.resize(BlockBitmap_IndexGroup.size());
// 循环遍历每个方块,生成 9 个图标
// BlockIndex 是每个方块 (m_InventoryBlockIcons/BlockBitmap_IndexGroup 每个元素) 的索引
for (UINT BlockIndex = 0; BlockIndex < BlockBitmap_IndexGroup.size(); BlockIndex++)
{
// 为每个不同的方块图标,创建一个 D2D 可兼容的新位图渲染目标
// (Compatible 可兼容的,早些时候 D3D10、D3D11 的渲染目标资源绑定,渲染到纹理也是它做的)
m_D2DDeviceContext->CreateCompatibleRenderTarget(
D2D1::SizeF(Slot_InnerSpace_Width, Slot_InnerSpace_Height), // 位图渲染目标的大小 (15x15)
D2D1::SizeU(DPI, DPI), // 位图渲染目标 DPI 大小
m_InventoryBlockBitmaps[0]->GetPixelFormat(), // 位图渲染目标的 D2D 格式,要和原资源一致
&m_BitmapRenderTarget // 要创建的位图渲染目标接口
);
// 位图渲染目标开启渲染
m_BitmapRenderTarget->BeginDraw();
// 位图渲染目标清空背景为透明黑色 (不填参数默认透明黑色),这样我们就得到了背景透明的位图,后续方便混合
m_BitmapRenderTarget->Clear();
// 循环遍历每个面,绘制三个面到位图渲染目标中 { 正面 (+X),后面 (-Z),上面 (+Y) }
// 到下一个面的步长是 4,所以不同面顶点计算式 VertexIndex = FaceIndex * 4 + ConnerPointIndex
for (UINT FaceIndex = 0; FaceIndex < 3; FaceIndex++)
{
// 提取每个面左上角 P0 (0, 0),右上角 P1 (w, 0),左下角 P2 (0, h) 的坐标
XMFLOAT2 P0, P1, P2;
XMStoreFloat2(&P0, BlockItemsVertex[FaceIndex * 4 + 0]); // 左上角
XMStoreFloat2(&P1, BlockItemsVertex[FaceIndex * 4 + 1]); // 右上角
XMStoreFloat2(&P2, BlockItemsVertex[FaceIndex * 4 + 3]); // 左下角
// 预渲染的绘制需要 3x2 仿射矩阵,由于 2D 变换最后一列是固定的 [0, 0, 1],D2D 索性把它简化了
// 这个二维仿射变换包含缩放,旋转,错切与平移,它表示将 纹理位图 (矩形) 映射到 方块面平行四边形
// 你会疑惑方块面明明有四个点,为什么只用了其中三个,因为三个点就可以唯一确定一个仿射变换了
// 关于矩阵中每一项的推导过程,可以问 AI
D2D1_MATRIX_3X2_F BitmapAffineMatrix = {
(P1.x - P0.x) / BlockBitmap_Width, (P1.y - P0.y) / BlockBitmap_Width,
(P2.x - P0.x) / BlockBitmap_Height, (P2.y - P0.y) / BlockBitmap_Height,
P0.x, P0.y
};
// 位图渲染目标设置并更新仿射矩阵,接下来会影响到下面的渲染操作
m_BitmapRenderTarget->SetTransform(BitmapAffineMatrix);
// 获得对应方块下某个面对应的纹理 (位图) 索引
UINT FaceTexture_InBitmapsArrayIndex = BlockBitmap_IndexGroup[BlockIndex].FaceBitmapIndex[FaceIndex];
// 渲染目标绘制位图,将位图渲染到对应的方块面上
m_BitmapRenderTarget->DrawBitmap(
m_InventoryBlockBitmaps[FaceTexture_InBitmapsArrayIndex].Get(),
Source_BlockItems_Rect
);
}
// 三个面都绘制完成,结束渲染
m_BitmapRenderTarget->EndDraw();
// 将绘制好的位图导出到对应的 D2Dbitmap,生成预渲染位图
m_BitmapRenderTarget->GetBitmap(&m_InventoryBlockIcons[BlockIndex]);
}
}
DrawBitmap 的重载函数也有带矩阵变换的版本,不过我们不用它,因为真没什么必要每帧都进行一次纹理映射,直接上预制菜就行,何况 11On12 本来性能就有点差,感兴趣的读者可以自行试试。
屏蔽调试层警告:STEP04_IgnoreClearValueWarning

如图,这个警告烦人吗?我觉得挺烦人的。
D3D12 为了优化性能,鼓励开发者在创建资源时提供一个 ClearValue (深度缓冲资源就是这样做的),这样后续的 Clear 操作可以由驱动进行加速,如果没有提供,就会触发这个警告,提示性能可能稍差。
然而我们没有方法让 DXGI 交换链下的 D3D12RenderTarget 设置 ClearValue。我个人猜测,上面 D2DEngine 创建的位图渲染目标,它内部使用的纹理也会映射到 D3D11On12 包装的资源,这些包装的资源没有设置 ClearValue,也会不小心触发 D3D12 调试层警告。D2D 的渲染目标/设备上下文一旦 Clear 或者 SetTransform 就会触发这些警告,相当烦人。有些开发者也深受其扰,标准做法是直接屏蔽它:
cpp
// 屏蔽 MissingClearValue 带来的调试层警告刷屏,这个 D3D12 WARNING 太阴间了...
// 我们没有方法让 DXGI 交换链下的 D3D12RenderTarget 设置 ClearValue
// D3D12 为了优化性能,鼓励开发者在创建资源时提供一个 ClearValue (深度缓冲资源就是这样做的)
// 这样后续的 Clear 操作可以由驱动进行加速,如果没有提供,就会触发这个警告,提示性能可能稍差
// 上面 D2DEngine 创建的位图渲染目标,它内部使用的纹理也会映射到 D3D11On12 包装的资源
// 这些包装的资源没有设置 ClearValue,也会触发 D3D12 调试层警告 (巨硬的神秘代码发力了)
// D2DEngine 的位图渲染目标/设备上下文一旦 Clear 或者 SetTransform 就会触发这些警告,相当烦人...
void STEP04_IgnoreClearValueWarning()
{
#if defined(_DEBUG)
// 临时调试层消息队列,用于获取并屏蔽 D3D12 WARNING
ComPtr<ID3D12InfoQueue> _temp_DebugInfoQueue;
// 查询接口支持,如果支持就创建调试层消息队列
m_D3D12Device.As(&_temp_DebugInfoQueue);
// 定义要抑制的警告 ID
D3D12_MESSAGE_ID hideMessages[1] =
{
// 没设置 ClearValue 的警告 ID
D3D12_MESSAGE_ID_CLEARRENDERTARGETVIEW_MISMATCHINGCLEARVALUE,
};
// 调试层消息过滤结构体
D3D12_INFO_QUEUE_FILTER filter = {};
filter.DenyList.NumIDs = 1; // 要屏蔽的消息 ID 数
filter.DenyList.pIDList = hideMessages; // 指向消息数据的指针
// 向消息过滤器添加要屏蔽的 Warning/Error,这样就不用见到 Warning 在下面的调试窗口刷屏了
// 慎用消息过滤,除非迫不得已 (就像现在这样),否则不要使用,会错过很多一击致命的问题根源
_temp_DebugInfoQueue->AddStorageFilterEntries(&filter);
#endif
}
慎用消息过滤,除非迫不得已 (就像现在这样),否则不要使用,在 DEBUG 阶段下会错过很多一击致命的问题根源。
第十七章全资源及代码
详情见 github/gitee 原项目地址的 017-DrawItemsAndMerge:



下一章,我们将认识屏幕射线相交检测,学会方块的破坏与放置,初步学习动态资源管理,正式解决第 8 章的深度冲突问题,同时业务逻辑代码量会成倍增加。

