# 材质配置说明

# 前言

本文将详细介绍材质文件的结构与配置方式。

# 材质文件的加载

材质文件存放于资源包的materials文件夹下,我们打开resource_packs\vanilla\materials目录,可看到包含有下面这些文件,这些是微软原生定义的材质文件:

material_list

而resource_packs\vanilla_netease\materials目录下的材质文件,则是网易对原生材质文件的修改与扩充。

下面我们就先以原生微软的材质文件进行讲解,首先,目录下面的文件基本都是以".material"为后缀的文件,除此之外,还有3个重要的json文件,分别是common.json,fancy.json,sad.json。

我们先来看看sad.json和fancy.json,他们是用于控制画质表现的,内部各自定义了一个材质文件列表,fancy.json通常比sad.json多定义几个材质文件,并且会为某些材质文件多添加一些额外的宏,这些宏会使shader采用不同的代码块,实现对画质和性能的控制:

[ // sad.json
	{"path":"materials/sad.material"},
	{"path":"materials/entity.material"},
	{"path":"materials/terrain.material"},
	{"path":"materials/portal.material"},
	{"path":"materials/barrier.material"},
	{"path":"materials/wireframe.material"}
]
[ // fancy.json
	{"path":"materials/fancy.material", "+defines":["FANCY"]},
	{"path":"materials/entity.material", "+defines":["FANCY"]},
	{"path":"materials/terrain.material", "+defines":["FANCY"]},
	{"path":"materials/hologram.material"},
	{"path":"materials/portal.material", "+defines":["FANCY"]},
	{"path":"materials/barrier.material"},
	{"path":"materials/wireframe.material"}
]

fancy.json相比sad.json多定义了fancy和hologram两个材质文件,并且为某些材质文件内的所有材质增加了FANCY宏。开发者可以在shader中通过FANCY宏来选择不同的计算策略:

#ifdef FANCY
	// 完整版效果
#else
	// 青春版效果
#endif

通过游戏内的设置/视频/精美图像开关可以实现fancy材质和sad材质的切换。当精美图像开关打开时,fancy.json中的材质文件就会生效,反之sad.json中的材质文件就会生效: fancy_switch

为了实现更好的表现效果,fancy.json中的材质通常使用较复杂的运算,而sad.json中的材质则通过牺牲渲染表现来换取更好的性能。 开发者若需要编写开销较大的材质shader,可以考虑利用FANCY宏同时编写一个低消耗的sad版本,然后分别把材质分别放在fancy与sad内定义的相应的材质文件中,玩家可以在游戏中通过精美图像开关自行控制是否开启相应效果;如果开发者不需要区分材质的性能开销,则可以考虑仅使用common.json,所有在common.json中定义的材质文件都会被加载。

# 材质定义与引用

在此我们将简单介绍MC材质是如何被定义与引用的。

# 材质定义

假设有一个开发者自定义的材质文件example.material,它的基本格式框架如下:

{ // example.material
	"materials": {
		"version": "1.0.0", // 表示material文件格式版本 必不可少

		// 材质1
		"mat_example": {
			// ...具体的材质定义
		},

		// 材质2 它引用了mat_example
		"other_mat:mat_example": {
			// ...具体的材质定义
		}
	}
}

可以看到,所有的材质定义都被包含在materials这个字段内,并且有一个version字段也被包含在materials内,用来标识这个材质文件的格式版本。我们还可以注意到这样的字符串:"other_mat:mat_example",它表示材质other_mat继承自材质mat_example,继承后的材质具有父材质的所有属性,在此基础上,子材质other_mat还可以新增属性,或者覆盖父材质中已有的属性。 接下来看材质mat_example的具体配置格式:

"mat_example": {
	"vertexShader": "shaders/glsl/mat_example.vertex",
	"fragmentShader": "shaders/glsl/mat_example.fragment",

	"+defines": [
		"USE_SKINNING",
		"USE_OVERLAY",
		"NETEASE_SKINNING"
	],
	"+samplerStates": [
		{
			"samplerIndex": 0,
			"textureFilter": "Point"
		}
	],
	"states": [
		"Blending",
		"DisableDepthWrite"
	]
}

以下是对材质mat_example配置的解读:

  • vertexShaderfragmentShader定义了这个材质使用的顶点着色器和片元着色器的路径,开发者需要自行实现对应的着色器
  • +defines定义了材质启用的宏定义,在shader中这些宏定义将会生效
  • +samplerStates定义了shader接受的采样纹理列表,纹理个数仅为1,其中samplerIndex为0表示材质启用的纹理是TEXTURE_0textureFilter指定了纹理采样的过滤方式为点过滤/点滤波
  • state定义了材质额外开启的渲染状态,其中的Blending表示使用alpha混合,DisableDepthWrite表示材质将不会影响屏幕的深度信息。

更具体的材质配置参数可以参考本文档的其余部分。

# 材质引用

接上节,我们定义了一个名为mat_example的自定义材质,根据材质类型不同,引用材质的方式也有所不同。

# 网易骨骼模型引用材质

网易骨骼模型配置写在vanilla_netease/models/netease_models.json文件中,开发者可以看到类似这样的模型配置段:

"model_name": {
	"dy_load": true,
	"mesh": "mesh/model_name_mesh.json",
	"skeleton": "skeleton/model_name_skeleton.json"
}

这段配置没有给出引用的材质名,因此游戏会在渲染这个模型时默认使用entity_for_skeleton材质,我们可以增加一个material字段,显式地引用我们在entity.material文件中自定义的材质:

"model_name": {
	"dy_load": true,
	"mesh": "mesh/model_name_mesh.json",
	"skeleton": "skeleton/model_name_skeleton.json",
	"material": "mat_example"
}

除此之外,我们还支持开发者使用多pass特性来加强骨骼模型的渲染效果。假设开发者希望让一个模型在单帧内使用不同材质各渲染一次,则可以将material字段值改为数组:

"material": ["mat0", "mat1", "mat2"]

该模型将在每一帧按顺序渲染数组中所引用的材质。 在游戏内调取出这个模型,就可以预览到自定义材质的效果。

骨骼模型所引用的材质,其着色器需要支持NETEASE_SKINNING宏的功能,具体可以参考entity_for_skeleton材质的着色器实现。

# 原版模型引用材质

在实体配置文件vanilla/entity/xxx.entity.json中,我们可以找到与材质有关的配置内容:

{
	"format_version": "1.8.0",
	"minecraft:client_entity": {
		"description": {
			"identifier": "netease:xxx",
			"materials": {
				"default": "xxxxx",
				... // 其他材质配置
			},
			... // 其他字段
		}
	}
}

其中materials中的default字段,就指定了这个实体在常态下使用的材质,我们可以将这个字段的值修改为我们自定义的材质,让这个实体在渲染时具有我们自定义的效果:

"default": "mat_example"

实体模型材质使用的着色器,需要支持USE_SKINNING宏,具体可以参考entity_static_netease或者entity_static材质的实现。

# 后处理引用材质

开发者可以在vanilla_netease/graphics_settings/post_process.json找到后处理相关配置,在配置文件的process_array字段下,我们可以看到若干个后处理的定义,在此以老电视机效果作为例子:

{
	"name": "oldtv",
	"enable": false,
	"paras": [
		{ "name": "density", "value": 0.1, "range": [0.0, 1.0] },
		{ "name": "strength", "value": 1.0, "range": [0.0, 1.0] },
		{ "name": "snow_size", "value": 2.0, "range": [0.5, 16.0] },
		{ "name": "noise_fps", "value": 6.0, "range": [0.01, 64.0] },
		{ "name": "black_zone", "value": 0.2, "range": [0.0, 1.0] }
	],
	"pass_array":[ // 后处理pass数组
		{ // 第0个pass
			"render_target":{
				"width":1.0,
				"height":1.0
			},
			"material":"old_tv" // 第0个pass使用的材质
		}
	]
}

该配置定义了后处理的名称、默认是否开启、着色器参数、后处理pass数组等信息。可以看到,后处理pass数组中的单个pass包含一个material字段,这代表了这个pass将使用的材质,我们可以修改它的值,改变这个pass使用的材质。

制作后处理材质需要定义某些特殊的材质配置,并且着色器要基于全屏范围进行计算,具体可以参考vanilla_netease/materials/postprocess.material中的oldtv材质

# 中国版粒子特效引用材质

中国版粒子特效可以使用网易MCStudio生成制作。中国版粒子特效具有默认材质,因此在特效配置中并没有显式地记录引用材质的名称。开发者可以在中国版粒子特效中,加入如下字段来改变粒子特效所引用的材质:

"materialname": {
	"value": "my_material"
}

# 材质属性

我们把所有字段按功能分到以下几类中:

# 渲染状态

# states

配置渲染环境,可以有以下的值:

EnableAlphaToCoverage :半透明对象顺序无关渲染方式的一种,支持MSAA的环境下这个开关才有用,开启后物体边缘会根据透明度作更精确的柔和和过渡,也可用于有大量网格交错重叠的一些复杂场景。
Wireframe : 绘制线框模式
Blending : 开启颜色混合模式,常用于渲染半透明对象。声明这个之后通常也需要声明混合因子blendSrc,blendDst

DisableColorWrite : 不往颜色缓冲区写入颜色值,RGBA通道均不写入
DisableAlphaWrite : 不往颜色缓冲区写入透明度alpha值,允许写入RGB值
DisableRGBWrite : 不往颜色缓冲区写入透明度RGB值,允许写入Alpha值

DisableDepthTest : 关闭深度测试
DisableDepthWrite : 关闭深度写入

DisableCulling : 不开启背面剔除,会使模型的三角形无论朝向都会被渲染出来。默认情况下会开启背面剔除,即没有面朝相机的三角形会被剔除。
InvertCulling :开启正面剔除并且禁用背面剔除,使得朝向相机的三角形被剔除。

StencilWrite :开启蒙版写入
EnableStencilTest : 开启蒙版测试

# 着色器路径

# vertexShader

顶点着色器的路径,通常为shaders/glsl/XXX.vertex。

# vrGeometryShader 或 geometryShader

几何着色器的路径,通常为shaders/glsl/XXX.geometry,移动端用不到,无须考虑修改。

# fragmentShader

片段着色器的路径,通常为shaders/glsl/XXX.fragment。

# Shader宏定义

# defines

为使用到的Shader定义宏。为了代码的复用,我们很多不同的材质会使用相同的shader。此时若希望shader里面某处根据当前材质执行不一样的逻辑,则可以通过材质defines声明的宏去做判断。 我们可以用entity_for_skeleton这个材质为例,它定义了USE_SKINNING,USE_OVERLAY,NETEASE_SKINNING三个宏。

"+defines": [ "USE_SKINNING", "USE_OVERLAY", "NETEASE_SKINNING" ]

于是在着色器可以中通过#ifdef#if等语句来对宏进行判断并执行不同的逻辑,宏的判断语句是编译期处理的,性能不会因分支而下降:

#ifdef NETEASE_SKINNING
	MAT4 boneMat = transpose(mat3x4ToMat4(BONES_70[int(BONEID_0)]));
	entitySpacePosition = boneMat * POSITION;
	entitySpaceNormal = boneMat * NORMAL;
#else
	#if defined(LARGE_VERTEX_SHADER_UNIFORMS)
		entitySpacePosition = BONES[int(BONEID_0)] * POSITION;
		entitySpaceNormal = BONES[int(BONEID_0)] * NORMAL;
	#else
		entitySpacePosition = BONE * POSITION;
		entitySpaceNormal = BONE * NORMAL;
	#endif // defined(LARGE_VERTEX_SHADER_UNIFORMS)
#endif // NETEASE_SKINNING

# 运行时状态

# depth 深度测试

# depthFunc

深度检测通过函数,可以使用以下的值:

Always : 总是通过
Equal : 深度值与缓冲区值相等时通过
NotEqual :深度值与缓冲区值不相等时通过
Less :深度值小于缓冲区值时通过
Greater :深度值大于缓冲区值时通过
GreaterEqual :深度值大于等于缓冲区值时通过
LessEqual :深度值小于等于缓冲区值时通过

相关联的states渲染环境配置:

DisableDepthTest : 关闭深度测试
DisableDepthWrite : 关闭深度写入

# Stencil 蒙版测试

# stencilRef

与蒙版缓冲区比较或要被写入的值

# stencilRefOverride

是否使用缓冲区当前的值作为stencilRef,支持0或1:

1 : 使用配置的stencilRef,若配置了stencilRef则stencilRefOverride自动取1
0 : 使用缓冲区当前的值作为stencilRef,此情况下不配置stencilRef
# stencilReadMask

蒙版缓冲区的值与stencilRef值在比较前均会先与stencilReadMask进行位与运算

# stencilWriteMask

stencilRef值在写入蒙版缓冲区前会与stencilWriteMask进行位与运算

# frontFace, backFace

配置网格正面或反面使用什么蒙版测试函数,另外,判断的顺序为先蒙版检测,再深度检测,需要配置以下操作:

	stencilFunc : stencilRef与蒙版缓冲区比较时使用的方法,支持下面的值:
		Always : 总是通过
		Equal : stencilRef与缓冲区值相等时通过
		NotEqual :stencilRef与缓冲区值不相等时通过
		Less :stencilRef小于缓冲区值时通过
		Greater :stencilRef大于缓冲区值时通过
		GreaterEqual :stencilRef大于等于缓冲区值时通过
		LessEqual :stencilRef小于等于缓冲区值时通过	

	stencilFailOp :stencilFunc比较函数返回失败的时候执行的处理,支持下面的值:
		Keep : 保留缓冲区原本数值
		Replace : 往缓冲区写入 stencilRef位与stencilWriteMask 的值

	stencilDepthFailOp : stencilFunc比较函数返回成功, 但深度测试失败的时候执行的处理,支持下面的值:
		Keep : 保留缓冲区原本数值
		Replace : 往缓冲区写入 stencilRef位与stencilWriteMask 的值

	stencilPassOp : stencilFunc比较函数返回成功,而且深度测试成功的时候执行的处理,支持下面的值:
		Keep : 保留缓冲区原本数值
		Replace : 往缓冲区写入 stencilRef位与stencilWriteMask 的值

相关联的states渲染环境配置:

StencilWrite :开启蒙版写入
EnableStencilTest : 开启蒙版测试

最后我们看一段例子:

"shadow_back": {
	"+states": [
		"StencilWrite",
		"DisableColorWrite",
		"DisableDepthWrite",
		"InvertCulling",
		"EnableStencilTest"
	],

	"frontFace": {
		"stencilFunc": "Always",
		"stencilFailOp": "Keep",
		"stencilDepthFailOp": "Keep",
		"stencilPassOp": "Replace"
	},
	"backFace": {
		"stencilFunc": "Always",
		"stencilFailOp": "Keep",
		"stencilDepthFailOp": "Keep",
		"stencilPassOp": "Replace"
	},

	"stencilRef": 1,
	"stencilReadMask": 255,
	"stencilWriteMask": 1,

	// 省略其余部分
}

例子中StencilWrite代表支持蒙版缓冲区的写入,EnableStencilTest代表开启蒙版测试,frontFace配置了正向片元的模板测试策略,代表正向片元的蒙版测试总是通过(Always),深度测试不通过则保持缓冲区值不变(Keep),若深度测试通过则会将模板缓冲更新为stencilRef的值。backFace同理。

# Blend 半透明对象颜色混合

半透明对象的渲染需要配置混合因子,最终输出的rgb颜色值 = 当前颜色值 * 源混合因子 + 缓冲区中的颜色值 * 目标混合因子

# blendSrc

源混合因子

# blendDst

目标混合因子

# alphaSrc

计算alpha时的源混合因子,通常不配置取默认值

# alphaDst

计算alpha时的目标混合因子,通常不配置取默认值


混合因子总共可以取下面的值:

DestColor : 缓冲区颜色值
SourceColor : 当前颜色值
Zero : (0,0,0)
One : (1,1,1)
OneMinusDestColor : (1,1,1) - 缓冲区颜色值
OneMinusSrcColor : (1,1,1) - 当前颜色值
SourceAlpha : 当前颜色中的alpha值
DestAlpha : 缓冲区颜色中的alpha值
OneMinusSrcAlpha : 1 - 当前颜色值中的alpha值

在引擎中,默认值为:

blendSrc :SourceAlpha 
blendDst :OneMinusSrcAlpha
alphaSrc :One
alphaDst :OneMinusSrcAlpha

相关联的states渲染环境配置:

Blending : 开启颜色混合模式,常用于渲染半透明对象。声明这个之后通常也需要声明混合因子blendSrc,blendDst
DisableColorWrite : 不往颜色缓冲区写入颜色值,RGBA通道均不写入
DisableAlphaWrite : 不往颜色缓冲区写入透明度alpha值,允许写入RGB值
DisableRGBWrite : 不往颜色缓冲区写入透明度RGB值,允许写入Alpha值

# sample 纹理采样

# samplerStates

配置采样状态,值为一个列表,根据需要采样的纹理个数为每一个纹理进行配置,通常若顶点属性中声明了UV0, UV1,代表需要采样两个纹理,这里则需要配置两个元素。下面看子元素的定义:

{
	"samplerIndex": 0,
	"textureFilter": "Point",
	"textureWrap": "Repeat"
}

每个属性的定义如下:

# samplerIndex

数字,代表当前正在设置第几张纹理的属性,由0开始

# textureFilter

纹理过滤模式(默认为Point),当实际显示的纹理贴图相比于原图进行了放大或缩小时,新的分辨率贴图与原分辨率贴图上像素点的映射关系,可以有以下的值:

	Point : 点采样
	Bilinear : 双线性采样
	Trilinear : 三线性采样
	MipMapBilinear : MipMap双线性采样
	TexelAA :纹素抗锯齿(不是所有设备都支持,不建议使用)
	PCF :通过比较函数进行采样(不是所有设备都支持,不建议使用)
# textureWrap

纹理包裹模式,控制uv若在[0,1]之外的时候应该采样到什么样的纹理,可以有如下的值:

	Repeat : 重复,即把值求模到[01]之间进行采样
	Clamp : 边缘采样,采样最靠近的边缘的值,即1.1比较靠近1,则取1-0.1比较靠近0,则取0

# vertex 顶点属性

# vertexFields

顶点属性,用于声明使用这个材质进行渲染的网格每个顶点保存有什么属性,由美术制作资源的时候决定,可能用到的有以下的值:

Position : 模型空间坐标
Color : 颜色
Normal : 法线
UV0 :纹理采样坐标
UV1 :纹理采样坐标
UV2 :纹理采样坐标
BoneId0 : 骨骼ID,骨骼模型中用到

# rasterizer 光栅化环境配置

# msaaSupport

配置MSAA(多重采样抗锯齿)的支持(引擎中的默认值为NonMSAA)

NonMSAA : 在没有开启MSAA的时候材质允许使用
MSAA : 在开启MSAA的时候材质允许使用
Both :无论是否开启MSAA,材质都允许使用。通常使用这个值就可以了。
# 深度偏移

深度偏移主要用于解决z-fighting问题,即当两个物体深度相近,则渲染时可能会出现某些帧显示这个物体,某些帧显示另一个物体这种闪烁现象。深度偏移的原理是把其中一个对象往深度大或者小的方向偏移一下,使他们的深度不再一样。 可配置以下四个变量:

depthBias
slopeScaledDepthBias
depthBiasOGL
slopeScaledDepthBiasOGL

具体偏移的深度为:

offset = (slopeScaledDepthBias * m) + (depthBias * r)

在OGL平台上则为:

offset = (slopeScaledDepthBiasOGL * m) + (depthBiasOGL * r)

m是多边形的深度的斜率(在光栅化阶段计算得出)中的最大值。一个多边形越是与近裁剪面平行,m就越接近0。 r是能产生在窗口坐标系的深度值中可分辨的差异的最小值,r是由具体实现OpenGL的平台指定的一个常量。


相关联的states渲染环境配置:

Wireframe : 绘制线框模式
DisableCulling : 同时渲染正面和反面
InvertCulling :使用正面裁剪。默认情况下为背面裁剪,声明这个之后则渲染背面,裁剪掉正面

# primitive 图元

# primitiveMode

图元渲染模式(引擎中的默认值为TriangleList):

None : 不渲染,正常情况下不会为这个值
QuadList :四边形模式
TriangleList : 每三个顶点绘制一个三角形的模式,例如第一个三角形使用顶点v0,v1,v2,第二个使用v3,v4,v5
TriangleStrip : 每一个顶点会与前两个出现的顶点构成三角形,结构复杂一点,但会节省数据量
LineList : 每两个顶点绘制一条线段
Line : 每一个顶点会与前一个出现的一个顶点构成线段。

# 材质变体

# variants

用于快速基于大部分相同定义实现多种子材质。看下面entity_static这个实际例子:

    "entity_static": {
      "vertexShader": "shaders/entity.vertex",
      "vrGeometryShader": "shaders/entity.geometry",
      "fragmentShader": "shaders/entity.fragment",
      "vertexFields": [
        { "field": "Position" },
        { "field": "Normal" },
        { "field": "UV0" }
      ],
      "variants": [
        {
          "skinning": {
            "+defines": [ "USE_SKINNING" ],
            "vertexFields": [
              { "field": "Position" },
              { "field": "BoneId0" },
              { "field": "Normal" },
              { "field": "UV0" }
            ]
          }
        },
        {
          "skinning_color": {
            "+defines": [ "USE_SKINNING", "USE_OVERLAY" ],
            "+states": [ "Blending" ],
            "vertexFields": [
              { "field": "Position" },
              { "field": "BoneId0" },
              { "field": "Color" },
              { "field": "Normal" },
              { "field": "UV0" }
            ]
          }
        }
      ],
      "msaaSupport": "Both",
      "+samplerStates": [
        {
          "samplerIndex": 0,
          "textureFilter": "Point"
        }
      ]
    },

variants即为材质变体的声明,上述声明了skinning和skinning_color两个子变体,子变体中对外部某些字段进行了改写。实际使用中,相当于快速定义了两个材质,本体和变体间用点"."连接,两个材质分别为entity_static.skinningentity_static.skinning_color

除此之外,后续如果有其它材质继承自entity_static,比如entity_dynamic,则此材质也会同时继承有此两种变体,分别为entity_dynamic.skinningentity_dynamic.skinning_color

# 材质合并规则

不同目录文件中声明了同一材质时的,在加载后会根据以下规则进行合并: 1.通常情况下后加载的文件的材质的字段会覆盖之前加载的 2.以下字段特殊,除了替换以外,还支持使用"+"添加与使用"-"删除属性的操作:

defines
states
samplerStates

举一个的例子,例如包体文件中声明了这么一材质(省略无关代码),定义了三个宏:

"testMat": {
	"defines": [ "MACRO_1", "MACRO_2", "MACRO_3" ],
}

此时,一个Mod也声明了此材质,定义了另外三个宏:

"testMat": {
	"defines": [ "MACRO_4", "MACRO_5", "MACRO_6" ],
}

上述情况下,最终运行时相当于defines字段被覆盖,实际运行时生效的宏只有: MACRO_4, MACRO_5, MACRO_6

若MOD中定义的时候使用了"+"符号:

"testMat": {
	"+defines": [ "MACRO_4", "MACRO_5", "MACRO_6" ],
}

相当于在原来的基础上添加定义,则实际运行时生效的宏有: MACRO_1, MACRO_2, MACRO_3, MACRO_4, MACRO_5, MACRO_6

若MOD中定义的时候使用了"-"符号:

"testMat": {
	"-defines": [ "MACRO_3"],
}

相当于在原来的基础上删除某些定义,则实际运行时生效的宏只有: MACRO_1, MACRO_2

若多个文件都对同一材质进行了定义,而且分别涉及有覆盖,添加,删除操作,则依次生效的顺序为: 先执行所有的覆盖操作,再执行所有的添加操作,最后执行所有的删除操作。

即如果有其中一个材质文件声明删除MACRO_3操作:

"testMat": {
	"-defines": [ "MACRO_3"],
}

则无论其它文件怎么覆盖,添加MACRO_3,最终合成后这一个材质一定不会有MACRO_3宏。