이제 앞서 배운것들을 종합하여 간단한 높이맵(heightmap) 을 기반으로 하는 지형(terrain mesh)를 만들어 보자. 높이 맵 기반 지형은 그리드에 적용할 높이값이 있는 2D 배열을 만들어 기준으로 삼는다. 높이값의 2D 배열을 얻는 쉬운 방법은 이미지 편집 프로그램에서 높이를 그리는 것이다. 예를 들어 아래에는 저자가 그린 이미지를 볼수 있다. 64x64 픽셀의 이미지로 각각의 픽셀의 색이 높이를 의미한다.
이 이미지를 불러온 다음 이미지에서 높이 맵을 생성해보자. 이미지를 불러오기 위해서 아래와 같이 ImageLoader 클래스를 사용한다.
const imgLoader = new THREE.ImageLoader();
imgLoader.load('resources/images/heightmap-64x64.png', createHeightmap);
그리고 높이맵을 생성해줄 함수를 2번째 파라미터로 넣어주면 이 함수를 통해 높이맵이 생성된다. 높이맵을 생성해줄 함수이름을 createHeightmap 라고 하고 아래와 같이 만들어준다.
function createHeightmap(image) {
// 이미지를 canvas 에 일단 그린 다음 getImageData 함수를 호출하면
// 이미지 각 픽셀에 관한 data 를 얻을수 있다.
const ctx = document.createElement('canvas').getContext('2d');
const {width, height} = image;
ctx.canvas.width = width;
ctx.canvas.height = height;
ctx.drawImage(image, 0, 0);
const {data} = ctx.getImageData(0, 0, width, height);
const geometry = new THREE.Geometry();
getImageData 로부터 얻어지는 data 객체에 대해 조금 더 살펴보면 좋을것 같다.
canvas DOM 에서 getContext 함수를 호출하는데 이때 "2d" 파라미터를 넣어 호출하면 CanvasRenderingContext2D 라는 객체가 생성된다. 이 객체의 drawImage 함수를 사용해서 이미지를 객체에 채워주고 다시 getImageData 라는 함수로 얻는 것이 바로 ImageData 라는 클래스의 객체인데 이 객체는 위 코드에서 data 라는 변수에 저장되고 있다.
ImageData 객체에는 전체 이미지 픽셀들의 r,g,b,a 값이 전부 들어있는데, 각각 8비트 길이(0에서 255까지의 값의 범위)로 이루어진 R, G, B, Alpha 값이 1차원 Array 로 들어가 있다.
이미지로부터 이렇게 모든 픽셀의 r,g,b,a 값을 data라는 변수에 담아 추출했으므로 이제 셀들로 이루어진 그리드를 만들어 보자. 여기서 말하는 셀은 이미지의 각 픽셀의 중앙점으로 이루어진 새로운 사각형이라고 생각하면 된다.
이해를 돕기위해 잠깐 그림으로 그려보았다.
간단하게 하려고 3 x 3 픽셀들, 총 9개의 픽셀로 이루어진 그림이라고 가정해보았다. 그리고 픽셀마다 인덱스 값을 붙여서 가장 왼쪽 위를 0, 가장 오른쪽 아래를 8이라고 인덱스를 정해주었다.
이 3 x 3 픽셀 크기의 이미지를 getImageData 함수를 이용해 rgba 값들의 Array 로 뽑아내어 data 라는 변수에 집어 넣었다고 이해하면 된다.
그림 오른쪽 위의 RGBA RGBA ... 이렇게 이어진 Array 가 data 에 들어있는 내용이라고 이해하면 된다.
그리고 셀을 구성하기 위해 각 픽셀의 중앙점들을 연결하면 3 x 3 픽셀의 이미지로부터 2 x 2 크기의 셀들을 얻을수 있다. (왼쪽 아래의 붉은색 사각형 4개)
위의 그림과 아래의 코드를 참조하면 이해가 쉬울 것이다.
const cellsAcross = width - 1;
const cellsDeep = height - 1;
for (let z = 0; z < cellsDeep; ++z) {
for (let x = 0; x < cellsAcross; ++x) {
// compute row offsets into the height data
// we multiply by 4 because the data is R,G,B,A but we
// only care about R
const base0 = (z * width + x) * 4;
const base1 = base0 + (width * 4);
// look up the height for the for points
// around this cell
const h00 = data[base0] / 32;
const h01 = data[base0 + 4] / 32;
const h10 = data[base1] / 32;
const h11 = data[base1 + 4] / 32;
// compute the average height
const hm = (h00 + h01 + h10 + h11) / 4;
// the corner positions
const x0 = x;
const x1 = x + 1;
const z0 = z;
const z1 = z + 1;
// remember the first index of these 5 vertices
const ndx = geometry.vertices.length;
// add the 4 corners for this cell and the midpoint
geometry.vertices.push(
new THREE.Vector3(x0, h00, z0),
new THREE.Vector3(x1, h01, z0),
new THREE.Vector3(x0, h10, z1),
new THREE.Vector3(x1, h11, z1),
new THREE.Vector3((x0 + x1) / 2, hm, (z0 + z1) / 2),
);
// create 4 triangles
geometry.faces.push(
new THREE.Face3(ndx + 0, ndx + 4, ndx + 1),
new THREE.Face3(ndx + 1, ndx + 4, ndx + 3),
new THREE.Face3(ndx + 3, ndx + 4, ndx + 2),
new THREE.Face3(ndx + 2, ndx + 4, ndx + 0),
);
// add the texture coordinates for each vertex of each face
const u0 = x / cellsAcross;
const v0 = z / cellsAcross;
const u1 = (x + 1) / cellsDeep;
const v1 = (z + 1) / cellsDeep;
const um = (u0 + u1) / 2;
const vm = (v0 + v1) / 2;
geometry.faceVertexUvs[0].push(
[ new THREE.Vector2(u0, v0), new THREE.Vector2(um, vm), new THREE.Vector2(u1, v0) ],
[ new THREE.Vector2(u1, v0), new THREE.Vector2(um, vm), new THREE.Vector2(u1, v1) ],
[ new THREE.Vector2(u1, v1), new THREE.Vector2(um, vm), new THREE.Vector2(u0, v1) ],
[ new THREE.Vector2(u0, v1), new THREE.Vector2(um, vm), new THREE.Vector2(u0, v0) ],
);
}//for (let x = 0; x < cellsAcross; ++x)
}//for (let z = 0; z < cellsDeep; ++z)
이제 이 함수 createHeightmap은 아래의 코드로 마무리 한다.
geometry.computeFaceNormals();
// center the geometry
geometry.translate(width / -2, 0, height / -2);
const loader = new THREE.TextureLoader();
const texture = loader.load('resources/images/star.png');
const material = new THREE.MeshPhongMaterial({color: 'green', map: texture});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
}
이 코드의 결과물은 원문의 가장 마지막 부분에 나와있다.
나도 heightmap 을 이렇게 2D 이미지로부터 작성하는 예제를 분석하는것은 처음이었다. 그래서 찬찬히 그림도 그려보았던 것이 도움이 되었다.