DIYPlay
블로그

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

DIYPlayer

·

2달 전

3d
렌더링
쉐이더

버텍스 쉐이더, 픽셀 쉐이더의 작동 방식을 더 쉽게 이해할 수 있게 직접 구현해보는 것을 목표로 합니다.

 

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

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

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

 2번 포스팅에서는 화면에 직접 픽셀 단위로 3D 이미지를 그려보는 작업을 해보았습니다. 아래 링크를 통해 이전 포스팅을 보실 수 있습니다.

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

 

 이번 포스팅에서는 버텍스의 위치를 계산하는 버텍스 쉐이더 함수와 픽셀의 색상을 결정하는 픽셀 쉐이더 함수를 별도로 만들어서 쉐이더의 역할을 하도록 만들겠습니다.

1. 버텍스 쉐이더

 버텍스 쉐이더는 간단합니다. 변환 행렬을 정점에 적용하는 시점에서 별도의 함수를 호출하겠습니다.  

가장 중요한 역할은 삼각형을 그릴 수 있도록 각 정점을 변환해서 반환해주는 것입니다.

function vertexShder(pos, mat) {
	return mat.applyToVertex(pos);
}

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 = vertexShder(p1, mat);
        const v2 = vertexShder(p2, mat);
        const v3 = vertexShder(p3, mat);
    }
}

 

2. 픽셀 쉐이더

 픽셀 쉐이더는 삼각형을 그릴 때 삼각형에 속하는 픽셀의 색상을 결정합니다.  

 삼각형은 세 개의 점으로 구성되어 있어서 픽셀의 색상을 이 세 점의 영향을 받습니다.  이때 얼마나 영향을 받을지 결정하기 위해 무게중심 좌표(Barycentric coordinate)를 사용합니다. 

function area(v1, v2) {
    return Math.abs(v1.x * v2.y - v1.y * v2.x) / 2;
}

function barycentricCoordinates(a, b, c, p) {
    const ab = Vector2.sub(a, b);
    const ac = Vector2.sub(a, c);
    const ap = Vector2.sub(a, p);
    const bp = Vector2.sub(b, p);
    const cp = Vector2.sub(c, p);
    
    // 삼각형 ABC의 면적 계산
    const areaABC = area(ab, ac);
    
    // 삼각형 PBC, PCA, PAB의 면적 계산
    const areaPBC = area(bp, cp);
    const areaPCA = area(cp, ap);
    const areaPAB = area(ap, bp);
    
    // 바리센트릭 좌표 계산
    const lambdaA = areaPBC / areaABC;
    const lambdaB = areaPCA / areaABC;
    const lambdaC = areaPAB / areaABC;
    return new Vector3(lambdaA, lambdaB, lambdaC);
}

 전체 삼각형의 면적을 구하고 색을 결정할 지점과  영향 받을 꼭지점을 제외한 나머지 두 꼭지점의 면적에서 전체 면적을 나누면 해당 지점이 그 꼭지점과 얼마나 양향을 받는지 결정되는거 같군요.

 

세 꼭지점이 각각 다른 색을 가지고 있을 대 픽셀의 색을 결정하는 코드입니다.


function drawTriangle(v0, v1, v2, c0, c1, c2) {
    const x0 = v0.x;
    const y0 = v0.y;
    const x1 = v1.x;
    const y1 = v1.y;
    const x2 = v2.x;
    const y2 = v2.y;

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

    // 삼각형 내의 각 픽셀을 채우기
    for (let y = minY; y <= maxY; y++) {
        for (let x = minX; x <= maxX; x++) {
            // 삼각형 내부에 있는지 확인
            let w0 = edgeFunction(x1, y1, x2, y2, x, y);
            let w1 = edgeFunction(x2, y2, x0, y0, x, y);
            let w2 = edgeFunction(x0, y0, x1, y1, x, y);
            if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
                // context2D.fillRect(x, y, 1, 1); // 픽셀 채우기
                const coords = barycentricCoordinates(
					{x: x0, y: y0},
					{x: x1, y: y1},
					{x: x2, y: y2},
					{x, y});
				const color = pixelShader(coords, c0, c1, c2);
				context2D.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
               	context2D.fillRect(x, y, 1, 1); // 픽셀 채우기
            }
        }
    }
}

function pixelShader(coords, c1, c2, c3) {
	return {
    	r: Math.floor(c1.r * coords.x + c2.r * coords.y + c3.r * coords.z),
    	g: Math.floor(c1.g * coords.x + c2.g * coords.y + c3.g * coords.z),
    	b: Math.floor(c1.b * coords.x + c2.b * coords.y + c3.b * coords.z),
    	a: Math.floor(c1.a * coords.x + c2.a * coords.y + c3.a * coords.z),
	}
}

3. 리펙토링

 조금 더 범용적인 쉐이더 사용을 위해 쉐이더를 별도의 클래스로 만들고 버텍스와 픽셀쉐이더만 외부에서 재정의 가능하도록 바꾸겠습니다. 

class Shader {
    constructor() {
        this.attribute = {};
        this.vertexShader = (data, att)=>{};
        this.pixelShader = (data, att)=>{};
    }

    vertex(data) {
        const out = this.vertexShader(data, this.attribute) || {};    
        return {
            ...out,
            position: out.position || {x: 0, y: 0, z: 0},
        }
    }
	
	//세 정점이 반환한 값 그대로 전달하도록 구현
    pixel(vo1, vo2, vo3, coords) {
        const out = this.pixelShader(vo1, vo2, vo3, coords, this.attribute) 
        	|| {r: 0, g: 0, b: 0, a: 0};
        	
        return out;
    }
}

 실제 픽셀 쉐이더에서는 세 정점의 정보는 다루지 않고 보간된 속성만 전달 된다고 합니다. 하지만 이번 프로젝트에서는 편의성과 낮은 처리비용을 위해 픽셀 쉐이더로 정점 쉐이더가 반환한 데이터를 모두 전달하도록 하겠습니다.

 

쉐이더 사용 예 1) 

shader.attribute.mat = mat;

shader.vertexShader = function(data, attribute) {
    const mat = attribute.mat;
    const localPosition = data.position;

    const position = mat.applyToVertex(localPosition);

    return {
        position : position,
        color: data.color,
    }
}

shader.pixelShader = function(vo1, vo2, vo3, coords, attribute) {
    const c1 = vo1.color;
    const c2 = vo2.color;
    const c3 = vo3.color;
    return {
        r: c1.r * coords.x + c2.r * coords.y + c3.r * coords.z,
        g: c1.g * coords.x + c2.g * coords.y + c3.g * coords.z,
        b: c1.b * coords.x + c2.b * coords.y + c3.b * coords.z,
        a: c1.a * coords.x + c2.a * coords.y + c3.a * coords.z,
    }
}

 

쉐이더 사용 예 2) 플랫 쉐이딩

shader.attribute.mat = mat;
shader.attribute.t += 0.1;
shader.attribute.light = Vector3.nomalize({x: 0, y: -10, z: -10});
shader.attribute.lightPower = 0.5;

shader.vertexShader = function(data, attribute) {
    const mat = attribute.mat;
    const pos = data.position;
    const nm = data.normal;

    const position = mat.applyToVertex(pos);
    const normal = mat.applyToVertex(nm,  0);
    
    position.x += Math.sin(attribute.t) * normal.x * 10;
    position.y += Math.cos(attribute.t) * normal.y * 10;
    

    return {
        position : position,
        color: data.color,
        normal: normal,
    }
}

shader.pixelShader = function(vo1, vo2, vo3, coords, attribute) {
    const c1 = vo1.color;
    const c2 = vo2.color;
    const c3 = vo3.color;
    const normal = vo1.normal;
    const light = attribute.light;
    const lightPower = attribute.lightPower;

    const dotProduct = normal.x * light.x + normal.y * light.y + normal.z * light.z;
    const lightingIntensity = Math.max(dotProduct, 0);

    const color = {
        r: c1.r * coords.x + c2.r * coords.y + c3.r * coords.z,
        g: c1.g * coords.x + c2.g * coords.y + c3.g * coords.z,
        b: c1.b * coords.x + c2.b * coords.y + c3.b * coords.z,
        a: c1.a * coords.x + c2.a * coords.y + c3.a * coords.z,
    }

    color.r = color.r * (1-lightPower) * lightingIntensity + color.r * lightPower;
    color.g = color.g * (1-lightPower) * lightingIntensity + color.g * lightPower;
    color.b = color.b * (1-lightPower) * lightingIntensity + color.b * lightPower;


    return color;
}

 

프로젝트 바로가기

3D 렌더링 DIY 3 - 쉐이더

이전 글

DIY 에디터 소개

다음 글

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

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

    Copyright © DIYPlay All rights reserved.