材质变体

所谓材质变体,指的是一份材质代码文件,最终对应的是多份运行时gpu程序。比如,shader代码里面有开关或者选项,不同的组合对应不同的最终gpu program。那么,所有的这些组合对应的gpu program,可以统一理解为这个材质对应的所有变体。
比如下面shader代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float4 color;
float3 normal;

void setColorAndNormal() {
#if COLOR_RED
color = float4(1, 0, 0, 1);
#else
color = float4(1, 1, 1, 1);
#endif

#if NORMAL_POSITIVE
normal = float3(0, 1, 0);
#else
normal = float3(0, -1, 0);
#endif
}

有2个开关,COLOR_RED,NORMAL_POSITIVE。每个开关都有2种状态,开或者关。那么,可以组合出2*2=4种状态。
类似C语言,glsl或者hlsl也支持#define宏,因此也有大于2个状态的开关,比如COLOR_RED == 0、COLOR_RED == 1、COLOR_RED == 2。总的状态计算方式是所有开关的状态数相乘,也就是复杂度是指数级的。

传统变体(静态编译变体)

概念

这里的传统变体指的是针对每一种组合状态都编译生成单独的着色器代码。实际上,目前绝大部分引擎实现的变体方案都是这种方式。

实现思路

编译期

算法基本思路很简单,遍历所有的状态组合,针对当前状态,#define相应的宏,然后编译当前代码。
比如,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define COLOR_RED 1
#define NORMAL_POSITIVE 0

float4 color;
float3 normal;

void setColorAndNormal() {
#if COLOR_RED
color = float4(1, 0, 0, 1);
#else
color = float4(1, 1, 1, 1);
#endif

#if NORMAL_POSITIVE
normal = float3(0, 1, 0);
#else
normal = float3(0, -1, 0);
#endif
}

对应的代码就是开关COLOR_RED打开、NORMAL_POSITIVE关闭的变体组合状态。
同时,将当前状态的激活关键字(变体开关)组合与编译后的代码做映射,保存在编译结果中。

运行时

根据当前的变体开关选择,映射到具体的代码。这里的映射方式与编译期的算法类似。比如,在材质类里面有一个hashmap, 保持变体状态组合到具体gpu program的映射。如果,hashmap内不存在这个映射,那么从编译期生成的代码内加载具体的编译后shader code,然后创建gpu program。

问题

由于需要在编译期就决定所有的状态组合,那么很可能会出现包体和内存爆炸的情况。比如,有10个开关,每个开关有2种状态,那么就是1024个变体,对应1024份代码。假设,一份代码的尺寸是10kb,那么就是10mb,有10个这样的材质,那么包体占用就是100mb,内存占用会更大。这就是游戏项目中常说的变体爆炸问题。

动态变体

概念

相比于传统变体,动态变体的最大优势是不会出现变体爆炸问题。在编译期间的编译结果,只有一份代码,同时保存变体定义信息。在运行时,二次编译生成真正的中间代码(spir-v)或者gpu上的汇编代码。

实现思路

编译期

不需要复杂的遍历算法,直接编译shader代码即可。但是,需要工具链或者图形API支持。比如,使用vulkan支持的Specialization Constants实现变体,那么可以在编译期保存Specialization Constants定义信息的同时,使用spirv-tools编译生成一份spir-v中间代码。

运行时

加载这份编译后的代码,比如spir-v中间代码。针对,当前的变体设置,对spir-v进一步处理成指定的变体状态或者将变体设置提交给vulkan,让驱动去编译。

问题

这种方式会有一定的局限性,无法优化所有的情况。比如驱动可能有bug,无法优化掉一些复杂变体组合的情况或者一些复杂的代码,导致真正运行的代码有多余的指令,引起性能大幅度下降。

Vulkan Specialization Constants变体

概念

不同的图形API对动态变体的支持情况不一样,比如OpenGL不支持,Vulkan支持Specialization Constants,metal支持Function Constants。
这里专门指代基于Vulkan Specialization Constants实现的变体系统。Vulkan的官方文章:Utilizing Specialization Constants
对Specialization Constants有具体的介绍,并且与UBO做了对比。

实现思路

使用Specialization Constants实现材质代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
layout(constant_id = 0) const bool COLOR_RED = true;
layout(constant_id = 1) const bool NORMAL_POSITIVE = true;

float4 color;
float3 normal;

void setColorAndNormal() {
if (COLOR_RED) {
color = float4(1, 0, 0, 1);
}
else {
color = float4(1, 1, 1, 1);
}

if (NORMAL_POSITIVE) {
normal = float3(0, 1, 0);
}
else {
normal = float3(0, -1, 0);
}

}

比如上述代码,定义了2个Specialization Constants变量:COLOR_RED和NORMAL_POSITIVE 。同时在代码内使用了这2个变量作为开关进行分支选择,注意:从语法上,Specialization Constants是作为变量处理,而不是宏。

使用spirv-tools编译带有Specialization Constants信息的材质代码

这一步与传统变体的区别是不需要遍历所有的变体状态组合,直接编译代码即可。对于,vulkan来说,使用glslang库调用spirv-tools编译代码就可以获得带有Specialization Constants信息的spir-v中间代码。

运行时决定Specialization Constants

上一步得到的是一份带有Specialization Constants信息的spir-v中间代码,如果获得最终的运行时代码了?

变体组合映射gpu program

这部分类似传统变体方案,需要将变体组合状态映射到具体的Specialization Constants设置。

设置Specialization Constants

有两种实现思路,各有优劣,下面具体说明。
1. 使用vulkan的Specialization Constants接口
使用vulkan的Specialization Constants,在在vulkan的pipeline中传递运行时的Specialization Constants设置信息。因为Specialization Constants是PSO的一部分,因此这种方式需要重新编译gpu program和PSO。由于,不需要完整编译gpu program,因此与切换gpu program的方案(传统变体)这个方案会编译更快。

2. 使用spirv-optimizer剔除分支
第二种方式是使用spir-optimizer里面的pass处理spir-v中间代码,比如设置Specialization Constants的值后,剔除dead code和Specialization Constants信息等,直接获得最终不带Specialization Constants信息的spir-v。这个spir-v就可以直接传递vulkan创建gpu program。

3. 两个方案对比

  • vulkan的Specialization Constants依赖驱动的JIT编译结果,如果驱动实现有问题,那么实际上Specialization Constants无法精准剔除代码,导致性能达不到预期。
  • spirv-optimizer剔除代码的方式,可以避免驱动的问题,在不同的驱动上表现一致;而且方便调试,比如可以在RenderDoc上抓取最终运行代码,或者mali offline compiler离线查看,确定最终运行的变体状态,但是这个方案依赖这个中间处理工具的能力。

问题

  • 依赖高级特性,在传统图形API上不支持。只能针对运行Vulkan的平台做优化。
  • 一些复杂的情况无法兼容,比如高通驱动无法优化比较复杂的使用Specialization Constants的代码等,会出现明显性能下降;spirv-optimizer对于将Specialization Constants作为变量传递的代码无法识别等。
  • 使用vulkan的Specialization Constants接口在驱动上进行JIT编译的方案,运行结果依赖具体硬件的驱动实现,结果不稳定。
  • 使用spirv-optimizer剔除分支的方案需要额外的运行时处理时间,会引起切换变体卡顿,引擎需要妥善处理,比如异步调用spirv-optimizer,同时对优化后的spir-v缓存。

动态变体与传统变体的对比

优势

  • 可以解决变体爆炸问题。
  • 可能更快的编译PSO。

劣势

  • 跨平台差,需要高级图形API特性。
  • 可能依赖硬件驱动实现。
  • 可能引入运行时卡顿。

其它问题探讨

变体收集

变体收集是另一个经常讨论的问题。一般需要引擎支持才能实现完整的变体收集。下面讲一个之前实现过的方案。

  • 引擎runtime
    引擎内有一个统一的ShaderProgramManger。该管理器内有2层hash,保存了所有材质和材质变体组合对应的gpu program。引擎内所有切换变体的操作最终都通过该类来查找gpu program。因此,该类完整收集了当前引擎运行状态下所有的变体组合。那么,可以在该类里面实现dump接口,遍历所有缓存的材质变体组合,输出文件作为变体集合文件。
    实际项目中,可以用自动化系统运行常见的场景,在合适的时机调用dump接口进行收集。
  • 变体集合编译
    假如使用spirv-optimizer的方案实现动态变体,那么可以针对变体集合文件内的收集到的变体状态组合,提前编译出最终的spir-v。在ShaderProgramManger查找变体时候,判断有提前预编译的情况,可以直接加载,而不是去调用spirv-optimizer处理。

变体预热

  • 变体切换
    对于传统变体来说,就是根据变体设置查找相应的着色器代码;对于动态变体来说,可能需要对spir-v代码进行预处理。

  • 编译PSO
    对于使用vulkan的Specialization Constants接口的方案来说,gpu program已经确定,需要设置Specialization Constants,再重新编译PSO。这个过程实际上是对gpu program重新编译获得最终的版本,由于有之前的编译信息,会比编译完整的gpu program更快。
    对于其它方案,实际上是编译完整的gpu program,与使用vulkan的Specialization Constants接口的方案对比,速度更慢。

  • 实现思路
    比如引擎可以加载变体集合文件,根据变体集合文件的描述,提前编译对应变体的代码以及PSO。

动态变体无法解决PSO编译的问题

网上也有讨论Specialization Constants的文章,比如:【笔记】Shader变体大杀器:specialization constants。该文章的评论里面提到Specialization Constants无法解决PSO的预热问题,从而对Specialization Constants进行了否定。实际上,这个是概念上的混淆。无论如何,PSO是需要重新编译的,因为最终的渲染状态数目是没有改变的;动态变体只是将确定最终gpu program的过程延迟到运行时决定,从而避免变体爆炸,并没有减少材质变体的状态总数。期望通过
Specialization Constants减少PSO数目或者加快PSO预热是方向上的错误。正确的思路是从PSO的收集缓存等方面来考虑,避免第一次切换到该PSO的卡顿。

传统图形API与现代图形API

传统图形API指的是OpenGL/DirectX11这类简单易用、驱动托管严重的图形接口;而现代图形API则指的是Vulkan/Metal/DirectX12这类使用复杂、暴露更多底层硬件功能来显式控制的弱驱动设计的图形接口。
现代图形API与传统图形API相比,使用和设计上有不少的区别,下面总结一些Vulkan与OpenGL的区别来对比说明。

渲染状态管理

OpenGL是一个全局的状态机,而Vulkan提供了PSO(PipelineState Object)来保存不同的管线状态。这样的区别是:

驱动实现

OpenGL由于要在驱动内管理和保存全局的状态,实现复杂。而Vulkan将状态管理暴露给应用层管理,不再需要在驱动层处理,从而驱动实现更为简单。

状态切换性能

OpenGL由于是全局状态机,在渲染时候经常需要设置和恢复状态,都会触发驱动内对全局状态机的管理,容易引发CPU上的性能瓶颈。但是Vulkan的PSO可以进行预编译,将不同的状态提前存储到不同的PSO上,同时对PSO收集离线缓存和提前预加载编译,等到渲染时候进行PSO切换即可。这样可以大幅度降低因为渲染状态切换导致的CPU性能瓶颈。
同时,Vulkan的渲染状态是缓存在PSO内,相比OpenGL的全局状态管理,可能性能本身就更好。

多线程支持

OpenGL只支持单线程提交DC,而Vulkan支持多线程提交DC。
具体思路:Vulkan支持多线程录制command后再提交到command queue。比如,每个线程创建一个command buffer,并行录制command到command buffer,再提交到指定的command queue。
更进一步,Vulkan支持多个command queue,这些queue可能在硬件层(驱动内)是并行的,比如Graphics的queue和compute的queue。

Shader编译

OpenGL将Shader的编译直接交给驱动负责。而Vulkan则将Shader使用spirv-tools编译成跨平台的中间语言spir-v,驱动只负责编译spir-v。这样的好处是:

  1. 跨平台:驱动只需要处理中间格式,方便使用工具将不同的语言比如hlsl、glsl等都统一编译成spirv。
  2. 性能:驱动只需要编译中间语言,不需要对着色器代码做负责的编译检查语法分析等,性能更好。

资源绑定

OpenGL的资源绑定都是一次API调用,比如glBindTexture等。而Vulkan支持在PSO上一次性绑定多个资产,以及Bindless等。具体可以参考文章:游戏引擎随笔 0x13:现代图形 API 的 Bindless

内存管理

OpenGL不支持显式的内存管理,都是托管给驱动负责。但是Vulkan支持或者说必须显式的管理与分配内存,因为驱动不再负责。具体来说是,Vulkan必须应用层申请内存同时进行管理,而OpenGL不需要。

1
2
3
4
5
6
7
8
9
10
11
12
// Vulkan 显式分配显存
VkMemoryAllocateInfo allocInfo{};
allocInfo.allocationSize = size;
allocInfo.memoryTypeIndex = findMemoryType(...);
vkAllocateMemory(device, &allocInfo, nullptr, &deviceMemory);
vkBindBufferMemory(device, buffer, deviceMemory, 0);

// OpenGL 隐式分配
GLuint buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);

比如上述代码,OpenGL是直接调用glGenBuffers获得buffer的id即可,内部由驱动去申请和管理内存;而Vulkan则需要调用vkAllocateMemory具体的申请内存,后续的管理和同步也需要应用层负责。因此,理论上Vulkan可以实现更接近应用需要的内存管理机制。

同步机制

这里的同步包括CPU和GPU的同步,以及GPU不同的渲染操作之间的同步等。OpenGL的同步都由驱动隐式完成,应用层无法控制。而Vulkan提供了多种手段来显式控制同步,以更好的优化性能。

  1. Fence用于CPU和GPU之间的同步,比如CPU等待某个GPU操作完成。
  2. Semaphores用GPU内部的同步。
  3. Pipeline Barrier用于同一个queue内同步。
  4. Event可以用于等待Pipeline事件发生,也可以用于CPU和GPU之间的同步。

错误验证

OpenGL由于是驱动管理全局的状态,而且驱动内置了错误管理和验证机制,导致驱动的实现复杂并且有较大的性能损失。相反,Vulkan的错误验证层是可选的组件,并且在Vulkan层,并没有在硬件的驱动内实现;在开发阶段,可以开启验证层排查问题,而真正的运行阶段并没有验证层。

  1. 性能:Vulkan没有错误验证层性能更好,驱动实现更简单。
  2. 开发:Vulkan由于没有驱动兜底,因此开发难度更大,需要更仔细的调试和验证。

适合场景

以上这些对比区别,都是CPU层面的,因此Vulkan这种现代图形API适合需要对CPU性能做极致优化的场景。如果,性能瓶颈在GPU上,将图形API从OpenGL切换到Vulkan上也无法解决问题。相信随着硬件的发作,Vulkan会逐渐替代OpenGL/OpenGLES成为事实上的默认跨平台图形API。

参考资料

剖析虚幻渲染体系(13)- RHI补充篇:现代图形API之奥义与指南
游戏引擎随笔 0x13:现代图形 API 的 Bindless
游戏引擎随笔 0x07:现代图形 API 的同步

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数据。

水体效果简介

这里讲的是模拟水体效果的一系列技术点。主要包括,着色模型、水体颜色、法线模拟水面流动、FlowMap控制水面流动、深度、折射、反射、焦散、泡沫、假SSS模拟等。下面具体说明。

着色模型


LightingMode有PBR、BlingPhong、None三种选项。默认使用PBR着色,可以为了极致的性能选择BlingPhong,甚至不着色直接输出albedo。

1
2
3
4
5
6
7
8
#if defined(_PBR)
half4 color = UniversalFragmentPBR(inputData, surfaceData);
#elif defined(_BLING_PHONG)
surfaceData.specular = lerp(_BlingPhongSpecColor.rgb, surfaceData.albedo, surfaceData.metallic);
half4 color = UniversalFragmentBlinnPhong(inputData, surfaceData);
#else
half4 color = half4(surfaceData.albedo * surfaceData.occlusion, surfaceData.alpha);
#endif

可以看到PBR的结果更加细腻,最明显的区别就是高光。PBR可能看到金黄色的高光,但是BlingPhong的高光需要调整。因此,使用了一个trick,额外提供了一个BlingPhong的高光颜色来近似,同时用金属度在高光颜色和albedo之间进行插值。
具体效果:

Output

output内有两个选项,第一个是在最终渲染结果的a通道上叠加一个透明度,用于整体调整透明度;一个是缩放最终的着色结果,用于整体调亮调暗。
具体效果:

水体颜色


水体分为浅水颜色和深水颜色。浅水区域即靠近岸边的区域,深水区域即其它区域。那么怎么区分,浅水区域和深水区域了?使用水深,即使用水的深度差。这个深度差是使用水底的深度和水面的深度相减得到,具体实现在深度部分会具体讲述。
那么,最终的颜色即使用浅水颜色和深水颜色插值得到,过渡算法即是使用深度差进行smoothstep。具体代码:

1
half4 baseColor = lerp(_ShallowBaseColor, _DeepBaseColor, smoothstep(_WaterDepthAdd, _ShallowDepthCutOff + _WaterDepthAdd, _WaterDepthMultiple * sceneDeltaDepth));

ShallowDepthCutOff用于控制浅水的范围。至于WaterDepthAdd、WaterDepthMultiple是额外提供的调整水深度范围的参数。
具体效果:

可以看到调整ShallowDepthCutOff可以扩大或者缩小浅水区域的范围。

水体透明度

除去output对透明度的修改外,还有水体颜色的a通道对透明度的控制,比如浅水透明度更高,深水透明度更低,然后使用深度进行过渡。
更重要的是实现水体边缘的平滑过渡,否则水体边缘是硬边,无法做到与岸边的平滑融合。具体实现:

1
color.a *= max(0.0, smoothstep(-0.001, _TransparentDepthCutOff, sceneDeltaDepth) - _TransparentAdd);

算法原理类似水体颜色过渡,使用深度差在-0.001(略小于0)和TransparentDepthCutOff之间进行smoothstep插值,然后乘到原始的透明度上。
具体效果:

深度

水面深度

水面深度指的是当前着色的像素点深度,这里我们要计算从摄像机开始的线性深度,具体代码如下:

1
2
3
4
5
6
7
8
9
10
inline float GetRealtimeWaterEyeDepth(float4 projection)
{
float depth = projection.z / projection.w;
#if !UNITY_REVERSED_Z
depth = depth * 0.5 + 0.5;
#endif
return LinearEyeDepth(depth, _ZBufferParams);
}

float waterDepth = GetRealtimeWaterEyeDepth(input.screenPos);

对于性能敏感的场景,我们也可以使用一张离线的深度图。这种情况下可以假设使用正交模型,从顶往下离线渲染一张RT保存了深度图。如OfflineDepth的设置所示,需要一个摄像机原点和一个正交投影范围,根据这些信息,那么还原离线线性深度的代码如下:

1
2
3
4
5
6
7
8
9
10
11
inline float GetOfflineWaterEyeDepth(float3 positionWS)
{
float eyeDepth = _OfflineTextureWorldOrgin.y - positionWS.y;//离线相机从上往下拍摄
float n = _OfflineTextureCameraSettings.y;
float f = _OfflineTextureCameraSettings.z;

eyeDepth = clamp(eyeDepth, n, f);
return eyeDepth;
}

float waterDepth = GetOfflineWaterEyeDepth(input.positionWS);

该函数实际上就是计算距离离线深度图相机的线性距离。

水底深度

同样分为实时和离线两种情况,实时深度需要从实时深度图读取深度再转换为线性深度;离线深度则是从离线深度图内读取深度,再根据正交投影信息转换为线性深度。
代码如下:

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
inline float2 GetOfflineWorldUV(float3 positionWS)
{
return (positionWS.xz - (_OfflineTextureWorldOrgin.xz - _OfflineTextureCameraSettings.xx)) / (2 * _OfflineTextureCameraSettings.xx);
}

inline float GetSceneEyeDepth(float2 uv)
{
#if _REALTIME_DEPTH_MAP
float depth = SampleSceneDepth(uv);
depth = LinearEyeDepth(depth, _ZBufferParams);
#else
float depth = SAMPLE_TEXTURE2D_X(_OfflineDepthMap, sampler_OfflineDepthMap, uv).r;//offline tool has fixed UNITY_REVERSED_Z
float n = _OfflineTextureCameraSettings.y;
float f = _OfflineTextureCameraSettings.z;
depth = n + (f - n) * depth;
#endif

return depth;
}

#if _REALTIME_DEPTH_MAP
float sceneDepth = GetSceneEyeDepth(screenUV);
#else
float sceneDepth = GetSceneEyeDepth(offlineWorldUV);
#endif

当然,实时深度必须开启渲染管线的深度图设置。

归一化深度差

上面2个步骤的水底深度减去水面深度,得到的就是深度差,然后用这个深度除以最大深度差得到的就是归一化的深度差。具体代码如下:

1
float sceneDeltaDepth = max((sceneDepth - waterDepth) / _MaxWaterDepthDiff, 0.0);

注意,MaxWaterDepthDiff是一个可调节的参数,用户可以方便控制最大的水深(可以超过或者小于实际的最大水深),这样可以整体调整整个水体的深度。

归一化深度差的用途

比如,前面说到的浅水区域和深水区域定义和过渡、水体边缘平滑过渡等。以及后续的焦散、泡沫等效果,也可以用深度差控制显示范围。

法线模拟水面流动

我们知道水面是流动的,要模拟流动主要有两种方式,一种是顶点动画,一种是在片元内移动法线。顶点动画比较适合模拟海浪,性能消耗比较大。这里,我们使用的是法线来模拟水面的流动。
SurfaceInputs内有一张法线输入,以及2个法线的ScaleSpeed;按照不同的移动速度采用出2个法线值,然后进行BlendNormal,作为最终的法线。
具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
float2 baseNormalUV = TRANSFORM_TEX(uv, _NormalMap);
UVPanner1 = _Time.y * 0.1f * _NormalScaleSpeed1.yz;
UVPanner2 = _Time.y * 0.1f * _NormalScaleSpeed2.yz;

half4 normalColor1 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner1);
half4 normalColor2 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner2);

half3 normalTS1 = UnpackNormalScale(normalColor1, _NormalScaleSpeed1.x);
half3 normalTS2 = UnpackNormalScale(normalColor2, _NormalScaleSpeed2.x);

outSurfaceData.normalTS = BlendNormal(normalTS1, normalTS2);

至于BlendNormal的实现,CommonMaterial.hlsl内具体代码:

1
2
3
4
5
// assume compositing in tangent space
real3 BlendNormal(real3 n1, real3 n2)
{
return normalize(real3(n1.xy * n2.z + n2.xy * n1.z, n1.z * n2.z));
}

可以参考文章:Normal Blend方法总结的Partial Derivative Blending部分。

FlowMap控制水面流动


上面我们实现了水面流动的模拟,但是有时候我们需要用贴图来控制水面的整体流动方向,这个时候就需要一种叫做FlowMap的技术。FlowMap实际上是一张记录了2D向量场的纹理,我们可以从这张图里面读取1个flowDir表示UV的移动方向;另外需要计算一个phase,用于表示当前的相位,结合flowDir可以获得2个UV;最后,用这个2个UV采用法线贴图,再进行插值即可。注意,插值算法也要依赖phase。详细的解释和FlowMap的应用可以参考文章:FlowMap技术介绍 和 案例展示

具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float2 baseNormalUV = TRANSFORM_TEX(uv, _NormalMap);

float2 flowDir = (SAMPLE_TEXTURE2D(_FlowMap, sampler_FlowMap, uv).rg * 2.0f - 1.0f) * (-_FlowDirSpeed);
float phase0 = frac(_Time.y * _FlowTimeSpeed);
float phase1 = frac(_Time.y * _FlowTimeSpeed + 0.5f);
UVPanner1 = flowDir * phase0;
UVPanner2 = flowDir * phase1;

half4 normalColor1 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner1);
half4 normalColor2 = SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, baseNormalUV + UVPanner2);

flowLerp = abs(phase0 - 0.5f) / 0.5f;
half4 normalColor = lerp(normalColor1, normalColor2, flowLerp);
outSurfaceData.normalTS = UnpackNormalScale(normalColor, _FlowNormalScale);

具体效果:

可以看到激活FlowMap后,水体流向会按照这张2D向量场贴图的方向流动。

折射


类似于深度的计算,同样有实时折射和离线折射两种方式。实时折射需要渲染管线开启颜色贴图,而离线折射则需要使用工具预先生成一张颜色贴图,类似于离线深度,这里同样使用自顶向下的离线相机拍摄一张离线颜色贴图。
具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline half3 GetRefractionColor(float2 uv, float2 noise, float sceneDeltaDepth)
{
half weight = saturate(1 - smoothstep(_WaterDepthAdd, _RefractionDepthCutOff + _WaterDepthAdd, sceneDeltaDepth));
uv += noise * _RefractionNoiseIntensity * 0.1;

#if _REALTIME_REFRACTION
half3 sceneColor = SampleSceneColor(uv).rgb;
#else
half3 sceneColor = SAMPLE_TEXTURE2D_LOD(_OfflineRefractionMap, sampler_OfflineRefractionMap, uv, sceneDeltaDepth * 10 * _OfflineRefractionLodMultiple).rgb;
#endif

return weight * sceneColor * _RefractionColor * _RefractionIntensity;
}

#if _REALTIME_REFRACTION
refractColor = GetRefractionColor(screenUV, inputData.normalWS.xz, sceneDeltaDepth);
#elif _OFFLINE_REFRACTION
refractColor = GetRefractionColor(offlineWorldUV, inputData.normalWS.xz, sceneDeltaDepth);
#endif

实时折射

从上述代码可以看到,实时折射需要调用SampleSceneColor采样实时的颜色贴图,采样左边是屏幕空间uv。

离线折射

而离线折射,从一张OfflineRefractionMap内采样颜色信息,同时提供了贴图LOD控制。

叠加折射

折射颜色与反射颜色是通过菲涅尔算法插值后,再叠加到最终的着色结果上。

1
2
3
4
5
6
7
8
inline half CalculateFresnelTerm(half3 normalWS, half3 viewDirectionWS)
{
return 1.0 - dot(normalWS, viewDirectionWS);
}

half fresnelTerm = CalculateFresnelTerm(inputData.normalWS, inputData.viewDirectionWS);

color.rgb += lerp(refractColor, reflectColor, fresnelTerm);

这里的Fresnel使用了最简单的计算法线与视线夹角的实现。

具体效果


由于水是半透明的,不开启折射也能一定程度上看到水底的效果。因此,默认情况下,不开启折射以节省性能。切换到离线折射时候,由于没有设置贴图,默认是白色贴图会导致整体变凉。实时折射情况下,调整折射噪声的强度可以看到一定程度的水底扭曲。

反射


反射通常指的是反射周围环境的效果,由于一般水体面积很大,不需要精准反应反射物体的位置,因此不需要实时镜面反射。比较合适的方式是使用Cubemap或者反射探针反射。

Cubemap反射

1
2
3
4
5
6
7
8
inline half3 GetCubemapReflection(half3 reflectVector)
{
#if _CUBEMAP_REFLECTION
return SAMPLE_TEXTURECUBE_LOD(_ReflectionCubeMap, sampler_ReflectionCubeMap, reflectVector, _ReflectionCubemapLod).rgb * _ReflectionCubemapColor * _ReflectionCubemapIntensity;
#else
return half3(0, 0, 0);
#endif
}

Cubemap即显式指定一张固定的Cubemap,从这张图采样作为反射信息,同时支持指定贴图LOD。

Probe反射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline half3 GetProbeReflection(half3 reflectVector, float3 positionWS, half smoothness, half occlusion)
{
#if _PROBE_REFLECTION
reflectVector = BoxProjectedCubemapDirection(reflectVector, positionWS, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax);
half mip = PerceptualRoughnessToMipmapLevel(PerceptualSmoothnessToPerceptualRoughness(smoothness));
half4 encodedIrradiance = SAMPLE_TEXTURECUBE_LOD(unity_SpecCube0, samplerunity_SpecCube0, reflectVector, mip);

#if defined(UNITY_USE_NATIVE_HDR)
half3 irradiance = encodedIrradiance.rgb;
#else
half3 irradiance = DecodeHDREnvironment(encodedIrradiance, unity_SpecCube0_HDR);
#endif

return irradiance * occlusion * _ProbeReflectionColor * _ProbeReflectionIntensity;
#else
return _GlossyEnvironmentColor.rgb * occlusion;
#endif
}

该函数实际上还是采样一个引擎内置的Cubemap:unity_SpecCube0。同时,新增了BoxProjectedCubemapDirection支持,该函数使用世界空间位置和Cubemap的位置、范围对反射方向进行了校正,该算法同样可以迁移到普通的Cubemap上。urp管线内BoxProjectedCubemapDirection的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
half3 BoxProjectedCubemapDirection(half3 reflectionWS, float3 positionWS, float4 cubemapPositionWS, float4 boxMin, float4 boxMax)
{
// Is this probe using box projection?
if (cubemapPositionWS.w > 0.0f)
{
float3 boxMinMax = (reflectionWS > 0.0f) ? boxMax.xyz : boxMin.xyz;
half3 rbMinMax = half3(boxMinMax - positionWS) / reflectionWS;

half fa = half(min(min(rbMinMax.x, rbMinMax.y), rbMinMax.z));

half3 worldPos = half3(positionWS - cubemapPositionWS.xyz);

half3 result = worldPos + reflectionWS * fa;
return result;
}
else
{
return reflectionWS;
}
}

具体算法描述可以参考文章:BoxProjectedCubemapDirection—镜面反射中的盒型投影函数

具体效果


可以看到切换到实时反射后,水面能够显示出天空盒的颜色。

焦散


焦散用于模拟水底的焦散现象。实现焦散效果需要一张焦散模式的贴图,采样这张贴图的结果和折射效果进行结合。
实现的难点在于如何让焦散看起来在水底,而不是漂浮在水面。这可以使用水底的世界坐标xz分量去采样焦散贴图,这样焦散的效果与水底的世界空间位置就强关联,看起来与水面就会分离。
另外,焦散贴图采样时候需要往相反方向采样两次,再取两次采样的最大值作为焦散强度。这样做是为了实现焦散的来回抖动效果,如果只采样一次就是规律性移动。
具体代码:

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
inline half3 GetCausticColor(float2 uv, float sceneDeltaDepth)
{
#if _CAUSTICS
uv = TRANSFORM_TEX(uv, _CausticsMaskMap);
half weight = saturate(1 - smoothstep(_WaterDepthAdd, _CausticsDepthCutOff + _WaterDepthAdd, sceneDeltaDepth));
half3 causticsMask1 = SAMPLE_TEXTURE2D(_CausticsMaskMap, sampler_CausticsMaskMap, uv + _Time.y * _CausticsSpeed * 0.1).rgb;
half3 causticsMask2 = SAMPLE_TEXTURE2D(_CausticsMaskMap, sampler_CausticsMaskMap, uv - _Time.y * _CausticsSpeed * 0.1).rgb;
return weight * _CausticsColor * _CausticsIntensity * min(causticsMask1, causticsMask2) * 10;
#else
return half3(0, 0, 0);
#endif
}

#if _CAUSTICS
float2 causticsUV = input.uvAndFogAtten.xy;
#if _REALTIME_DEPTH_MAP
#if UNITY_REVERSED_Z
float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_PointClamp, screenUV + inputData.normalWS.xz * 0.01).r;
#else
float deviceDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_PointClamp, screenUV + inputData.normalWS.xz * 0.01).r;
deviceDepth = deviceDepth * 2.0 - 1.0;
#endif
causticsUV = ComputeWorldSpacePosition(screenUV + inputData.normalWS.xz * 0.01, deviceDepth, unity_MatrixInvVP).xz;
#endif
refractColor += lerp(1, refractColor, _CausticsRefractWeight) * GetCausticColor(causticsUV, sceneDeltaDepth);
#endif

具体效果:

泡沫


这个效果是模拟水面的边缘部分的泡沫流动现象。主要包括泡沫的着色以及泡沫的流动,因此需要一张表示泡沫的贴图。比如下面这张贴图的g通道。

实现泡沫的流动类似云阴影的实现,泡沫本身的流动以及泡沫的边缘流动,方法是按照不同的速率采样泡沫贴图两次,然后使用PS的线性加深模式进行叠加即可。
具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
inline half3 GetFoamColor(float2 uv, float2 UVPanner1, float2 UVPanner2, float flowLerp, float sceneDeltaDepth)
{
#if _FOAM
uv = TRANSFORM_TEX(uv, _FoamMap);
half weight = 1 - smoothstep(_WaterDepthAdd, _FoamDepthCutOff + _WaterDepthAdd, sceneDeltaDepth);
half foamWeight1 = SAMPLE_TEXTURE2D(_FoamMap, sampler_FoamMap, uv + UVPanner1 * _FoamSpeed1).g;
half foamWeight2 = SAMPLE_TEXTURE2D(_FoamMap, sampler_FoamMap, uv + UVPanner2 * _FoamSpeed2).g;
half foamWeight = saturate(2 * foamWeight1 + foamWeight2 - 1);

return _FoamColor * weight * foamWeight * _FoamIntensity;
#else
return half3(0, 0, 0);
#endif
}

#if _FOAM
color.rgb += GetFoamColor(input.uvAndFogAtten.xy, UVPanner1, UVPanner2, flowLerp, sceneDeltaDepth);
#endif

最后将泡沫颜色叠加到渲染结果上即可。
具体效果:

假SSS模拟


这个是对SSS的简单模拟。实现算法比较简单,根据光照方向做一定偏移作为SSS的方向,然后计算SSS方向与视线方向的夹角,以这个夹角作为因子来计算额外的SSS光强度,叠加到最终的渲染上。
具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
inline half3 GetSSSColor(Light light, half3 viewDirectionWS, half3 normalWS, float sceneDeltaDepth)
{
#if _SSS
half3 sssLightDirection = normalize(light.direction + normalWS * _SSSLightDistortion);
half sss = pow(saturate(dot(viewDirectionWS, sssLightDirection)), _SSSLightPower) * _SSSLightIntensity;
half weight = 1 - smoothstep(_WaterDepthAdd, _SSSLighDepthCutOff + _WaterDepthAdd, sceneDeltaDepth);

return _SSSColor * light.color * sss * weight;
#else
return half3(0, 0, 0);
#endif
}

#if _SSS
color.rgb += GetSSSColor(GetMainLight(), inputData.viewDirectionWS, inputData.normalWS, sceneDeltaDepth);
#endif

具体效果:

整体效果

参考资料

Normal Blend方法总结
FlowMap技术介绍 和 案例展示
BoxProjectedCubemapDirection—镜面反射中的盒型投影函数

云阴影效果简介

这里说的云阴影,是使用噪声图或者特定规律的随机图模拟的假云阴影。效果类似于大片云投影在大地上,同时阴影按照一定速度移动和随机流动变化,并且支持双层阴影叠加。效果如下:

全局参数

全局云阴影开关

可以看到,有一个是否启用(Enable)的全局开关控制是否开启全局云阴影。这可以使用全局变体实现,由于全局变体无法剔除,容易引入变体爆炸问题。因此,最好使用材质局部变体替代。

云阴影颜色

有一个全局的云阴影颜色用于调整整体的云阴影颜色,比如可以调出红色等特殊颜色的云阴影,默认是黑色。

云阴影参数

从参数设置可以看到,云阴影分为两层。每层有大小、强度、方向、速度以及噪声的强度、速度、tiling。噪声是为了模拟云阴影边缘的快速变化抖动效果。同时,需要一张云阴影贴图来控制这两层的参数;规定贴图的R、G通道是第一层阴影的强度和噪声的强度,B、A通道是第二层阴影的强度和噪声的强度。

Shader或者材质参数

材质云阴影开关

Shader上有专门的变体开关控制是否激活云阴影。这里针对的是当前材质的开关,使用local变体即可满足据要求。

材质云阴影强度

同时,有一个强度滑块控制该材质的云阴影强度。这样,可以定制不同材质接收到云阴影强度。

实现原理

修改albedo

1
2
3
#if _GLOBAL_CLOUD_SHADOW
surfaceData.albedo = ApplyGlobalCloudShadow修改(surfaceData.albedo, input.positionWS, _CloudShadowIntensity);
#endif

从上述代码可以看到,在开启变体_GLOBAL_CLOUD_SHADOW时候,使用函数ApplyGlobalCloudShadow修改albedo。

计算CloudShadow

1
2
3
4
5
6
7
8
9
inline half3 ApplyGlobalCloudShadow(half3 albedo, float3 positionWS, half intensity = 1.0f)
{
half firstCloudShadowIntensity = GetFirstGlobalCloudShadowIntensity(positionWS);
half secondCloudShadowIntensity = GetSecondGlobalCloudShadowIntensity(positionWS);

albedo *= lerp(half3(1, 1, 1), _CloudShadowColor, intensity * max(firstCloudShadowIntensity, secondCloudShadowIntensity));
return albedo;
}

  1. 函数ApplyGlobalCloudShadow首先计算两层云阴影强度,再使用max获得两层的最大强度(两层阴影叠加的位置取最大值)作为云阴影强度。
  2. 然后使用这个云阴影强度在白色与云阴影颜色之间进行插值获得最终的云阴影颜色。
  3. 最终,将该云阴影颜色乘到albedo上。

计算单层CloudShadow强度

该函数代码比较关键,不再直接提供源码,有需要的可以理解后再自行实现。

  1. 使用传入的positionWS计算出worldUV。
  2. 使用worldUV结合云阴影的大小、速度计算出云阴影的位置(cloudUV),然后使用cloudUV采用贴图的R通道获得云阴影强度。
  3. 使用worldUV结合云阴影噪声的速度、tiling计算出云阴影噪声的位置(cloudNoiseUV),然后使用cloudNoiseUV采用贴图的B通道获得云阴影噪声强度。
  4. 将云阴影强度和云阴影噪声强度进行叠加获得整体的云阴影强度。注意:叠加算法很关键,需要使用PS的线性加深模式。

整体效果

一、玻璃效果

首先来讲如何模拟玻璃效果。玻璃的渲染包括三部分,普通场景物体的渲染、反射和折射模拟、毛玻璃模拟。作为场景物体,那么类似其它场景物体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。

妆容效果

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