2.2 가우시안 블러 구현하기
2D 이미지 처리는 WebGPU에 대한 훌륭한 사용 사례입니다. 이 튜토리얼에서는 가장 일반적인 2D 이미지 필터 중 하나인 가우시안 블러를 구현할 것입니다. 단순한 구현은 간단하지만, 다섯 번의 점진적인 개선을 통해 최적화된 버전을 만드는 것을 목표로 할 것입니다.
2D 이미지 처리에서는 일반적으로 화면에 정렬된 사각형에 처리할 이미지를 렌더링합니다. 일반적인 설정은 이미 익숙하실 것이므로 여기서는 반복하지 않겠습니다. 대신, 프래그먼트 셰이더에 구현된 블러 알고리즘에 중점을 둘 것입니다. 자세한 파이프라인 설정 및 기타 세부 사항은 샘플 코드를 참조하시기 바랍니다.
가장 기본적인 버전부터 시작하겠습니다. 이 초기 버전은 아직 진정한 가우시안 블러는 아닙니다. 각 픽셀에 대해 주변 색상의 평균을 계산하는데, 이는 종종 박스 블러라고 불립니다. 이와 대조적으로, 완전한 가우시안 블러는 가우시안 커널을 기반으로 가중 평균을 계산합니다.
플레이그라운드 실행 - 2_02_1_blur_1JavaScript 설정은 텍스처 매핑 튜토리얼 및 다른 많은 이전 샘플들과 매우 유사하므로, 이미 알고 있는 내용을 반복적으로 보여주는 것은 생략하겠습니다. 이 튜토리얼의 주요 초점은 알고리즘이 주로 구현되는 프래그먼트 셰이더에 있습니다.
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
const kernelSize = 8.0;
var color = vec4(0.0, 0.0, 0.0, 0.0);
var intensity: f32 = 0.0;
for(var y: f32 = - kernelSize; y <= kernelSize; y+=1.0) {
let offsettedY = y + in.pixel_coord.y;
if (offsettedY >= 0.0 && offsettedY <= img_size.y ) {
for (var x: f32 = -kernelSize; x<=kernelSize; x+=1.0) {
let offsettedX = x + in.pixel_coord.x;
if (offsettedX >= 0.0 && offsettedX <= img_size.x) {
let tex_coord = vec2(offsettedX / img_size.x, offsettedY / img_size.y);
let c = textureSampleLevel(t_diffuse, s_diffuse, tex_coord,0);
color += c;
intensity += 1.0;
}
}
}
}
color /= intensity;
color.w = 1.0;
return color;
}
기본 버전의 일반적인 아이디어는 간단합니다. 각 픽셀에 대해 주변 15x15 윈도우를 살펴봅니다. 이 윈도우에서 모든 픽셀을 가져와 평균 색상을 계산합니다. 이전에 `textureSample` 함수는 균일한 제어 흐름(uniform control flow) 내에서 호출되어야 한다고 언급했음을 상기하십시오. 여기서는 `textureSampleLevel`이라는 약간 다른 변형을 사용하는데, 이는 균일한 제어 흐름이 아닙니다. 차이점은 무엇일까요?
`textureSampleLevel` 함수는 `textureSample`에 비해 하나의 추가 매개변수, 즉 레벨 ID를 필요로 합니다. 결과적으로 `textureSampleLevel`은 밉매핑을 수행하지 않고 명시적인 레벨 인덱스를 요구합니다. `textureSample`에서 올바른 밉맵 레벨을 결정하는 로직이 균일한 제어 흐름을 필요로 하는 이유입니다. 밉매핑이 필요 없는 경우, 우리는 균일한 제어 흐름 외부에서 텍스처를 샘플링할 수 있습니다. 올바른 밉맵 레벨을 결정하는 데 왜 균일성이 필요한지는 향후 장에서 설명할 것입니다.
이제 개선된 버전을 살펴보겠습니다:
플레이그라운드 실행 - 2_02_2_blur_2@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
const kernelSize = 8.0;
var color = vec4(0.0, 0.0, 0.0, 0.0);
var intensity: f32 = 0.0;
const sigma = 8.0;
const PI = 3.1415926538;
for(var y: f32 = - kernelSize; y <= kernelSize; y+=1.0) {
let offsettedY = y + in.pixel_coord.y;
if (offsettedY >= 0.0 && offsettedY <= img_size.y ) {
for (var x: f32 = -kernelSize; x<=kernelSize; x+=1.0) {
let offsettedX = x + in.pixel_coord.x;
if (offsettedX >= 0.0 && offsettedX <= img_size.x) {
let tex_coord = vec2(offsettedX / img_size.x, offsettedY / img_size.y);
let gaussian_v = 1.0 / (2.0 * PI * sigma * sigma) * exp(-(x*x + y*y) / (2.0 * sigma * sigma));
let c = textureSampleLevel(t_diffuse, s_diffuse, tex_coord,0);
color += c * gaussian_v;
intensity += gaussian_v;
}
}
}
}
color /= intensity;
color.w = 1.0;
return color;
}
이 버전은 이전 버전과 크게 다르지 않지만, 이제 각 색상에 가중치를 적용합니다. 이 가중치는 각 픽셀이 중심 픽셀로부터의 거리에 따라 가우시안 값을 기반으로 하며, 이는 더 가까운 텍셀이 멀리 떨어진 텍셀보다 최종 색상에 더 많이 기여하도록 보장합니다.
2D 가우시안의 방정식을 상기해봅시다:
G(x,y) = \frac{1}{2\pi\sigma^2}e^{-\frac{x^2+y^2}{2\sigma^2}}
위 버전의 문제는 각 프래그먼트마다 가우시안 값을 다시 계산한다는 점입니다. 15x15 윈도우는 모든 프래그먼트에 대해 동일하므로 이는 불필요합니다. 15x15 윈도우 내 각 픽셀의 가우시안 값은 중심으로부터의 거리에 의해서만 결정되므로 모든 프래그먼트에서 값이 동일합니다. 따라서 가우시안 가중치를 한 번만 계산하고 이를 조회 테이블에 캐시해야 합니다. 이렇게 함으로써 반복적으로 재계산하는 대신 가우시안 가중치를 가져올 수 있습니다. 코드를 accordingly 수정해 봅시다.
@group(0) @binding(2)
var img_size: vec2;
//texture doesn't need , because it is non-host-sharable (what does it mean?)
@group(0) @binding(3)
var t_diffuse: texture_2d;
@group(0) @binding(4)
var s_diffuse: sampler;
@group(0) @binding(5)
var kernel: array;
@group(0) @binding(6)
var kernel_size: f32;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
var color = vec4(0.0, 0.0, 0.0, 0.0);
var intensity: f32 = 0.0;
for(var y: f32 = - kernel_size; y <= kernel_size; y+=1.0) {
let offsettedY = y + in.pixel_coord.y;
if (offsettedY >= 0.0 && offsettedY <= img_size.y ) {
for (var x: f32 = -kernel_size; x<=kernel_size; x+=1.0) {
let offsettedX = x + in.pixel_coord.x;
if (offsettedX >= 0.0 && offsettedX <= img_size.x) {
let indexY = u32(y + kernel_size);
let indexX = u32(x + kernel_size);
let index = indexY * (u32(kernel_size) * 2 + 1) + indexX;
let tex_coord = vec2(offsettedX / img_size.x, offsettedY / img_size.y);
let gaussian_v = kernel[index];
let c = textureSampleLevel(t_diffuse, s_diffuse, tex_coord,0);
color += c * gaussian_v;
intensity += gaussian_v;
}
}
}
}
color /= intensity;
color.w = 1.0;
return color;
}
가우시안 가중치는 JavaScript에서 계산되어 스토리지 배열로 전달됩니다. 스토리지 배열을 접한 것은 이번이 처음입니다. 가중치 배열의 크기가 윈도우 크기와 관련되어 있고, 윈도우 크기를 조절할 수 있도록 유연하게 만들고 싶기 때문에 스토리지 버퍼를 사용합니다. 결과적으로 배열 크기는 미리 결정될 수 없습니다. 런타임 크기 배열은 유니폼 주소 공간이 아닌 스토리지 주소 공간에서만 사용할 수 있으므로, 여기서는 스토리지 버퍼가 적절한 선택입니다. 스토리지 버퍼는 셰이더에서 쓰기 가능한 값을 지원하는 등 추가적인 이점을 제공합니다. 향후 튜토리얼에서 스토리지 버퍼의 더 많은 사용법을 살펴볼 것입니다.
let kValues = []
const kernelSize = 8.0;
const sigma = 8.0;
let intensity = 0.0;
for (let y = - kernelSize; y <= kernelSize; y += 1.0) {
for (let x = -kernelSize; x <= kernelSize; x += 1.0) {
let gaussian_v = 1.0 / (2.0 * Math.PI * sigma * sigma) * Math.exp(-(x * x + y * y) / (2.0 * sigma * sigma));
intensity += gaussian_v;
kValues.push(gaussian_v);
}
}
const kernelBuffer = new Float32Array(kValues);
const kernelBufferStorageBuffer = createGPUBuffer(device, kernelBuffer, GPUBufferUsage.STORAGE);
const kernelSizeBuffer = new Float32Array([kernelSize]);
const kernelBufferSizeUniformBuffer = createGPUBuffer(device, kernelSizeBuffer, GPUBufferUsage.UNIFORM);
let uniformBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 1,
visibility: GPUShaderStage.VERTEX,
buffer: {}
},
{
binding: 2,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
},
{
binding: 3,
visibility: GPUShaderStage.FRAGMENT,
texture: {}
},
{
binding: 4,
visibility: GPUShaderStage.FRAGMENT,
sampler: {}
},
{
binding: 5,
visibility: GPUShaderStage.FRAGMENT,
buffer: {
type: 'read-only-storage'
}
},
{
binding: 6,
visibility: GPUShaderStage.FRAGMENT,
buffer: {}
}
]
});
let uniformBindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
entries: [
{
binding: 0,
resource: {
buffer: translateMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUniformBuffer
}
},
{
binding: 2,
resource: {
buffer: imgSizeUniformBuffer
}
},
{
binding: 3,
resource: texture.createView()
},
{
binding: 4,
resource:
sampler
},
{
binding: 5,
resource: {
buffer: kernelBufferStorageBuffer
}
},
{
binding: 6,
resource: {
buffer: kernelBufferSizeUniformBuffer
}
}
]
});
위 코드 스니펫은 JavaScript에서 커널 데이터를 채우는 방법을 보여줍니다. 셰이더에 지정된 스토리지 주소 공간과 일치하도록 `STORAGE`로 설정된 사용법에 주목하십시오. `uniformBindGroupLayout`을 정의할 때 `type: 'read-only-storage'`를 지정해야 합니다. 2D 가우시안 커널은 1D 배열로 펼쳐집니다. 셰이더 내부에서는 xy 좌표에서 1D 접근 인덱스를 복구합니다. `kernelSize`는 윈도우 크기를 정의하며, windowSize = 2 * kernelSize + 1입니다.
지금까지 살펴본 코드 스니펫의 문제는 각 프래그먼트마다 15x15번 반복해야 한다는 점입니다. 이는 특히 픽셀 수에 곱해질 때 상당한 양의 계산을 수반합니다. 이를 더 최적화하려면 루프 반복 횟수를 줄여야 합니다.
\begin{aligned}
G(x,y) &= \frac{1}{2\pi\sigma^2}e^{-\frac{x^2+y^2}{2\sigma^2}}
G(x,y) &= \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{x^2}{2\sigma^2}}\times \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{y^2}{2\sigma^2}}
\end{aligned}
2D 가우시안 방정식을 살펴보면, 곱셈으로 분리될 수 있음을 알 수 있습니다. 가우시안 커널의 왼쪽 열을 예로 들어, 먼저 이를 1D 가우시안으로 취급하여 각 텍셀에 대한 수직 가중 합을 계산할 수 있습니다. 그런 다음, 이를 수평 가우시안인 \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\frac{x^2}{2\sigma^2}}으로 곱합니다. 따라서 계산을 두 개의 패스로 나눌 수 있습니다. 첫 번째 패스에서는 1D 가우시안 블러를 수직으로 계산하고, 두 번째 패스에서는 수평으로 계산합니다. 최종 결과는 15x15 윈도우에서 계산하는 것과 동일합니다.
이 접근 방식의 이점은 루프 횟수를 크게 줄인다는 점입니다. 수직 1D 가우시안을 계산하기 위해 15번만 루프하면 됩니다. 수평 계산을 위한 15번의 루프를 추가하면, 이전의 15x15 (225)번과 비교하여 총 30번만 루프하면 됩니다. 이는 상당한 절약입니다.
그러나 이 최적화는 두 개의 셰이더로 두 번의 패스를 필요로 합니다:
@group(0) @binding(0)
var img_size: vec2;
//texture doesn't need , because it is non-host-sharable (what does it mean?)
@group(0) @binding(1)
var t_diffuse: texture_2d;
@group(0) @binding(2)
var s_diffuse: sampler;
@group(0) @binding(3)
var kernel: array;
@group(0) @binding(4)
var kernel_size: f32;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
var color = vec4(0.0, 0.0, 0.0, 0.0);
var intensity: f32 = 0.0;
for(var y: f32 = - kernel_size; y <= kernel_size; y+=1.0) {
let offsettedY = y + in.pixel_coord.y;
if (offsettedY >= 0.0 && offsettedY <= img_size.y ) {
let indexY = u32(y + kernel_size);
let tex_coord = vec2(in.pixel_coord.x / img_size.x, offsettedY / img_size.y);
let gaussian_v = kernel[indexY];
let c = textureSampleLevel(t_diffuse, s_diffuse, tex_coord,0);
color += c * gaussian_v;
intensity += gaussian_v;
}
}
color /= intensity;
color.w = 1.0;
return color;
}
그리고 수평:
@group(0) @binding(2)
var img_size: vec2;
@group(0) @binding(3)
var v_result: texture_2d;
@group(0) @binding(4)
var s_diffuse: sampler;
@group(0) @binding(5)
var kernel: array;
@group(0) @binding(6)
var kernel_size: f32;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
var color = vec4(0.0, 0.0, 0.0, 0.0);
var intensity: f32 = 0.0;
for(var x: f32 = - kernel_size; x <= kernel_size; x+=1.0) {
let offsettedX = x + in.pixel_coord.x;
if (offsettedX >= 0.0 && offsettedX <= img_size.x ) {
let indexX = u32(x + kernel_size);
let tex_coord = vec2(offsettedX / img_size.x, in.pixel_coord.y / img_size.y);
let gaussian_v = kernel[indexX];
let c = textureSampleLevel(v_result, s_diffuse, tex_coord,0);
color += c * gaussian_v;
intensity += gaussian_v;
}
}
color /= intensity;
color.w = 1.0;
return color;
}
셰이더로 전달되는 커널도 약간 다릅니다:
let kValues = []
const kernelSize = 8.0;
const sigma = 8.0;
let intensity = 0.0;
for (let y = - kernelSize; y <= kernelSize; y += 1.0) {
let gaussian_v = 1.0 / Math.sqrt(2.0 * Math.PI * sigma * sigma) * Math.exp(-(y * y) / (2.0 * sigma * sigma));
intensity += gaussian_v;
kValues.push(gaussian_v);
}
const kernelBuffer = new Float32Array(kValues);
const kernelBufferStorageBuffer = createGPUBuffer(device, kernelBuffer, GPUBufferUsage.STORAGE);
const kernelSizeBuffer = new Float32Array([kernelSize]);
const kernelBufferSizeUniformBuffer = createGPUBuffer(device, kernelSizeBuffer, GPUBufferUsage.UNIFORM);
이 셰이더를 위해 두 개의 파이프라인을 생성하고 연속적으로 실행해야 합니다. 첫 번째 패스는 수직 결과를 계산하고 이를 임시 텍스처 맵에 캐시합니다. 두 번째 패스는 첫 번째 패스의 결과를 기반으로 최종 출력을 생성합니다.
const pipelineLayoutDescPass1 = { bindGroupLayouts: [uniformBindGroupLayoutPass1] };
const pipelineLayoutPass1 = device.createPipelineLayout(pipelineLayoutDescPass1);
const colorStatePass1 = {
format: 'rgba8unorm'
};
const pipelineDescPass1 = {
layout: pipelineLayoutPass1,
vertex: {
module: shaderModuleVertical,
entryPoint: 'vs_main',
buffers: [pixelCoordsBufferLayoutDescPass1]
},
fragment: {
module: shaderModuleVertical,
entryPoint: 'fs_main',
targets: [colorStatePass1]
},
primitive: {
topology: 'triangle-strip',
frontFace: 'ccw',
cullMode: 'none'
}
};
const pipelinePass1 = device.createRenderPipeline(pipelineDescPass1);
let colorAttachmentPass1 = {
view: pass1texture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: 'clear',
storeOp: 'store'
};
const renderPassDescPass1 = {
colorAttachments: [colorAttachmentPass1]
};
commandEncoder = device.createCommandEncoder();
passEncoder = commandEncoder.beginRenderPass(renderPassDescPass1);
passEncoder.setViewport(0, 0, texture.width, texture.height, 0, 1);
passEncoder.setPipeline(pipelinePass1);
passEncoder.setBindGroup(0, uniformBindGroupPass1);
passEncoder.setVertexBuffer(0, pixelCoordsBuffer);
passEncoder.draw(4, 1);
passEncoder.end();
let uniformBindGroupPass2 = device.createBindGroup({
layout: uniformBindGroupLayoutPass2,
entries: [
{
binding: 0,
resource: {
buffer: translateMatrixUniformBuffer
}
},
{
binding: 1,
resource: {
buffer: projectionMatrixUniformBuffer
}
},
{
binding: 2,
resource: {
buffer: imgSizeUniformBuffer
}
},
{
binding: 3,
resource: pass1texture.createView()
},
{
binding: 4,
resource:
sampler
},
{
binding: 5,
resource: {
buffer: kernelBufferStorageBuffer
}
},
{
binding: 6,
resource: {
buffer: kernelBufferSizeUniformBuffer
}
}
]
});
const pipelineLayoutDescPass2 = { bindGroupLayouts: [uniformBindGroupLayoutPass2] };
const pipelineLayoutPass2 = device.createPipelineLayout(pipelineLayoutDescPass2);
const colorStatePass2 = {
format: 'bgra8unorm'
};
const pipelineDescPass2 = {
layout: pipelineLayoutPass2,
vertex: {
module: shaderModuleHorizontal,
entryPoint: 'vs_main',
buffers: [positionBufferLayoutDesc, pixelCoordsBufferLayoutDescPass2]
},
fragment: {
module: shaderModuleHorizontal,
entryPoint: 'fs_main',
targets: [colorStatePass2]
},
primitive: {
topology: 'triangle-strip',
frontFace: 'ccw',
cullMode: 'none'
}
};
const pipelinePass2 = device.createRenderPipeline(pipelineDescPass2);
let pass2texture = context.getCurrentTexture();
let colorAttachmentPass2 = {
view: pass2texture.createView(),
clearValue: { r: 1.0, g: 0.0, b: 0.0, a: 1.0 },
loadOp: 'clear',
storeOp: 'store'
}
const renderPassDescPass2 = {
colorAttachments: [colorAttachmentPass2]
}
let passEncoder2 = commandEncoder.beginRenderPass(renderPassDescPass2);
passEncoder2.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder2.setPipeline(pipelinePass2);
passEncoder2.setBindGroup(0, uniformBindGroupPass2);
passEncoder2.setVertexBuffer(0, positionBuffer);
passEncoder2.setVertexBuffer(1, pixelCoordsBuffer);
passEncoder2.draw(4, 1);
passEncoder2.end();
device.queue.submit([commandEncoder.finish()]);
이 두 패스를 생성하고 실행하는 정확한 프로세스는 `render-to-texture` 튜토리얼에 설명된 것과 동일합니다.
플레이그라운드 실행 - 2_02_4_blur_4하지만 이것이 최적화의 끝은 아닙니다. 텍스처 맵 액세스에 내장된 이중 선형 보간을 활용하여 가우시안 블러의 성능을 향상시킬 또 다른 기회가 있습니다. 텍스처를 단순한 2D 배열로 모델링한다면, 각 텍셀에 접근하는 데 한 번의 텍스처 읽기가 필요할 것입니다. 그러나 텍스처 맵은 2D 배열과 정확히 같지 않습니다. 비정수 좌표를 사용하여 샘플링할 수 있기 때문입니다. 이 경우 이중 선형 보간이 개입되어 주변 픽셀의 가중 평균을 산출합니다. 예를 들어, 두 개의 인접한 텍셀의 합을 얻는 것이 목표라면, 텍스처 읽기 좌표를 그들 중간에 설정하고 값을 2로 다시 스케일링함으로써 두 값의 평균을 얻기 위해 한 번의 텍스처 읽기를 사용할 수 있습니다.
가우시안 가중치 값을 공식에 통합하여 더 스마트하게 만들 수 있습니다. 예를 들어, 위 그림에는 9개의 텍셀로 구성된 1D 텍스처 맵이 있습니다. 각 녹색 점은 텍셀을 나타냅니다. 만약 이것이 1D 배열이라면, 모든 값을 가져오기 위해 9번 접근해야 할 것입니다. 그러나 이것이 텍스처 맵이라면, 내장된 보간을 활용하여 한 번에 두 개의 텍셀에 접근할 수 있습니다. 총 5번의 접근만으로 작업을 완료할 수 있습니다.
첫 번째 단계는 인접한 텍셀을 쌍으로 묶는 것입니다. 이 텍셀들의 색상 값이 A, B, \ldots, I이고 가우시안 가중치가 a, b, \ldots, i라고 가정할 때, 우리가 궁극적으로 계산하고자 하는 값은 다음과 같습니다:
\frac{aA + bB + ... +iI}{a + b + ... + i}
A와 B에 집중해봅시다. 텍스처 좌표를 \frac{b}{a+b}로 설정하면 다음 샘플링 값을 얻을 수 있습니다:
\begin{aligned}
v &= \frac{a}{a+b} \times A + (1 - \frac{b}{a+b}) \times B
v &= \frac{aA+bB}{a+b}
\end{aligned}
우리가 원하는 것은 aA + bB이므로, a+b로 다시 스케일링하기만 하면 됩니다. 모든 두 텍셀 그룹에 대해 이 과정을 반복하고, 마지막으로 가중 합을 모든 가우시안 가중치의 합으로 나눕니다. 이 접근 방식은 본질적으로 텍스처에 9번 접근하는 것과 동일한 결과를 얻습니다.
위의 과정은 다음과 같은 변경 사항으로 구현됩니다:
let kValues = [];
let offsets = [];
const kernelSize = 8.0;
const sigma = 8.0;
let intensity = 0.0;
for (let y = - kernelSize; y <= kernelSize; y += 1.0) {
let gaussian_v = 1.0 / Math.sqrt(2.0 * Math.PI * sigma * sigma) * Math.exp(-(y * y) / (2.0 * sigma * sigma));
intensity += gaussian_v;
kValues.push(gaussian_v);
offsets.push(y);
}
let kValues2 = [];
let offsets2 = [];
let i = 0;
// loop for all 2-texel groups
for (; i < kValues.length - 1; i += 2) {
const A = kValues[i];
const B = kValues[i + 1];
const k = A + B;
const alpha = A / k;
const offset = offsets[i] + alpha;
kValues2.push(k / intensity);
offsets2.push(offset);
}
// if the window size is an odd number, there should be one lingering texel left
if (i < kValues.length) {
const A = kValues[i];
const offset = offsets[i];
kValues2.push(A / intensity);
offsets2.push(offset);
}
셰이더에서는 두 가지 주요 변경 사항을 도입합니다. 첫째, 텍스처 접근 오프셋(좌표)을 결정하기 위해 조회를 사용하여 두 개의 인접한 텍셀을 효율적으로 혼합할 수 있습니다. 둘째, 최종 가중 합을 적절히 조정하기 위해 스케일 백 인수를 통합합니다.
@group(0) @binding(2)
var img_size: vec2;
@group(0) @binding(3)
var v_result: texture_2d;
@group(0) @binding(4)
var s_diffuse: sampler;
@group(0) @binding(5)
var weights: array;
@group(0) @binding(6)
var offsets: array;
@group(0) @binding(7)
var kernel_size: u32;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
var color = vec4(0.0, 0.0, 0.0, 0.0);
for(var x: u32 = 0; x < kernel_size; x++) {
let offsettedX = offsets[x] + in.pixel_coord.x;
let tex_coord = vec2(offsettedX / img_size.x, in.pixel_coord.y / img_size.y);
// access texture in middle of two samples
let c = textureSampleLevel(v_result, s_diffuse, tex_coord,0);
// scales back by weight
color += c * weights[x];
}
color.w = 1.0;
return color;
}
이러한 변경 사항을 통해 이전과 동일한 결과를 얻으면서도 성능을 크게 향상시킬 수 있습니다.
플레이그라운드 실행 - 2_02_5_blur_5