ashen-aetna-ko

잿빛 에트나

— 재 덮인 화산 위에서 Rust와 함께 비틀거리기

(3D 그래픽, Rust, Vulkan, ash에 대한 튜토리얼)

카메라: 원근 투영

이제 움직일 수 있는 카메라는 갖췄지만, 아직 ‘진정한 3D 느낌’은 부족합니다. 이를 개선하기 위해, 다음 목표는 원근 투영(perspective projection)을 도입하는 것입니다. 원근 투영은 우리 눈이 하는 것과 유사하게 모든 물체를 변환합니다(멀리 있는 물체를 더 작게 보이게 하는 등). 물론 이 투영은 4x4 행렬로 표현될 것입니다.

이 장은 두 부분으로 구성됩니다. 먼저 원근 행렬에 대한 수학적 유도를 하고, 그 다음 우리 프로그램에 이를 포함시키기 위한 비교적 작은 수정 작업을 진행합니다.

투영은 어떻게 작동할까요?

가장 간단한 모델은 다음과 같습니다: 어딘가에 스크린을 놓고, 모든 점을 당신이 볼 법한 위치에 그리는 것입니다. ‘본다’는 것은 눈(단순화를 위해 원점에 있다고 가정)과 해당 점을 잇는 직선이 스크린과 교차하는 지점을 의미합니다.

우리는 모든 점을 그리고 싶지는 않고, 특정 범위 내에 있는 점들만 그리고 싶습니다. (거의 머리 바로 위에 있는 것과 발 근처에 있는 것을 동시에 볼 수는 없지 않나요?)

그 범위를 기술하는 한 가지 방법은 화각(opening angle) φ(또는 ‘y방향 시야각’, ‘fovy’)를 명시하는 것입니다.

이것은 또한 스크린을 어디에 배치해야 할지에 대한 힌트를 줍니다: Vulkan이 우리에게 보여줄 가장 큰 y값은 1이라는 것을 알고 있습니다. 우리는 z축과 φ/2의 각도를 이루는 선 위의 점이 y=1에 오도록 눈과 스크린 사이의 거리 d를 선택해야 합니다.

분명히, 입니다.

하지만 다시 점(들)으로 돌아가서: 좌표 (y,z)의 점은 어디로 가게 될까요? 모든 성분을 같은 비율로 스케일링하면 점들은 원점을 지나는 동일한 직선 위에 유지되고, 우리는 를 원하므로, 가 될 것 같군요. 또는 3D에서도 (그림에 포함시키는 것이 도움이 되지는 않지만, 아이디어는 같습니다): (x,y,z)에 있는 점은 에 위치하게 됩니다.

이제, 우리는 3D 그래픽에서 점에 대해 동차 좌표(homogeneous coordinates)를 사용하기로 합의했습니다(15장16장을 다시 보세요): 이 용어로는 다음과 같습니다.

그리고 이것은 같은 점입니다.

(이것이 훨씬 더 나은 표현 방식인데, 이제 나눗셈을 할 필요가 없고, 실제로 (원본 점 의) 변환을 다음과 같이 행렬로 쓸 수 있기 때문입니다.

이것은 얼마나 멀리 있든 모든 점을 가져와 z=d인 스크린에 배치합니다. 이제, 이것은 최종적으로 스크린에 투영하는 데에는 맞지만, 우리는 점들을 [-1,1]x[-1,1]x{d}가 아닌 [-1,1]x[-1,1]x[0,1]의 ‘상자’로 옮기는 데 관심이 있습니다. 또한, 얼마나 멀리 있든 모든 점이 필요한 것은 아닙니다. 사실, z=n인 ‘가까운 평면(near plane)’과 z=f인 ‘먼 평면(far plane)’으로 뷰 영역을 제한합시다:

투영을 어떻게 바꿔야 할까요? z 성분에 무언가 손을 대야 합니다: dz 대신에, 아마도 우리가 아직 찾아야 하는 계수 m과 b에 대해, mz+b 같은 형태가 되어야 할 것입니다. 즉:

m과 b에 대한 올바른 값은 무엇일까요? z=n은 0으로 보내져야 하고, z=f는 1이 되어야 합니다. 따라서:

첫 번째 방정식은 임을 보여주고, 두 번째 식에서 첫 번째 식을 빼면 이므로, 이고 입니다. 이것으로 우리 행렬은 다음과 같이 됩니다.

우리 스크린이 정사각형이라면 이걸로 끝입니다. 하지만 대부분의 스크린은 그렇지 않으므로(그리고 우리가 만든 창의 크기는 800x600 픽셀이라고 생각합니다), 이 부분에 대해 무언가 조치를 취해야 합니다. 조치를 취한다는 것은 x 성분을 스케일링하는 것을 의미합니다. ‘종횡비(aspect ratio)’ a (너비 나누기 높이)를 가져와서 x 성분을 스케일링합니다.

(이제 x 값은 a배 더 커져도 여전히 스크린에 맞춰집니다. 즉, [-1,1]x[-1,1]x[0,1] 범위에 들어가게 됩니다.)

(죄송합니다만, x좌표는 상상에 맡겨야겠습니다 (시간이 너무 많이 걸려서요); x좌표가 있다면 왼쪽의 모양은 피라미드의 절두체(frustum)가 될 것입니다; 그리고 실제로 ‘뷰 절두체(view frustum)’라고 불립니다.)

우리가 필요했던 파라미터는 시야각, 종횡비, 그리고 가까운 평면과 먼 평면의 거리였습니다.

impl Camera {
    fn update_projectionmatrix(&mut self) {
        let d = 1.0 / (0.5 * self.fovy).tan();
        self.projectionmatrix = na::Matrix4::new(
            d / self.aspect,
            0.0,
            0.0,
            0.0,
            0.0,
            d,
            0.0,
            0.0,
            0.0,
            0.0,
            self.far / (self.far - self.near),
            -self.near * self.far / (self.far - self.near),
            0.0,
            0.0,
            1.0,
            0.0,
        );
    }

그리고

struct Camera {
    viewmatrix: na::Matrix4<f32>,
    position: na::Vector3<f32>,
    view_direction: na::Unit<na::Vector3<f32>>,
    down_direction: na::Unit<na::Vector3<f32>>,
    fovy: f32,
    aspect: f32,
    near: f32,
    far: f32,
    projectionmatrix: na::Matrix4<f32>,
}
impl Default for Camera {
    fn default() -> Self {
        let mut cam = Camera {
            viewmatrix: na::Matrix4::identity(),
            position: na::Vector3::new(0.0, 0.0, 0.0),
            view_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 0.0, 1.0)),
            down_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 1.0, 0.0)),
            fovy: std::f32::consts::FRAC_PI_3,
            aspect: 800.0 / 600.0,
            near: 0.1,
            far: 100.0,
            projectionmatrix: na::Matrix4::identity(), 
        };
        cam.update_projectionmatrix();
        cam.update_viewmatrix();
        cam
    }
}

(update_matrix 함수들을 호출하도록 했기 때문에, 그냥 identity 행렬로 설정할 수 있습니다.)

좋습니다. (fovy를 설정하고 projectionmatrix를 자동으로 업데이트하는 함수를 추가할 수도 있겠지만, 이 행렬을 사용하여 뷰를 변경하는 것이 더 중요합니다.)

빠르게 확인해보고 싶다면, 이 행렬을 그냥 뷰 행렬에 끼워 넣을 수 있습니다. update_viewmatrix의 마지막 줄을 다음과 같이 바꾸면

        self.viewmatrix = self.projectionmatrix * m;

우리가 성취한 것을 처음으로 엿볼 수 있습니다. 이것은 원근 정보를 셰이더로 보내는 괜찮은 전략일 수 있지만, 저는 두 행렬을 분리해서 유지하고 싶습니다. 이 줄을 다시 원래대로 되돌립시다.

        self.viewmatrix =  m;

그리고 다른 곳에서 작업을 시작합니다. 셰이더에서는, UniformBufferObject에 또 다른 변수를 포함시키고, 계산 중에도 사용합니다:

#version 450

layout (location=0) in vec3 position;
layout (location=1) in mat4 model_matrix;
layout (location=5) in vec3 colour;

layout (set=0, binding=0) uniform UniformBufferObject {
	mat4 view_matrix;
	mat4 projection_matrix;
} ubo;

layout (location=0) out vec4 colourdata_for_the_fragmentshader;

void main() {
    gl_Position = ubo.projection_matrix*ubo.view_matrix*model_matrix*vec4(position,1.0);
    colourdata_for_the_fragmentshader=vec4(colour,1.0);
}

만약 우리가 이미 가지고 있던 구조체의 다른 멤버로 이 행렬을 추가한다면, 디스크립터 셋 레이아웃을 변경할 필요가 없습니다:

        let descriptorset_layout_binding_descs = [vk::DescriptorSetLayoutBinding::builder()
            .binding(0)
            .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
            .descriptor_count(1)
            .stage_flags(vk::ShaderStageFlags::VERTEX)
            .build()];

모든 것이 여전히 잘 맞는 것 같습니다.

유니폼 버퍼는 두 배 커져야 합니다:

        let mut uniformbuffer = Buffer::new(
            &allocator,
            128,
            vk::BufferUsageFlags::UNIFORM_BUFFER,
            vk_mem::MemoryUsage::CpuToGpu,
        )?;

그리고 디스크립터 셋 바인딩의 범위도 128바이트 크기로 두 배가 됩니다:

            let buffer_infos = [vk::DescriptorBufferInfo {
                buffer: uniformbuffer.buffer,
                offset: 0,
                range: 128,
            }];

마지막으로, 유니폼 버퍼를 채우는 부분에 주의를 기울여야 합니다: 예를 들어, 이 코드를

        let cameratransform: [[f32; 4]; 4] = [
            na::Matrix4::identity().into(),
        ];
        uniformbuffer.fill(&allocator, &cameratransform)?;

이렇게 바꿀 수 있습니다.

        let cameratransforms: [[[f32; 4]; 4]; 2] = [
            na::Matrix4::identity().into(),
            na::Matrix4::identity().into(),
        ];
        uniformbuffer.fill(&allocator, &cameratransforms)?;

Camera::update_buffer 함수에서도 마찬가지입니다:

    fn update_buffer(&self, allocator: &vk_mem::Allocator, buffer: &mut Buffer) {
        let data: [[[f32; 4]; 4]; 2] = [self.viewmatrix.into(), self.projectionmatrix.into()];
        buffer.fill(allocator, &data);
    }

두 행렬을 결합하고 그 값들의 슬라이스를 buffer.fill()에 보내는 다른 방법들도 있지만, 저는 이 버전에 만족합니다.

이 카메라 설정으로는, 우리는 항상 사물의 한가운데에 있게 됩니다. 이는 자연스러운 일입니다: 모델을 (0,0,0)에 설정하기가 매우 쉬운데, 바로 우리 카메라가 위치한 곳입니다. 아마도 다른 기본 위치를 사용해야 할 것 같습니다:

            position: na::Vector3::new(-3.0, -3.0, -3.0),
            view_direction: na::Unit::new_normalize(na::Vector3::new(1.0, 1.0, 1.0)),
            down_direction: na::Unit::new_normalize(na::Vector3::new(-1.0, 2.0, -1.0)),

또는

            position: na::Vector3::new(0.0, -3.0, -3.0),
            view_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 1.0, 1.0)),
            down_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 1.0, -1.0)),

두 경우 모두 원점을 바라보지만, 바깥쪽에 위치합니다. 앞으로는 후자의 설정을 사용하겠습니다.

간단한 제정신 체크(sanity check): 위치 벡터와 뷰 방향이 서로 반대 방향을 가리키는데, 이는 원점을 바라보고 있다는 의미입니다. 두 경우 모두, 뷰 방향과 아래쪽 방향은 직교합니다. 또한 길이가 1입니다. (Vector3::new가 직접 그렇다는 것이 아니라, new_normalize가 관여합니다.)

이제 사용해보니 CameraDefault 구현이 더 이상 마음에 들지 않습니다. Defaultlet cam=Camera{fovy: 1.65, .. Default::default()}; 같은 구문에서 잘 동작해야 하는데, 우리의 default()는 그렇지 않습니다: 우리는 먼저 모든 값을 알고 난 후에 update 함수를 호출해야 합니다. default를 먼저 생성하고 그 값 중 일부를 변경할 수 없습니다. Default 구현을 제거합시다.

대신 CameraBuilder는 어떨까요?

struct CameraBuilder {
    position: na::Vector3<f32>,
    view_direction: na::Unit<na::Vector3<f32>>,
    down_direction: na::Unit<na::Vector3<f32>>,
    fovy: f32,
    aspect: f32,
    near: f32,
    far: f32,
}

즉, Camera의 필드 중 마지막에 계산되어야 하는 필드들을 제외한 모든 필드를 저장합니다.

CameraBuilder를 생성한다는 것은 기본값을 삽입하는 것을 의미합니다:

impl Camera {
    fn builder() -> CameraBuilder {
        CameraBuilder {
            position: na::Vector3::new(0.0, -3.0, -3.0),
            view_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 1.0, 1.0)),
            down_direction: na::Unit::new_normalize(na::Vector3::new(0.0, 1.0, -1.0)),
            fovy: std::f32::consts::FRAC_PI_3,
            aspect: 800.0 / 600.0,
            near: 0.1,
            far: 100.0,
        }
    }

CameraBuilder는 값을 설정하기 위한 여러 함수를 갖게 됩니다:

    fn position(mut self, pos: na::Vector3<f32>) -> CameraBuilder {
        self.position = pos;
        self
    }
    fn fovy(mut self, fovy: f32) -> CameraBuilder {
        self.fovy = fovy.max(0.01).min(std::f32::consts::PI - 0.01);
        self
    }
    fn aspect(mut self, aspect: f32) -> CameraBuilder {
        self.aspect = aspect;
        self
    }
    fn near(mut self, near: f32) -> CameraBuilder {
        if near <= 0.0 {
            println!("setting near plane to negative value: {} — you sure?", near);
        }
        self.near = near;
        self
    }
    fn far(mut self, far: f32) -> CameraBuilder {
        if far <= 0.0 {
            println!("setting far plane to negative value: {} — you sure?", far);
        }
        self.far = far;
        self
    }
    fn view_direction(mut self, direction: na::Vector3<f32>) -> CameraBuilder {
        self.view_direction = na::Unit::new_normalize(direction);
        self
    }
    fn down_direction(mut self, direction: na::Vector3<f32>) -> CameraBuilder {
        self.down_direction = na::Unit::new_normalize(direction);
        self
    }

모든 함수는 CameraBuilder를 반환하여 Camera::builder().position(/*value*/).fovy(/*value*/).view_direction(/*value*/) 처럼 연쇄적으로 호출(chaining)될 수 있습니다.

우리는 (편의를 위해) 정규화되지 않은 방향 벡터도 받고, fovy의 값은 (여전히 비합리적으로 넓은 범위이긴 하지만) 제한하며, near와 far 평면의 이상한 값에 대해서는 경고를 출력합니다. 이것들에 대한 참고: 우리는 이 값들이 같아지거나 near가 0이 되는 것을 절대 원치 않습니다. (우리 투영 행렬의 원소들에 끔찍한 일이 일어날 것입니다.)

builder의 모든 부분은 마지막에 build() 호출로 완성됩니다. 그리고 ash와는 달리, Deref를 구현하고 build를 절대 사용하지 않을 이유가 없습니다. (우리는 수명 정보를 잃을 수 있는 참조나, 내부적으로 C 포인터인 값을 다루고 있지 않습니다.)

그래서:

impl CameraBuilder {
    fn build(self) -> Camera {
        if self.far < self.near {
            println!(
                "far plane (at {}) closer than near plane (at {}) — is that right?",
                self.far, self.near
            );
        }
        let mut cam = Camera {
            position: self.position,
            view_direction: self.view_direction,
            down_direction: na::Unit::new_normalize(
                self.down_direction.as_ref()
                    - self
                        .down_direction
                        .as_ref()
                        .dot(self.view_direction.as_ref())
                        * self.view_direction.as_ref(),
            ),
            fovy: self.fovy,
            aspect: self.aspect,
            near: self.near,
            far: self.far,
            viewmatrix: na::Matrix4::identity(),
            projectionmatrix: na::Matrix4::identity(),
        };
        cam.update_projectionmatrix();
        cam.update_viewmatrix();
        cam
    }

near와 far 평면에 대한 또 다른 제정신 체크(sanity check)가 있고, down_directionview_direction에 직교하도록 보장하는 몇 줄의 코드가 있습니다(우리 뷰 행렬의 유도는 그것에 의존했습니다) — 공식은 이전 장을 확인하세요.

마지막으로, 카메라 생성을 다음으로 교체합니다.

    let mut camera = Camera::builder().build();

카메라 설정은 여전히 개선될 수 있습니다: 움직임이 완벽하게 직관적이지 않고 (하지만 키 누름의 최적 효과는 애플리케이션의 목적에 따라 크게 달라집니다); view_direction을 직접 설정할 좋은 방법이 없고 (down_direction은 반드시 직교를 유지해야 하므로; build()에 포함시킨 것과 비슷한 트릭을 사용할 수 있습니다), 회전만 다룰 수 있습니다; 원근 파라미터를 설정할 방법이 없습니다 (또는 오히려, 나중에 update_projectionmatrix를 수동으로 호출해야 합니다) — 하지만 이쯤에서 마무리하겠습니다.

계속