UE4 landscape 使用 Texture Array

Landscape 使用 TextureArray

UE4 中 Landscape 一般会用到多张贴图来丰富地形地貌,例如下面是一个地形的例子:

这个地形包含 7 个 Layer,每个 Layer 由三张贴图组成:

然后再加上权重图,在打安卓包时,就会出现如下错误:

UATHelper: Packaging (Android (ASTC)):   LogShaderCompilers: Display: shader uses 19 
    samplers exceeding the limit of 16

UATHelper: Packaging (Android (ASTC)):   LogShaderCompilers: Display: shader uses 21 
    samplers exceeding the limit of 16

UATHelper: Packaging (Android (ASTC)):   LogShaderCompilers: Display: shader uses 20 
    samplers exceeding the limit of 16

UATHelper: Packaging (Android (ASTC)):   LogShaderCompilers: Warning: Failed to compile 
    Material /Game/STF/Pack03-LandscapePro/Environment/Landscape/Landscape/
    M_landscapeGround_ajustabel.M_landscapeGround_ajustabel (MI:/Game/STF/
    Pack03-LandscapePro/Maps/TestMap.testmap:PersistentLevel.Landscape_1.
    LandscapeMaterialInstanceConstant_290) for platform GLSL_ES3_1_ANDROID, 
    Default Material will be used in game.

然后真机上测试,地形会使用默认的材质,这显然不是我们想要的效果,要解决这个问题一个是减少贴图数量,另外一个办法就是使用 TextureArray。

1 TextureArray

在 UE4 4.26 版本,TextureArray 功能是默认开启的:

static TAutoConsoleVariable<int32> CVarAllowTexture2DArrayAssetCreation(
    TEXT("r.AllowTexture2DArrayCreation"),
    1,
    TEXT("Enable UTexture2DArray assets"),
    ECVF_Default
);

1.1 创建 TextureArray

创建 TextureArray 的方法有两种,一种是直接创建,通过右键菜单,直接创建资源:

然后打开 TextureArray 资源,既可设置 TextureArray 中的贴图列表,注意:只有大小、格式一致的贴图才可以放到通一个 TextureArray 里,如果新增加的贴图不匹配,TextureArray 会自动删除最后一个新增的贴图。

bool UTexture2DArray::CheckArrayTexturesCompatibility()
{
    bool bError = false;

    if (TextureSourceCmp.GetSizeX() != SizeX || TextureSourceCmp.GetSizeY() != SizeY)
    {
        UE_LOG(LogTexture, Warning, TEXT("Texture2DArray creation failed. 
            Textures %s and %s have different sizes."), *TextureName, *TextureNameCmp);
        bError = true;
    }

    if (PixelFormatCmp != PixelFormat)
    {
        UE_LOG(LogTexture, Warning, TEXT("Texture2DArray creation failed. 
            Textures %s and %s have incompatible pixel formats."), *TextureName, 
            *TextureNameCmp);
        bError = true;
    }

    return (!bError);
}

当然我们也可以选中一堆贴图,然后将选中的贴图直接生成一个 TextureArray 资源。

往 TextureArray 中增加贴图

然后可以在编辑界面,修改 TextureArray 的一些属性:

  • 开启 Mipmaps
  • 修改压缩格式

1.2 使用 TextureArray

创建好 TextureArray 后,在材质中使用的方法如下,正常我们采样贴图做法如下:

使用 TextureArray 后,UVs 坐标不再是“二维”的了,而是“三维”,第三个分量需要指定采样 TextureArray 中第几张贴图的索引值(0 ~ num - 1):

1.3 sample 对比

使用 unlit 模式下查看,不使用 TextureArray 的 Sample 数目为 3:

同样模式下查看,使用 TextureArray 的 Sample 数目为 1,纹理数就降下来了:

Lit 模式下,会有额外的 sample 次数,因此在 lit 模式下对应的 sample 不一样。

1.4 Texture2DArray 源码

FTextureResource* UTexture2DArray::CreateResource()
{
    const FPixelFormatInfo& FormatInfo = GPixelFormats[GetPixelFormat()];
    if (GetNumMips() > 0 && FormatInfo.Supported)
    {
        return new FTexture2DArrayResource(this, 
            GetResourcePostInitState(PlatformData, GSupportsTexture2DArrayStreaming));
    }
}


void FStreamableTextureResource::InitRHI()
{
    CreateTexture();
}

class FTexture2DArrayResource : public FStreamableTextureResource
{
protected:

    void CreateTexture() final override
    {
       
        TRefCountPtr<FRHITexture2DArray> TextureArray = 
            RHICreateTexture2DArray(FirstMip.SizeX, FirstMip.SizeY, FirstMip.SizeZ, 
            PixelFormat, State.NumRequestedLODs, 1, CreationFlags, CreateInfo);

        TextureRHI = TextureArray;
    }
}

// 最终调用,然后转成平台相关接口
GDynamicRHI->RHICreateTexture2DArray(SizeX, SizeY, 
    SizeZ, Format, NumMips, NumSamples, Flags, InResourceState, CreateInfo);

1.5 修改地形材质

演示 Demo 的材质原先如下,将单独的贴图采样,修改成 TextureArray:

这里注意下贴图格式跟采样格式要匹配

贴图格式:

采样类型:

  1. 这里 Diffuse 采样需要使用 Color 类型
  2. Normal 采样使用 Normal 类型
  3. Roughness 这里给的是 Grayscale,因此采样类型需要改成 Grayscale

使用 TextureArray 修改之前的地形材质,打包然后在真机运行:

地形有部分区域出现了明显的方格,以及死黑区域。

1.6 解决方块问题

经过尝试,发现当靠近地形时,会有明显方块,但是远距离查看地形时,采样正确,猜测是顶点采样的 UV 出问题了

这是近处的效果:

这是远处的效果:

然后经过尝试发现,使用普通的 TextureCord 能正常显示贴图,然后使用了 LandscapeCorrd 然后配合 Divide,且当除数不为 1 时,就会出现方块:

于是查看 Landscape shader 源码,来尝试解决问题。

2 Landscape 材质

新建一个简单的地形材质,节点如下:

使用 RenderDoc 抓帧,可以看到在手机上,地形的 Shader 主要有两个:

MobileBasePassVertexShader.usf
MobileBasePassPixelShader.usf

2.1 Pixel Shader

通过 RenderDoc 截取到地形渲染的 PS shadner 代码如下,采样贴图的 UV 数据来源 TexCorrds

// Engine\Shaders\Private\MobileBasePassPixelShader.usf
void Main(
    FVertexFactoryInterpolantsVSToPS Interpolants, 
    FMobileBasePassInterpolantsVSToPS BasePassInterpolants,
    in float4 SvPosition
)
{
    FPixelMaterialInputs PixelMaterialInputs;
    FMaterialPixelParameters MaterialParameters = GetMaterialPixelParameters(
        Interpolants, SvPosition);
    // CalcMaterialParametersEx 定义
    // 材质编辑器 -> windows -> shader code -> hlsl 导出代码
    CalcMaterialParametersEx(MaterialParameters, PixelMaterialInputs, 
        In.SvPosition, ScreenPosition, In.bIsFrontFace, TranslatedWorldPosition, 
        TranslatedWorldPosition)
    {
        CalcPixelMaterialInputs(MaterialParameters, PixelMaterialInputs)
        {
            // Local7 Local8 其实就是将 TexCorrd X Y 分别取出来
            float Local7 = dot(MaterialParameters.TexCoords[0].xy,  float2 (0, 1));
            float Local8 = dot(MaterialParameters.TexCoords[0].xy,  float2 (1 ,0));

            float2  Local9 = (1 *  float2(Local8, Local7));
            float2  Local10 = (Local9 +  float2(0, 0)); 
            // 这里是材质编辑器中采样用到的 Param_1
            float2  Local11 = (Local10 * Material_ScalarExpressions[0].y); 
            float4 Local13 = ProcessMaterialColorTextureLookup(Texture2DSampleBias(
                Material_Texture2D_1, Material_Texture2D_1Sampler, Local11, 
                View_MaterialTextureMipBias));
        }
    }
}

TexCorrd 来源这个函数:

FMaterialPixelParameters GetMaterialPixelParameters(
    FVertexFactoryInterpolantsVSToPS Interpolants, 
    float4 SvPosition)
{
    FMaterialPixelParameters Result = MakeInitializedMaterialPixelParameters();


#if NUM_MATERIAL_TEXCOORDS     // XY layer
    Result.TexCoords[0] = Interpolants.LayerTexCoord.xy;
#endif

    return Result;
}


// 这是 VS 到 PS 参数的类型定义:
struct FMobileShadingBasePassVSToPS
{
    struct FVertexFactoryInterpolantsVSToPS
    {
        float2  LayerTexCoord   : TEXCOORD0; // xy == texcoord
    };  FactoryInterpolants;

    struct FSharedMobileBasePassInterpolants
    {
        float4 PixelPosition    : TEXCOORD8; // xyz = world position, w = clip z
    }  BasePassInterpolants;


    float4 Position : SV_POSITION;
};

其中,FMobileShadingBasePassVSToPS 就是 VS 中的输出结果对象类型,接下来就看下 FactoryInterpolants 这个变量的生成过程。

2.2 顶点 Shader 逻辑

PS 里的输入就是从 C++ 中传入的 Index Buff

Uniform 主要包含两个

Primitive Uniform

Landscape Uniform

// Engine\Shaders\Private\MobileBasePassVertexShader.usf
void Main(
    FVertexFactoryInput Input, 
    out FMobileShadingBasePassVSOutput Output
)
{
    // 这里之前讲过,如果不考虑 LOD 的情况,返回的坐标是每个顶点位置偏移
    FVertexFactoryIntermediates VFIntermediates = GetVertexFactoryIntermediates(Input);

    float4 WorldPositionExcludingWPO = VertexFactoryGetWorldPosition(Input, 
        VFIntermediates);
}
float3 GetLocalPosition(FVertexFactoryIntermediates Intermediates)
{
    // LocalPosition 可以看做是每个顶点在各自 Section 中的 x y
    // ZW 是对应 Section  (0, 0) (0, 1) (1, 0) (1, 1)
    // SubsectionOffsetParams : (0.5, 0.5, 0.5, 7),w 表示每个 Section 的大小
    return INVARIANT(Intermediates.LocalPosition + float3(Intermediates.InputPosition.zw
        * LandscapeParameters.SubsectionOffsetParams.ww,0));
}

float4 VertexFactoryGetWorldPosition(FVertexFactoryInput Input, 
    FVertexFactoryIntermediates Intermediates)
{
    return INVARIANT(TransformLocalToTranslatedWorld(GetLocalPosition(Intermediates)));
}

// Primive.LocalToWorld
// 100   0     0     0
// 0     100   0     0
// 0     0     100   0
// 100   200   0     1
float4 TransformLocalToTranslatedWorld(float3 LocalPosition)
{
    float3 RotatedPosition = Primitive.LocalToWorld[0].xyz * LocalPosition.xxx 
        + Primitive.LocalToWorld[1].xyz * LocalPosition.yyy 
        + Primitive.LocalToWorld[2].xyz * LocalPosition.zzz;
    
    return float4(RotatedPosition + (Primitive.LocalToWorld[3].xyz + 
        ResolvedView.PreViewTranslation.xyz),1);
}

然后就能计算出每个顶点的坐标了,我们主要关注的是 PS 中采样用到的 TexCoord_0,因此继续查看这个变量的计算过程。

VS 向 PS 传参的类型是 FMobileShadingBasePassVSOutput

#define FMobileShadingBasePassVSOutput FMobileShadingBasePassVSToPS
#define VertexFactoryGetInterpolants VertexFactoryGetInterpolantsVSToPS

// Engine\Shaders\Private\MobileBasePassVertexShader.usf
// VS Main 函数入口
void Main(
    FVertexFactoryInput Input
    , out FMobileShadingBasePassVSOutput Output
    )
{
    // 省去一堆代码
    // 之前的坐标计算
    float4 WorldPositionExcludingWPO = VertexFactoryGetWorldPosition(Input, 
        VFIntermediates);

    // FactoryInterpolants 的生成在这里
    Output.FactoryInterpolants = VertexFactoryGetInterpolants(Input, 
        VFIntermediates, VertexParameters);
}

// Engine\Shaders\Private\LandscapeVertexFactory.ush
FVertexFactoryInterpolantsVSToPS VertexFactoryGetInterpolantsVSToPS(
    FVertexFactoryInput Input, 
    FVertexFactoryIntermediates Intermediates, 
    FMaterialVertexParameters VertexParameters)
{
    FVertexFactoryInterpolantsVSToPS Interpolants;

    Interpolants = (FVertexFactoryInterpolantsVSToPS)0;
    // 随后计算 TexCorrd
    FLandscapeTexCoords LandscapeTexCoords = GetLandscapeTexCoords(InputPosition, 
        Intermediates)

#if (ES3_1_PROFILE)
    Interpolants.LayerTexCoord = LandscapeTexCoords.LayerTexCoord;
    Interpolants.WeightMapTexCoord  = LandscapeTexCoords.WeightMapTexCoord; 
#endif
}

FLandscapeTexCoords GetLandscapeTexCoords(
    FVertexFactoryInput Input, 
    FVertexFactoryIntermediates Intermediates)
{

    FLandscapeTexCoords Result;
    // 根据输入跟 Uniform 中的值,输出 Texcorrd
    // LocalPosition :  0,0 ~ 7,7
    // SubsectionSizeVertsLayerUVPan : 8, 0.14286, 0, 0
    // InputPosition.zw : 0,0 ~ 1,1
    // SubsectionOffsetParams : 0.5, 0.5, 0.5, 7
    Result.LayerTexCoord.xy = Intermediates.LocalPosition.xy + 
        LandscapeParameters.SubsectionSizeVertsLayerUVPan.zw + 
        Intermediates.InputPosition.zw * LandscapeParameters.SubsectionOffsetParams.ww;
    
    return Result;
}

可以得出计算得出的 LayerTexCoord 其实就是 Landscape 中每个顶点对应在 Component 中的位置。最终计算出来的 TextCorrd_0 结果如下图,可以看到计算得出的 UV 其实大部分都会大于 1,采样的时候贴图设置的是 Wrap,因此最终地形上的纹理会平铺。

下面是不同方式采样贴图,跟是否使用高精度的对照图,左边列的是使用 TextureCoord 采样贴图的(Corrd),右边列是使用 LandScapeCorrd 方式(LandScape),上面一排是未勾选高精度(normal),下面一排是勾选了高精度的(hp)。

而且离地形远点越远,偏差越大

RenderDoc 抓帧,FS 输出的 TextureCorrd0 数据完全一致。顶点 Shader 没问题,只能继续分析 Pixel Shader,通过 RenderDoc 抓取 PS 代码发现:

//  LandscapeCorrd
highp vec2 v31 = vec2(1.0, 0.0);
float h32 = dot(in_TEXCOORD0, v31);
vec2 v30;
v30.x = h32;
highp vec2 v33 = vec2(-0.0, 1.0);
float h34 = dot(in_TEXCOORD0, v33);
v30.y = h34;
vec2 v35 = v30 * _30.pu_m[2].xx;
highp float f36 = 0.5;
highp float f5 = f36;


highp vec2 v38 = v35;
highp vec4 v39 = _30.pu_m[0];
float h40 = dot(v7, v39);
highp vec2 v41 = v35;
highp vec4 v42 = _30.pu_m[1];
float h43 = dot(v7, v42);
vec3 v37 = clamp((texture(Material_Texture2D_0, v38).xyz * vec3(h40)) + 
    (texture(Material_Texture2D_1, v41).xyz * vec3(h43)), vec3(0.0), vec3(1.0));

// TextureCord
highp float f31 = _30.pu_m[2].x;
vec2 v32 = in_TEXCOORD0 * vec2(f31);
vec2 v30 = v32;
highp float f33 = 0.5;
highp float f5 = f33;
highp vec2 v35 = v30;
highp vec4 v36 = _30.pu_m[0];
float h37 = dot(v7, v36);
highp vec2 v38 = v30;
highp vec4 v39 = _30.pu_m[1];
float h40 = dot(v7, v39);
vec3 v34 = clamp((texture(Material_Texture2D_0, v35).xyz * vec3(h37)) + 
    (texture(Material_Texture2D_1, v38).xyz * vec3(h40)), vec3(0.0), vec3(1.0));

精简后得到的 Diff 如下:

//  LandscapeCorrd
highp vec2 v31 = vec2(1.0, 0.0);
float h32 = dot(in_TEXCOORD0, v31);
vec2 v30;
v30.x = h32;
highp vec2 v33 = vec2(-0.0, 1.0);
float h34 = dot(in_TEXCOORD0, v33);
v30.y = h34;
vec2 v35 = v30 * _30.pu_m[2].xx;

// TextureCord
highp float f31 = _30.pu_m[2].x;
vec2 v32 = in_TEXCOORD0 * vec2(f31);
vec2 v30 = v32;

两种方式只是最终获取 UV 的计算方式不同,尝试修改 Shader 代码,将出现偏差的代码改成:

//  LandscapeCorrd
highp vec2 v31 = vec2(1.0, 0.0);
float h32 = dot(in_TEXCOORD0, v31);
vec2 v30;
v30.x = h32;
highp vec2 v33 = vec2(-0.0, 1.0);
float h34 = dot(in_TEXCOORD0, v33);
v30.y = h34;
- vec2 v35 = v30 * _30.pu_m[2].xx;
+ vec2 v35 = in_TEXCOORD0 * _30.pu_m[2].xx;

应用修改后,采样完全正确,因此定位到问题是计算 UV 坐标阶段,然后分别使用 Debug 功能,获取最终反编译后的代码,对比后发现如下差异:

// 修改后                           // 修改前
*_277 = _276;                      *_277 = _276;
float2 _279 = *in_TEXCOORD0;       float2 _279 = *v30 : [[RelaxedPrecision]];

发现修改前后差异是变量 V30 后有个: RelaxedPrecision,不强制驱动使用fp16计算,具体解析在此链接

然后尝试回退代码,将其中用到的变量都改成高精度:

highp vec2 v31 = vec2(1.0, 0.0);
- float h32 = dot(in_TEXCOORD0, v31);
+ highp float h32 = dot(in_TEXCOORD0, v31);
- vec2 v30;
+ highp vec2 v30;
v30.x = h32;
highp vec2 v33 = vec2(-0.0, 1.0);
- float h34 = dot(in_TEXCOORD0, v33);
+ highp float h34 = dot(in_TEXCOORD0, v33);
v30.y = h34;
vec2 v35 = v30 * _30.pu_m[2].xx;

反编译后的代码 diff 如下:

应用修改后,效果也完全正确,因此,当使用 LandscapeCord 节点获取 UV 坐标时,UE4 编译生成的代码,会对变量做优化,增加 RelaxedPrecision,这就导致在不同的设备上,运行计算的精度是不确定的,因此在使用该节点时需要注意。

最后抓帧查看 Landscape 开启高精度后的 ps 代码,来验证一下之前的问题:

// 开启高精度后,v13 被定义成了 highp,对比未开启高精度的 v30
highp vec2 v13;
v13.x = dot(in_TEXCOORD0, vec2(1.0, 0.0));
v13.y = dot(in_TEXCOORD0, vec2(-0.0, 1.0));
highp vec2 v14 = v13 * _16.pu_h[7].xx;
highp vec3 v15 = clamp((texture(Material_Texture2D_0, v14).xyz * 
    vec3(dot(v5, _16.pu_h[5]))) + (texture(Material_Texture2D_1, v14).xyz * 
    vec3(dot(v5, _16.pu_h[6]))), vec3(0.0), vec3(1.0));