0. 高斯模糊基础
高斯模糊的原理已有很多文章介绍。实践上讲,就是对图像做一个卷积(通俗讲,就是采样相邻的几个像素并乘算上对应的权重)。我这里想采用的卷积核是5×5的(数据来自于这里的讨论):
{
0.01, 0.02, 0.04, 0.02, 0.01,
0.02, 0.04, 0.08, 0.04, 0.02,
0.04, 0.08, 0.16, 0.08, 0.04,
0.02, 0.04, 0.08, 0.04, 0.02,
0.01, 0.02, 0.04, 0.02, 0.01
};
现在,我想尝试在UE的材质节点中进行高斯模糊的操作。
1. 面临的问题和思路
在UE材质中所面临的一个问题就是——
采样操作需要重复多次,但是材质节点中并没有 循环 的操作,如果重复创建25个节点会比较麻烦且难以维护。
一种解决方法是借助外部的shader文件。(可以参考A Guide to Creating Your Own Shaders in Unreal Engine - EmptyQ)
而我不想借助外部的shader文件,我希望只使用材质节点完成。
开始我是这样想的:
材质最终还是会转换为HLSL代码。转换后的代码可以在材质编辑器中查看:
因此,每个材质节点都会以某种形式转换为HLSL代码。那采样的节点自然也会转换为对应的HLSL代码。所以,我可以使用文本差异工具,来比较出添加采样节点后HLSL代码增加的部分,而这部分就是纹理采样的代码。
而 Custom节点 可以插入HLSL代码,因此,知道了采样的HLSL代码是什么,就可以在代码中循环了。
不过后来发现,其实在 Custom节点官方文档 里的范例就是展示做相同的事,而且代码表达上更为优雅:(不过其中有个问题值得探究,见文末【延伸探究:TexSampler参数哪里来的】)
因此,接下来我将仿照这里的写法。
2. 实现
我的Custom的节点代码如下:
float kernel[25] =
{
0.01, 0.02, 0.04, 0.02, 0.01,
0.02, 0.04, 0.08, 0.04, 0.02,
0.04, 0.08, 0.16, 0.08, 0.04,
0.02, 0.04, 0.08, 0.04, 0.02,
0.01, 0.02, 0.04, 0.02, 0.01
};
float3 result = float3(0,0,0);
float step = range/5;
for(int x=0;x<5;x++)
for(int y=0;y<5;y++)
{
float2 uv = UV;
uv.x+=step*(x-2);
uv.y+=step*(y-2);
float3 color = Texture2DSample(Tex, TexSampler, uv);
result += color*kernel[x*5+y];
}
return result;
其中step
是采样时邻居的距离。
节点连接如下:
效果:
可以看到,基础效果是实现了。
不过一个很明显的瑕疵是:边界感。
3. 降低采样的Mip来避免瑕疵
这种瑕疵随着模糊范围的升高而越来越明显:
不难想到,这种瑕疵是因为采样的Mip等级太高所致的:可以想象,当采样的“邻居”相距很远的时候,也意味一个颜色的影响的距离会变得很远,而Mip等级高时颜色的变化频率也会更高,因此颜色的突变也会影响很远。
那么,Mip等级多少合适呢?
不难想到,它由step
决定:
- 假设图像分辨率512×512。那么
step
是 1 512 \frac{1}{512} 5121时,正好一步采样1个像素,那么Mip等级就是0
。 - 假设
step
是 1 256 \frac{1}{256} 2561时,那么一次采样跨越了2个像素,那么Mip等级就是1
- 假设
step
是 1 128 \frac{1}{128} 1281时,那么一次采样跨越了4个像素,那么Mip等级就是2
- 也就是说:一步采样的像素数=图像分辨率×
step
。Mip等级就是以2为底取“一步采样的像素数”的对数。
即:
M
i
p
等
级
=
log
2
(
图
像
分
辨
率
×
s
t
e
p
)
Mip等级=\log_2(图像分辨率×step)
Mip等级=log2(图像分辨率×step)
接下来实践一下。
首先,计算mip等级。
然后,采样的函数由 Texture2DSample
变成 Texture2DSampleLevel
,这将可以添加一个参数来指定Mip等级:
float kernel[25] =
{
0.01, 0.02, 0.04, 0.02, 0.01,
0.02, 0.04, 0.08, 0.04, 0.02,
0.04, 0.08, 0.16, 0.08, 0.04,
0.02, 0.04, 0.08, 0.04, 0.02,
0.01, 0.02, 0.04, 0.02, 0.01
};
float3 result = float3(0,0,0);
float step = range/5;
float mip = log2(TextureSize*step);
for(int x=0;x<5;x++)
for(int y=0;y<5;y++)
{
float2 uv = UV;
uv.x+=step*(x-2);
uv.y+=step*(y-2);
float3 color = Texture2DSampleLevel(Tex, TexSampler, uv, mip);
result += color*kernel[x*5+y];
}
return result;
纹理尺寸暂时硬编码为512:
效果:
可以看到之前的瑕疵已经没有了。
4*. 测试直接降低Mip的效果
毕竟,上面的方式采样了多次,效率是相对较低的。
其实,如果不强求“高斯模糊”的方式,直接降低Mip的方式也是可以达成 “模糊” 的目标的。
首先为了最好的效果,可以将图像的Mip生成方式变为Blur:
然后,将采样的Mip计算方式变为绝对值,而此值由参数决定:
测试效果:
对比下来,高斯模糊(左)相对于降低Mip的方式(右)更柔和,变化更细腻,毕竟Mip降低时的分辨率是较低的。而降低Mip的方式的优点是效率高。
延伸探究:TexSampler参数哪里来的
在 Custom节点官方文档 里的范例,以及本文的Custom节点的代码中,可以看到代码中的TexSampler
变量并没有在输入的列表中,那么它是哪里来的呢?
我想在这里深入探究一下。
首先,Custome节点的C++类是UMaterialExpressionCustom
。
而UMaterialExpressionCustom::Compile
函数在 MaterialExpressions.cpp中定义:
int32 UMaterialExpressionCustom::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
...
return Compiler->CustomExpression(this, CompiledInputs);
}
可以看到它最后调用了 Compiler(FMaterialCompiler
) 的CustomExpression
函数。
FHLSLMaterialTranslator
是实际的FMaterialCompiler
。而在它的CustomExpression
函数实现中终于看到了相关的逻辑:
int32 FHLSLMaterialTranslator::CustomExpression( class UMaterialExpressionCustom* Custom, TArray<int32>& CompiledInputs )
{
...
// Add call to implementation function
FString CodeChunk = FString::Printf(TEXT("CustomExpression%d(Parameters"), CustomExpressionIndex);
for( int32 i = 0; i < CompiledInputs.Num(); i++ )
{
// skip over unnamed inputs
if( Custom->Inputs[i].InputName.IsNone() )
{
continue;
}
FString ParamCode = GetParameterCode(CompiledInputs[i]);
EMaterialValueType ParamType = GetParameterType(CompiledInputs[i]);
CodeChunk += TEXT(",");
CodeChunk += *ParamCode;
if (ParamType == MCT_Texture2D || ParamType == MCT_TextureCube || ParamType == MCT_Texture2DArray || ParamType == MCT_TextureExternal || ParamType == MCT_VolumeTexture)
{
CodeChunk += TEXT(",");
CodeChunk += *ParamCode;
CodeChunk += TEXT("Sampler");
}
}
CodeChunk += TEXT(")");
ResultIdx = AddCodeChunk(
OutputType,
*CodeChunk
);
return ResultIdx;
}
可以看到,它在处理所有的输入的时候,如果发现类型是MCT_Texture2D
等纹理,会自动再补上一个同名且后接"Sampler"的参数。
这下就明白它的逻辑了:
如果Custom节点的输入中有名为XXX
的纹理,那么在编译时就会自动加上一个名为XXXSampler
的输入,因此可以在Custom节点的代码中直接使用这个Sampler作为采样函数的参数。