filament是Google开源的一个跨平台实时pbr渲染引擎。注意,这是一个渲染引擎,不是一个完整的游戏引擎。
filament的材质系统文档:Filament Materials Guide,pbr算法文档:Physically Based Rendering in Filament。这些文档只是从使用层面简单介绍材质系统和使用的PBR算法等,并没有深入介绍材质的整体流程和一些关键技术细节。因此,本文打算深入介绍材质系统相关的整体流程以及材质渲染相关的关键技术细节。

一. 材质编写

这部分大概介绍下材质相关的语法。以下面的材质示例代码来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
material {    
name : "Textured material",
requires : [ uv0, color ],
shadingModel : lit,
blending : opaque,
parameters : [
{ type : sampler2d, name : baseColor },
{ type : float, name : metallic },
{ type : float, name : roughness }
],
constants : [
{
name : overrideAlpha,
type : bool
},
{
name : customAlpha,
type : float,
default : 0.5
}
]
}

vertex {
void materialVertex(inout MaterialVertexInputs material) {
material.color *= sin(getUserTime().x);
material.uv0 *= sin(getUserTime().x);
}
}

fragment {
void material(inout MaterialInputs material) {
prepareMaterial(material);
material.baseColor = texture(materialParams_baseColor, getUV0());
material.metallic = materialParams.metallic;
material.roughness = materialParams.roughness;

if (materialConstants_overrideAlpha) {
material.baseColor.a = materialConstants_customAlpha;
material.baseColor.rgb *= material.baseColor.a;
}
}
}

从上述示例代码来看,一个材质分开三块:material、vertex、fragment。其中,material是材质熟悉块,vertex是顶点代码块,fragment是片元代码块。如果是一个compute材质,那么只有一个compute代码块。

1.1 材质属性块

这部分包括所有的材质设置,比如渲染状态设置、材质的uniform参数以及各种其它设置。举例说明,blending是混合模式,是渲染状态设置;shadingModel是光照模型,类似一个材质变体设置;parameters则是uniform参数,比如float最终是存放在材质的uniform buffer内,而sampler2d是生成uniform sampler。

1.2 代码块

filament的材质是一种surface材质。surface材质是一种受限制的材质,意思是一种只开放表面属性修改的材质,这种材质书写方式入门比较简单,但是功能比较受限制。unity的默认管线也支持surface材质,具体可以参考Introduction to surface shaders in the Built-In Render Pipeline

1.2.1 顶点代码

vertex下的入口函数是materialVertex,只能在该函数内修改inout的MaterialVertexInputs参数material来定制顶点着色器。MaterialVertexInputs是顶点的输入定义结构体,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct MaterialVertexInputs {
#ifdef HAS_ATTRIBUTE_COLOR
vec4 color;
#endif
#ifdef HAS_ATTRIBUTE_UV0
vec2 uv0;
#endif
#ifdef HAS_ATTRIBUTE_UV1
vec2 uv1;
#endif
#ifdef VARIABLE_CUSTOM0
vec4 VARIABLE_CUSTOM0;
#endif
#ifdef VARIABLE_CUSTOM1
vec4 VARIABLE_CUSTOM1;
#endif
#ifdef VARIABLE_CUSTOM2
vec4 VARIABLE_CUSTOM2;
#endif
#ifdef VARIABLE_CUSTOM3
vec4 VARIABLE_CUSTOM3;
#endif
#ifdef HAS_ATTRIBUTE_TANGENTS
vec3 worldNormal;
#endif
vec4 worldPosition;
#ifdef VERTEX_DOMAIN_DEVICE
#ifdef MATERIAL_HAS_CLIP_SPACE_TRANSFORM
mat4 clipSpaceTransform;
#endif // MATERIAL_HAS_CLIP_SPACE_TRANSFORM
#endif // VERTEX_DOMAIN_DEVICE
};

比如,color、uv0、uv1都是顶点属性。

1.2.2 片元代码

fragment也是类似的逻辑,入口函数是material,只能通过修改inout的MaterialInputs参数material来定制片元着色器。MaterialInputs结构体是pbr或者更复杂的渲染模型的属性,定义如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
struct MaterialInputs {
vec4 baseColor;
#if !defined(SHADING_MODEL_UNLIT)
#if !defined(SHADING_MODEL_SPECULAR_GLOSSINESS)
float roughness;
#endif
#if !defined(SHADING_MODEL_CLOTH) && !defined(SHADING_MODEL_SPECULAR_GLOSSINESS)
float metallic;
float reflectance;
#endif
float ambientOcclusion;
#endif
vec4 emissive;

#if !defined(SHADING_MODEL_CLOTH) && !defined(SHADING_MODEL_SUBSURFACE) && !defined(SHADING_MODEL_UNLIT)
vec3 sheenColor;
float sheenRoughness;
#endif

float clearCoat;
float clearCoatRoughness;

float anisotropy;
vec3 anisotropyDirection;

#if defined(SHADING_MODEL_SUBSURFACE) || defined(MATERIAL_HAS_REFRACTION)
float thickness;
#endif
#if defined(SHADING_MODEL_SUBSURFACE)
float subsurfacePower;
vec3 subsurfaceColor;
#endif

#if defined(SHADING_MODEL_CLOTH)
vec3 sheenColor;
#if defined(MATERIAL_HAS_SUBSURFACE_COLOR)
vec3 subsurfaceColor;
#endif
#endif

#if defined(SHADING_MODEL_SPECULAR_GLOSSINESS)
vec3 specularColor;
float glossiness;
#endif

#if defined(MATERIAL_HAS_NORMAL)
vec3 normal;
#endif
#if defined(MATERIAL_HAS_BENT_NORMAL)
vec3 bentNormal;
#endif
#if defined(MATERIAL_HAS_CLEAR_COAT) && defined(MATERIAL_HAS_CLEAR_COAT_NORMAL)
vec3 clearCoatNormal;
#endif

#if defined(MATERIAL_HAS_POST_LIGHTING_COLOR)
vec4 postLightingColor;
float postLightingMixFactor;
#endif

#if !defined(SHADING_MODEL_CLOTH) && !defined(SHADING_MODEL_SUBSURFACE) && !defined(SHADING_MODEL_UNLIT)
#if defined(MATERIAL_HAS_REFRACTION)
#if defined(MATERIAL_HAS_ABSORPTION)
vec3 absorption;
#endif
#if defined(MATERIAL_HAS_TRANSMISSION)
float transmission;
#endif
#if defined(MATERIAL_HAS_IOR)
float ior;
#endif
#if defined(MATERIAL_HAS_MICRO_THICKNESS) && (REFRACTION_TYPE == REFRACTION_TYPE_THIN)
float microThickness;
#endif
#elif !defined(SHADING_MODEL_SPECULAR_GLOSSINESS)
#if defined(MATERIAL_HAS_IOR)
float ior;
#endif
#endif
#endif

#if defined(MATERIAL_HAS_SPECULAR_FACTOR)
float specularFactor;
#endif

#if defined(MATERIAL_HAS_SPECULAR_COLOR_FACTOR)
vec3 specularColorFactor;
#endif

};

因此,只需要简单的在入口函数内修改属性,就可以便捷的实现材质效果。

1.2.3 compute代码块

如果使用的是compute材质,那么代码块是compute代码块。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
material {
name: testCompute,
domain: compute,
groupSize: [32, 32, 1],
parameters: [
{type : sampler2d, name : color}
]
}

compute {
void compute() {
int3 id = int3(getGlobalInvocationID());
}
}

二. 材质编译

filament有一个专门的材质编译工具matc。该工具主要做了两件事情:一个是解析material材质属性块,根据材质属性块生成代码,需要序列化的材质属性写入材质属性数据块中;一个是编译材质代码,将编译后的所有的变体代码写入到代码数据块中。
整体流程图:

2.1 材质属性编译

材质属性,指定是material块内的定义。材质属性主要包括两类,生成代码的属性和其它属性。生成代码的属性包括parameters和constants属性,parameters和constants属性会生成代码定义,同时也会进行序列化;而其它类型的材质属性会在解析后进行序列化。

2.1.1 parameters属性

parameters块内定义的属性,type指定类型,主要是两类:数值类型和采样器类型。最终,所有的数值类型会存放到一个着材质实例的uniform buffer内。而每个采样器类型的参数,都会生成一个着材质实例的uniform sampler。

2.1.2 其它属性

这部分属性包括,材质渲染状态设置、光照模型相关等。对于编译器来说,这部分设置解析出来后再序列化就行。

2.2 材质代码编译

2.2.1 生成材质属性定义

示例代码的parameters生成的ubo定义可能如下:

  1. Uniform Buffer生成
1
2
3
4
layout(binding = 10, std140, set = 1) uniform MaterialParams {
float metallic;
float roughness;
} materialParams;
  1. Uniform Sampler生成
    示例代码的parameters生成的uniform sampler定义可能如下:
1
layout(binding = 10, set = 2) uniform lowp sampler2D materialParams_baseColor;
  1. Specialization Constants生成
    示例代码的constants生成的specialization constants定义可能如下
1
2
layout (constant_id = 8) const bool materialConstants_overrideAlpha = false;
layout (constant_id = 9) const float materialConstants_customAlpha = 0.500000;

至于什么是specialization constants,请参考文档:Utilizing Specialization Constants。简而言之,这是一种将编译器的预处理阶段的宏延迟到gpu编译阶段的手段。

三. 材质加载和材质实例创建

3.1 整体介绍

这部分主要涉及三个类,材质类Material、材质示例类MaterialInstance、材质解析类MaterialParser。Material类对应的是Unity的Shader类,MaterialInstance类对应的是Unity的Material类。整体流程如下:

3.2 材质创建

3.2.1 数据解析

  1. MaterialParser
    材质创建的第一步是解析二进制的材质数据,即材质编译阶段序列化各种数据块,包括材质属性数据块以及材质代码数据块。该类的parse函数会遍历解析出所有的数据Chunk,保存起来作为后续真正的数据解析使用。
  2. 兼容检测
    将所有的数据块全部加载到内存中后,再做一些基本的检测,比如材质版本号匹配、shaderModel(平台)匹配等,如果通过才真正去创建材质。

    3.2.2 创建材质对象

    材质数据加载后,并且通过兼容性检测后,会调用engine的createMaterial函数去创建材质对象。filament的所有gpu相关对象最终都是通过engine类来创建管理的。

    3.2.3 初始化材质属性

    在FMaterial类的构造函数内,会通过调用MaterialParser的具体解析函数,将所有材质数据属性解析出来,然后做好相应的运行时状态初始化。
  3. 描述集Layout
    材质内有两个DescriptorSetLayout,一个是材质本身的DescriptorSetLayout,一个是PerView的DescriptorSetLayout。这两个layout是用于提交材质级别和PerView级别的数据。材质本身的DescriptorSetLayout用于后续初始化材质实例的DescriptorSet;材质实例的DescriptorSet用于提交材质数据。
  4. SpecializationConstants
    specializationConstants是一种新的动态变体技术,具体在生成材质属性定义里面有介绍。在材质解析阶段,需要解析出材质的specializationConstants设置,然后在切换材质变体时候通过传递给对应的gpu program进行切换。
    filament的specializationConstants变体问题
    filament将specializationConstants数据保存在材质内,会导致设置时候引起该材质所有的材质实例变化。这并不喝了,因为变体从使用上是材质实例级别的,不同的材质实例需要保存不同的变体设置,所以应该通过材质实例来保存和设置变体数据。
  5. pushConstants
    关于什么是pushConstants ,参考文档:Push Constants。简单理解,即这是一块区分于UBO的小数据块,相比UBO有一定的性能优势,但是大小受限制。
    实际上,当前版本的filament只是用pushConstants处理了引擎morphing数据,并没有开放材质级别的写法。如下代码,
    1
    2
    3
    4
    5
    6
    7
    utils::FixedCapacityVector<filament::MaterialPushConstant> const PUSH_CONSTANTS = {
    {
    "morphingBufferOffset",
    filament::backend::ConstantType::INT,
    filament::backend::ShaderStage::VERTEX,
    },
    };
    材质文件固定序列化上述代码的pushConstants。
  6. 其它属性
    其它属性涉及范围比较广,比如渲染状态的设置,包括深度测试/写入、混合、模板测试等,也包括材质相关的设置,比如材质效果相关的设置等。这些属性都会序列化为数据块,然后在材质创建时候,通过MaterialParser解析出来保存在材质内。

    3.3 材质实例创建

    创建材质实例有两种路径,但是初始化流程是一致的,都是初始化描述集和其它材质属性。
  7. 初始化描述集
    材质实例的DescriptorSet通过材质的DescriptorSetLayout进行初始化。材质实例使用该DescriptorSet进行数据的提交和绑定,包括Sampler和Uniform Buffer。因此,需要在初始化阶段将材质Uniform Buffer的通过调用setBuffer设置给DescriptorSet。
  8. 初始化其它材质属性
    其它材质属性,基本是通过从材质或者其它材质实例内拷贝的方式设置的。这些存储在材质实例内的材质属性,通常与渲染状态或者渲染效果相关,比如混合、深度测试/写入等。

四. 材质变体

filament支持基于宏的传统变体,即每一个变体是一个gpu program。在编译材质时候,根据不同的宏定义组合编译出不同的gpu program;在运行阶段,根据stage和变体匹配到对应的gpu program。

4.1 变体定义

4.1.1 Surface材质变体

filament有一个Variant类,里面定义了Surface材质可以使用的变体。如下摘自其变体注释代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// DIR: Directional Lighting
// DYN: Dynamic Lighting
// SRE: Shadow Receiver
// SKN: Skinning
// DEP: Depth only
// FOG: Fog
// PCK: Picking (depth variant only)
// VSM: Variance shadow maps
// STE: Instanced stereo rendering
//
// X: either 1 or 0
// +-----+-----+-----+-----+-----+-----+-----+-----+
// Variant | STE | VSM | FOG | DEP | SKN | SRE | DYN | DIR | 256
// +-----+-----+-----+-----+-----+-----+-----+-----+
// PCK
//
// Standard variants:
// +-----+-----+-----+-----+-----+-----+-----+-----+
// | STE | VSM | FOG | 0 | SKN | SRE | DYN | DIR | 128 - 44 = 84
// +-----+-----+-----+-----+-----+-----+-----+-----+
// Vertex shader X 0 0 0 X X X X
// Fragment shader 0 X X 0 0 X X X
// Fragment SSR 0 1 0 0 0 1 0 0
// Reserved X 1 1 0 X 1 0 0 [ -4]
// Reserved X 0 X 0 X 1 0 0 [ -8]
// Reserved X 1 X 0 X 0 X X [-32]
//
// Depth variants:
// +-----+-----+-----+-----+-----+-----+-----+-----+
// | STE | VSM | PCK | 1 | SKN | 0 | 0 | 0 | 16 - 4 = 12
// +-----+-----+-----+-----+-----+-----+-----+-----+
// Vertex depth X X 0 1 X 0 0 0
// Fragment depth 0 X X 1 0 0 0 0
// Reserved X 1 1 1 X 0 0 0 [ -4]
//
// 96 variants used, 160 reserved (256 - 96)
//
// note: a valid variant can be neither a valid vertex nor a valid fragment variant
// (e.g.: FOG|SKN variants), the proper bits are filtered appropriately,
// see filterVariantVertex(), filterVariantFragment().
  1. 8位变体
  2. 顶点和片元的变体是分开的
  3. X表示该变体位可以切换,1或者0表示只能设置为固定值。
  4. Reserved是无效的变体组合。

    4.1.2 PostProcess材质变体

1
2
3
4
5
6
7
8
static constexpr size_t POST_PROCESS_VARIANT_BITS = 1;
static constexpr size_t POST_PROCESS_VARIANT_COUNT = (1u << POST_PROCESS_VARIANT_BITS);
static constexpr size_t POST_PROCESS_VARIANT_MASK = POST_PROCESS_VARIANT_COUNT - 1;

enum class PostProcessVariant : uint8_t {
OPAQUE,
TRANSLUCENT
};

根据上述定义,后处理材质支持2个变体。

4.1.3 Compute材质变体

compute材质不支持变体切换。

4.2 变体编译

  1. 计算所有有效变体组合
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Generate all shaders and write the shader chunks.

std::vector<Variant> variants;
switch (mMaterialDomain) {
case MaterialDomain::SURFACE:
variants = determineSurfaceVariants(mVariantFilter, isLit(), mShadowMultiplier);
break;
case MaterialDomain::POST_PROCESS:
variants = determinePostProcessVariants();
break;
case MaterialDomain::COMPUTE:
variants = determineComputeVariants();
break;
}

determineSurfaceVariants函数是跟Variant类的定义,遍历出所有有效的顶点变体和片元变体。determinePostProcessVariants则是返回2个固定的顶点和片元变体。determineComputeVariants返回默认的一个0变体。

  1. 根据变体组合生成宏定义
    材质编译工具的代码生成类ShaderGenerator里面有一个generateSurfaceMaterialVariantDefines函数,该函数会根据变体组合variant来生成对应的宏定义。其部分代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void ShaderGenerator::generateSurfaceMaterialVariantDefines(utils::io::sstream& out,
ShaderStage stage, MaterialBuilder::FeatureLevel featureLevel,
MaterialInfo const& material, filament::Variant variant) noexcept {

bool const litVariants = material.isLit || material.hasShadowMultiplier;

CodeGenerator::generateDefine(out, "VARIANT_HAS_DIRECTIONAL_LIGHTING",
litVariants && variant.hasDirectionalLighting());
CodeGenerator::generateDefine(out, "VARIANT_HAS_DYNAMIC_LIGHTING",
litVariants && variant.hasDynamicLighting());
CodeGenerator::generateDefine(out, "VARIANT_HAS_SHADOWING",
litVariants && filament::Variant::isShadowReceiverVariant(variant));
CodeGenerator::generateDefine(out, "VARIANT_HAS_VSM",
filament::Variant::isVSMVariant(variant));
CodeGenerator::generateDefine(out, "VARIANT_HAS_STEREO",
hasStereo(variant, featureLevel));

switch (stage) {
case ShaderStage::VERTEX:
CodeGenerator::generateDefine(out, "VARIANT_HAS_SKINNING_OR_MORPHING",
hasSkinningOrMorphing(variant, featureLevel));
break;
case ShaderStage::FRAGMENT:
CodeGenerator::generateDefine(out, "VARIANT_HAS_FOG",
filament::Variant::isFogVariant(variant));
CodeGenerator::generateDefine(out, "VARIANT_HAS_PICKING",
filament::Variant::isPickingVariant(variant));
CodeGenerator::generateDefine(out, "VARIANT_HAS_SSR",
filament::Variant::isSSRVariant(variant));
break;
case ShaderStage::COMPUTE:
break;
}
}

所有的宏定义组合就对应一个变体组合的program。

4.3 变体切换

变体编译阶段是根据材质类型、Stage(顶点/片元)、变体组合来生成对应的program的。因此,变体切换阶段,也是根据这些信息查找出对应的program代码。然后,使用这个代码(比如SPIR-V中间代码)来创建gpu program。
部分关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void FMaterial::prepareProgramSlow(Variant variant,
backend::CompilerPriorityQueue priorityQueue) const noexcept {
assert_invariant(mEngine.hasFeatureLevel(mFeatureLevel));
switch (getMaterialDomain()) {
case MaterialDomain::SURFACE:
getSurfaceProgramSlow(variant, priorityQueue);
break;
case MaterialDomain::POST_PROCESS:
getPostProcessProgramSlow(variant, priorityQueue);
break;
case MaterialDomain::COMPUTE:
// TODO: implement MaterialDomain::COMPUTE
break;
}
}

bool MaterialChunk::getBinaryShader(BlobDictionary const& dictionary,
ShaderContent& shaderContent, ShaderModel shaderModel, filament::Variant variant, ShaderStage shaderStage) {

if (mBase == nullptr) {
return false;
}

uint32_t key = makeKey(shaderModel, variant, shaderStage);
auto pos = mOffsets.find(key);
if (pos == mOffsets.end()) {
return false;
}

shaderContent = dictionary[pos->second];
return true;
}

getSurfaceProgramSlow最终会调用到MaterialChunk::getBinaryShader从二进制材质数据中查找出指定变体的二进制代码。makeKey函数的参数是shaderModel(平台)、variant、shaderStage,这个key就是变体查找的键值。

4.4 变体使用

filament的变体使用方式分为三步:

  1. 计算变体组合
    这一步通常是渲染管线或者渲染Pass在计算。比如,ColorPass或者DepthPass、PickingPass等。
  2. prepareProgram
    使用第一步计算出的变体调用材质函数的prepareProgram以准备变体。
1
2
3
4
5
6
void prepareProgram(Variant variant,
backend::CompilerPriorityQueue priorityQueue = CompilerPriorityQueue::HIGH) const noexcept {
if (UTILS_UNLIKELY(!isCached(variant))) {
prepareProgramSlow(variant, priorityQueue);
}
}
  1. getProgram
    使用第一步计算出的变体调用材质函数的getProgram获得对应变体的program的handle。
1
2
3
4
5
6
backend::Handle<backend::HwProgram> getProgram(Variant variant) const noexcept {
assert_invariant(mCachedPrograms[variant.key]);
return mCachedPrograms[variant.key];
}

pipeline.program = ma->getProgram(info.materialVariant);

然后将获得的program赋值给pipeline的program。

4.5 再谈SpecializationConstants变体

4.5.1 filament变体的问题

根据前述变体使用的步骤,在使用filament的变体时候并不方便。

1. 首先,变体设置是需要在外部计算的。
2. 其次,变体设置并没有保存在材质实例内,反而需要外部代码保存。
3.从使用角度上来说,应该在材质实例内保存其对应的variant,然后一个材质实例对应一个program才更方便理解。

4.5.2 filament的SpecializationConstants变体问题

filament的specializationConstants变体也存在类似的问题。specializationConstants变体是通过传入设置数据给gpu program对象来生效的,而且specializationConstants变体的设置还存在在材质内。

4.5.3 优化思路

因此,比较好的优化方向是将variant和specializationConstants的状态都保存在材质实例内;然后,通过材质实例来从材质中获得不同的program和设置不同的specializationConstants数据。

五. 渲染数据的提交和绑定

最后再来讲一讲渲染使用的数据提交和绑定。渲染数据一般指定是Buffer和Sampler,也包括其它一些特殊数据,比如SpecializationConstants和pushConstants。从作用范围上来区分,渲染数据一般能分为三个级别,渲染当前pass要使用的全局数据、当前使用材质的数据、当前drawcall对应的物体的数据。
DescriptorSet
首先,需要明确filament提交和绑定Buffer和Sampler数据的封装类DescriptorSet。filament通过该类提交和绑定Buffer和Sampler。因此,后续三个级别的数据提交都是通过对该类的封装进行。

5.1 渲染Pass数据提交和绑定

通常是封装一个Pass级别的数据提交类,比如ColorPassDescriptorSet、PostProcessDescriptorSet、SsrPassDescriptorSet。这些类里面有一个DescriptorSet对象用于真正的数据提交和绑定。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class PostProcessDescriptorSet {
public:
explicit PostProcessDescriptorSet() noexcept;

void init(FEngine& engine) noexcept;

void terminate(HwDescriptorSetLayoutFactory& factory, backend::DriverApi& driver);

void setFrameUniforms(backend::DriverApi& driver,
TypedUniformBuffer<PerViewUib>& uniforms) noexcept;

void bind(backend::DriverApi& driver) noexcept;

DescriptorSetLayout const& getLayout() const noexcept {
return mDescriptorSetLayout;
}

private:
DescriptorSetLayout mDescriptorSetLayout;
DescriptorSet mDescriptorSet;
};

void PostProcessManager::setFrameUniforms(backend::DriverApi& driver,
TypedUniformBuffer<PerViewUib>& uniforms) noexcept {
mPostProcessDescriptorSet.setFrameUniforms(driver, uniforms);
mSsrPassDescriptorSet.setFrameUniforms(uniforms);
}

void PostProcessManager::bindPostProcessDescriptorSet(backend::DriverApi& driver) const noexcept {
mPostProcessDescriptorSet.bind(driver);
}

后处理Pass会在适当的时候调用相关函数进行数据提交和绑定,一般是在Pass的最开始进行设置。
实际上,一些数据是全局的,可以一次设置后不用改变。所以,Pass级别的数据不一定完全遵守Pass级别的作用域。比如,PostProcessDescriptorSet的setFrameUniforms实际上是在renderJob一开始就调用了,如下代码:

1
2
3
4
5
6
7
8
void FRenderer::renderJob(RootArenaScope& rootArenaScope, FView& view) {
FEngine& engine = mEngine;
JobSystem& js = engine.getJobSystem();
FEngine::DriverApi& driver = engine.getDriverApi();
PostProcessManager& ppm = engine.getPostProcessManager();
ppm.setFrameUniforms(driver, view.getFrameUniforms());
...
}

5.2 材质数据提交和绑定

所有的渲染Pass都必须使用材质,渲染物体的Pass需要切换材质,后处理Pass则是使用一个材质。材质数据的使用也是需要有时机的。材质数据实际上会覆盖Pass级别的数据的设置,如果有重复的话;不过,按照filament的定义,这两部分数据是不会互相影响的,因为使用的是不同的DescriptorSetLayout。实际上,filament定义pipeline里面已经对PerView和PerMaterial的DescriptorSetLayout做了区分。PerView的layout就是前面所说的渲染Pass级别的数据。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
struct PipelineLayout {
using SetLayout = std::array<Handle<HwDescriptorSetLayout>, MAX_DESCRIPTOR_SET_COUNT>;
SetLayout setLayout; // 16
};

struct PipelineState {
Handle<HwProgram> program; // 4
Handle<HwVertexBufferInfo> vertexBufferInfo; // 4
PipelineLayout pipelineLayout; // 16
RasterState rasterState; // 4
StencilState stencilState; // 12
PolygonOffset polygonOffset; // 8
PrimitiveType primitiveType = PrimitiveType::TRIANGLES; // 1
uint8_t padding[3] = {}; // 3
};

static constexpr size_t MAX_DESCRIPTOR_SET_COUNT = 4; // This is guaranteed by Vulkan.

enum class DescriptorSetBindingPoints : uint8_t {
PER_VIEW = 0,
PER_RENDERABLE = 1,
PER_MATERIAL = 2,
};
在RenderPass执行时候渲染Command时候,给pipepline同时绑定PER_VIEW和PER_MATERIAL的layout。
// Each material has its own version of the per-view descriptor-set layout,
// because it depends on the material features (e.g. lit/unlit)
pipeline.pipelineLayout.setLayout[+DescriptorSetBindingPoints::PER_VIEW] =
ma->getPerViewDescriptorSetLayout(info.materialVariant).getHandle();

// Each material has a per-material descriptor-set layout which encodes the
// material's parameters (ubo and samplers)
pipeline.pipelineLayout.setLayout[+DescriptorSetBindingPoints::PER_MATERIAL] =
ma->getDescriptorSetLayout().getHandle();
5.2.1 材质commit数据
void FEngine::prepare() {
FEngine::DriverApi& driver = getDriverApi();

for (auto& materialInstanceList: mMaterialInstances) {
materialInstanceList.second.forEach([&driver](FMaterialInstance* item) {
item->commit(driver);
});
}
}

void PostProcessManager::commitAndRenderFullScreenQuad(backend::DriverApi& driver,
FrameGraphResources::RenderPassInfo const& out, FMaterialInstance const* mi,
PostProcessVariant variant) const noexcept {
mi->commit(driver);
mi->use(driver);
FMaterial const* const ma = mi->getMaterial();
PipelineState const pipeline = getPipelineState(ma, variant);

assert_invariant(
((out.params.readOnlyDepthStencil & RenderPassParams::READONLY_DEPTH)
&& !pipeline.rasterState.depthWrite)
|| !(out.params.readOnlyDepthStencil & RenderPassParams::READONLY_DEPTH));

driver.beginRenderPass(out.target, out.params);
driver.draw(pipeline, mFullScreenQuadRph, 0, 3, 1);
driver.endRenderPass();
}

void FMaterialInstance::commit(DriverApi& driver) const {
// update uniforms if needed
if (mUniforms.isDirty()) {
driver.updateBufferObject(mUbHandle, mUniforms.toBufferDescriptor(driver), 0);
}
// Commit descriptors if needed (e.g. when textures are updated,or the first time)
mDescriptorSet.commit(mMaterial->getDescriptorSetLayout(), driver);
}

prepare是在FRenderer::beginFrame里面调用的,即每帧开始时候会提交所有的材质实例数据,实际上这里主要是非处理材质,后处理材质是在commitAndRenderFullScreenQuad内提交的。commit内做了两件事情,一个是更新UBO数据,一个是通过DescriptorSet进行commit。

5.2.2 材质绑定数据

绑定材质数据是通过调用材质实例的use函数。材质绑定数据要在调用drawcall函数之前,比如renderpass里面就必须在执行每个command的drawcall前绑定,后处理这种Pass则需要在最终执行渲染drawcall之前绑定数据,可以参考commitAndRenderFullScreenQuad函数代码。下面代码段是RenderPass内绑定材质数据的代码:

1
2
3
4
5
6
7
8
9
if (UTILS_UNLIKELY(mi != info.mi)) {
// this is always taken the first time
assert_invariant(info.mi);

mi = info.mi;
...
// Each MaterialInstance has its own descriptor set. This binds it.
mi->use(driver);
}

上述代码在RenderPass::Executor::execute内执行command的一段,可以看到在材质实例变化时候,会调用材质实例的use函数绑定不同的材质级别数据。

5.3 Renderable数据提交和绑定

同一个渲染Pass的Command,可以包括多个材质实例;同一个材质实例,可以渲染多个物体。因此,物体级别的数据优先级别是最高的。

5.3.1 初始化Renderble数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct PrimitiveInfo { // 56 bytes
union {
FMaterialInstance const* mi;
uint64_t padding; // make this field 64 bits on all platforms
};
backend::RenderPrimitiveHandle rph; // 4 bytes
backend::VertexBufferInfoHandle vbih; // 4 bytes
backend::DescriptorSetHandle dsh; // 4 bytes
uint32_t indexOffset; // 4 bytes
uint32_t indexCount; // 4 bytes
uint32_t index = 0; // 4 bytes
uint32_t skinningOffset = 0; // 4 bytes
uint32_t morphingOffset = 0; // 4 bytes

backend::RasterState rasterState; // 4 bytes

uint16_t instanceCount; // 2 bytes [MSb: user]
Variant materialVariant; // 1 byte
backend::PrimitiveType type : 3; // 1 byte 3 bits
bool hasSkinning : 1; // 1 bit
bool hasMorphing : 1; // 1 bit
bool hasHybridInstancing : 1; // 1 bit

uint32_t rfu[2]; // 16 bytes
};
static_assert(sizeof(PrimitiveInfo) == 56);

struct alignas(8) Command { // 64 bytes
CommandKey key = 0; // 8 bytes
PrimitiveInfo info; // 56 bytes
};

如上代码,RenderPass内的Command定义内有一个图元信息结构PrimitiveInfo。该结构内有一个backend::DescriptorSetHandle成员dsh。dsh表示的就是Renderable级别的渲染数据。dsh是在RenderPass::generateCommandsImpl内通过场景数据FScene::RenderableSoa内的FScene::DESCRIPTOR_SET_HANDLE数据初始化的。

5.3.2 绑定Renderble数据

在执行每个command的drawcall之前,会调用driver.bindDescriptorSet绑定Renderable级别的数据。

1
2
3
driver.bindDescriptorSet(info.dsh,
+DescriptorSetBindingPoints::PER_RENDERABLE,
{{ offset, info.skinningOffset }, driver});

5.4 其它数据

5.4.1 SpecializationConstants

这部分之前提过,filament是通过切换变体时候,将材质内保存的constants设置数据传递给gpu program,也提到过这部分在实现上有一定的不合理。

5.4.2 PushConstant

1
2
3
4
5
if (UTILS_UNLIKELY(info.hasMorphing)) {
driver.setPushConstant(ShaderStage::VERTEX,
+PushConstantIds::MORPHING_BUFFER_OFFSET, int32_t(info.morphingOffset));
}
driver.draw2(info.indexOffset, info.indexCount, info.instanceCount);

pushConstant数据则是在调用drawcall之前通过driver.setPushConstant传入driver。目前,filament只支持固定的pushConstants数据。

一、玻璃效果

首先来讲如何模拟玻璃效果。玻璃的渲染包括三部分,普通场景物体的渲染、反射和折射模拟、毛玻璃模拟。作为场景物体,那么类似其它场景物体Shader一样,可以使用PBR、BlingPhong或者Matcap,甚至三阶色卡通渲染都可以。玻璃比较特殊的地方是模拟对环境的反射和折射,以及模拟玻璃污渍效果。
对于场景物体的基础着色部分不再赘述,下面来介绍环境反射和折射、玻璃污渍模拟部分。

1.1 环境反射和折射

对于不要求实时反映环境变化的效果,那么采样静态贴图进行模拟,是一种性能和效果都更优的方式。从效果上来说,美术可以自由定制贴图,那么可以方便控制效果;从性能上来说,不要求实时blit出当前的colorbuffer,性能远超实时反射和折射。

1.1.1 静态Cubemap模拟

最常见的方式是使用Cubemap来模拟环境的反射和折射。

反射

  1. 计算当前着色像素的反射方向。
  2. 使用反射方向去从Cubemap中采样出反射颜色
1
2
half3 reflectVector = reflect(-inputData.viewDirectionWS, inputData.normalWS);
half3 reflectColor = SAMPLE_TEXTURECUBE_LOD(_EnvironmentCubeMap, sampler_EnvironmentCubeMap, reflectVector, _EnvironmentCubemapLod).rgb * _EnvironmentReflectionColor * _EnvironmentReflectionIntensity;

如上述代码,使用内置函数reflect即可计算视线到当前像素的反射方向,然后用该方向去采样Cubemap即可。具体相关数学原理,比较简单,不再赘述。

折射

  1. 计算当前着色像素的折射方向。
  2. 使用折射方向去从Cubemap中采样出折射颜色。

如何计算折射方向?

  1. -viewDirectionWS。最简单的方式是假定折射方向没有发生偏转,那么简单使用相机到该像素点的方向即可,即-inputData.viewDirectionWS。由于,这本来就是一种近似效果,因此简单使用视线方向得到的结果也能差强人意。
  2. refract。即使用折射定律来计算折射方向,直接调用refract函数即可,需要提供参数来调整折射率。
  3. Refraction Model。生活中真正的玻璃,光线是先折射进入玻璃,然后再折射出来到空气中,我们需要的是最终的方向,而不是到玻璃内的折射方向。要模拟真实的折射方向,可以使用简化的模拟来模拟,比如假设折射是通过一定厚度的球体或者立方体。相关内容和代码,在HDRP内已经使用,参考文档:Refraction in the High Definition Render Pipeline的Refraction Model部分。代码在com.unity.render-pipelines.core内,因此urp也可以使用。如果需要使用该折射模型,搜索RefractionModelBox或者RefractionModelSphere即可。

最终结果

使用fresnel定律,将反射和折射颜色叠加起来作为最终的环境颜色。这部分的关键在于正确计算出反射和折射的贡献比例,并不一定需要严格计算fresnel定律,只需要接近该定律的现象即可。
fresnel定律的基本意思是:视线方向与法线的角度越大,反射越明显。对于基本只剩下反射的区域,也可以叫做掠角。
因此,最终结果可以使用下述代码叠加起来。

1
2
float fresnel = pow(saturate(1 - dot(inputData.viewDirectionWS, inputData.normalWS)), 5.0);
half3 color = reflectColor * fresnel + refractColor * (1 - fresnel);

最终效果:

1.1.2 Matcap模拟

使用Matcap来模拟的话,思路与Cubemap类似。问题转换成如何从2D的Matcap贴图中计算反射颜色和折射颜色。

反射

反射其实可以理解为高光,那么可以参考Matcap如何实现高光的模拟部分:Matcap模拟高光

折射

折射更像一个扭曲的过程,因为折射后方向发生了改变。那么,可以直接对uv进行扭曲,比如采样噪声图对uv进行叠加,再去采样一张折射matcap。

最终结果

与使用Cubemap类似,都需要使用计算fresnel定律计算折射和反射的混合比例。

1.1.3 实时反射和折射模拟

实时反射和折射,与前面两个算法的区别,是用反射和折射方向去采样当前的渲染结果,作为反射和折射的计算结果。

获得ColorBuffer

需要在管线内插入一个Pass,将ColorBuffer进行Blit到一个低分辨率的RT上,然后对该RT进行采样。对于URP渲染管线,我们只要设置请求OpaqueTexture后,既可以在Shader对_CameraOpaqueTexture进行采样。

反射

由于OpaqueTexture是屏幕空间纹理,那么需要在屏幕空间内计算反射方向,可以参考文章反射效果的实现总结的屏幕空间反射部分。
或者更直接参考GitHub上的开源项目:UnityURP-MobileScreenSpacePlanarReflection。在屏幕空间计算反射,算法部分比较复杂,不再赘述,请参考相关资料。

折射

由于反射要求方向精确,但是折射就没有这种要求,因此最简单的方式是计算出当前像素的屏幕空间位置后,然后对该位置进行扭曲,再采样OpaqueTexture即可获得折射结果。
当然如果要计算精确的折射方向,类似屏幕空间反射,都需要在屏幕空间内使用类似算法进行精确的方向计算, 然后再去采样屏幕空间RT。

最终结果

与使用Cubemap类似,都需要使用计算fresnel定律计算折射和反射的混合比例。

1.2 玻璃污渍模拟

该效果是对玻璃角落通常会出现污渍现象的模拟。通过观察,玻璃或者窗户一般是四个角落积累污渍。因此,可以计算与角落或者中心的距离,以这个距离归一化为默认的污渍强度。再结合一个污渍掩码贴图和污渍强度噪声贴图就可以让美术精细控制污渍了,当然不提供任何贴图也有默认的角落污渍。
具体功能,参考下图:

1.2.1 角落污渍

1
2
3
4
float uDis = min(abs(_FrostCenter.x - uv.x), abs(1 - _FrostCenter.x - uv.x));
float vDis = min(abs(_FrostCenter.y - uv.y), abs(1 - _FrostCenter.y - uv.y));
float dis = length(float2(uDis, vDis)) / 0.707;//斜边距离,然后归一化
dis = _FrostReverse * (1 - dis) + (1 - _FrostReverse) * dis;//反转距离

如上述代码,_FrostCenter定义的是污渍的中心,这个通常是(0.5,0.5),即UV的中心。然后,计算当前uv到中心的归一化距离,用该距离作为污渍强度。

1.2.2 污渍强度Noise

1
2
half noiseDistance = SAMPLE_TEXTURE2D(_FrostNoiseMap, sampler_FrostNoiseMap, TRANSFORM_TEX(uv, _FrostNoiseMap)).r * _FrostNoiseIntensity;
dis *= smoothstep(0, _FrostNoiseMax, noiseDistance);

从贴图内读取噪声强度,然后对强度进行smoothstep归一化,再乘以到原来的强度距离上。

1.2.3 污渍Mask

1
2
float4 frostMask = SAMPLE_TEXTURE2D(_FrostMaskMap, sampler_FrostMaskMap, TRANSFORM_TEX(uv, _FrostMaskMap));
dis *= lerp(1, lerp(frostMask.x, 1 - frostMask.x, _FrostMaskReverse), _FrostBlendFactor);//blend with mask

从贴图内读取mask,然后将mask乘到原有的强度上。至于_FrostMaskReverse则是强度反转控制,_FrostBlendFactor是mask比例控制。

1.2.4 将距离转化为污渍颜色

1
2
float weight = smoothstep(0, _FrostDistance / 0.707, dis);
color.rgb += weight * _FrostColor * _FrostIntensity;

_FrostDistance/0.707是归一化的最大距离。然后对dis进行smoothstep后就可以得到污渍强度。最后,将
污渍强度与污渍颜色、污渍整体强度相乘后叠加到最终颜色上即可。当然,也可以有其它的应用方式,比如用污渍weight来改变法线等。
最终效果如图:

二、窗户室内模拟

这是另外一种窗户模拟效果,跟玻璃效果差距较大,但是也可以作为通用Shader的一部分整合进来,因此放在一起讲述。详细的效果和算法可以参考文章:案例学习——Interior Mapping 室内映射(假室内效果)

该算法的整体思想,是计算当前视线与室内的交点,然后从室内环境Cubemap获取交点的颜色作为最终颜色。计算交点有两种方式,一种是在模型空间计算,一种是在切线空间计算。在模型空间计算,依赖模型空间坐标系的具体范围,更通用的方式是在切线空间计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
input.interiorViewDir.z *= 1 / (1 - _InteriorDepthScale) - 1;
half3 revseseViewDir = SafeNormalize(-input.interiorViewDir);

#if _INTERIOR_TANGENT
float2 interiorUV = frac(TRANSFORM_TEX(uv, _InteriorCubemap) + 0.0001);
// raytrace box from tangent view dir
float3 pos = float3(interiorUV * 2.0 - 1.0, 1.0);
#else
float3 pos = frac(input.positionOS * _InteriorCubemap_ST.xyx + _InteriorCubemap_ST.zwz + 0.0001);
// raytrace box from object view dir
// transform object space uvw( min max corner = (0,0,0) & (+1,+1,+1))
// to normalized box space(min max corner = (-1,-1,-1) & (+1,+1,+1))
pos = pos * 2.0 - 1.0;
#endif

float3 id = 1.0 / revseseViewDir;
float3 k = abs(id) - pos * id;
float kMin = min(min(k.x, k.y), k.z);
pos += kMin * revseseViewDir;

refractColor += SAMPLE_TEXTURECUBE(_InteriorCubemap, sampler_InteriorCubemap, pos.xyz).rgb * _InteriorIntensity;

_InteriorDepthScale表示室内的深度,对室内进行远近拉伸,默认是0.5,表示没有拉伸。详细的推导算法请参考上述文章。
具体效果:

外表凹凸不平的是玻璃本身的颜色贴图和法线贴图效果,内部是室内模拟效果。

参考资料

Refraction in the High Definition Render Pipeline
Matcap模拟高光
反射效果的实现总结
UnityURP-MobileScreenSpacePlanarReflection
案例学习——Interior Mapping 室内映射(假室内效果)

一、多层融合地形Shader介绍

所谓多层融合地形Shader,即多层地形效果过渡融合的Shader,比如黄色的土地上,融合淡绿色的草地,然后再点缀红色的花,这样就是三层效果融合。虽然,该效果常用在地形上,但是并不局限于地形,普通的场景模型等照样可以使用这样方式融合多层效果,比如静态的雪地,就可以使用该技术达到很精细的效果。
对于单独的一层效果,可以是PBR也可以是BlingPhong,甚至可以是Matcap。关键在于,如何将多层效果比较自然得融合起来。
类似下面的地形Shader效果:

可以看出该Shader,支持权重混合和高度混合,同时支持四层融合。

二、多层融合效果的基本实现思路

概况的说,一句话可以总结:对着色模型或者说光照模型的输入进行多层混合。比如,PBR的基本输入是基础色、法线、金属度、粗糙度、AO;BlingPhong的基本输入是基础色、法线、AO。其它的着色模型也是类似的思路,同样可以对Matcap和卡通渲染进行多层融合。

二、权重融合


权重融合的实现方式比较简单,用一个权重贴图控制四层的权重,同时提供数值缩放。这样,第一层的权重就是权重贴图的R通道乘以第一层的缩放(First Layer Weight),以此类推。然后,用得到的四个权重,应用到四层的基本输入上做加权平均,得到最终的输入。

三、高度融合

权重融合的缺点是边界过渡比较生硬,无法实现平滑的过渡。而高度融合是解决过渡生硬的一个好的方式。效果和思路可以参考文章:基于高度的纹理混合shader
基本思路:

  1. 求四层最大高度。
  2. 用最大权重减去过渡因子作为开始高度。
  3. 每一层的高度减去开始高度作为该层权重。
  4. 然后进行四层加权混合。
    具体代码可以参考下面函数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    half LayerHeightBlend(half4 height, half input1, half input2, half input3, half input4)
    {
    half heightStart = max(max(height.r, height.g), max(height.b, height.a)) - _HeightBlendFactor;
    half b1 = max(height.r - heightStart, 0);
    half b2 = max(height.g - heightStart, 0);
    half b3 = max(height.b - heightStart, 0);
    half b4 = max(height.a - heightStart, 0);
    return max((input1 * b1 + input2 * b2 + input3 * b3 + input4 * b4) / (b1 + b2 + b3 + b4), 0.0001);
    }
    效果对比:

第二个效果与第一个效果对比,有明显的过渡区域,而且过渡区域是有明显融合的,而单纯的权重融合只是效果的加权平均。

四、融合控制贴图编辑工具

为了方便美术所见即所得的编辑和预览最终的混合效果,那么实现一个编辑器工具,方便美术用笔刷实时来修改效果,同时在Unity编辑器内预览最终效果是非常有意义的,可以显著提高生产效率。
类似如下工具:

该工具的功能并不复杂,读取地形材质的设置,比如控制贴图信息和四层的贴图信息,提供笔刷功能,在Scene窗口去刷模型,壁画操作的结果再写回控制贴图。由于当前模型使用的正是笔刷引用的地形材质和控制贴图,因此就可以实时预览最终的渲染效果。
网上应该也有类似的插件或者开源代码。这里只是抛砖引玉,具体代码不会提供。

一、通用特效Shader介绍

1.1 什么是通用特效材质

Unity支持SRP Batcher后,使用UberShader的优势非常明显。所谓,UberShader,即一个超级Shader,覆盖一类功能,而不是多个分散的小Shader,比如一个通用特效Shader,整个项目内的所有特效都使用该Shader来制作所有的粒子特效或者模型特效,其它需求也类似,比如物体、地形等。

1.2 通用特效材质的意义

1.2.1 方便使用和沟通

整个项目只有一个特效Shader的情况下,美术很容易熟悉该Shader有哪些功能,能够尽可能复用该通用Shader的多个功能制作复杂特效;同时,避免美术与技术美术或者技术重复沟通类似的功能。

1.2.2 方便维护

对于技术美术或者开发同学来说,维护一个Shader的成本比维护多个的成本低很多。对于重复功能不需要再重复开发,优化性能时候也不用满世界扫描美术到底用的是什么Shader,只需要优化这一个Shader性能即可。

1.2.3 批次更低

由于支持SRP Batcher,Unity使用的是最终的变体级别合批。与多个分散小Shader相比,一个Shader质制作的特效更可能被SRP Batcher,因为使用的同一个变体的概率很高,而不同的Shader天然就是不同的变体,不可能被SRP Batcher。因此,只要复用率越高,被合并的批次越多。

1.2 通用特效材质主要模块

主要包括基础功能、Mask、Distortion(扭曲)、Dissolve(溶解)、Emission(自发光)、Reflection(反射)、其它等。每个大的模块都有变体开关,部分消耗大的小功能也需要有变体开关。这里只是列出本人项目中用到过的一些功能,基本上覆盖了常见的特效功能,但是不同项目会有不同的需求,因此除了常见的扭曲、溶解等还会有不少定制功能。这篇文章的目的,不在于介绍这些功能的实现细节,而是说明一个超级特效Shader在项目中使用的意义。

二、通用特效具体模块

2.1 Surface Options(基础设置)


Surface Type:不透明或者半透明
Render Face:Front(剔除背面)、Back(剔除前面)、Both(不剔除)
Custom ZWrite:是否覆盖深度写入
ZTest:深度测试
ColorMask:输出颜色通道
Alpha Clipping:Alpha裁剪
Enable Billboard:是否作为Billboard渲染(朝向相机)

2.2 Surface Inputs (基础输入)


Base Map:基础颜色贴图
Base Color:基础颜色
Enable BackFace Base Color:开启该选项,可以给背面单独制定基础颜色
Apply Vertex Color:开启该选项,顶点颜色应用到基础色上
BaseMap UvType:该设置有UV、ScreenUV、ReflectionUV三种类型。UV模式是使用模型UV0,ScreenUV是使用屏幕空间位置作为UV,ReflectionUV使用反射向量的两个分量比如xz作为UV。ScreenUV和ReflectionUV可以实现一些特殊效果。
BaseMap Rotation:UV模式下,对模型UV0旋转。
BaseMap U Speed:UV的U移动速度。
BaseMap V Speed:UV的V移动速度。
BaseMap Custom Speed (CustomData1.xy, UV0.zw):该功能是使用Unity粒子的CustomData1.xy来作为UV。如下所示的CustomData1.xy是曲线:

Normal Map:法线贴图,只在需要使用法线方向时候有意义,比如反射、Matcap等
Normal Scale:法线强度缩放
Normal U Speed:法线的U移动速度。
Normal V Speed:法线的V移动速度。

2.3 Mask Options


Mask贴图的作用是修改透明度,在半透明模式下可以只显示通过Mask的区域。

Mask Map (R):Mask贴图,默认使用R通道。
Mask Channel Mask (Default R):Mask贴图的通道掩码。
Mask Rotation:Mask贴图的UV旋转。
Mask U Speed:Mask贴图的U移动速度。
Mask V Speed:Mask贴图的V移动毒素。
Mask Custom Speed (CustomData1.zw, UV1.xy):使用Unity粒子的CustomData1.zw来作为Mask的UV。
Mask Intensity: Mask的强度缩放。
Mask Min:Mask的强度最小值。
Mask Max:Mask的强度最大值。最终的Mask强度会在该范围内SmoothStep。
下面是使用Mask的效果:

2.4 Distortion Options


Distortion的功能就是扭曲其它模块的采样UV,比如基础色、自发光、溶解等。
Distortion Map (RG) Mask(A):扭曲贴图,RG通道是扭曲强度,A通道是扭曲Mask。
Distortion Rotation:扭曲的UV旋转。
Distortion U Speed:扭曲的U移动速度。
Distortion V Speed:扭曲的V移动速度。
Distortion Intensity:扭曲的强度缩放。
下面是使用扭曲的效果:

2.5 Dissolve Options


Dissolve Map (R):溶解贴图,默认使用R通道。
Dissolve Channel Mask (Default R):溶解贴图的通道掩码。
Dissolve Rotation:溶解贴图的UV旋转。
Dissolve U Speed:溶解贴图的U移动速度。
Dissolve V Speed:溶解贴图的V移动速度。
Dissolve Intensity:溶解强度,注意不是溶解贴图的输入强度缩放。
Dissolve Width:溶解宽度。
Dissolve Edge Color:溶解边缘颜色。
Dissolve Edge Intensity:溶解边缘颜色强度缩放。
Dissolve Hard Edge:是否硬边溶解。
Dissolve Custom Intensity (CustomData2.x, UV1.z):使用粒子系统的CustomData2.x作为溶解强度。
Dissolve Custom Width (CustomData2.y, UV1.w):使用粒子系统的CustomData2.y作为溶解宽度。
溶解的功能比较复杂,最基本的思想是用一张贴图作为溶解强度输入,然后通过在溶解强度和宽度之间计算出溶解阈值,用这个阈值去修改透明度;溶解的边缘颜色则是将溶解阈值应用到单独是边缘颜色上再叠加到输出颜色上;至于硬边溶解是直接将溶解阈值取sign。
有兴趣还原的可以参考下面代码:

1
2
3
4
5
6
7
half disslove = dot(SAMPLE_TEXTURE2D(_DissolveMap, sampler_DissolveMap, input.uv3.xy + distortion), _DissolveChannelMask);
half dissloveIntensity = input.uv3.z;
half dissolveWidth = input.uv3.w;
half dissolveWithParticle = (dissloveIntensity * (1 + dissolveWidth) - dissolveWidth);
half dissolveAlpha = saturate(smoothstep(dissolveWithParticle, (dissolveWithParticle + dissolveWidth), disslove));
color.a *= _DissolveHardEdge ? sign(dissolveAlpha) : dissolveAlpha;
color.rgb += _DissolveEdgeColor.rgb * _DissolveEdgeColor.a * _DissolveEdgeIntensity * (_DissolveHardEdge ? sign(1 - dissolveAlpha) : (1 - dissolveAlpha));

下面是使用溶解的效果:

2.6 Emission Options


Emission Map:自发光贴图。
EmissionColor:自发光颜色。
Use WorldPos As UV:特殊需求,使用世界空间位置作为UV采样自发光贴图。
Emission U Speed:自发光的U移动速度。
Emission V Speed:自发光的V移动速度。
Emission Intensity:自发光的强度缩放。
自发光的原理比较简单,采样一张额外的自发光贴图叠加颜色,不再赘述。

2.7 RimLight Options


RimLight Color:边缘光颜色。
RimLight Width:边缘光宽度。
RimLight Smoothness:边缘光光滑度。
RimLight Intensity:边缘光强度缩放。
RimLight Min:边缘光最小强度。
RimLight Max:边缘光最大强度。
RimLight Reverse:是否反转强度。
边缘光是实际上是计算法线与视线的夹角来判断边缘,当夹角越大边缘光越强,最终将边缘光叠加回输出颜色上。具体代码如下:

1
2
3
4
5
half cosTheta = dot(normalWS, viewDirWS);
half rimLightStrength = pow(saturate(1 - 1 / _RimLightWidth * cosTheta), _RimLightSmoothness);
rimLightStrength = (_RimLightReverse ? 1 - rimLightStrength : rimLightStrength) * _RimLightIntensity;
rimLightStrength = smoothstep(_RimLightMinValue, _RimLightMaxValue, rimLightStrength);
color.rgb += _RimLightColor * rimLightStrength;

2.8 Light Options


Real Time Light Strength:实时光强度
Real Time Shadow Strength:实时光阴影强度
Real Time Shadow Color:实时光阴影颜色
Real Time Shadow Color Strength:实时光阴影颜色强度
强调一下:这里不是让特效Shader走完整的光照计算,而只是用光源的信息去修改最终的输出颜色。这样性能高效,看起来也在接受光源阴影。

2.9 其它功能

2.9.1 Depth Bias

深度偏移,特效有时候需要偏移深度来强制放在某些物体之前,这个功能比较有效果。

2.9.2 Reflection Options

使用反射方向采样Cubemap,将结果叠加到输出颜色上即可。

2.9.2 Matcap

参考上一篇文章的Matcap,对于特效的matcap可以简单实现即可。

一、概念和原理

1.1 什么是Matcap

什么是Matcap?Matcap实际上是Material Capture的缩写,即材质捕捉。实际上,这是一种离线渲染方案。类似光照烘焙,将光照或者其它更复杂环境下的渲染数据存储到一张2D贴图上, 再从这张2D贴图进行采样进行实时渲染。

Materials (MatCap)这篇文章对Matcap的定义是:
MatCap (Material Capture) shaders are complete materials, including lighting and reflections. They work by defining a color for every vertex normal direction relative to the camera.

1.2 如何理解Matcap

Matcap是一种在视线空间下使用单位法线采样单位球的离线渲染算法。

  • 为什么是视线空间?因为视线空间下,相机变化就可以看到不同的渲染结果。
  • 为什么使用法线去采样了?法线是描述表面朝向的向量,与渲染结果强相关,法线跟物体的曲率强相关等,因此这种算法经常用于 sculpting上。

1.3 Matcap的特点

Matcap的特点总结如下:

  • 使用视线空间下的法线向量采样2D贴图,作为光照和反射结果。
  • 在缺乏光照烘焙的环境下,可以一定程度上替代或者模拟光图。
  • 但是,Matcap代表的2D贴图不局限于光照信息,也可以理解为某种环境下的最终渲染结果。
  • 由于是离线方案,因此计算非常廉价,很适合低端机器或者特定场合下使用。

二、如何实现Matcap

2.1 如何获得Matcap贴图

按照定义,matcap贴图是一张2D贴图,内部包含一个单位球,表示光照信息。如何获得这样的贴图了?

  • 从网上的材质库下载
    比如,matcaps
  • 引擎预览材质球然后截图。
    材质预览matcap
    如上图,可以把右边的预览结果紧贴着球体进行截图。
    当然,如果严格按照定义,Matcap表示的是光照信息,不是所有材质预览的结果都可以当作Matcap贴图。

2.2 如何采样Matcap贴图

1
2
3
4
5
6
7
8
9
10
11
12
// -------------------------------
// Vertex
// -------------------------------
VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);
output.normalWS = normalInput.normalWS;

// -------------------------------
// Fragment
// -------------------------------
float3 viewNormal = mul((float3x3)GetWorldToViewMatrix(), normalWS);
float2 matCapUV = viewNormal.xy * 0.5 + 0.5;
half3 matcapColor = SAMPLE_TEXTURE2D(_Matcap, sampler_Matcap, matCapUV).rgb;

从上述glsl代码可以看出,需要把法线转换到视线空间,然后再将法线偏移到[0,1]的范围内,然后取xy分量作为uv,对matcap纹理进行采样。

三、Matcap的问题

3.1 边缘瑕疵

有时候使用Matcap渲染,模型上会出现一条线或者缝隙。可能的原因是采样到了贴图的边缘部分,而有些matcap贴图制作上不太好,边缘区域过大。
matcap边缘瑕疵
如上图所示:左边的matcap贴图就是一个非常不规范的matcap贴图,球没有紧贴边缘,而是出现大量空白部分,导致兔子的边缘出现大量的灰色边缘。
解决方式有两种,一种是强制采样内部的像素;另一种方式是修改采样算法,使得更合理避免出现边缘区域。

3.2 单点采样

对于平面来说,其法线朝着同一个方向的,因此会出现整个平面获得的matcap颜色都是同一个像素点,与正常的光照结果相差很大。我们希望的是,即使是一个平面,不同的像素点也是有不同的光照结果。

3.3 解决办法

3.1.1 缩放uv

第一种方式是对matcapUV进行缩放,比如缩小uv可以使得避免采样边缘区域。

1
float2 matCapUV = viewNormal.xy * 0.5 * _MatcapUVScale + 0.5;

这种方式可以简单的解决边缘瑕疵问题,但是无法解决单点采样。

3.1.2 使用视线空间下单位球的法线

matcap优化
如上图所示,在Matcap的定义中,我们处于视线空间内,视线方向始终是(0,0,1)。我们最终要使用的是单位球的N方向。假设反射方向是R,可以计算得到N是(Rx,Ry,Rz+1)。那么问题转化为求反射向量R。我们可以用视线空间的顶点和法线求得视线空间下的R,然后用视线空间的R去代替单位球上的反射向量R即可,即使两个方向向量不能等价,也可以得到相应正确的结果。
这种算法可以显著优化平面的单点采样问题。

1
2
3
4
5
6
7
8
#if _MATCAP_FIX_EDGE_FLAW
float3 r = reflect(input.positionVS, viewNormal);
r = normalize(r);
float m = 2.82842712474619 * sqrt(r.z + 1.0);
float2 matCapUV = r.xy / m * _MatcapUVScale + 0.5;
#else
float2 matCapUV = viewNormal.xy * 0.5 * _MatcapUVScale + 0.5;
#endif

matcap优化对比
从上图可以看出,对于平面来说,两种算法的效果差异非常明显。

四、Matcap与其它效果的结合

下面的测试均以如下Matcap贴图为例。
matcap输入

4.1 基础颜色

如果把Matcap当作光照的结果,那么可以额外提供基础颜色来控制最终结果。比如,提供基础颜色贴图和基础颜色,乘以到matcap上作为最终输出。
matcap基础色

4.2 法线贴图

既然matcap需要用到法线,那么可以额外提供法线贴图去修改像素的法线。
matcap法线
从上图可以看出,法线对最终的渲染结果影响显著。

4.3 自发光

类似正常的光照计算,可以在matcap的结果之上,再叠加自发光。

4.4 模拟高光

matcap本身已经是光照计算的结果,因此理论上贴图内带有了漫反射、高光、反射的信息。但是,通常情况下,matcap主要包括的还是漫反射信息,或者说表现不出明显的高光信息。
有一种简单模拟高光的方式,提供一个高光阈值,使用matcap减去该颜色阈值,然后除以1-阈值。最终结果再用原matcap颜色相乘避免过曝。
matcap高光

4.5 Cubemap反射

同时,可以额外利用cubemap计算静态反射结果叠加到最终着色上。
matcap反射

4.6 模拟边缘光

利用dot(normalWS, viewDirWS)计算出边缘光的强度,再将边缘光颜色与强度相乘叠加到最终着色结果上即可。

4.7 模拟折射

折射一种扭曲的效果,因此我们可以通过扭曲matcap的采样位置和反射的采样位置来模拟折射。同时,可以乘以边缘光的强度来模拟菲尼尔效应,也就是边缘光强的地方折射更强。然后,利用这个扭曲强度去偏移matcap的uv和反射向量,即可在一定程度上模拟折射的效果。
matcap折射
如上图所示,边缘的红色是边缘光;同时,噪声贴图作为折射扭曲强度贴图让边缘光看起来比较细碎,用来模拟折射效果。

4.8 光照强度

同时,也可以计算出真实的光照强度,将光照强度乘以matcap颜色,让matcap的着色结果受到灯光影响。不过,这跟matcap的初衷不太一致。

五、参考资料

Materials (MatCap))
https://github.com/nidorx/matcaps
MatCap Shader 改进:解决平面渲染和环境反射问题

前段时间做了下角色妆容的实现,想写个文章记录一下这个事情。妆容看起来很复杂,实际上整理实现思路很简单,主要是两个方面的内容,改变基础色和改变高光(金粉效果)。
先贴一个妆容效果:

没有开启妆容的情况下,基础色就是从颜色贴图和基础颜色中获得;开启妆容后,要根据各个状态模块的模板和比例来插值妆容颜色和基本颜色;金粉则是改变特定区域的高光,金粉的浓淡可以用滑块控制,最好同时结合妆容色的浓淡;额外可能需要改变特定妆容区域的光滑度,比如唇彩。

妆容界面


妆容开关

最上面有一个Toggle来开关妆容模块,可以使用shader_feature_local的关键字,比如_MAKEUP来区分。

妆容金粉

最上面的界面是指定金粉贴图贴图和相应的缩放,使用同样的UV缩放和位移的好处是避免重复读取金粉贴图,提高性能,避免每个妆容模块都要去读一次贴图。毕竟贴图是存储在内存中,要读取到GPU内的话,如果没有Cache中,则速度相比一个计算来说要慢一个数量级的可能。

妆容模块

接下来是具体的妆容模块,虽然模块比较多,实际上大同小异。功能都是通过通道贴图去改变指定位置的基础颜色,有些通道贴图还有图案的作用。有一些模块有额外的功能,比如唇彩的光滑度滑块、面纹的UV变化。

妆容的实现原理

下面介绍妆容的具体实现原理。

妆容颜色

这里的妆容颜色实际上对应的就是界面上具体的妆容模块。以第一个腮红为例子来说明,参考如下代码:

1
2
3
half3 makeup = baseColor;
half4 blush = SAMPLE_TEXTURE2D(_BlushMap, sampler_BlushMap, uv0);
makeup = lerp(makeup, blush.rgb * _BlushColor, blush.a * _BlusIntensity);

从代码可以看到妆容色makeup是基础颜色和妆容颜色的插值结果。妆容颜色是从通道贴图读取的rgb和腮红颜色的结合,同样插值比例是通道贴图的a和腮红比例的结合。不过大部分妆容模块的妆容颜色不需要通道贴图的rgb,这种通道贴图实际上可以做合并处理。

妆容模块的结合

妆容模块有一定的叠加顺序,最底部的是基础色,然后是按照顺序叠加的妆容模块,比如界面上的妆容模块顺序。那么,计算的时候,首先也是一个个按照顺序插值过来,比如先插值基础颜色和腮红,然后用插值结果继续和下一个妆容模块做插值,这样得到的最终妆容颜色就是多个妆容模块的结合。

妆容金粉

金粉实际上改变的是高光。没有金粉的话,高光就是默认的情况,比如pbr的金属流高光或者Bling-Phong的高光。有金粉的话,根据金粉计算出一个高光,同时与默认高光进行插值,插值的因子同具体妆容模块的颜色计算。
可以参考以下代码实现:

1
2
3
half3 golddustUV0 = SAMPLE_TEXTURE2D(_GolddustMap, sampler_GolddustMap, _GolddustUVTile * uv0 + _GolddustUVSpeed * _Time.x).rgb;
half4 blush = SAMPLE_TEXTURE2D(_BlushMap, sampler_BlushMap, uv0);
half3 specular = lerp((half3)0, _BlusGolddustColor * golddustUV0 * _BlusGolddustIntensity, blush.a * _BlusIntensity);

对于Pbr的金属流,默认的高光是0,所以金粉是为了增加额外的高光。关键的一句是在默认的高光和金粉高光之间做插值,插值比例是金粉浓度和妆容的比例。金粉高光是从金粉贴图读取出来同时应用金粉颜色和强度。

其它功能

比如唇彩模块可以改变光滑度,这个改变的前提是唇彩的通道贴图a通道是大于0的;另外还有面纹的一些UV变化,实际上这个是简单模仿贴花的功能。

性能优化

下面介绍一些妆容性能相关的优化策略。

妆容通道贴图采样器的合并

上述界面的妆容模块过多,如果每个妆容通道贴图一个采样器,肯定会超过限制。方式是所有妆容模块共用一个或者几个采样器。不过,理解上来说,一个贴图采样器对应一个贴图设置,所以去改变贴图的设置会不会有一些影响这个待验证。

妆容通道贴图合并

其实根本没必要一个妆容模块一个贴图,完全可以做贴图合并,比如不需要使用rgb的妆容模块,那么一个贴图可以对应四个妆容模块了。实际上,跟美术沟通后发现,妆容的效果主要是依赖妆容的通道掩码和妆容颜色,所以基本上不需要使用妆容贴图的rgb。

妆容颜色渲染到基础贴图

这个理论上来说算是终极优化吧。妆容会暴露很多参数给美术或者用户,用户调整这些参数后会得到一个化妆后的效果。关键的地方是,调整完成之后,可以理解为妹子化妆完成后,效果已经固定了。那么实际上,我们不需要每次再去计算妆容颜色,而是可以将妆容颜色渲染到一张单独的贴图上或者直接覆盖原本的BaseMap。以后的渲染,就不需要使用妆容模块了。

具体实现思路

可以新建一个Pass,将妆容颜色的计算结果单独走一遍Pass,同时结合原本的BaseMap作为基础颜色,渲染目标是一个RT,比如是BaseMap。这个Pass的开关可以提供接口供业务代码控制,在化妆完成后调用来覆盖原本的BaseMap。

妆容效果

最后上一点效果图吧,从美术大佬那边要来的图,凑合看看吧。

一、定位游戏性能瓶颈

1.1 游戏循环

基本循环:游戏逻辑-渲染提交-等待渲染完成(注意:游戏逻辑指的是除去渲染之外所有的CPU运算)。
基本的游戏循环可以理解为先执行游戏逻辑,比如获得输入,然后更新玩家位置,播放动画,物理碰撞等,然后渲染引擎会将要渲染的游戏画面信息提交到GPU,CPU则等待GPU完成该一帧的渲染结果。

1.1.1 单线程渲染


上图可以看到主线程直接提交渲染命令GCMD给渲染设备。主线程需要等待图形设备渲染完成。

1.1.2 多线程渲染


主线程:游戏逻辑-提交渲染到渲染线程-等待渲染完成。
渲染线程:提交渲染命令给GPU-等待渲染完成。
GPU:等待渲染命令-执行渲染。

从上图可以看到,主线程将渲染命令封装后提交给渲染线程,渲染再提交给图形设备渲染画面。因此,渲染线程会比主线程延迟一帧。虽然新增了渲染线程来减少CPU等待渲染完成的时间,但是每一帧主线程还是会等待渲染线程完成上一帧的渲染。因此,还是可以按照基本的游戏循环来分析每一帧的运行过程。

1.2 利用工具确定执行时间

我们需要使用工具,比如UWA分析报告或者Unity的Profile等来确定主线程的游戏逻辑、渲染提交、渲染等待,这三个部分的整体执行时间。

  1. 或者利用工具获得GPU每帧耗时是多少,是否超过帧率的要求,比如30帧的话,那么一帧是33.3ms,GPU的每帧耗时就不能就不能超过33.3ms,或者不能比CPU的耗时多,否则就是GPU的性能瓶颈。
  2. 如果GPU不存在瓶颈,那么问题就在CPU上,这个时候要进一步分析CPU的瓶颈是游戏逻辑还是渲染提交上,这个UWA的性能分析报告和Unity的Profile都可以查看。
  3. 如果渲染等待时间过长,说明渲染指令过多,这个时候就需要减少渲染指令的提交,一般就是采样各种合批策略,比如SRP合批、静态合批等,或者是合并网格、减少摄像机距离或者层级Culling等。
  4. 如果渲染等待时间比较小,而游戏逻辑占用时间过长,那么要去分析CPU的耗时,具体是哪一块耗时过多,比如物理、动作、逻辑等。

二、GPU性能优化

当GPU遇到性能瓶颈时候就要着重进行GPU的性能优化,下面介绍一些常用的优化策略。

2.1 降低分辨率

很多手机的显示器分辨率过高,GPU性能却跟不上。因此,降低分辨率或者对分辨率做限制也是常见的优化手段。比如,最高档设置可以限制1080屏幕高度,依次递减,最低档的设备720就可以。

2.2 减少OverDraw

OverDraw的意思是一个像素被重复绘制的次数,也就是该像素位置重复执行像素着色器(片元着色器)的次数。我们可以利用工具,比如固定管线的Unity编辑器是可以显示场景的OverDraw的,Urp的需要做一些扩展支持,来可视化游戏的OverDraw。对OverDraw特别高的部分要想办法优化,下面介绍一些Urp管线下的优化策略。

2.2.1 自定义渲染Pass

Urp的渲染Pass是渲染顺序中优先级最高的,因此同一个Pass对应的物体都会在一起按照一定的顺序渲染。那么,为了减少OverDraw或者提高合批的命中,要合理的设计相关的渲染Pass。比如,天空盒是最远的物体,会一直被遮挡,这种就没必要在场景或者角色之前渲染,否则会有没必要的OverDraw。那么,就可以对天空盒单独一个Pass,在场景和角色之后执行。同理,如果角色一直靠近摄像机,也可以强制角色在场景之前的Pass渲染。

2.2.2 渲染队列

渲染队列是在Shader中设置的,属于同一个Pass内的渲染顺序微调。同样的,还可以设置Render的sortingOrder等。类似的,在默认管线下,通常会调整天空盒的渲染队列为不透明物体之后来避免OverDraw。

2.2.3 合理的渲染顺序

Urp渲染管线对不透明物体会按照从前到后渲染(不支持GPU隐藏面消除),透明物体按照从后到前渲染。Shader的RenderType定义是半透明还是不透明物体。另外前述的渲染Pass和渲染队列等也会影响渲染顺序。合理的渲染顺序能够提高Early-Z的命中率,减少OverDraw。

2.2.4 减少大范围或者全屏特效

大范围的特效是OverDraw的杀手,尤其特效反复重叠的情况,项目中最好从设计层面规避这种情况的出现,实在没办法的再想办法优化特效本身的执行效率,比如特效的粒子数或者面片数、Shader复杂度等。

2.2.5 减少UI的重叠层数

由于UI是按照半透明物体渲染的,因此要尽可能减少UI的重叠。下面的UI要隐藏而不是被覆盖。不过,这一点通常要在UI框架中做好解决,因为不仅仅是有OverDraw,UI的网格计算等CPU消耗也很大。

2.3 提高Early-Z Test的命中率

理论上来说,从前到后渲染就是尽可能的提高Early-Z的命中率,从而降低OverDraw。所以,这一项优化通常是和优化OverDraw是一起进行的。

2.3.1 减少Alpha测试

但是Alpha-Test,也就是在Shader中丢弃像素,即在片元着色器中执行Discard指令,可能会破坏Early-Z Test,因为硬件不执行片元着色器就无法预测最终的深度。因此,要尽量避免大范围的Early-Z Test,除非特殊的渲染要求,比如溶解、植物、头发等,不要使用Alpha-Test。

2.4 减少半透明物体

半透明物体一个是渲染顺序必须从后到前,因此OverDraw严重,性能肯定比不上不透明物体。按照虚幻的官方文档,不透明最快,蒙版(Alpha测试)其次,半透明最慢(OverDraw严重)。

2.5 降低GPU带宽

2.5.1 压缩纹理

  1. 纹理压缩格式:比如现今基本都支持ASTC纹理压缩,通过合理设置不同资源的压缩率,尽可能压缩纹理大小。
  2. 纹理大小:一些贴图根本不需要过大的尺寸,但是美术导入的原始资源通常过大,因此可以在资源导入脚本中强制压缩到一定的尺寸或者写工具扫描压缩一遍。
  3. 去除不必要的纹理通道:比如Alpha通道或者灰度图改成单通道

2.5.2 减少纹理采样次数

  1. 尽可能关闭各向异性纹理
  2. 降低纹理质量
  3. UI或者角色展示场景关闭Mipmap

其中1和2都可以在工程设置的Quality中选择,可以根据不同的设备等级来选择不同的设置。3的话需要在贴图中设置,可以通过贴图导入脚本来设置。

2.5.3 提高纹理缓存命中率

  1. 减少贴图尺寸
  2. 开启Mipmap
    GPU的片内缓冲大小是有限的,因此尽可能小的贴图或者Mipmap才更可能被缓存命中。纹理被缓存命中,那么读取速度会比从内存中读取快一个数量级。

2.5.4 压缩网格

  1. 尽可能减少网格大小:比如限制面数、顶点数
  2. 开启顶点压缩
    网格的大小也会影响带宽,但是更多的是会影响渲染面数,从而增大GPU的负担。

2.5.5 减少全屏Blit

尽可能减少全屏特效,或者合并全屏特效的计算,减少全屏Blit的次数。

2.6 Shader优化

2.6.1 降低Shader中的数值精度

现在版本的Shader中已经不需要使用fixed类型,实际上Urp的Shader中也会编译失败。尽可能使用half类型,减少float类型的使用,float类型主要是用在postion和uv上,对应颜色等属性值尽量都用half。精度更大的话,计算时间就更多。

2.6.2 减少Shader中的分支和循环

尽可能避免分支出现,尽量不要使用循环。分支会破坏Shader的并行,严重影响Shader的执行效率。尽可能用其它方式替换,比如条件操作符、乘法等。

2.6.3 降低Shader的计算复杂度

分析一些OverDraw高或者屏幕占比高的Shader,尽可能或者根据项目要求来简化其计算复杂度,或者利用Shader LOD,写几个简化版本的Shader来对应中低端机器。

2.6.4 减少纹理的读取次数

在Shader中尽量减少纹理的读取次数,比如控制贴图对应的纹元可以一次性读取到变量中,不要反复读取。

2.6.5 Shader LOD

Unity的Shader支持LOD,可以针对中低端机器编写简化版本的SubShader,然后针对性的运行时切换到简化Shader运行。

2.7 尽可能剔除不必要的物体渲染

  1. 合理设置摄像机远平面距离,不渲染远处的物体
  2. 给物体设置不同的Layer,给不同的Layer设置不同的摄像机裁剪距离
  3. 使用Unity自带的遮挡剔除(要烘焙数据,占用额外的包体和内存,可能占用额外的CPU,一般不建议使用)
  4. 自定义的剔除算法,比如检测到物体超过摄像机多远,不渲染或者只渲染部分效果(可以参考项目内的WorldCullingManager,这个是利用Unity自带的CullingGroup来检测角色的距离变化,从而缩放角色的渲染效果)。

2.8 LOD

2.8.1 模型LOD

模型也可以使用Lod,默认情况下是随着摄像机距离变大切换到更简单的Lod。不过,也可以根据任意条件来切换Lod,比如机型匹配,帧率下降等。

2.8.2 动画LOD

可以在切换模型LOD时候,选择更简单的动画状态机,这个Unity是支持分层动画和Mask的,具体参考相关文档。

2.8.3 渲染LOD

  1. 不同的距离开启不一样的渲染效果,类似模型LOD,远处的物体采用更简单的渲染方式。
  2. 不同档次设置采用不同级别的渲染效果,比如切换Shader Lod,关闭一些Pass等。
  3. 不同重要度的物体,比如小怪等,可以关闭一些渲染特性或者效果,或者使用更低的Shader Lod等
  4. 同理,类似的其它一些缩放方式。

2.9 Fake渲染

2.9.1 假阴影

比如角色下面的影子是一个黑色面片,或者使用平面阴影(Planar Shadow)。

2.9.2 公告板

比如公告板技术实现的远处面片树木、面片房子等

三、CPU性能优化

按照第一节的游戏循环的说法,CPU的性能优化主要包括:游戏逻辑和渲染提交。或者更准确的说,应该把CPU性能优化分为渲染优化和其它的优化;其它的优化,主要指的是游戏逻辑相关的优化,具体包括:物体、动画、粒子、UI、资源加载、游戏逻辑(游戏层脚本)、GC等。下面,我们来一个个的做一些经验介绍。

3.1 渲染优化

CPU上的渲染主要包括两个部分,一个是计算需要渲染的物体,另一个是提交渲染和等待渲染完成。

3.1.1 Culling优化

Culling也可以叫做裁剪,实质上裁剪有很多种算法或者方式。从现今的游戏引擎来说,一般是使用层次包围盒(Bounding Volume Hierarchy),来粗略的和摄像机的可视范围做交集来进行裁剪。这一部分通常是在游戏引擎内的,因此我们不能控制。但是,SRP渲染管线提供了一些可能性来做优化。

  1. 隔帧Culling:如果游戏场景更新不频繁,那么可以隔帧或者过几帧才计算一次Culling。
  2. 修改Urp的Renderer中的SetupCullingParameters:该函数控制渲染引擎的裁剪参数,可以尝试修改该函数以提高特定项目的裁剪效率。
  3. 在主线程中自定义Culling逻辑:和2.6的第四点意思一样。

3.1.2 渲染批次优化

本质上是优化主线程提交给渲染线程的指令数目,从而减少渲染线程提交给图形接口的渲染指令数目。对应到Unity上则是各种合批策略的体现。

  1. 自定义Pass:对于要合批的物体,尤其是动态物体,比如特效,最好是定义一个专门的Pass,就不会被其它不相干物体打断动态合批(对于SRP Batcher没发现这种限制)。
  2. SRP Batcher:尽可能所有的Shader兼容SRP Batcher,除非特别低端的机器,这种合批方式对性能的提高很大。
  3. 静态合批:静态合批通常用于场景(SRP Batcher实际上也兼容静态合批),不过静态合批需要打开网格读写,静态合批只合并材质一样的物体,因此静态合批需要美术那边尽量提高材质的重用度后使用的意义才大;同时因为静态合批会合并网格(比如,同样的网格和材质出现多次,会将网格复制多次合并成一个更大的网格)可能会导致包体和内存显著增长;因此,静态合批最适合材质重复度高,网格重复很少的场景。
  4. 动态合批:静态合批的运行时版本,因为有运行时的CPU消耗,类似静态合批也可能会增大内存消耗;有限制,比如要求顶点属性之和不超过900等;适合于动态的小物体的合批,比如粒子特效、小道具等。
  5. Draw Instancing:也叫实例化渲染,适合的场景是网格重复多次(只是朝向、缩放等不一样),材质一样(或者材质属性基本一致的)的情况,比如大规模渲染树木和草地。
  6. 手动合并网格和材质:用软件来离线合并场景内的模型和材质;理论上来说,最自由但是最繁琐,如果场景小的话可以这样试试;合理控制的话,不会显著增大内存和包体,也没有运行时消耗,美术乐意的话,何乐而不为?

3.2 物理优化

3.2.1 降低Unity的物理更新频率

可以在工程设置的Physics选项中关闭Auto Simulation,然后选择在框架更新的时候降低频率(比如2倍的Fixed Timestep)来调用Physics.Simulate来更新物理。同样可以直接设置Fixed Timestep来降低更新频率。

3.2.2 少用或者不用MeshCollider

3.2.3 减少频繁射线检测的使用

可以缓存计算结果或者用更快速的检测方式替代,比如boxcast。

3.2.4 关闭碰撞矩阵中没必要的部分

3.3 动画优化

3.3.1 限制骨骼数目

要求美术制作时候在规定的骨骼数目范围内,骨骼数目会影响动画大小也会影响执行效率。通常80-100已经非常足够了。

3.3.3 动画的CPU性能优化

参考UWA的文章:Unity性能优化 — 动画模块
参考Unity文档:性能和优化

3.4 粒子优化

可以参考UWA的文章:粒子系统优化——如何优化你的技能特效
特效的优化一般在项目的中后期,快上线的时候,针对性的对战斗这种特效集中度很高的场景进行测试和优化。

3.4.1 限制特效的最大粒子数

通常会限制普通特效只能有5-10个粒子或者更小。

3.4.2 限制特效的批次

最好是一个特效能在几个批次或者1个批次内渲染完成。

3.4.3 限制特效使用的贴图尺寸

特效尽量使用小贴图,比如不超256或者512的,尽量都是128或者更细的贴图或者贴图合集。

3.4.3 限制特效的重叠层数和范围

这一部分应该算GPU的优化,可以减少OverDraw和GPU的计算。

3.5 UI优化

参考UWA的文章:Unity性能优化 — UI模块
优化UI的基本原则是:

  1. 减少UI变化重新生成网格:因为UI本质上也是网格加贴图绘制出来的,因此要避免各种操作或者设置到UI频繁变化导致网格重复生成。
  2. 减少UI的射线检测
  3. UI的图集控制:比如一个界面最多2个图集等
  4. 战斗这种3D场景可以不使用UGUI来绘制3D的UI,而是直接用3D网格来绘制,避免UGUI的各种性能消耗。

3.6 资源加载优化

参考UWA的文章:Unity性能优化系列—加载与资源管理

3.6.1 Shader变体的预热

Shader变体的预热比较耗时,可能需要拆分处理。Shader需要优化关键字数目,尤其是全局关键字数目,这个会显著影响Shader的包体和加载进来的内存。

3.6.2 游戏对象池

尽量使用对象池,对象池回收时候可以隐藏GO,也可以选择移动到远处(关闭组件)。

3.6.3 资源管理方案推荐YooAsset

3.7 游戏脚本优化

这里主要讲的是游戏框架和游戏逻辑的代码优化,包括C#和Lua。

3.7.1 游戏框架优化

框架应该尽可能优化,尽可能减少对使用者(游戏逻辑层)带来的性能损耗。

  1. 框架代码尽量不要有GC
  2. 框架代码尽量不要占用额外的大内存
  3. 框架代码尽量不要消耗过多的CPU,使用者不用担心性能消耗

    3.7.2 游戏逻辑优化

    具体的游戏逻辑优化,跟实际的游戏类型有关,需要针对性优化。

3.7.3 常见的脚本优化策略

  1. 缓存计算结果:中间结果或者初始参数尽量预计算好
  2. 不产生GC:任何引用类型的对象都尽量使用缓存池内的
  3. 对缓存友好的存储方式:比如尽量使用小数组存储数据,而不用链表或者字典
  4. 限帧法:限制部分逻辑的更新频率,比如2-3帧更新一次
  5. 多线程:部分独立性很强的逻辑,可以考虑多线程处理
  6. 主次法:比如非关键的角色或者物体,使用更少的计算逻辑
  7. 减少项目的MonoBehavior的更新函数入口,游戏逻辑尽量保持一个更新入口。

3.7.4 Lua代码优化

参考UWA的文章:Unity性能优化系列—Lua代码优化

3.8 GC优化

由于Unity的Mono堆在超过最大值或者一定数值后会自动扩容,而且扩容后无法往回缩,因此必须非常关注Mono堆的峰值。

  1. 降低Mono堆的峰值:这样可以避免Mono堆一直增长或者过大
  2. 降低GC的频率:减少不必要的CPU消耗,尽量使用缓存池中的引用对象

四、资源优化

4.1 纹理优化

这个在优化GPU代码有提到。

  1. 合理降低纹理大小
  2. 尽量使用更高的压缩格式(ASTC更高压缩率)
  3. UI或立绘关闭Mipmap
  4. 减少纹理通道
  5. 提高纹理复用(单色图复用,重复图案复用)

4.2 网格优化

  1. 关闭网格读写:除了特效外的网格关闭读写
  2. 开启项目的顶点压缩:会降低内存和GPU消耗,应该不会影响资源大小
  3. MeshCompression:开启据说会降低网格的资源占用,但是不影响内存占用
  4. 尽量减少面数和顶点数:和美术制作规范,正式资源要符合要求

4.3 动画优化

  1. 压缩方式选择Optimal:官方推荐的方式
  2. 如果没有缩放,去除Scale曲线。
  3. 网上的一些剔除动画原始数据的方法以实测为准,可能剔除后动画文件变小,包体变小了,但是内存中大小不变。

五、内存优化

5.1 优化资源

资源本身都会加载进入内存,因此优化资源本身大小对优化内存大小非常关键,第四节已经讲到。

5.2 优化打包和资源管理

减少打AB包时候的重复,以及智能的资源加载管理方案,可以减少AB包加载后的内存占用,以及去除没必要的资源常驻现象,同时也可以优化资源加载的CPU消耗。

5.3 优化Mono堆

前述已经提到Mono堆只增不涨,因此优化C#的Mono堆内存非常必要。

5.4 优化Native内存

一些插件包括引擎都会占用Native的内存,因此合理使用插件或者检测插件占用的Native内存在某些时候也有意义。如果插件造成的Native内存占用过多,是否可以考虑更换插件?比如音频插件等。

5.5 优化Lua内存

Lua同样有虚拟机有自己管理的堆内存,同样是不能无限增长的。因此,Lua代码也要避免频繁创建新的对象造成GC严重或者导致堆内存一直上升。
UWA和UPR都有检测Lua内存的选项,可以试试。

5.6 减少包体二进制大小

应用都会加载到内存中才会运行,因此更小的二进制包体自然会占用更小的内存。可以尝试剔除一些没有使用的代码(引擎代码或者C#脚本代码),这个Unity打包时候有相关设置。

5.7 配置优化

游戏项目到中后期配置可能会增大非常严重,如果一次性加载可能会造成加载时间过长,同时造成Mono内存增长过大。

  1. 避免一次性加载全部配置到C#中
  2. 如果内存占用过高,考虑其它压缩存储方式,比如二进制存储,不要使用Json
  3. Lua加载配置速度更快,但是配置过大同样内存占用高
  4. 实在占用过高,可以考虑小型数据库存储配置
  5. 是不是该让策划清理或者使用工具清理重复配置,配置本身是否严重冗余?

六、二进制包体优化

6.1 代码裁剪

Unity可以设置裁剪引擎代码,和脚本层代码。经过测试开启引擎代码裁剪问题不大,但是脚本层代码裁剪设置过高可能引起代码丢失问题,可能可以通过link.xml中的设置解决。

6.2 安卓架构

通常ARMv7和ARM64只需要打一个架构,当前ARM64的性能更好但是兼容性不够,关闭一个架构能减少包体。

6.3 其它跟App打包相关的方法

具体请查阅相关文档,参考并且实验是否有效。

七、其它

性能优化是一个迭代的长期工作,关键是底子打好,后期优化压力就小很多;或者明白优化的思路,能够快速定位关键的性能瓶颈。

一、卡通冰的效果

先看最终实现的卡通冰材质效果吧,如下所示:

也可以调出类似玻璃的效果:

如果对一个球应用卡通冰材质,然后打开各种选项,可以得到如下效果:

凹凸不平的地方是因为应用了法线贴图。

二、脚本和最终的材质界面

最终的材质界面,如下图所示:

通过材质界面可以清晰看出卡通冰效果的各个模块。
另外为了获得屏幕颜色需要挂上一个脚本(PostProcessEffect)表示当前管线需要执行CopyColorPass。

三、折射

冰效果最关键的部分是折射,注意是折射而不是半透明。折射是透光冰看过去,后面的背景会发生一定的扭曲;而半透明混合是冰本身的颜色和背景做一定的混合,无法背景实现扭曲的效果。这里的实现思路参考之前的屏幕扭曲特效的实现方式,具体可以参考文章:Urp下自定义特效管线和后处理特效实现
关于如何取得屏幕颜色贴图的方法不再赘述,另外为了优化性能,最终是判断是否需要获取屏幕颜色贴图(比如是否挂了屏幕特效脚本等)来决定是否执行CopyColorPass。

3.1 折射屏幕扭曲

获得屏幕颜色贴图后,只需要在屏幕空间下采样就能获得背景的颜色信息,至于扭曲的方式是通过一张扭曲贴图来采样当前位置的扭曲程度,这个扭曲程度加到屏幕空间UV上即可。一定程度的扭曲,能够模仿透过冰这种介质发生光线扭曲的这种效果。

3.2 折射强度控制

折射强度主要是通过NDotV来控制,另外提供了折射强度和控制贴图来调节。折射越强,越能透光冰看到后面的场景。至于为什么要使用NDotV,主要是为了贴近菲尼尔效应。根据菲涅尔效应,视线垂直于法线的情况下,反射越强,相应的折射越弱,NDotV越小。

四、卡通着色

这里的卡通着色就是一个二阶色的卡通着色,计算halfLambert,然后映射到2个颜色(暗色、亮色),中间的过渡用smoothstep插值。可以参考文章:Unity下的日式卡通渲染实现-着色篇(一)中的卡通着色部分。
那么卡通着色如何跟折射效果结合了?
可以使用折射强度去插值基础颜色和折射颜色,然后再用得到的基础色去计算卡通着色。

五、高光

高光就是Blinn-Phong的高光部分,计算NDotH,然后用pow(NDotH, 高光指数)来得到高光结果。比较简单,不再赘述。

六、边缘光

边缘光也是通过NDotV来判断边缘光程度,方法是判断NDotV是否小于边缘光宽度。这样不仅可以通过NDotV简单的模仿物体边缘判断,而且可以通过边缘光宽度来调整边缘光的大小。
高光和边缘光是叠加在卡通着色基础之上的,叠加比例是1-折射强度。

七、描边

描边就是使用沿着法线外扩的卡通渲染描边,可以参考文章:Unity下的日式卡通渲染实现-描边篇(三)

八、溶解

为了满足特效那边的冰消融的需求,额外添加了一个溶解部分。材质设置如下图:

溶解的实现很简单,提供一个溶解阈值,使用颜色贴图或者控制贴图的Alpha通道,来做AlphaTest。当然具体实现是用clip函数丢弃像素。

8.1 溶解颜色

为了模仿消融的效果,提供了一个溶解颜色来表示消融的过渡色,过渡色和本来的颜色通过smoothstep来插值,插值参数是溶解程度,溶解程度即是alpha减去溶解阈值。
效果如下图所示:

可以看到溶解的边缘有一个溶解过渡颜色。

九、控制贴图

为了方便美术控制效果,额外提供了一张控制贴图,四个通道分别控制:高光强度、边缘光强度、折射强度、溶解Alpha值。

十、参考资料

Urp下自定义特效管线和后处理特效实现
Unity下的日式卡通渲染实现-着色篇(一)
Unity下的日式卡通渲染实现-描边篇(三)

这边文章讲述的是项目中用到的一些卡通渲染描边相关技术。

一、Back Face外扩描边

背面外扩描边和后处理描边是卡通渲染中主要应用到的描边方式。

1.1 实现原理

第一个Pass正常渲染物体。第二个Pass只渲染背面,同时顶点沿着法线方向偏移,开启深度测试。
第二个Pass开启深度测试的用处一个是重叠部分不会显示出来,另外可以利用Early-Z减少需要处理的片元数量。

1.2 描边的法线优化

由于我们是沿着法线偏移顶点,那么最终的描边结果对法线的依赖很大。如果法线分布有问题,可能造成描边断裂的情况。如下图所示:

这是因为四个面的法线都是垂直于面的,在角的地方没有连续性。一种比较好的解决方式是计算平均法线存储在不使用的uv通道内,比如uv8,然后使用这个平均法线去计算描边。
何谓平均法线?即顶点周围面法线的平均或者加权平均。
如何计算这个平均法线了?这个可以编写外部工具对fbx直接离线修改;或者编写Unity的脚本修改uv8,不过在Unity内已经修改不了Fbx文件了,所以去修改Mesh的uv8数据,实际上这个修改是存储在工程的缓存数据内的,因此需要给Mesh新增一个Tag,如果有这个Tag导入Mesh的时候就需要就需要计算平滑法线。

1.3 根据摄像机修正描边宽度

一个是距离摄像机的距离,理论上来说应该是距离摄像机越远描边应该越小,这个可以用摄像机空间的z值来表示。另外一个是Fov,Fov越大描边应该越小。
加入这2个修正因子后,描边的粗细会看起来自然很多。

1.4 描边深度偏移控制消隐

有些地方美术实际上不希望出现描边。比如,头发的中间部位,美术只希望头发的边缘能看到描边。但是,正对着角色的时候,头发的中间部分实际上也是外扩的边缘,同样会看到描边。
这种情况可以通过使用深度偏移来修改顶点着色器的裁剪坐标,从而消隐描边。实际上,就是把不需要看到的描边往里推,从而被角色本身覆盖,就看不到描边了。
那么,哪些描边需要消隐了?就是下面要说的顶点色。

1.5 顶点色控制描边宽度和深度偏移

我们提供了顶点色的两个通道来分别控制描边的粗细和深度偏移。粗细很好理解,就是有些部位描边宽有些更窄。深度偏移就是上面说的消隐问题,有些地方的描边希望看不到就可以增加一定的深度偏移使其被角色挡住。
顶点色需要美术使用DCC工具去涂色,或者也可以在Unity中使用编辑器去涂,然后保存下来。还是类似的问题,在Unity中不能修改原始的Fbx文件,因此涂色后的网格数据只能保存为.asset。
当前项目中使用的是这篇文章:在Unity中写一个简单的顶点颜色编辑器的顶点色工具,基本需求能够满足。

最终的描边效果如下所示:
卡通渲染外扩描边
可以看到头顶的头发通过顶点色深度偏移控制了消隐,头发的描边粗细也是通过另一个顶点色通道控制的。

二、 后处理描边

后处理描边是在图像空间使用边缘检测因子得到边缘信息,通常是检测深度图或者法线图,比颜色图效果更好。因为,深度或者法线在角色边缘有明显的不连续性。但是,后处理描边的缺点是无法控制描边或者说很难像外扩描边一样精细的控制描边效果,同时还会把内描边也检测出来。
对于头发的描边,后处理描边这些缺点就是非常致命的,因为我们主要用的还是外扩描边。

三、其它描边方式

3.1 NdotV

类似简单的边缘光实现方式,也可以用来做描边,但是效果和控制力度肯定是达不到需求的。

3.2 深度贴图描边

之前说的深度贴图边缘光和阴影同样可以用来做描边,也能使用顶点颜色提供一定的控制粒度,比如控制粗细,但是也无法做深度偏移消隐等。

四、内描边

所谓内描边,指的是物体内部的描边,非物体边缘看到的描边。之前说的技术基本上都是针对外描边的。

4.1 本村线

简单来说,是直接在贴图上画描边,同时这些描边基本是跟轴对齐的。不过工作量很大,而且很大细节需要控制,很少有美术愿意采用这种方式,因此不做过多的讨论。
如下图:
!](https://raw.githubusercontent.com/xpc-yx/markdown_img/master/小书匠/本村线描边.jpg)

4.2 后处理内描边

网上有文章提到二之国中的做法是在顶点属性通道中记录边缘程度,然后在后处理中来进行绘制内描边。
如下图所示:

猜测是类似于顶点色的方式,让美术使用工具在顶点色中涂色边缘程度,
然后需要一个Pass将顶点色上的边缘程度属性输出到一个RT上,最终在后处理Pass中检测这个RT对应像素的边缘程度完成内描边。
至于如何将边缘程度转换为描边,需要参考相关资料才能弄清楚了,这里的日文看不懂啊。

五、参考资料

【01】从零开始的卡通渲染-描边篇
卡通渲染学习总结

这边文章讲述的是项目中用到的一些卡通渲染阴影相关技术。

一、SDF面部阴影

SDF这个概念具体是什么意思了?可以去观看闫令琪在B站上的101课程,有一节专门讲述了SDF的定义和混合SDF能够产生什么效果。其实SDF面部阴影基本的思想就是混合面部的SDF得到一张阴影阈值图,然后利用这张阴影阈值图实现二维的阴影渲染。
比较详细的解释可以参考这篇文章,卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP)

1.1 如何计算SDF

根据SDF的定义(到边界的最短有符号距离,形状内部为负,外部为正),计算二维的SDF其实很容易,可以使用C++写程序离线处理图片得到对应的SDF图。不过,我们现在需要的是更进一步的阴影阈值图。

1.2 如何计算阴影阈值图


阴影阈值图实际上是根据上述的多个角度的阴影图,首先计算每个阴影图的SDF图;然后将所有的SDF图递归混合起来,最终的输出就是阴影阈值图。
如何混合SDF图了?根据前后2个SDF图的在对应像素点的差异值来插值前后2个SDF图。
不过这些阴影图是有一定的要求的:图片必须连续且后一张必须可以覆盖前一张(可以是暗部覆盖也可以是亮部覆盖,但只能是一种)。
这个离线计算阴影阈值图的程序网上已经有人给出来了:如何快速生成混合卡通光照图

1.3 如何渲染SDF面部阴影

文章卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP)给出的Shader代码基本上问题不大,不过有2个比较明显的问题。
朝向问题:美术出的资源坐标系以及顶点位置不一定是朝着Unity局部坐标系的正Y轴,最好使用脚本传入正Y轴,否则有些模型的阴影就会反了。
左右判断问题:经过验证应该用right去判断还不是left,同样最好是用脚本传入,否则阴影过渡可能出现问题。
最后的if判断可以省略:直接用smoothstep计算bias即可达到效果。
最终的效果如下:
SDF阴影动画

二、自阴影

自阴影实际上是一种ShadowMap的变形,默认的ShadowMap会应用到所有开启了阴影投射的问题上。如果角色还使用这个ShadowMap可能会造成分辨率不够,比如阴影不够清晰、阴影锯齿严重等。因此,对所有的角色重新投影到一个新的ShadowMap上,然后利用这个新的ShadowMap计算自阴影。

2.1 收集所有激活的角色

给每个角色增加一个MonoBehavior脚本,该脚本激活的时候收集角色的包围盒,角色删除时候取消
对应的包围盒。对所有的角色包围盒计算一个并集,将该并集作为正交相机的渲染范围去渲染下一步的阴影Pass。

2.2 自定义ShadowCastPass

增加Urp的自定义Pass,该Pass的渲染模板是额外的ShadowMap对应的RT。在该Pass执行的时候去收集上述的角色包围盒并集,同时将并集作为正交相机的渲染范围,然后去渲染自定义ShadowTag的Pass。
Shader内的阴影Pass实现跟Urp默认的阴影Pass基本一致。

2.3 渲染自阴影

自阴影的渲染基本与传统的ShadowMap一致。不过,需要注意的是阴影投影矩阵已经变化了,需要从脚本中传入Shader。同时,采样阴影贴图的z值需要增加偏移参数去调整,以获得好的效果。美术可能还需要去控制特定区域的阴影强弱,比如可以使用顶点色来控制阴影强度。
效果如下图:

三、深度边缘阴影

深度边缘阴影和上一篇讲的深度边缘光的原理类似,都必须利用深度贴图来判断当前像素处于边缘
的程度。同样,角色Shader中需要增加DepthOnly来输出深度到深度贴图了。
效果如下图:

对比自阴影的效果图,可以看到在细节的遮挡区域阴影效果得到了进一步的增强。

四、默认的Urp阴影

默认的Urp阴影决定的是主灯的阴影效果,比如角色走入主方向光形成的阴影区域内,那么角色的亮度是否需要做一定的调整了?
在urp的shader源码中,这个阴影体现为mainlight的shadowAttenuation大小,该值会去缩放主灯的颜色亮度。如果直接使用该值去缩放灯光,那么会出现一定的自遮挡的阴影,而且这个阴影很丑。因此,可以使用这个shadowAttenuation去缩放卡通着色后的结果,从而体现出角色在不同光照区域有一定的表现差异。
不过,如果采用shadowmask的光照烘焙方式,烘焙后场景的静态物体就不会投影阴影了。因此,这个时候通过shadowmap得到的实时阴影就是不正确的,表现就会看起来很怪异。
有什么解决办法了?
方法一:一种简单的方式是让场景美术摆放一些大的不烘焙的隐藏面片来投影实时阴影。
方法二:另一种方式采用遮挡探针(和Unity的光照探针是一起的,实质上就是布置光照探针),类似光照探针一样,可以让动态的物体采用烘焙的阴影信息,不过这个时候主角就不采样shadowmap了。
方法三:改成DistanceShadowMask方式,这样子烘焙后的静态物体也会投影实时阴影,可以避免布置光照探针。不过性能损失会增大不少。

五、阴影如何跟着色结合?

对于每种阴影可以指定一个阴影颜色,上述的各种算法只是计算对应的阴影强度。最终,使用阴影强度插值卡通着色结果和阴影颜色,就可以得到应用阴影后的效果。

六、参考资料

卡通渲染之基于SDF生成面部阴影贴图的效果实现(URP)
如何快速生成混合卡通光照图