DIYPlay
블로그

3D 렌더링 DIY 프로젝트 2 - 래스터라이즈

DIYPlayer

·

3달 전

3d
렌더링
브리젠햄
스캔래인

화면에 3D 이미지(3D 물체)를 픽셀 단위로 직접 그려보는 것을 목표로 합니다.

 

 3D 렌더링 과정을 직접 구현해보는 프로젝트의 2번째 포스팅 입니다.

 1번 포스팅에서는 정점 변환에 필요한 변환 행렬과 만들고 정사각형에 변환 행렬을 적용해 보는 작업을 해보았습니다. 아래 링크를 통해 이전 포스팅을 보실 수 있습니다.

3D 렌더링 DIY 프로젝트 1 - 정점 변환

 

 이번 포스팅에서는 3D 이미지를 어떻게 픽셀 단위로 화면에 표시될 수 있는지 직접 구현해 보겠습니다.

1. 데이터 준비

 이번에도 정육면체를 렌더링 해보겠습니다. 정육면체를 그리기 위한 데이터는 저번 포스팅과 동일합니다.

 8개의 꼭지점 좌표와 꼭지점을 알아볼 수 있게 색을 지정해 주었습니다.

 1개의 면 당 삼각형 2개로 총 12개의 삼각형을 꼭지점 인덱스로 구성했습니다.

const vertices = [	
	{ position: {x: -50, y: -50,z: -50}, color: '#ff0000' },
    { position: {x:  50, y: -50,z: -50}, color: '#00ff00' },
    { position: {x:  50, y:  50,z: -50}, color: '#0000ff' },
    { position: {x: -50, y:  50,z: -50}, color: '#ffff00' },

    { position: {x: -50, y: -50,z:  50}, color: '#ff00ff' },
    { position: {x:  50, y: -50,z:  50}, color: '#00ffff' },
    { position: {x:  50, y:  50,z:  50}, color: '#FF9390' },
    { position: {x: -50, y:  50,z:  50}, color: '#FF9200' },
];

const indices = [	
	[0, 1, 2],   //앞면
    [0, 2, 3],   //앞면
    
    [4, 5, 1],   //윗면
    [4, 1, 0],   //윗면

    [1, 5, 6],   //오른면
    [1, 6, 2],   //오른면
    
    [4, 0, 3],   //왼면
    [4, 3, 7],   //왼면

    [3, 2, 6],   //아랫면
    [3, 6, 7],   //아랫면

    [5, 4, 7],   //뒷면
    [5, 7, 6],   //뒷면
];

 

2. 픽셀 단위로 선 그리기

  저번 포스팅에서는 면을 표현하기 위해 삼각형을 canvas의 moveTo()lineTo() API를 사용 했습니다.

  이번 포스팅에서는 두 좌표에 직접 선을 그리기 위해 브리젠햄 직선 알고리즘(Bresenham's line algorithm) 을 사용해 보겠습니다. 

 가로가 세로보다 길 때는 x좌표를 지속적으로 늘려주며 중단점을 계산해서 일점 값 이상이 때만 y좌표를 늘려주고, 세로가 가로보다 길 때는 반대로 y좌표를 지속적으로 늘려주며 중단점 값의 따라 x좌표를 늘려 주는식으로 동작하는 알고리즘으로 보입니다.

function bresenham(sx, sy, fx, fy) {
    const dx = Math.abs(fx - sx);
    const dy = Math.abs(fy - sy);
    const mx = sx < fx ? 1 : -1;
    const my = sy < fy ? 1 : -1;

    if(dy <= dx) {
        let p = (2 * dy) - dx;
        for(let x = 0, y = 0; x <= dx; x++) {
            context2D.fillRect(sx + x * mx, sy + y * my, 1, 1);
            if(p < 0) {
                p += (2 * dy);
            }
            else {
                y++;
                p += 2 * (dy - dx);
            }
        }
    }
    else {  
        let p = (2 * dx) - dy;
        for(let x = 0, y = 0; y <= dy; y++) {
            context2D.fillRect(sx + x * mx, sy + y * my, 1, 1);
            if(p < 0) {
                p += 2 * dx;
            }
            else {
                x++;
                p += 2 * (dx - dy);
            }
        }
    }    
}

 

정육면체 데이터에 변환 행렬을 적용해 브리젠햄  함수를 호출하는 코드도 작성해 주겠습니다.

디버깅 용으로 꼭지점 구분을 위해 꼭지점의 인덱스를 표시해주는 함수도 추가하겠습니다.

const transform = {
    position: {x: canvas.width/2, y: canvas.height/2, z: 0},
    rotation: {x: 15, y: 30, z: 0},
    scale:    {x: 4, y: 4, z: 4},
}

function animate() {
   transform.rotation.z += 1;
   transform.rotation.y += 1;
    
    const mat = new Matrix4x4();

    mat.multiply( Matrix4x4.translate(transform.position.x, transform.position.y, transform.position.z));
    mat.multiply( Matrix4x4.rotateX(transform.rotation.x) );
    mat.multiply( Matrix4x4.rotateY(transform.rotation.y) );
    mat.multiply( Matrix4x4.rotateZ(transform.rotation.z) );
    mat.multiply( Matrix4x4.scale(transform.scale.x, transform.scale.y, transform.scale.z) );

    context2D.clearRect(0, 0, canvas.width, canvas.height);
    drawVertex(vertices, indices, mat);

    requestAnimationFrame(animate);
}
animate();


function drawVertex(vertices, indices, mat) {
    for(let i = 0; i < indices.length; i++) {
        const face = indices[i];
        const i1 = face[0];
        const i2 = face[1];
        const i3 = face[2];
        const p1 = vertices[i1].position;
        const p2 = vertices[i2].position;
        const p3 = vertices[i3].position;
        const v1 = mat.applyToVertex(p1);
        const v2 = mat.applyToVertex(p2);
        const v3 = mat.applyToVertex(p3);

        const ab = Vector3.sub(v2, v1);
        const ac = Vector3.sub(v3, v1);
        const c = Vector3.cross(ab, ac);
        const d = Vector3.dot(c, {x:0, y: 0, z: -1});
        if(d > 0) {
            context2D.fillStyle = "#ffffff20";
        }
        else {
            context2D.fillStyle = "#ffffff"
        }
        
        bresenham(v1.x, v1.y, v2.x, v2.y);
        bresenham(v2.x, v2.y, v3.x, v3.y);
        bresenham(v3.x, v3.y, v1.x, v1.y);
    }

    for(let i = 0; i < vertices.length; i++) {
        const pointData = vertices[i];
        const position = mat.applyToVertex(pointData.position);
        const color = pointData.color;
        drawPoint(position, color, i.toString());
    }
}

function drawPoint(pos, color, label) {
    context2D.strokeStyle = color;
    context2D.font = '30pt aria';
    context2D.strokeText(label, pos.x, pos.y);
}

 

3. 삼각형 채우기

다음 단계로 삼각형 내부를 채우는 작업을 위해 스캔 라인 기법을 활용해 보겠습니다.

삼각형의 바운딩 영역을 계산해서 좌상단부터 차례대로 한 픽셀씩 해당 픽셀이 삼각형 내부에 포함되는지 여부를 파악해주는 방법입니다.

function edgeFunction(x0, y0, x1, y1, x, y) {
	// (x0, y0) -> (x1, y1) 선과 x, y 좌표의 관계를 판단
	// 0 : 선위에 위치
	// < 0 : 선 오른쪽에 위치
	// > 0 : 선 왼쪽에 위치
    return (y - y0) * (x1 - x0) - (x - x0) * (y1 - y0);
}

function drawTriangle(x0, y0, x1, y1, x2, y2) {
    // 삼각형 영역을 찾기 위한 최소 및 최대 좌표
    var minX = Math.round(Math.min(x0, x1, x2));
    var minY = Math.round(Math.min(y0, y1, y2));
    var maxX = Math.round(Math.max(x0, x1, x2));
    var maxY = Math.round(Math.max(y0, y1, y2));

    // 삼각형 내의 각 픽셀을 채우기
    for (var y = minY; y <= maxY; y++) {
        for (var x = minX; x <= maxX; x++) {
            // 삼각형 내부에 있는지 확인
            var w0 = edgeFunction(x1, y1, x2, y2, x, y);
            var w1 = edgeFunction(x2, y2, x0, y0, x, y);
            var w2 = edgeFunction(x0, y0, x1, y1, x, y);
            if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
                context2D.fillRect(x, y, 1, 1); // 픽셀 채우기
            }
        }
    }
}

캔버스 크기가 너무 크면 연산하는데 시간이 오래 걸리니 적당하게 캔버스 크기를 조절해야 합니다.

가로, 세로 각각 200 픽셀로 설정 했을 때의 결과입니다. 

 

모든 면이 흰색이라 구분이 힘드니 삼각형마다 다른 색으로 채워 주겠습니다.

 

 

프로젝트 바로가기

3D 렌더링 DIY 2 - 래스터라이즈

 

이전 글

3D 렌더링 DIY 프로젝트 3 - 쉐이더

다음 글

3D 렌더링 DIY 프로젝트 1 - 정점 변환

댓글 0
    서비스 이용약관|개인정보 보호정책

    Copyright © DIYPlay All rights reserved.