8.9、材质的实现

下面是材质结构体的部分代码:

// 简单的结构体来表示我们所演示的材料
struct Material
{
    // 材质唯一对应的名称(便于查找)
    std::string Name;

    // 本材质的常量缓冲区索引
    int MatCBIndex = -1;

    // 漫反射在SRV堆中的索引(在第九章的纹理贴图中会使用)
    int DiffuseSrvHeapIndex = -1;


    int NormalSrvHeapIndex = -1;

    //已更新标志(dirty flag,也称为脏标志),用于表示本材质已经发生变动
    //因为每一个帧资源(Frame Resource)都有材质常量缓冲区,所以我们需要对每个
    //帧资源进行更新,因此当我们修改某一个材质的时候,我们需要对每一个帧资源进
    //行更新,即 NumFrameDirty = gNumFrameResource
    int NumFramesDirty = gNumFrameResources;

    // 用于着色的材质常量缓冲区数据
    DirectX::XMFLOAT4 DiffuseAlbedo = { 1.0f, 1.0f, 1.0f, 1.0f };
    DirectX::XMFLOAT3 FresnelR0 = { 0.01f, 0.01f, 0.01f };
    float Roughness = .25f;
    DirectX::XMFLOAT4X4 MatTransform = MathHelper::Identity4x4();
};

为了模拟出真实世界中的材质,我们需要设置DiffuseAlbedo(漫反射反照率)FresnelR0(介质的一种属性)这对和真实度相关的数值,然后添加一些细节调整,以达到更佳的视觉效果。在材质结构体中,我们把粗糙度指定在归一化的浮点值范围之间[0, 1]之间。0代表理想的光滑镜面,1代表最粗糙的物体表面。

但现在还有一个问题:应该按照什么粒度来指定材质的数据?因为同一表面不同点处的材质数据可能会不一样,比如一辆汽车,车身、车窗和车灯的反射和吸收的光量都是不一样的。

解决这个问题的方法之一就是以每个顶点为基准来指定材质的具体数值,然后再光栅化阶段对这些顶点中的材质属性进行线性插值,以求取三角形中每一个点的材质数值。但是这种方法和上一章的演示程序一样,都无法实现精细的效果,每个顶点的颜色都很粗糙。事实上,更加普遍的解决方法是使用纹理贴图,不过这要再下一章进行介绍。

在本章中,我们允许对材质进行频繁的更改,因此我们为每一种材质定义了唯一的属性,并将他们列在一个表里:

void LitWavesApp::BuildMaterials()
{
    auto grass = std::make_unique<Material>();
    grass->Name = "grass";
    grass->MatCBIndex = 0;
    grass->DiffuseAlbedo = XMFLOAT4(0.2f, 0.6f, 0.2f, 1.0f);
    grass->FresnelR0 = XMFLOAT3(0.01f, 0.01f, 0.01f);
    grass->Roughness = 0.125f;

    auto water = std::make_unique<Material>();
    water->Name = "water";
    water->MatCBIndex = 1;
    water->DiffuseAlbedo = XMFLOAT4(0.0f, 0.2f, 0.6f, 1.0f);
    water->FresnelR0 = XMFLOAT3(0.1f, 0.1f, 0.1f);
    water->Roughness = 0.0f;

    mMaterials["grass"] = std::move(grass);
    mMaterials["water"] = std::move(water);
}

通过上卖弄的表,我们可以将材质数据存放在系统内存中,从而使GPU能够在着色器中访问到这些材质数据。同时我们还要将相关的数据复制到常量缓冲区中,并将存有每个材质常量的常量缓冲区添加到每一个帧资源中:

struct MaterialConstants
{
    DirectX::XMFLOAT4 DiffuseAlbedo = { 1.0f, 1.0f, 1.0f, 1.0f };
    DirectX::XMFLOAT3 FresnelR0 = { 0.01f, 0.01f, 0.01f };
    float Roughness = 0.25f;

    // 下一章的纹理贴图会使用
    DirectX::XMFLOAT4X4 MatTransform = MathHelper::Identity4x4();
};

在更新函数中,当材质数据发生变化之后(即存在所谓的脏标志),我们便会将其复制到常量缓冲区对应的子区域中,因此GPU中的材质常量缓冲区中的数据总是和系统内存的最新数据保持一致的:

void LitWavesApp::UpdateMaterialCBs(const GameTimer& gt)
{
    auto currMaterialCB = mCurrFrameResource->MaterialCB.get();
    for(auto& e : mMaterials)
    {
        //当材质数据发生变化时,就对每一个帧资源进行更新
        Material* mat = e.second.get();
        if(mat->NumFramesDirty > 0)
        {
            XMMATRIX matTransform = XMLoadFloat4x4(&mat->MatTransform);

            MaterialConstants matConstants;
            matConstants.DiffuseAlbedo = mat->DiffuseAlbedo;
            matConstants.FresnelR0 = mat->FresnelR0;
            matConstants.Roughness = mat->Roughness;

            currMaterialCB->CopyData(mat->MatCBIndex, matConstants);

            // 也需要对下一个FrameResource进行更新
            mat->NumFramesDirty--;
        }
    }
}

到现在为止,每一个渲染项都已经拥有了一个指向Material结构体的指针,多个渲染项可以指向引用相同的Material对象,每一个Material对象都存有一个索引,用于在材质常量缓冲区中指向它自己的常量数据。下面代码演示了如何用不用的材质来绘制渲染项:

void LitWavesApp::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(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);

        D3D12_GPU_VIRTUAL_ADDRESS objCBAddress = objectCB->GetGPUVirtualAddress() + ri->ObjCBIndex*objCBByteSize;
        D3D12_GPU_VIRTUAL_ADDRESS matCBAddress = matCB->GetGPUVirtualAddress() + ri->Mat->MatCBIndex*matCBByteSize;

        cmdList->SetGraphicsRootConstantBufferView(0, objCBAddress);
        cmdList->SetGraphicsRootConstantBufferView(1, matCBAddress);

        cmdList->DrawIndexedInstanced(ri->IndexCount, 1, ri->StartIndexLocation, ri->BaseVertexLocation, 0);
    }
}

重点:我们需要获取三角形网格表面上每一点处的法向量,用来确定光线照射到网格的角度(用于朗伯余弦定律),而为了获取每一个点的近似法向量,我们就需要在顶点这一层来指定法线。然后再三角形的光栅化过程中,便会利用这些顶点法线来进行插值计算。

8.10、平行光源

平行光源(parallel light)也称为方向光源(directional light),是一种距离目标物体极远的光源。因此我们可以将这种光源发出的光线视为彼此平行的光线。(计算方法略)

8.11、点光源

一个和点光源(point light)比较贴切的现实实例时灯泡,它能以球面向各个方向发出光线,特别的,对于任意一点P,由位置W处点光源发出的光线,总有一束会传播到P点。点光源和平行光源之间唯一的区别就是光向量的计算方法,点光源的光向量随着目标点的不同而改变,而对于平行光而言,光向量始终保持不变。(计算方法略)

8.12、聚光灯光源

一个和聚光灯光源(sportLight)相近的现实实例是手电筒,从本质上来讲,聚光灯是由位置W向方向d照射出范围内呈圆锥体的光(计算方法略)

8.13光照的具体实现

在本节中,我们将对上述三种光源的实现细节进行讨论

8.13.1、Light结构体

在d3dUtil文件中,我们定义了下列结构体来描述光源,此结构体可以表示方向光源、点光源和聚光灯光源。但是根据光源的具体类型,我们并不会使用到其中的所有数据。

struct Light
{
    // 光源的颜色
    DirectX::XMFLOAT3 Strength = { 0.5f, 0.5f, 0.5f };
    // 仅供点光源和聚光灯光源使用
    float FalloffStart = 1.0f;
    // 仅供方向光源和聚光灯使用
    DirectX::XMFLOAT3 Direction = { 0.0f, -1.0f, 0.0f };
    // 仅供点光源和聚光灯使用
    float FalloffEnd = 10.0f;
    // 仅供点光源和聚光灯使用
    DirectX::XMFLOAT3 Position = { 0.0f, 0.0f, 0.0f };
    // 仅供聚光灯使用
    float SpotPower = 64.0f;
};

同时在文件LightingUtil.hlsl中则定义了与之对应的结构体:

struct Light
{
    float3 Strength;
    float FalloffStart;
    float3 Direction;
    float FalloffEnd;
    float3 Position;
    float SpotPower;
};

结构体Light中的数据成员的排列顺序是不可以随便指定的,需要遵循HLSL的结构体封装规则。这条规则大意是将结构体中的元素打包成4D向量,而且不能将单个元素一分为二分到两个4D向量中。所以Light结构体经过打包之后是这样的:

    vertor4D 1: (Strength.x, Strength.y, strength.z, FalloffStart);
    vector4D 2: (Direction.x, Direction.y, Direction.z, FalloffEnd);
    vector4D 3: (Position.x, Position.y, Position.z, SpotPower);

如果将上述结构体的数据成员的排列顺序进行变更:

struct Light
{
    DirectX::XMFLOAT3 Strength = { 0.5f, 0.5f, 0.5f };
    DirectX::XMFLOAT3 Direction = { 0.0f, -1.0f, 0.0f };
    DirectX::XMFLOAT3 Position = { 0.0f, 0.0f, 0.0f };
    float FalloffStart = 1.0f;
    float FalloffEnd = 10.0f;
    float SpotPower = 64.0f;
};

//HLSL文件:
struct Light
{
    float3 Strength;
    float3 Direction;
    float3 Position;
    float FalloffStart;
    float FalloffEnd;
    float SpotPower;
};

则它将被打包为:

    vertor4D 1: (Strength.x, Strength.y, strength.z, empty);
    vector4D 2: (Direction.x, Direction.y, Direction.z, empty);
    vector4D 3: (Position.x, Position.y, Position.z, empty);
    vector4D 4: (FalloffStart, FalloffEnd, SpotPower, empth);

显然第二种方案占用了更多的空间,而且因为c++和HLSL对应的结构体封装规则并不相同,如果不按照HLSL的规则来实现c++和HLSL的结构体,可能会导致通过memcpy函数从CPU上传到GPU常量缓冲区的数据将会导致渲染错误

8.13.2、常用的辅助函数

下面3个函数定义在LightingUtil.hlsl文件中,由于这些函数可以处理多种类型的光照,所以我们将他们定义为辅助函数

CalcAttenuation实现了一种线性衰减因子的计算方法,可以将其应用于点光源和聚光灯光源
SchlickFresnel代替菲涅尔方程的石里克近似,此函数基于光向量L和表面法线n之间的夹角近似的计算出以n为法线的表面所反射光的百分比
BlinnPhong计算反射到观察者眼中的光量(该值为漫反射光量和镜面反射光量的总和)
/*
**  summary:线性衰减因子的计算方法
**  Parameters:
**      d:距离光源的距离
**      falloffStart:开始衰减的距离(未到达这个距离前保持最大强度)
**      falloffEnd:大于这个距离将不会受到光照(即衰减因子为0)
**  Return:衰减因子
*/
float CalcAttenuation(float d, float falloffStart, float falloffEnd)
{
    // 线性衰减
    return saturate((falloffEnd-d) / (falloffEnd - falloffStart));
}
//R0:介质的一种属性,会影响反射光量
float3 SchlickFresnel(float3 R0, float3 normal, float3 lightVec)
{
    float cosIncidentAngle = saturate(dot(normal, lightVec));

    float f0 = 1.0f - cosIncidentAngle;
    float3 reflectPercent = R0 + (1.0f - R0)*(f0*f0*f0*f0*f0);

    return reflectPercent;
}
float3 BlinnPhong(float3 lightStrength, float3 lightVec, float3 normal, float3 toEye, Material mat)
{
    const float m = mat.Shininess * 256.0f;
    float3 halfVec = normalize(toEye + lightVec);

    float roughnessFactor = (m + 8.0f)*pow(max(dot(halfVec, normal), 0.0f), m) / 8.0f;
    float3 fresnelFactor = SchlickFresnel(mat.FresnelR0, halfVec, lightVec);

    float3 specAlbedo = fresnelFactor*roughnessFactor;

    specAlbedo = specAlbedo / (specAlbedo + 1.0f);

    return (mat.DiffuseAlbedo.rgb + specAlbedo) * lightStrength;
}

上述代码中所用的HLSL内部函数:dot、pow和max分别表示向量点积函数、幂函数和取最大值函数。

8.13.3、实现方向光源

给定观察位置E、材质属性和以n为法线的表面上可见一点p,则下列HLSL函数将输出自某一方向光源发出,经上述表面以方向 v = normalize(E - p)反射入观察者眼中的光量。

float3 ComputeDirectionalLight(Light L, Material mat, float3 normal, float3 toEye)
{
    // 光向量和光的传播方向刚好相反
    float3 lightVec = -L.Direction;

    // 通过朗伯余弦定律按比例降低光强
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;

    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

8.13.4、实现点光源

给出观察点E、材质属性和以n为法线的表面上可见一点p,则下面的HLSL函数将输出来自点光源放出的光线,经上述表面以 v = normalize(E - p)方向反射到观察者眼中的光量。

float3 ComputePointLight(Light L, Material mat, float3 pos, float3 normal, float3 toEye)
{
    // 自物体表面指向光源的光向量
    float3 lightVec = L.Position - pos;

    // 光源距离表面的距离
    float d = length(lightVec);

    // 范围检测(如果超出衰减距离,则该表面无法接收到光照)
    if(d > L.FalloffEnd)
        return 0.0f;

    // 对光向量进行规格化处理
    lightVec /= d;

    // 通过朗伯余弦定律按比例降低光强
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;

    // 根据距离计算衰减因子,然后计算出衰减后的光
    float att = CalcAttenuation(d, L.FalloffStart, L.FalloffEnd);
    lightStrength *= att;

    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

8.13.5、实现聚光灯光源

指定观察点E、材质属性以及以n为法线的表面上的一点p,则下列HLSL函数将输出来自聚光灯光源,经过上述表面以方向v = normalize(E - p)反射到观察者眼中的光量。

float3 ComputeSpotLight(Light L, Material mat, float3 pos, float3 normal, float3 toEye)
{
    // 光向量等于自表面指向光源的向量
    float3 lightVec = L.Position - pos;

    // 计算物体表面距离光源的距离
    float d = length(lightVec);

    // 范围检测
    if(d > L.FalloffEnd)
        return 0.0f;

    // 规格化光向量
    lightVec /= d;

    // 根据朗伯余弦定律按比例降低光强
    float ndotl = max(dot(lightVec, normal), 0.0f);
    float3 lightStrength = L.Strength * ndotl;

    // 根据距离计算衰减因子
    float att = CalcAttenuation(d, L.FalloffStart, L.FalloffEnd);
    lightStrength *= att;

    // 根据聚光灯模型对光强进行缩放处理
    float spotFactor = pow(max(dot(-lightVec, L.Direction), 0.0f), L.SpotPower);
    lightStrength *= spotFactor;

    return BlinnPhong(lightStrength, lightVec, normal, toEye, mat);
}

8.13.6、多种光照的叠加

光照是可以叠加的,所以在拥有多个光照的场景中,我们需要遍历每一个光源,然后计算他们在我们要计算光照的点或者像素上的贡献值求和。

实例框架最多支持16个光源,即光源数量不可以超过16个。此外,代码所约定的是方向光必须存放在光照数组的开始部分,然后是点光源,最后存放聚光灯光源。下列代码用于计算某一点的光照方程:

#define MaxLights 16

// 绘制过程中所使用的杂项常量数据
cbuffer cbPass : register(b2)
{
    ……
    //[0, NUM_DIR_LIGHTS]表示的是平行光源
    //[NUM_DIR_LISGHTS, NUM_DIR_LIGHTS + NUM_POINT_LIGHTS]表示的是点光源
    //[NUM_DIR_LIGHTS + NUM_POINT_LIGHTS, NUM_DIR_LIGHTS + NUM_POINT_LIGHTS + NUM_SPOT_LIGHTS]
    //表示的是聚光灯光源
    Light gLights[MaxLights];
};

float4 ComputeLighting(Light gLights[MaxLights], Material mat,
                       float3 pos, float3 normal, float3 toEye,
                       float3 shadowFactor)
{
    float3 result = 0.0f;

    int i = 0;

#if (NUM_DIR_LIGHTS > 0)
    for(i = 0; i < NUM_DIR_LIGHTS; ++i)
    {
        result += shadowFactor[i] * ComputeDirectionalLight(gLights[i], mat, normal, toEye);
    }
#endif

#if (NUM_POINT_LIGHTS > 0)
    for(i = NUM_DIR_LIGHTS; i < NUM_DIR_LIGHTS+NUM_POINT_LIGHTS; ++i)
    {
        result += ComputePointLight(gLights[i], mat, pos, normal, toEye);
    }
#endif

#if (NUM_SPOT_LIGHTS > 0)
    for(i = NUM_DIR_LIGHTS + NUM_POINT_LIGHTS; i < NUM_DIR_LIGHTS + NUM_POINT_LIGHTS + NUM_SPOT_LIGHTS; ++i)
    {
        result += ComputeSpotLight(gLights[i], mat, pos, normal, toEye);
    }
#endif

    return float4(result, 0.0f);
}

8.13.7、HLSl主文件

01-15 17:19