# GIF 만들기

가끔 멋진 시뮬레이션이나 애니메이션을 만들고 나면, 이를 자랑하고 싶을 때가 있습니다. 비디오를 녹화할 수도 있지만, 트위터에 올릴 간단한 결과물을 위해 비디오 녹화 프로그램을 사용하는 것은 좀 과할 수 있습니다. 바로 이럴 때 GIF (opens new window)가 유용합니다.

참고로, GIF는 JIF(지프)가 아니라 GHIF(기프)로 발음합니다. JIF는 땅콩버터 (opens new window) 브랜드 이름일 뿐만 아니라, 다른 이미지 형식 (opens new window)의 이름이기도 합니다.

# 어떻게 GIF를 만들까요?

실제 이미지를 인코딩하기 위해 gif 크레이트 (opens new window)를 사용하여 함수를 만들 것입니다.

fn save_gif(path: &str, frames: &mut Vec<Vec<u8>>, speed: i32, size: u16) -> Result<(), failure::Error> {
    use gif::{Frame, Encoder, Repeat, SetParameter};
    
    let mut image = std::fs::File::create(path)?;
    // 원문 코드에 너비, 높이 인수가 누락되어 있습니다. 올바른 사용법은 다음과 같을 수 있습니다:
    // let mut encoder = Encoder::new(&mut image, size, size, &[])?;
    let mut encoder = Encoder::new(&mut image, size, size, &[])?;
    encoder.set(Repeat::Infinite)?;

    for mut frame in frames {
        // 원문 코드에 너비, 높이 인수가 누락되어 있습니다. 올바른 사용법은 다음과 같을 수 있습니다:
        // encoder.write_frame(&Frame::from_rgba_speed(size, size, &mut frame, speed))?;
        encoder.write_frame(&Frame::from_rgba_speed(size, size, &mut frame, speed))?;
    }

    Ok(())
}

이 코드를 사용하기 위해 필요한 것은 GIF의 프레임, 재생 속도, 그리고 GIF의 크기(너비와 높이를 따로 사용할 수도 있지만, 여기서는 하나로 사용했습니다)뿐입니다.

# 어떻게 프레임을 만들까요?

창 없는 쇼케이스를 확인했다면, 우리가 wgpu::Texture에 직접 렌더링한다는 것을 아실 겁니다. 렌더링할 텍스처와 그 결과물을 복사할 버퍼를 생성할 것입니다.

// 렌더링할 텍스처 생성
let texture_size = 256u32;
let rt_desc = wgpu::TextureDescriptor {
    size: wgpu::Extent3d {
        width: texture_size,
        height: texture_size,
        depth_or_array_layers: 1,
    },
    mip_level_count: 1,
    sample_count: 1,
    dimension: wgpu::TextureDimension::D2,
    format: wgpu::TextureFormat::Rgba8UnormSrgb,
    usage: wgpu::TextureUsages::COPY_SRC
        | wgpu::TextureUsages::RENDER_ATTACHMENT,
    label: None,
};
let render_target = framework::Texture::from_descriptor(&device, rt_desc);

// wgpu는 텍스처에서 버퍼로 복사 시 `wgpu::COPY_BYTES_PER_ROW_ALIGNMENT`에 맞춰
// 정렬할 것을 요구합니다. 이 때문에, 패딩이 추가된 행당 바이트 수(padded_bytes_per_row)와
// 패딩이 없는 행당 바이트 수(unpadded_bytes_per_row)를 모두 저장해야 합니다.
let pixel_size = mem::size_of::<[u8;4]>() as u32;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let unpadded_bytes_per_row = pixel_size * texture_size;
let padding = (align - unpadded_bytes_per_row % align) % align;
let padded_bytes_per_row = unpadded_bytes_per_row + padding;

// 텍스처를 복사하여 데이터를 가져올 수 있도록 버퍼 생성
let buffer_size = (padded_bytes_per_row * texture_size) as wgpu::BufferAddress;
let buffer_desc = wgpu::BufferDescriptor {
    size: buffer_size,
    usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
    label: Some("Output Buffer"),
    mapped_at_creation: false,
};
let output_buffer = device.create_buffer(&buffer_desc);

이제 프레임을 렌더링하고, 그 프레임을 Vec<u8>로 복사할 수 있습니다.

let mut frames = Vec::new();

for c in &colors {
    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: None,
    });

    let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
        label: Some("GIF Pass"),
        color_attachments: &[
            wgpu::RenderPassColorAttachment {
                view: &render_target.view,
                resolve_target: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(
                        wgpu::Color {
                            r: c[0],
                            g: c[1],
                            b: c[2],
                            a: 1.0,
                        }
                    ),
                    store: wgpu::StoreOp::Store,
                },
            }
        ],
        depth_stencil_attachment: None,
    });

    rpass.set_pipeline(&render_pipeline);
    rpass.draw(0..3, 0..1);

    drop(rpass);

    encoder.copy_texture_to_buffer(
        wgpu::ImageCopyTexture {
            texture: &render_target.texture,
            mip_level: 0,
            origin: wgpu::Origin3d::ZERO,
            aspect: wgpu::TextureAspect::All,
        },
        wgpu::ImageCopyBuffer {
            buffer: &output_buffer,
            layout: wgpu::ImageDataLayout {
                offset: 0,
                bytes_per_row: Some(std::num::NonZeroU32::new(padded_bytes_per_row).unwrap()),
                rows_per_image: Some(std::num::NonZeroU32::new(texture_size).unwrap()),
            },
        },
        render_target.desc.size,
    );

    queue.submit(std::iter::once(encoder.finish()));
    
    // 맵핑 요청 생성
    let buffer_slice = output_buffer.slice(..);
    // 비동기 맵핑 요청. 참고: map_async는 &self를 받으므로 request 변수가 필요하지 않을 수 있습니다.
    let (tx, rx) = futures_intrusive::channel::shared::oneshot_channel();
    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
        tx.send(result).unwrap();
    });
    // GPU 작업이 끝날 때까지 대기
    device.poll(wgpu::PollType::Wait);
    let result = rx.receive().await;
    
    match result {
        Some(Ok(())) => {
            let padded_data = buffer_slice.get_mapped_range();
            // 각 행에서 패딩을 제거하여 실제 이미지 데이터만 수집
            let data = padded_data
                .chunks(padded_bytes_per_row as _)
                .map(|chunk| &chunk[..unpadded_bytes_per_row as _])
                .flatten()
                .copied()
                .collect::<Vec<_>>();
            drop(padded_data);
            output_buffer.unmap();
            frames.push(data);
        }
        _ => { eprintln!("Something went wrong") }
    }
}

이 작업이 끝나면 save_gif() 함수에 프레임들을 전달할 수 있습니다.

save_gif("output.gif", &mut frames, 1, texture_size as u16).unwrap();

이것이 기본적인 과정입니다. 텍스처 배열을 사용하고 그리기 명령을 한 번에 보내는 방식으로 성능을 개선할 수도 있지만, 이 예제는 기본적인 아이디어를 전달하는 데 중점을 둡니다. 제가 작성한 셰이더를 사용하면 다음과 같은 GIF를 얻을 수 있습니다.

./output.gif

Last Updated: 6/11/2025, 8:57:07 PM