Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第九章:贴图

原文: Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第九章:贴图

代码工程地址:

https://github.com/jiabaodan/Direct12BookReadingNotes



学习目标

  1. 学习如何定义将一个纹理映射到一个三角形上;
  2. 学习如何创建和启用纹理;
  3. 学习纹理如何被过滤后生产一个更加平滑的图像;
  4. 学习如何将一个纹理通过地址模式展开多次;
  5. 学习如何将多个纹理合并成一个新贴图和特殊效果;
  6. 学习一些基本的纹理动画生成的效果。
    在这里插入图片描述


1 纹理和资源回顾

回顾我们已经在第四章使用过的纹理,深度缓存和后台缓存都是一张2D纹理,它们由ID3D12Resource包含的值为D3D12_RESOURCE_DIMENSION_TEXTURE2D的D3D12_RESOURCE_DESC::Dimension属性的接口来表示。
纹理和缓冲(buffer)不同,它不仅仅是保存数据,它还可以包含纹理细化等级(mipmap levels),并且GPU可以对它做特殊操作,比如应用过滤器(filters)和多重纹理映射。因为要支持这些特殊操作,所以它的格式有限制。格式通过DXGI_FORMAT枚举来定义:
纹理格式可以定义完整的类型,比如:DXGI_FORMAT_R32G32B32_FLOAT,包含3个32位浮点数;也可以定义无类型格式,比如:DXGI_FORMAT_R8G8B8A8_TYPELESS。

根据DX11的文档:定义完整类型的格式,可以进行运行时的优化。也就是说为了性能,只有当你真正需要无类型格式,否则都定义成完整类型的格式。

纹理可以绑定到渲染管线很多阶段,重用的方式是做为一个渲染目标,或者着色器的资源。为了让纹理用作渲染目标和着色器资源,我们需要创建2个描述:1、D3D12_DESCRIPTOR_HEAP_TYPE_RTV;2、D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV:

// Bind as render target.
CD3DX12_CPU_DESCRIPTOR_HANDLE rtv = …;
CD3DX12_CPU_DESCRIPTOR_HANDLE dsv = …;
cmdList->OMSetRenderTargets(1, &rtv, true, &dsv);

// Bind as shader input to root parameter.
CD3DX12_GPU_DESCRIPTOR_HANDLE tex = …;
cmdList->SetGraphicsRootDescriptorTable(rootParamIndex, tex);

资源描述本质上做了2件事情:1、告诉D3D该资源将如何被使用;2、如果资源定义时是无类型的,那么需要定义它的类型。



2 纹理坐标

为了添加贴图u,v坐标,修改定点结构如下:

struct Vertex
{
	DirectX::XMFLOAT3 Pos;
	DirectX::XMFLOAT3 Normal;
	DirectX::XMFLOAT2 TexC;
};

>>>>>>>>>>>>>>>>>>
std::vector<D3D12_INPUT_ELEMENT_DESC> mInputLayout =
{
	{ 
		"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
		D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
	},
	
	{ 
		"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12,
		D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
	},
	
	{
		"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24,
		D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
	},
};

我们可以将多个图片放到一个大纹理中(texture atlas),然后应用于多个物体。这样可以避免多次资源加载,减少DrawCall,优化性能。



3 纹理数据资源

对于实时图形应用,DDS(DirectDraw Surface format)文件格式更好,GPU可以直接原生使用很多它的文件格式;并且它支持压缩,可以让GPU原生解压缩。


3.1 DDS概览

DDS是3D图形更理想的格式是因为,它支持专门针对3D图形设计的特殊格式和纹理类型。它本质上是针对GPU设计的图像类型。比如DDS支持下面的特征:

  1. mipmaps;
  2. compressed formats that the GPU can natively decompress;
  3. texture arrays;
  4. cube maps;
  5. volume textures。

DDS文件支持DXGI_FORMAT枚举中的部分类型(不是全部),对于非压缩的数据可以使用:

  1. DXGI_FORMAT_B8G8R8A8_UNORM或者DXGI_FORMAT_B8G8R8X8_UNORM,用以low-dynamic-range图像;
  2. DXGI_FORMAT_R16G16B16A16_FLOAT用以high-dynamic-range图像。

为了让纹理能够快速应用,我们需要将它们放到GPU内存中。为了解决这些需求,D3D支持压缩纹理格式:BC1, BC2, BC3, BC4, BC5, BC6, 和 BC7:

  1. BC1 (DXGI_FORMAT_BC1_UNORM):一个支持三个颜色通道和一个1bit alpha组件的压缩格式;
  2. BC2 (DXGI_FORMAT_BC2_UNORM):一个支持三个颜色通道和一个4bit alpha组件的压缩格式;
  3. BC3 (DXGI_FORMAT_BC3_UNORM):一个支持三个颜色通道和一个8bit alpha组件的压缩格式;
  4. BC4 (DXGI_FORMAT_BC4_UNORM):一个支持完整一个颜色通道的压缩格式;
  5. BC5 (DXGI_FORMAT_BC5_UNORM):一个支持2个完整颜色通道的压缩格式;
  6. BC6 (DXGI_FORMAT_BC6_UF16):一个支持压缩HDR图像数据的压缩格式;
  7. BC7 (DXGI_FORMAT_BC7_UNORM):一个支持高质量RGBA的压缩格式,它主要用以减少压缩法相贴图导致的错误。

一个压缩的纹理只能用以着色器输入参数,不能用以渲染目标。
因为块压缩算法是以4x4像素块来工作的,所以纹理的大小必须是4的倍数。

再次声明,使用这个格式的好处是它们可以压缩保存在GPU内存,并且当要使用的时候可以直接被GPU解压缩;另一个好处是可以节省你的硬盘空间。


3.2 创建DDS文件

下面是两种可以将传统文件(.bmp .png等)转换成DDS文件的方法:

  1. NVIDIA有一个PS的插件可以支持在PS中导出DDS文件:https://developer.nvidia.com/nvidiatexture-tools-adobe-photoshop,它可以支持设置DXGI_FORMAT格式和生成mipmaps;
  2. 微软提供了一个叫texconv的命令行工具,可以转换成DDS文件。它还可以用来改变图像大小,修改像素格式生成mipmaps等,你可以找到对应文档和下载链接在:https://directxtex.codeplex.com/wikipage?title=Texconv&referringTitle=Documentation

下面的命令就是一个例子,输入一个bricks.bmp文件,导出一个bricks.dds文件并设置格式为BC3_UNORM并且创建10 mipmaps:

texconv -m 10 -f BC3_UNORM treeArray.dds

微软提供了另一个叫texassemble的命令行工具,可以用来创建保存了texture arrays, volume maps, and cube maps的DDS文件,它的文档和下载链接:https://directxtex.codeplex.com/wikipage?title=Texassemble&referringTitle=Documentation

VS2015有一个内置的图像编辑器可以支持DDS文件。你可以直接拖拽DDS文件到VS中查看。



4 创建和使用一个纹理


4.1 加载DDS文件

微软提供了一个轻量级源代码来加载DDS文件:
https://github.com/Microsoft/DirectXTK/wiki/DDSTextureLoader
但是在写本书的时候,这个代码只能支持DX11。我们需要修改DDSTextureLoader.h/.cpp文件来支持DX12:

HRESULT DirectX::CreateDDSTextureFromFile12(
	_In_ ID3D12Device* device,
	_In_ ID3D12GraphicsCommandList* cmdList,
	_In_z_ const wchar_t* szFileName,
	_Out_ Microsoft::WRL::ComPtr<ID3D12Resource>& texture,
	_Out_ Microsoft::WRL::ComPtr<ID3D12Resource>& textureUploadHeap);
  1. device:指向要创建纹理的D3D设备;
  2. cmdList:向GPU提交命令的命令列表;
  3. szFileName:需要加载的文件名称;
  4. texture:返回加载好数据的纹理资源;
  5. textureUploadHeap:返回一个使用为将资源赋值到默认堆的上传堆的纹理资源,这个资源在GPU指向完命令前不能被销毁。

为了创建加载一个叫WoodCreate01.dds的纹理,我们可以这样写:

struct Texture
{
	// Unique material name for lookup.
	std::string Name;
	std::wstring Filename;
	Microsoft::WRL::ComPtr<ID3D12Resource> Resource = nullptr;
	Microsoft::WRL::ComPtr<ID3D12Resource> UploadHeap = nullptr;
};

auto woodCrateTex = std::make_unique<Texture>();
woodCrateTex->Name = "woodCrateTex";
woodCrateTex->Filename = L"Textures/WoodCrate01.dds";

ThrowIfFailed(DirectX::CreateDDSTextureFromFile12(
	md3dDevice.Get(), mCommandList.Get(),
	woodCrateTex->Filename.c_str(),
	woodCrateTex->Resource, 
	woodCrateTex->UploadHeap));

4.2 SRV堆

当一个纹理资源被创建以后,我们需要创建一个可以让我们把它设置到根签名参数槽来让着色器程序使用的SRV描述。为了达到这个目的,我们需要先使用ID3D12Device::CreateDescriptorHeap创建一个描述堆来保存SRV描述。下面的代码创建了3个描述可以保存CBV,SRV或者UAV描述:

D3D12_DESCRIPTOR_HEAP_DESC srvHeapDesc = {};
srvHeapDesc.NumDescriptors = 3;
srvHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV;
srvHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;

ThrowIfFailed(md3dDevice->CreateDescriptorHeap(
	&srvHeapDesc,
	IID_PPV_ARGS(&mSrvDescriptorHeap)));

4.3 创建SRV描述

当我们创建后SRV对后,我们需要创建真正的描述。一个SRV描述是通过填D3D12_SHADER_RESOURCE_VIEW_DESC对象来创建:

typedef struct D3D12_SHADER_RESOURCE_VIEW_DESC
{
	DXGI_FORMAT Format;
	D3D12_SRV_DIMENSION ViewDimension;
	UINT Shader4ComponentMapping;
	
	union
	{
		D3D12_BUFFER_SRV Buffer;
		D3D12_TEX1D_SRV Texture1D;
		D3D12_TEX1D_ARRAY_SRV Texture1DArray;
		D3D12_TEX2D_SRV Texture2D;
		D3D12_TEX2D_ARRAY_SRV Texture2DArray;
		D3D12_TEX2DMS_SRV Texture2DMS;
		D3D12_TEX2DMS_ARRAY_SRV Texture2DMSArray;
		D3D12_TEX3D_SRV Texture3D;
		D3D12_TEXCUBE_SRV TextureCube;
		D3D12_TEXCUBE_ARRAY_SRV TextureCubeArray;
	};
} D3D12_SHADER_RESOURCE_VIEW_DESC;

typedef struct D3D12_TEX2D_SRV
{
	UINT MostDetailedMip;
	UINT MipLevels;
	UINT PlaneSlice;
	FLOAT ResourceMinLODClamp;
} D3D12_TEX2D_SRV;

对于2D纹理,我们只关心共用体中的3D12_TEX2D_SRV部分:

  1. Format:资源的格式:如果在创建的时候设置的是有类型的,直接设置为DXGI_FORMAT枚举中的类型;如果在创建的时候设置的是无类型的,那么必须设置成DXGI_FORMAT枚举中有类型的类型。
  2. ViewDimension:我们使用的是2D纹理,所以设置成D3D12_SRV_DIMENSION_TEXTURE2D,其他格式包括TEXTURE1D、TEXTURE3D和TEXTURECUBE;
  3. Shader4ComponentMapping:当一个纹理映射到着色器程序中时,它会返回一个纹理数据的vector,这个值可以用来重新排序向量的组件;本书中直接设置为D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING,代表我们不重置4个组件的顺序。
  4. MostDetailedMip:指定最多细节的mipmap等级的索引,范围是从0到MipCount-1;
  5. MipLevels:mipmap levels的数量,从MostDetailedMip开始。该值和MostDetailedMip可以定义mipmap等级的子区间,设置-1就是从MostDetailedMip减少到最少mipmap等级;
  6. PlaneSlice:平面的索引;
  7. ResourceMinLODClamp:指定可以访问的最小的mipmap等级,0.0代表所有的都可以访问,3.0代表从等级3.0到MipCount-1可以访问。

下面的代码为3个资源创建了描述:

// Suppose the following texture resources are already created.
// ID3D12Resource* bricksTex;
// ID3D12Resource* stoneTex;
// ID3D12Resource* tileTex;
// Get pointer to the start of the heap.
CD3DX12_CPU_DESCRIPTOR_HANDLE hDescriptor(
	mSrvDescriptorHeap->GetCPUDescriptorHandleForHeapStart());
	
D3D12_SHADER_RESOURCE_VIEW_DESC srvDesc = {};
srvDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
srvDesc.Format = bricksTex->GetDesc().Format;
srvDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MostDetailedMip = 0;
srvDesc.Texture2D.MipLevels = bricksTex->GetDesc().MipLevels;
srvDesc.Texture2D.ResourceMinLODClamp = 0.0f;

md3dDevice->CreateShaderResourceView(bricksTex.Get(), &srvDesc, hDescriptor);

// offset to next descriptor in heap
hDescriptor.Offset(1, mCbvSrvDescriptorSize);
srvDesc.Format = stoneTex->GetDesc().Format;
srvDesc.Texture2D.MipLevels = stoneTex->GetDesc().MipLevels;
md3dDevice->CreateShaderResourceView(stoneTex.Get(), &srvDesc, hDescriptor);

// offset to next descriptor in heap
hDescriptor.Offset(1, mCbvSrvDescriptorSize);
srvDesc.Format = tileTex->GetDesc().Format;
srvDesc.Texture2D.MipLevels = tileTex->GetDesc().MipLevels;
md3dDevice->CreateShaderResourceView(tileTex.Get(), &srvDesc, hDescriptor);

4.4 绑定纹理到渲染管线

目前为止,我们通过逐绘制调用更新材质常量缓冲来指定材质。这代表当前绘制调用中几何体都会使用一种材质。这个就限制了我们不能逐像素的改变材质,所以我们的场景就缺少细节。解决想法是通过纹理映射的纹理贴图来获取材质数据而不是常量缓冲。这就可以进行逐像素的改变材质来增加细节和真实感。
本章中,我们增加了一个diffuse albedo纹理贴图来定义材质的diffuse albedo组件。FresnelR0和Roughness材质值将继续逐绘制调用的在常量缓冲中更新,但是在Normal Mapping章中,我们将介绍如何通过纹理贴图来进行逐像素定义。我们将合并纹理的diffuse albedo值在像素着色器中:

// Get diffuse albedo at this pixel from texture.
float4 texDiffuseAlbedo = gDiffuseMap.Sample(gsamAnisotropicWrap, pin.TexC);

// Multiple texture sample with constant buffer albedo.
float4 diffuseAlbedo = texDiffuseAlbedo * gDiffuseAlbedo;

通常情况下我们设置DiffuseAlbedo=(1,1,1,1),不去改变texDiffuseAlbedo。但是有些特殊效果需要调整这个值(比如,砖块调整成淡蓝色)。
我们在材质中增加一个索引,代表对应SRV在堆中的索引:

struct Material
{
	…
	// Index into SRV heap for diffuse texture.
	int DiffuseSrvHeapIndex = -1;
	…
};

然后假设根签名已经定义了一个SRV绑定到0槽上,我们就可以使用纹理绘制物体通过下面的代码:

void CrateApp::DrawRenderItems(
	ID3D12GraphicsCommandList* cmdList,
	const std::vector<RenderItem*>& ritems)
{
	UINT objCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(ObjectConstants));
	UINT matCBByteSize = d3dUtil::CalcConstantBufferByteSize(sizeof(MaterialConstants));
	
	auto objectCB = mCurrFrameResource->ObjectCB->Resource();
	auto matCB = mCurrFrameResource->MaterialCB->Resource();
	
	// For each render item…
	for(size_t i = 0; i < ritems.size(); ++i)
	{
		auto ri = ritems[i];
		cmdList->IASetVertexBuffers(0, 1, &ri->Geo->VertexBufferView());
		cmdList->IASetIndexBuffer(&ri->Geo->IndexBufferView());
		cmdList->IASetPrimitiveTopology(ri->PrimitiveType);
		
		**CD3DX12_GPU_DESCRIPTOR_HANDLE tex(
			mSrvDescriptorHeap->GetGPUDescriptorHandleForHeapStart());
		tex.Offset(ri->Mat->DiffuseSrvHeapIndex, mCbvSrvDescriptorSize);**
		
		D3D12_GPU_VIRTUAL_ADDRESS objCBAddress =
			objectCB->GetGPUVirtualAddress() + ri->ObjCBIndex*objCBByteSize;
		D3D12_GPU_VIRTUAL_ADDRESS matCBAddress =
			matCB->GetGPUVirtualAddress() + ri->Mat->MatCBIndex*matCBByteSize;
			
		**cmdList->SetGraphicsRootDescriptorTable(0, tex);**
		cmdList->SetGraphicsRootConstantBufferView(1, objCBAddress);
		cmdList->SetGraphicsRootConstantBufferView(3, matCBAddress);
		
		cmdList->DrawIndexedInstanced(ri->IndexCount,
			1, ri->StartIndexLocation,
			ri->BaseVertexLocation, 0);
	}
}

纹理图集可以增加性能,因为可以绘制多个几何体在同一个绘制调用中。即使在DX12中绘制调用的开销已经明显较少了,但是减少绘制调用还是非常值得的。



5 滤波器


5.1 放大

放大有两种方式:常量差值(下图a)和线性差值(下图b)。
在这里插入图片描述
2D线性差值叫做双线性差值,如下图所示:
在这里插入图片描述
下图展示了使用真实数据资源,两种差值方式的对比,线性差值要更平滑一些,但是比起原本的资源,看起来效果也不是那么好:
在这里插入图片描述
常量差值也叫点滤波器(point filtering),线性差值也叫线性滤波器(linear filtering),它们是D3D的术语。


5.2 缩小

常量和线性滤波器依然适用于缩小。还有一种叫做多级渐进纹理的技术可以高效模拟缩小操作,但是会占用更多的内存。在运行时,程序员需要通过图形硬件会基于mipmap做两件不同的事情:

  1. 为纹理选择更适用于当前屏幕分辨率的mipmap等级,应用需要的常量或者线性插值。你只需要选择最接近的mipmap等级-----这个叫点滤波器(point filtering)mipmaps;
  2. 为纹理选择2个更适用于当前屏幕分辨率的mipmap等级,然后应用需要的常量或者线性插值,计算出两个纹理的颜色,然后线性插值这两个纹理的颜色-----这个叫线性滤波器(linear filtering)mipmaps;
    在这里插入图片描述
    利用PS、texconv或者其它软件生成mipmap chain的时候不能保证能够保留重要的细节,有时候需要美术在缩小的纹理上重新处理细节。

5.3 各向异性滤波器

这个滤波器帮助较少当几何体的法向量和摄像机看向的方向夹角变大时,产生的纹理变形和扭曲。这个滤波器的运算成本是最大的。
在这里插入图片描述



6 地址模式

D3D允许我们通过4种方式来扩展纹理获取颜色的方式:包裹(wrap)、边缘色彩(border color)、clamp和镜像(mirror)。

  1. 包裹扩展是让纹理方程在每一个整数那里重复图像:
    在这里插入图片描述
  2. 边缘色彩扩展是然超出 [ 0 , 1 ] 2 [0, 1]^2 范围的地方使用程序员设置的颜色:
    在这里插入图片描述
  3. clamp扩展是超出 [ 0 , 1 ] 2 [0, 1]^2 范围的地方保留最接近纹理位置的颜色:
    在这里插入图片描述
  4. 镜像扩展是在每一个整数位置那里镜像图像:
    在这里插入图片描述

地址模式的默认值是wrap。贴图如果是无缝的,wrap就可以得到很好的效果。
地址模式在D3D12_TEXTURE_ADDRESS_MODE枚举中:

typedef enum D3D12_TEXTURE_ADDRESS_MODE
{
	D3D12_TEXTURE_ADDRESS_MODE_WRAP = 1,
	D3D12_TEXTURE_ADDRESS_MODE_MIRROR = 2,
	D3D12_TEXTURE_ADDRESS_MODE_CLAMP = 3,
	D3D12_TEXTURE_ADDRESS_MODE_BORDER = 4,
	D3D12_TEXTURE_ADDRESS_MODE_MIRROR_ONCE = 5
} D3D12_TEXTURE_ADDRESS_MODE;


7 采样对象(SAMPLER OBJECTS)

使用什么滤波器和地址模式是通过采样对象来定义的,一个应用程序一般都需要多个采样对象。


7.1 创建采样器

为了绑定采样器到着色器程序,我们需要描述到采样对象,下面的代码是一个例子:

CD3DX12_DESCRIPTOR_RANGE descRange[3];
descRange[0].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);
descRange[1].Init(D3D12_DESCRIPTOR_RANGE_TYPE_SAMPLER, 1, 0);
descRange[2].Init(D3D12_DESCRIPTOR_RANGE_TYPE_CBV, 1, 0);

CD3DX12_ROOT_PARAMETER rootParameters[3];
rootParameters[0].InitAsDescriptorTable(1, &descRange[0], D3D12_SHADER_VISIBILITY_PIXEL);
rootParameters[1].InitAsDescriptorTable(1, &descRange[1], D3D12_SHADER_VISIBILITY_PIXEL);
rootParameters[2].InitAsDescriptorTable(1, &descRange[2], D3D12_SHADER_VISIBILITY_ALL);

CD3DX12_ROOT_SIGNATURE_DESC descRootSignature;
	descRootSignature.Init(3, rootParameters, 0,
	nullptr,
	D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_

如果我们要设置一个采样器描述,我们需要一个采样器堆。采样器堆是通过填充一个D3D12_DESCRIPTOR_HEAP_DESC结构示例也定义的:

D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER:
D3D12_DESCRIPTOR_HEAP_DESC descHeapSampler = {};
descHeapSampler.NumDescriptors = 1;
descHeapSampler.Type = D3D12_DESCRIPTOR_HEAP_TYPE_SAMPLER;
descHeapSampler.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE;

ComPtr<ID3D12DescriptorHeap> mSamplerDescriptorHeap;
ThrowIfFailed(mDevice->CreateDescriptorHeap(&descHeapSampler,
	__uuidof(ID3D12DescriptorHeap),
	(void**)&mSamplerDescriptorHeap));

当创建好采样器堆后,我们可以创建采样器描述。需要填充一个D3D12_SAMPLER_DESC示例:

typedef struct D3D12_SAMPLER_DESC
{
	D3D12_FILTER Filter;
	D3D12_TEXTURE_ADDRESS_MODE AddressU;
	D3D12_TEXTURE_ADDRESS_MODE AddressV;
	D3D12_TEXTURE_ADDRESS_MODE AddressW;
	FLOAT MipLODBias;
	UINT MaxAnisotropy;
	D3D12_COMPARISON_FUNC ComparisonFunc;
	FLOAT BorderColor[ 4 ];
	FLOAT MinLOD;
	FLOAT MaxLOD;
} D3D12_SAMPLER_DESC;
  1. Filter:D3D12_FILTER枚举中的一个类型;
  2. AddressU:水平方向的地址模式;
  3. AddressV:竖直方向的地址模式;
  4. AddressW:深度方向上的地址模式(3D纹理);
  5. MipLODBias:偏移的mipmap等级,0.0位不偏移;
  6. MaxAnisotropy:最大的各向异性值,在1~16之间(包含1和16),这个只对D3D12_FILTER_ANISOTROPIC和D3D12_FILTER_COMPARISON_ANISOTROPIC有效,越大的值开销越大,但是可以给出更好的效果。
  7. ComparisonFunc:高级应用,可以实现一些特殊效果,比如阴影贴图,目前就直接设置成D3D12_COMPARISON_FUNC_ALWAYS;
  8. BorderColor:地址模式为D3D12_TEXTURE_ADDRESS_MODE_BORDER时的四周颜色;
  9. MinLOD:最小mipmap等级;
  10. MaxLOD:最大mipmap等级;

下面是一些常用的D3D12_FILTER类型:

  1. D3D12_FILTER_MIN_MAG_MIP_POINT:点滤波器,和点滤波器mipmap;
  2. D3D12_FILTER_MIN_MAG_LINEAR_MIP_POINT:双线性滤波器,和点滤波器mipmap;
  3. D3D12_FILTER_MIN_MAG_MIP_LINEAR:双线性滤波器,和在两个最接近的mipamap等级之间的线性滤波器,也叫三线性滤波器。
  4. D3D12_FILTER_ANISOTROPIC:各向异性滤波器。

你可以从这些例子中找到其他的排列,或者查看SDK文档中的D3D12_FILTER枚举。
下面的代码是一个如何创建采样器描述的例子:

D3D12_SAMPLER_DESC samplerDesc = {};
samplerDesc.Filter = D3D12_FILTER_MIN_MAG_MIP_LINEAR;
samplerDesc.AddressU = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.AddressV = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.AddressW = D3D12_TEXTURE_ADDRESS_MODE_WRAP;
samplerDesc.MinLOD = 0;
samplerDesc.MaxLOD = D3D12_FLOAT32_MAX;
samplerDesc.MipLODBias = 0.0f;
samplerDesc.MaxAnisotropy = 1;
samplerDesc.ComparisonFunc = D3D12_COMPARISON_FUNC_ALWAYS;

md3dDevice->CreateSampler(&samplerDesc,
	mSamplerDescriptorHeap->GetCPUDescriptorHandleForHeapStart());

下面的代码展示了如何绑定一个采样器描述到根签名参数槽中:

commandList->SetGraphicsRootDescriptorTable(1,
	samplerDescriptorHeap->GetGPUDescriptorHandleForHeapStart());

7.2 静态采样器

一个应用程序通常只使用少数采样器,所以,D3D提供了一个特殊捷径来定义一个采样器数组,并且不需要再创建采样器堆。CD3DX12_ROOT_SIGNATURE_DESC类的Init函数有两个参数,可以让你定义一个叫做静态采样器的数组。静态采样器通过D3D12_STATIC_SAMPLER_DESC结构来描述,这个结构和D3D12_SAMPLER_DESC结构非常相似,但是有下面的一些例外:

  1. 边缘颜色有一些限制,只能使用下面的值:
enum D3D12_STATIC_BORDER_COLOR
{
	D3D12_STATIC_BORDER_COLOR_TRANSPARENT_BLACK = 0,
	D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK = (D3D12_STATIC_BORDER_COLOR_TRANSPARENT_BLACK + 1 ) ,
	D3D12_STATIC_BORDER_COLOR_OPAQUE_WHITE = (D3D12_STATIC_BORDER_COLOR_OPAQUE_BLACK + 1 )
}D3D12_STATIC_BORDER_COLOR;
  1. 它包含了其他内容来定义着色器寄存器,寄存器空间和着色器可见度(正常情况下需要定义在堆中)。

并且你只能定义2032个静态采样器,一般情况下应用程序也不需要这么多;如果你真需要更多,可以通过采样器堆来使用非静态采样器。
在我们的Demo中我们使用静态采样器,下面的代码就是使用静态采样器的例子。我们会定义很多静态采样器,但是Demo中可能不需要用到,对于多出来的采样器,不会造成太多的问题:

std::array<const CD3DX12_STATIC_SAMPLER_DESC, 6> TexColumnsApp::GetStaticSamplers()
{
	// Applications usually only need a handful of samplers. So just define them
	// all up front and keep them available as part of the root signature.
	const CD3DX12_STATIC_SAMPLER_DESC pointWrap(
		0, // shaderRegister
		D3D12_FILTER_MIN_MAG_MIP_POINT, // filter
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_WRAP); // addressW
		
	const CD3DX12_STATIC_SAMPLER_DESC pointClamp(
		1, // shaderRegister
		D3D12_FILTER_MIN_MAG_MIP_POINT, // filter
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP); // addressW
		
	const CD3DX12_STATIC_SAMPLER_DESC linearWrap(
		2, // shaderRegister
		D3D12_FILTER_MIN_MAG_MIP_LINEAR, // filter
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_WRAP); // addressW

	const CD3DX12_STATIC_SAMPLER_DESC linearClamp(
		3, // shaderRegister
		D3D12_FILTER_MIN_MAG_MIP_LINEAR, // filter
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP); // addressW
		
	const CD3DX12_STATIC_SAMPLER_DESC anisotropicWrap(
		4, // shaderRegister
		D3D12_FILTER_ANISOTROPIC, // filter
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_WRAP, // addressW
		0.0f, // mipLODBias
		8); // maxAnisotropy
		
	const CD3DX12_STATIC_SAMPLER_DESC anisotropicClamp(
		5, // shaderRegister
		D3D12_FILTER_ANISOTROPIC, // filter
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressU
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressV
		D3D12_TEXTURE_ADDRESS_MODE_CLAMP, // addressW
		0.0f, // mipLODBias
		8); // maxAnisotropy
		
	return {
		pointWrap, pointClamp,
		linearWrap, linearClamp,
		anisotropicWrap, anisotropicClamp };
}

void TexColumnsApp::BuildRootSignature()
{
	CD3DX12_DESCRIPTOR_RANGE texTable;
	texTable.Init(D3D12_DESCRIPTOR_RANGE_TYPE_SRV, 1, 0);
	
	// Root parameter can be a table, root descriptor or root constants.
	CD3DX12_ROOT_PARAMETER slotRootParameter[4];
	slotRootParameter[0].InitAsDescriptorTable(1, &texTable, D3D12_SHADER_VISIBILITY_PIXEL);
	slotRootParameter[1].InitAsConstantBufferView(0);
	slotRootParameter[2].InitAsConstantBufferView(1);
	slotRootParameter[3].InitAsConstantBufferView(2);
	auto staticSamplers = GetStaticSamplers();
	
	// A root signature is an array of root parameters.
	CD3DX12_ROOT_SIGNATURE_DESC rootSigDesc(4,
		slotRootParameter,
		(UINT)staticSamplers.size(),
		staticSamplers.data(),
		D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_

	// create a root signature with a single slot which points to a
	// descriptor range consisting of a single constant buffer
	ComPtr<ID3DBlob> serializedRootSig = nullptr;
	ComPtr<ID3DBlob> errorBlob = nullptr;
	
	HRESULT hr = D3D12SerializeRootSignature(&rootSigDesc,
		D3D_ROOT_SIGNATURE_VERSION_1,
		serializedRootSig.GetAddressOf(),
		errorBlob.GetAddressOf());
		
	if(errorBlob != nullptr)
	{
		::OutputDebugStringA((char*)errorBlob->GetBufferPointer());
	}
	
	ThrowIfFailed(hr);
	ThrowIfFailed(md3dDevice->CreateRootSignature(
		0,
		serializedRootSig->GetBufferPointer(),
		serializedRootSig->GetBufferSize(),
		IID_PPV_ARGS(mRootSignature.GetAddressOf())));
}


8 在着色器中的纹理采样

一个纹理对象在HLSL中被指定为纹理寄存器:

Texture2D gDiffuseMap : register(t0);

类似的采样器对象在HLSL中被指定为采样器寄存器:

SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);

现在给出纹理和采样器,我们可以在像素着色器中使用Texture2D::Sample方法:

Texture2D gDiffuseMap : register(t0);
SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);

struct VertexOut
{
	float4 PosH : SV_POSITION;
	float3 PosW : POSITION;
	float3 NormalW : NORMAL;
	float2 TexC : TEXCOORD;
};

float4 PS(VertexOut pin) : SV_Target
{
	float4 diffuseAlbedo =
	gDiffuseMap.Sample(gsamAnisotropicWrap, pin.TexC) *
	gDiffuseAlbedo;
	…

这个方法返回了差值后的颜色值。



9 板条箱Demo

现在我们复习一下,添加纹理到一个箱子上的主要的点。


9.1 定义纹理坐标

GeometryGenerator::CreateBox方法创建了纹理坐标,所以纹理可以覆盖到整个箱子上。

GeometryGenerator::MeshData
GeometryGenerator::CreateBox( float width, float height, float depth, uint32 numSubdivisions)
{
	MeshData meshData;
	Vertex v[24];
	float w2 = 0.5f*width;
	float h2 = 0.5f*height;
	float d2 = 0.5f*depth;
	
	// Fill in the front face vertex data.
	v[0] = Vertex(-w2, -h2, -d2, …, 0.0f, 1.0f);
	v[1] = Vertex(-w2, +h2, -d2, …, 0.0f, 0.0f);
	v[2] = Vertex(+w2, +h2, -d2, …, 1.0f, 0.0f);
	v[3] = Vertex(+w2, -h2, -d2, …, 1.0f, 1.0f);
	
	// Fill in the back face vertex data.
	v[4] = Vertex(-w2, -h2, +d2, …, 1.0f, 1.0f);
	v[5] = Vertex(+w2, -h2, +d2, …, 0.0f, 1.0f);
	v[6] = Vertex(+w2, +h2, +d2, …, 0.0f, 0.0f);
	v[7] = Vertex(-w2, +h2, +d2, …, 1.0f, 0.0f);
	
	// Fill in the top face vertex data.
	v[8] = Vertex(-w2, +h2, -d2, …, 0.0f, 1.0f);
	v[9] = Vertex(-w2, +h2, +d2, …, 0.0f, 0.0f);
	v[10] = Vertex(+w2, +h2, +d2, …, 1.0f, 0.0f);
	v[11] = Vertex(+w2, +h2, -d2, …, 1.0f, 1.0f);

9.2 创建纹理

我们在初始化的时候创建纹理:

// Helper structure to group data related to the texture.
struct Texture
{
	// Unique material name for lookup.
	std::string Name;
	std::wstring Filename;
	Microsoft::WRL::ComPtr<ID3D12Resource> Resource = nullptr;
	Microsoft::WRL::ComPtr<ID3D12Resource> UploadHeap = nullptr;
};

std::unordered_map<std::string, std::unique_ptr<Texture>> mTextures;
void CrateApp::LoadTextures()
{
	auto woodCrateTex = std::make_unique<Texture>();
	woodCrateTex->Name = "woodCrateTex";
	woodCrateTex->Filename = L"Textures/WoodCrate01.dds";
	ThrowIfFailed(DirectX::CreateDDSTextureFromFile12(md3dDevice.mCommandList.Get(), 
		woodCrateTex->Filename.c_str(),
		woodCrateTex->Resource, woodCrateTex->UploadHeap));
	mTextures[woodCrateTex->Name] = std::move(woodCrateTex);
}

9.3 设置纹理

当一个纹理创建好,并且一个SRV被创建到一个描述堆的时候,就可以绑定纹理到渲染管线:

// Get SRV to texture we want to bind.
CD3DX12_GPU_DESCRIPTOR_HANDLE tex(
	mSrvDescriptorHeap->GetGPUDescriptorHandleForHeapStart());
	
tex.Offset(ri->Mat->DiffuseSrvHeapIndex, mCbvSrvDescriptorSize);
…
// Bind to root parameter 0. The root parameter description specifies which
// shader register slot this corresponds to.
cmdList->SetGraphicsRootDescriptorTable(0, tex);

9.4 更新HLSL

// Defaults for number of lights.
#ifndef NUM_DIR_LIGHTS
#define NUM_DIR_LIGHTS 3
#endif
#ifndef NUM_POINT_LIGHTS
#define NUM_POINT_LIGHTS 0
#endif
#ifndef NUM_SPOT_LIGHTS
#define NUM_SPOT_LIGHTS 0
#endif

// Include structures and functions for lighting.
#include "LightingUtil.hlsl"

Texture2D gDiffuseMap : register(t0);

SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5);

// Constant data that varies per frame.
cbuffer cbPerObject : register(b0)
{
	float4x4 gWorld;
	**float4x4 gTexTransform;**
};

// Constant data that varies per material.
cbuffer cbPass : register(b1)
{
	float4x4 gView;
	float4x4 gInvView;
	float4x4 gProj;
	float4x4 gInvProj;
	float4x4 gViewProj;
	float4x4 gInvViewProj;
	float3 gEyePosW;
	float cbPerObjectPad1;
	float2 gRenderTargetSize;
	float2 gInvRenderTargetSize;
	float gNearZ;
	float gFarZ;
	float gTotalTime;
	float gDeltaTime;
	float4 gAmbientLight;
	
	// Indices [0, NUM_DIR_LIGHTS) are directional lights;
	// indices [NUM_DIR_LIGHTS,
	NUM_DIR_LIGHTS+NUM_POINT_LIGHTS) are point lights;
	
	// indices [NUM_DIR_LIGHTS+NUM_POINT_LIGHTS,
	//
	NUM_DIR_LIGHTS+NUM_POINT_LIGHT+NUM_SPOT_LIGHTS)
	
	// are spot lights for a maximum of MaxLights per object.
	Light gLights[MaxLights];
};

cbuffer cbMaterial : register(b2)
{
	float4 gDiffuseAlbedo;
	float3 gFresnelR0;
	float gRoughness;
	**float4x4 gMatTransform;**
};

struct VertexIn
{
	float3 PosL : POSITION;
	float3 NormalL : NORMAL;
	**float2 TexC : TEXCOORD;**
};

struct VertexOut
{
	float4 PosH : SV_POSITION;
	float3 PosW : POSITION;
	float3 NormalW : NORMAL;
	**float2 TexC : TEXCOORD;**
};

VertexOut VS(VertexIn vin)
{
	VertexOut vout = (VertexOut)0.0f;
	
	// Transform to world space.
	float4 posW = mul(float4(vin.PosL, 1.0f), gWorld);
	vout.PosW = posW.xyz;
	
	// Assumes nonuniform scaling; otherwise, need to use
	// inverse-transpose of world matrix.
	vout.NormalW = mul(vin.NormalL, (float3x3)gWorld);
	
	// Transform to homogeneous clip space.
	vout.PosH = mul(posW, gViewProj);
	
	**// Output vertex attributes for interpolation across triangle.
	float4 texC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform);
	vout.TexC = mul(texC, gMatTransform).xy;**
	
	return vout;
}

float4 PS(VertexOut pin) : SV_Target
{
	**float4 diffuseAlbedo = gDiffuseMap.Sample(gsamAnisotropicWrap, pin.TexC) * gDiffuseAlbedo;**

	// Interpolating normal can unnormalize it, so renormalize it.
	pin.NormalW = normalize(pin.NormalW);
	
	// Vector from point being lit to eye.
	float3 toEyeW = normalize(gEyePosW - pin.PosW);
	
	// Light terms.
	float4 ambient = gAmbientLight*diffuseAlbedo;
	const float shininess = 1.0f - gRoughness;
	Material mat = { diffuseAlbedo, gFresnelR0, shininess };
	float3 shadowFactor = 1.0f;
	float4 directLight = ComputeLighting(gLights, mat, pin.PosW, pin.NormalW, toEyeW, shadowFactor);
	float4 litColor = ambient + directLight;
	
	// Common convention to take alpha from diffuse albedo.
	litColor.a = diffuseAlbedo.a;
	
	return litColor;
}


10 变换纹理

有两个常量缓冲的变量我们目前还没有讨论:gTexTransform和gMatTransform。它们是顶点着色器用来变换纹理坐标的:

// Output vertex attributes for interpolation across triangle.
float4 texC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform);
vout.TexC = mul(texC, gMatTransform).xy;

纹理坐标表示2D纹理上的点,所以我们可以移动,旋转,缩放它们。
使用4 x 4矩阵变换2D坐标,我们需要把坐标增加到4D:

vin.TexC ---> float4(vin.Tex, 0.0f, 1.0f)

变换过后,需要把坐标变回到2D:

vout.TexC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform).xy;

我们之所以分开为2个变换矩阵:gTexTransform和gMatTransform是因为有些时候材质需要变换纹理(比如流动的水流),但是有些时候纹理变换是物体对象的一个属性。



11 具有纹理的山和水Demo

在这里插入图片描述


11.1 创建网格纹理坐标

根据下图可以看出,网格的纹理坐标为:
在这里插入图片描述
在这里插入图片描述
创建代码如下:

GeometryGenerator::MeshData GeometryGenerator::CreateGrid(float width, float depth, uint32 m, uint32 n)
{
	MeshData meshData;
	uint32 vertexCount = m*n;
	uint32 faceCount = (m-1)*(n-1)*2;
	float halfWidth = 0.5f*width;
	float halfDepth = 0.5f*depth;
	float dx = width / (n-1);
	float dz = depth / (m-1);
	**float du = 1.0f / (n-1);
	float dv = 1.0f / (m-1);**
	meshData.Vertices.resize(vertexCount);
	
	for(uint32 i = 0; i < m; ++i)
	{
		float z = halfDepth - i*dz;
		for(uint32 j = 0; j < n; ++j)
		{
			float x = -halfWidth + j*dx;
			meshData.Vertices[i*n+j].Position = XMFLOAT3(x, 0.0f, z);
			meshData.Vertices[i*n+j].Normal = XMFLOAT3(0.0f, 1.0f, 0.0f);
			meshData.Vertices[i*n+j].TangentU = XMFLOAT3(1.0f, 0.0f, 0.0f);
			
			**// Stretch texture over grid.
			meshData.Vertices[i*n+j].TexC.x = j*du;
			meshData.Vertices[i*n+j].TexC.y = i*dv;**
		}
	}

11.2 纹理铺展

为了将纹理铺到整个网格,我们选择地址模式和缩放纹理坐标:

void TexWavesApp::BuildRenderItems()
{
	auto gridRitem = std::make_unique<RenderItem> ();
	gridRitem->World = MathHelper::Identity4x4();
	XMStoreFloat4x4(&gridRitem->TexTransform, XMMatrixScaling(5.0f, 5.0f, 1.0f));
	…
}

11.3 纹理动画

为了让水可以流动,我们添加一个AnimateMaterials函数,我们使用wrap地址模式在一个无缝纹理上。下面的代码就是一个例子:

void TexWavesApp::AnimateMaterials(const GameTimer& gt)
{
	// Scroll the water material texture coordinates.
	auto waterMat = mMaterials["water"].get();
	float& tu = waterMat->MatTransform(3, 0);
	float& tv = waterMat->MatTransform(3, 1);
	
	tu += 0.1f * gt.DeltaTime();
	tv += 0.02f * gt.DeltaTime();
	
	if(tu >= 1.0f)
		tu -= 1.0f;
	if(tv >= 1.0f)
		tv -= 1.0f;
		
	waterMat->MatTransform(3, 0) = tu;
	waterMat->MatTransform(3, 1) = tv;
	
	// Material has changed, so need to update cbuffer.
	waterMat->NumFramesDirty = gNumFrameResources;
}


12 本章总结

  1. 纹理坐标用来将纹理上的三角形映射到3D三角形;
  2. 游戏中最普遍的纹理创建方式是,美术通过PS等软件创建好资源,然后游戏中加载成ID3D12Resource对象;但是对于实时图形应用程序,DDS格式文件更好,它支持多种GPU本地支持的格式,而且可以被GPU解压缩;
  3. 有两种方法可以将传统图像格式文件转换为DDS文件:在图像编辑器中导出DDS文件;使用微软命令行工具:texconv。
  4. 我们可以通过CreateDDSTextureFromFile12方法创建纹理并加载硬盘上的文件,该方法实现在Common/DDSTextureLoader.h/.cpp文件中;
  5. GPU原生可以支持3种滤波器:放大,缩小和mipmap;
  6. 地址模式定义了D3D对超出[0, 1]范围的纹理坐标如何处理;
  7. 纹理坐标可以被移动,缩放,旋转,就类似于其他顶点。


13 练习题

1. 修改Crate Demo达到不同的寻址模式和滤波器效果:

代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter9_Exercises_1_CrateTexMode工程
在这里插入图片描述

修改renderitem中的TexTransform或者材质中的MatTransform:

// 对纹理放大5倍
XMStoreFloat4x4(&boxRitem->TexTransform, XMMatrixScaling(5.0f, 5.0f, 1.0f));

然后修改像素着色器中的采样器

float4 diffuseAlbedo = gDiffuseMap.Sample(gsamAnisotropicClamp, pin.TexC) * gDiffuseAlbedo;

2. 使用DirectX Texture Tool创建mipmap,每个等级使用不同的颜色,然后在Crate Demo中增加控制距离,观察在点和线性滤波器下的mipmap的变化:

代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter9_Exercises_2_CrateMipmap工程
在这里插入图片描述
根据要求创建好mipmap问题,然后加载进去即可

3&4. 用乘法合并两张纹理,并旋转:

代码在https://github.com/jiabaodan/Direct12BookReadingNotes中的Chapter9_Exercises_4_CrateTexAnim工程
在这里插入图片描述
根签名里加个槽,然后把纹理放进去,然后修改Shader:

VS:
// 按照中心点旋转
vin.TexC -= float2(0.5, 0.5);

// Output vertex attributes for interpolation across triangle.
   float4 texC = mul(float4(vin.TexC, 0.0f, 1.0f), gTexTransform);
   vout.TexC = mul(texC, gMatTransform).xy;

// 按照中心点旋转
vout.TexC += float2(0.5, 0.5);

PS:
float4 diffuseAlbedo = (gDiffuseMapFront.Sample(gsamAnisotropicWrap, pin.TexC) 
	* gDiffuseMapBack.Sample(gsamAnisotropicWrap, pin.TexC)) * gDiffuseAlbedo;

6. 在之前的LitColumns Demo里加上材质:

猜你喜欢

转载自www.cnblogs.com/lonelyxmas/p/10817154.html