最近在使用wgpu做一个类似MineCraft的demo,当进行贴图采样时发现了一些问题。
由于texture很小(16px*16px)的缘故,默认的双线性采样、mipmap、各项异性抗锯齿都会有许多问题。一开始我想到了两个方案:
- 近处使用临近采样,远处使用双线性采样并开启mipmap和各项异性采样。
- 在把texture传入显存时对它做上采样到足够大。
方案一的效果很差,远处很模糊,抖动也很明显。
方案二更是要至少扩大为8倍的贴图才能有较好的结果,而这个预计会浪费64倍的显存,显然时不可接受的。
在网上搜索、询问一番后,我找到了合理的方法。
方法概述
本质也是邻近采样和双线性采样的结合,而且没有太多额外的开销。
在近处,大部分像素风格的贴图上的纹素都是显著大于屏幕像素的,此时大部分的像素可以采用邻近采样的方式直接获取颜色,而在纹素之间的像素则可以用插值的方法来做平滑过渡消除锯齿。而在足够远的地方就会退化为双线性采样并获得正确的mipmap和各项异性效果。
简单实现
先只考虑贴图正对屏幕的情况,WGSL的Shder如下:
@fragment
fn fs_main(
in: VertexOutput
) -> @location(0) vec4f {
//使用了texture array
let texture: texture_2d<f32> = texture_array[in.texture_index];
let texture_size = vec2<f32>(textureDimensions(texture));
let tx = frac(in.uv * texture_size);
let tx_offset = clamp(tx * pixelPertexels, 0.0, 0.5)
+ clamp((tx - 1.0) * pixelPertexels + 0.5 , 0.0, 0.5);
let uv = (floor(in.uv) + tx_offset) / texture_size;
return textureSample(
texture,
anisotropy_sampler,
uv,
);
}
其中in.uv * texture_size是为了把uv从归一化坐标,转化为以纹素为单位的坐标,人话就是这个屏幕像素在贴图的哪里。再对其取小数部分,得到像素pixel中心在纹素texel区域中的相对位置信息。比如(0.5,0.5)是在纹素中央的像素,(0,0.25)是在左下角边缘四分之一处的像素。
pixelPertexels可以用导数计算或者uniform传参等形式传入,在近处一般很大;比如6.6,代表每6.6个像素才有一个纹素那么大。
tx_offset是用来判断当前像素是否处于两个纹素的交界处的,也就是否需要插值,还是直接采样中心的纯色。tx * pixelPertexels当像素足够贴近左下边缘时会得到接近0的数值,说明处于边缘位置;另一边是同理的。我个人觉得这个乘法挺难懂得,可以写成if的形式帮助理解这里的含义:
result.x = 0.0;
//靠近左边缘了需要插值
if(tx.x < 1.0/pixelPertexels){
//得到靠近的比例
let r = tx.x * pixelPertexels;
result.x += r;
}
else{
//左下完全可以看成是在纹素内的
result.x += 0.5;
}
由于采样器是正常的双线性采样器,在我们偏移了UV之后,除了纹素中心的位置(0.5,0.5)的颜色其它颜色都是已经插值过的,其插值的比例也会映射到对应的偏移上,非常巧妙的实现了边缘色彩的插值。而当整数缩放时则完全不会有边缘插值的情况。
适配更多情况
但是很显然,在我们的需求中,纹理不可能永远完全正对着屏幕。处理斜着的纹理需要一些近似,WGSL代码如下:
@fragment
fn fs_main(
in: VertexOutput
) -> @location(0) vec4f {
let texture: texture_2d<f32> = texture_array[in.texture_index];
let texture_size = vec2<f32>(textureDimensions(texture));
//导数用来计算不是正对着的情况下的包围框的大小
let dx = dpdx(in.uv);
let dy = dpdy(in.uv);
let box_size = clamp(((dx + dy) * texture_size), vec2f(1e-5), vec2f(1.0));
let tx = in.uv * texture_size - 0.5 * box_size;
//let tx_offset = clamp((fract(tx) - (vec2(1.0) - box_size)/box_szie),
// vec2(0.0), vec2(1.0));
let tx_offset = smoothstep(1.0 - box_size, vec2(1.0), fract(tx));
let uv = (floor(tx) + 0.5 + tx_offset) / texture_size;
return textureSampleGrad(
texture,
anisotropy_sampler,
uv,
dx,
dy
);
}
dpdx(in.uv)和dpdy(in.uv)是uv对于屏幕x和y的变化率的导数,将他们相加,当正对着屏幕时可以看作是texel per pixel(纹素每像素);不是正对着时,可以看作是投影到屏幕的uv的包围框,其单位是和uv本身一致的。
将上述数据乘以texture_size得到像素在纹理中的大小,也就是box_size。clamp一下防止除0异常或太大了。
tx是像素中心点在纹理里的坐标,由uv转换到纹理在减掉半个像素(在纹理里的)大小得到。
tx_offset仍然是用来当前像素是否处于两个纹素的交界处的,这里有一些简化计算具体可以看参考资料里的视频,用smoothstep可以得到更好一些的边缘采样效果。
textureSampleGrad则是保证各项异性采样正确的。
最终结果如图所示:
看不懂啊