2.2 가우시안 블러 구현하기

2D 이미지 처리는 WebGPU에 대한 훌륭한 사용 사례입니다. 이 튜토리얼에서는 가장 일반적인 2D 이미지 필터 중 하나인 가우시안 블러를 구현할 것입니다. 단순한 구현은 간단하지만, 다섯 번의 점진적인 개선을 통해 최적화된 버전을 만드는 것을 목표로 할 것입니다.

2D 이미지 처리에서는 일반적으로 화면에 정렬된 사각형에 처리할 이미지를 렌더링합니다. 일반적인 설정은 이미 익숙하실 것이므로 여기서는 반복하지 않겠습니다. 대신, 프래그먼트 셰이더에 구현된 블러 알고리즘에 중점을 둘 것입니다. 자세한 파이프라인 설정 및 기타 세부 사항은 샘플 코드를 참조하시기 바랍니다.

가장 기본적인 버전부터 시작하겠습니다. 이 초기 버전은 아직 진정한 가우시안 블러는 아닙니다. 각 픽셀에 대해 주변 색상의 평균을 계산하는데, 이는 종종 박스 블러라고 불립니다. 이와 대조적으로, 완전한 가우시안 블러는 가우시안 커널을 기반으로 가중 평균을 계산합니다.

플레이그라운드 실행 - 2_02_1_blur_1

JavaScript 설정은 텍스처 매핑 튜토리얼 및 다른 많은 이전 샘플들과 매우 유사하므로, 이미 알고 있는 내용을 반복적으로 보여주는 것은 생략하겠습니다. 이 튜토리얼의 주요 초점은 알고리즘이 주로 구현되는 프래그먼트 셰이더에 있습니다.

@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입니다.

플레이그라운드 실행 - 2_02_3_blur_3

지금까지 살펴본 코드 스니펫의 문제는 각 프래그먼트마다 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}

AB에 집중해봅시다. 텍스처 좌표를 \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
GitHub에 의견 남기기