우리 씬(scene)에는 여러 개의 상자가 있습니다. 각기 다른 위치에 놓을 수 있죠. 그런데 크기를 다르게 하거나, 회전시킬 수도 있을까요?
정점(vertex) 데이터는 여전히 공유할 수 있다면 좋을 것입니다.
따라서, 이는 인스턴스(instance) 데이터의 일부가 될 것입니다.
논리적으로 보입니다. 인스턴스 데이터에는 이미 전체 모델의 위치(오프셋)를 저장하고 있으니까요.
스케일링(scaling)과 회전(rotation)은 어떻게 포함시킬 수 있을까요?
음, 둘 다 선형 변환(linear transformation)입니다. 그리고 이동(translation)과 함께 하나의 4x4 행렬에 담을 수 있습니다. (솔직히 말해, 여기서는 전체 행렬보다 더 적은 항목으로도 충분히 해결할 수 있습니다. 하지만 시도하지는 않겠습니다.)
이 행렬(
라고 부릅시다)으로 무엇을 할까요? 그릴 때 모델의 모든 정점 위치
(기억하세요: 위치는 4개의 성분을 가진 벡터입니다)에 이 행렬을 곱합니다. 즉, 정점을
가 아닌
에 그립니다.
만약
가 어떤 벡터
만큼의 이동을 인코딩한다면, 예를 들어 모델을 만들 때 원점에 있다고 기술했던 점을
에 그리게 됩니다.
는 좌표를 모델-로컬 시스템(원점 = 모델의 중심, 또는 “발” 아래 중앙 지점)에서 월드 좌표계(모든 객체의 위치를 기술하는 전역 좌표계, 원점 = 위치를 지정할 기준이 되는 공간의 특별한 지점)로 변환합니다.
이 행렬은 보통 “모델 행렬(model matrix)”이라고 불립니다. 그 성분들은 어떻게 알아낼까요? 벡터
등이 어떻게 변환되어야 하는지 생각하고, 그것을 행렬의 열(column)로 작성하면 됩니다 (14장 및 그 이후 장 참조).
따라서 position_offset 대신 전체 4x4 행렬을 정점 셰이더(vertex shader)로 전달하고, 셰이더에서 이 행렬을 위치 벡터에 적용해야 합니다. GLSL에는 mat4라는 적절한 변수 타입이 있습니다.
#version 450
layout (location=0) in vec3 position;
layout (location=1) in mat4 model_matrix;
layout (location=2) in vec3 colour;
layout (location=0) out vec4 colourdata_for_the_fragmentshader;
void main() {
gl_Position = model_matrix*vec4(position,1.0);
colourdata_for_the_fragmentshader=vec4(colour,1.0);
}
다음으로 수정해야 할 것은 정점 속성 설명(vertex attribute descriptions)이라는 것을 배웠습니다. [f32; 3]의 위치 오프셋에 대해서는 다음과 같았습니다.
vk::VertexInputAttributeDescription {
binding: 1,
location: 1,
offset: 0,
format: vk::Format::R32G32B32_SFLOAT,
},
그리고 분명히 포맷을 바꿔야 합니다. 하지만 포맷 목록을 보면, [f32; 16](4x4 행렬에 필요한 것)에 적합한 변형이 실제로 없다는 것을 알 수 있습니다.
어떻게 해야 할까요? 음, 여러 개의 로케이션(location)을 사용해서 행렬을 열 단위로 전송하면 됩니다. (행렬이
라면, 먼저
[1,5,9,13]을 보내고, 그 다음 [2,6,10,14] 등을 보냅니다.)
let vertex_attrib_descs = [
vk::VertexInputAttributeDescription {
binding: 0,
location: 0,
offset: 0,
format: vk::Format::R32G32B32_SFLOAT,
},
vk::VertexInputAttributeDescription {
binding: 1,
location: 1,
offset: 0,
format: vk::Format::R32G32B32A32_SFLOAT,
},
vk::VertexInputAttributeDescription {
binding: 1,
location: 2,
offset: 16, // 4 * 4바이트
format: vk::Format::R32G32B32A32_SFLOAT,
},
vk::VertexInputAttributeDescription {
binding: 1,
location: 3,
offset: 32, // 8 * 4바이트
format: vk::Format::R32G32B32A32_SFLOAT,
},
vk::VertexInputAttributeDescription {
binding: 1,
location: 4,
offset: 48, // 12 * 4바이트
format: vk::Format::R32G32B32A32_SFLOAT,
},
vk::VertexInputAttributeDescription {
binding: 1,
location: 5,
offset: 64, // 16 * 4바이트
format: vk::Format::R32G32B32_SFLOAT,
},
];
let vertex_binding_descs = [
vk::VertexInputBindingDescription {
binding: 0,
stride: 12, // 3 * 4바이트
input_rate: vk::VertexInputRate::VERTEX,
},
vk::VertexInputBindingDescription {
binding: 1,
stride: 76, // (16 + 3) * 4바이트
input_rate: vk::VertexInputRate::INSTANCE,
},
];
(로케이션 1, 2, 3, 4가 행렬입니다.)
셰이더를 수정해야 합니다:
#version 450
layout (location=0) in vec3 position;
layout (location=1) in mat4 model_matrix;
layout (location=5) in vec3 colour;
layout (location=0) out vec4 colourdata_for_the_fragmentshader;
void main() {
gl_Position = model_matrix*vec4(position,1.0);
colourdata_for_the_fragmentshader=vec4(colour,1.0);
}
여기서 mat4를 여전히 로케이션 1로 유지하고 있다는 점에 주목하세요. (실제로는 로케이션 1, 2, 3, 4이지만 자동으로 합쳐집니다.) 수정해야 했던 것은 색상(colour)의 로케이션입니다: 로케이션 2를 사용할 수 없고, 다음 “비어있는” 로케이션을 사용해야 합니다 (VertexInputAttributeDescription과 비교해보세요).
다음으로 변경할 곳은 InstanceData 구조체입니다:
#[repr(C)]
struct InstanceData {
modelmatrix: [f32; 16],
colour: [f32; 3],
}
그리고 이 구조체가 생성되는 모든 곳도 변경해야 합니다:
cube.insert_visibly(InstanceData {
modelmatrix: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
colour: [1.0, 0.0, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 1.0,
],
colour: [0.6, 0.5, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 0.0, 1.0,
],
colour: [0.0, 0.5, 0.0],
});
이 변경만으로도 프로그램은 다시 동일한 결과물을 내며 실행될 것입니다.
(만약 이 변경으로 인해 화면이 검게 변했다면, 먼저 정점 속성과 바인딩 설명의 offset과 stride를 확인하는 것을 추천합니다.)
저는 이 코드가 별로 마음에 들지 않습니다.
modelmatrix: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
[f32; 16] 대신 [[f32; 4]; 4]를 사용해서 가독성을 좀 더 높일 수 있을 것 같습니다:
#[repr(C)]
struct InstanceData {
modelmatrix: [[f32; 4]; 4],
colour: [f32; 3],
}
그리고
cube.insert_visibly(InstanceData {
modelmatrix: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
colour: [1.0, 0.0, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.25, 0.0, 1.0],
],
colour: [0.6, 0.5, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.5, 0.0, 1.0],
],
colour: [0.0, 0.5, 0.0],
});
이것이 필요한 유일한 변경 사항입니다: 이 f32 값들이 메모리에 어떻게 저장되는지는 영향을 받지 않으며, 우리의 정점 셰이더(나 정점 버퍼)는 어떤 차이도 감지하지 못할 것입니다.
하지만 이 방식은 여전히 혼란스럽습니다. 이 코드의 줄은 실제로는 행렬의 열인데, 이는 잊어버리기 쉽습니다. 또한, 이 행렬들을 행렬*행렬이나 행렬*벡터 같은 일반적인 연산에 바로 사용할 수도 없습니다.
코드에 전용 행렬 및 벡터 타입을 사용하는 것이 좋습니다. 직접 구현을 시작할 수도 있겠지만, 선형 대수학에 특화된 크레이트(crate)를 포함하는 것이 더 합리적으로 보입니다. 제 선택은 nalgebra입니다. Cargo.toml에 새 줄을 추가합니다: nalgebra = "0.18.0", 그리고 코드에 새 줄을 추가합니다: use nalgebra as na;
그러면 행렬 생성은 다양한 형태를 띨 수 있습니다:
let matrix1 = na::Matrix4::from_column_slice(&[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 0.0, 1.0,
]);
let matrix2 = na::Matrix4::from_row_slice(&[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]);
let matrix3 = na::Matrix4::<f32>::new(
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
);
let matrix4 = na::Matrix4::from_columns(&[
na::Vector4::new(1.0, 0.0, 0.0, 0.0),
na::Vector4::new(0.0, 1.0, 0.0, 0.0),
na::Vector4::new(0.0, 0.0, 1.0, 0.0),
na::Vector4::new(0.0, 0.5, 0.0, 1.0),
]);
let matrix5 = na::Matrix4::from_rows(&[
na::RowVector4::new(1.0, 0.0, 0.0, 0.0),
na::RowVector4::new(0.0, 1.0, 0.0, 0.5),
na::RowVector4::new(0.0, 0.0, 1.0, 0.0),
na::RowVector4::new(0.0, 0.0, 0.0, 1.0),
]);
let matrix6 = na::Matrix4::<f32>::new(
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.5, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0,
);
println!("{}", matrix1);
println!("{}", matrix2);
println!("{}", matrix3);
println!("{}", matrix4);
println!("{}", matrix5);
println!("{}", matrix6);
이 모든 행렬들은 동일합니다(그리고 이전의 마지막 행렬과도 같습니다). 검사할 때는 dbg!보다 println!을 추천합니다.
이 코드 예제에서 볼 수 있는 작은 “꼼수”는 matrix6 정의에서 줄 끝에 있는 (빈) 주석입니다. 이는 rustfmt가 이 줄들을 그대로 두고 “일반적인 행렬처럼 보이는” 가독성 있는 형태로 유지하도록 속이는 것입니다. (행렬 항목이 너무 길어져서 rustfmt가 어쨌든 별도의 줄로 나누고 싶어하면 더 이상 작동하지 않습니다.)
nalgebra에는 “이 벡터는 단위 길이를 가짐”이나 “이 변환은 회전(그리고 회전일 뿐임)” 등과 같은 정보를 담는 추가적인 타입들이 많이 있다는 점은 주목할 가치가 있습니다.
— 만약 이전과 동일한 행렬을 좀 더 자기 설명적인 방식으로 구성하고 싶다면:
let matrix7 = na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0));
또한 이 행렬들을 셰이더에서 사용할 수 있는 올바른 형태로 바꿀 수 있습니다:
let matrix_for_vulkan: [[f32; 4]; 4] = matrix7.into();
( .to_slice 메소드에 의존하거나 InstanceData의 modelmatrix 타입을 na::Matrix4로 조정할 수도 있습니다. 우리는 둘 다 하지 않습니다.)
상자 생성은 다음과 같을 수 있습니다:
cube.insert_visibly(InstanceData {
modelmatrix: na::Matrix4::identity().into(),
colour: [1.0, 0.0, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.25, 0.0)).into(),
colour: [0.6, 0.5, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: (na::Matrix4::from_scaled_axis(na::Vector3::new(
0.0,
0.0,
std::f32::consts::FRAC_PI_3,
)) * na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0)))
.into(),
colour: [0.0, 0.5, 0.0],
});
마지막 큐브의 변환인 “회전*이동(Rotation*Translation)”에 대한 두 가지 코멘트: 첫째, 회전은 “z축에 대한 회전”(이 함수에서 벡터의 방향)으로 주어지며, 각도(여기서는 π/3)는 주어진 벡터의 길이에 인코딩됩니다. 둘째, “회전*이동”은 먼저 이동하고 그 다음에 회전한다는 의미입니다 (생각해보세요: R*T*v = R*(T*v)). 그리고 (시도해보세요!): 순서는 중요합니다 (회전과 이동 같은 연산, 또는 더 일반적으로 행렬의 곱셈에서는 아주 특별한 경우를 제외하고는 순서가 중요합니다).
우리의 큐브 정의에서
impl Model<[f32; 3], InstanceData> {
fn cube() -> Model<[f32; 3], InstanceData> {
let lbf = [-0.1, 0.1, 0.0]; //lbf: left-bottom-front
let lbb = [-0.1, 0.1, 0.1];
let ltf = [-0.1, -0.1, 0.0];
let ltb = [-0.1, -0.1, 0.1];
let rbf = [0.1, 0.1, 0.0];
let rbb = [0.1, 0.1, 0.1];
let rtf = [0.1, -0.1, 0.0];
let rtb = [0.1, -0.1, 0.1];
Model {
vertexdata: vec![
lbf, lbb, rbb, lbf, rbb, rbf, //bottom
ltf, rtb, ltb, ltf, rtf, rtb, //top
lbf, rtf, ltf, lbf, rbf, rtf, //front
lbb, ltb, rtb, lbb, rtb, rbb, //back
lbf, ltf, lbb, lbb, ltf, ltb, //left
rbf, rbb, rtf, rbb, rtb, rtf, //right
],
handle_to_index: std::collections::HashMap::new(),
handles: Vec::new(),
instances: Vec::new(),
first_invisible: 0,
next_handle: 0,
vertexbuffer: None,
instancebuffer: None,
}
}
}
우리는 좌표 0.1을 사용합니다. 이것들을 1.0으로 변경합시다 — 그게 더 표준적으로 보입니다 — 그리고 사용하는 모델들을 스케일링하는 편이 낫겠습니다. 이는 우리의 새로운 InstanceData로 할 수 있는 일입니다.
cube.insert_visibly(InstanceData {
modelmatrix: na::Matrix4::new_scaling(0.1).into(),
colour: [1.0, 0.0, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: (na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.25, 0.0))
* na::Matrix4::new_scaling(0.1))
.into(),
colour: [0.6, 0.5, 0.0],
});
cube.insert_visibly(InstanceData {
modelmatrix: (na::Matrix4::from_scaled_axis(na::Vector3::new(
0.0,
0.0,
std::f32::consts::FRAC_PI_3,
)) * na::Matrix4::new_translation(&na::Vector3::new(0.0, 0.5, 0.0))
* na::Matrix4::new_scaling(0.1))
.into(),
colour: [0.0, 0.5, 0.0],
});
(먼저 스케일링하고, 그 다음에 이동합니다. 만약 먼저 이동했다면, 이동 벡터도 스케일링될 것입니다.)