목차

WebGLFundamentals.org

Fix, Fork, Contribute

WebGL 3D - 방향성 조명

이 글은 WebGL 관련 시리즈의 한 부분으로, 첫 번째는 WebGL 기초였습니다. 또한 이 글은 WebGL 3D 카메라에서 이어집니다. 아직 읽지 않았다면 거기부터 시작하는 게 좋습니다.

조명을 구현하는 방법에는 여러 가지가 있습니다. 아마 가장 간단한 건 방향성 조명일 겁니다.

방향성 조명은 빛이 한 방향에서 균일하게 들어온다고 가정합니다. 맑은 날의 태양이 종종 방향성 조명으로 여겨지는데요. 빛살이 물체의 표면을 모두 평행하게 비춘다고 간주할 수 있는 정도로 멀리 있기 때문입니다.

실제로 방향성 조명을 계산하는 것은 굉장히 쉽습니다. 빛이 어떤 방향으로 가고 있는지 알고 물체의 표면이 향하는 방향을 알면, 두 방향의 스칼라곱을 구할 수 있고 두 방향 사이의 각도에 대한 코사인을 얻을 수 있습니다.

점을 드래그해보세요

점을 끌어서 서로 정확히 반대에 두면 스칼라곱이 -1인 것을 알 수 있습니다. 정확히 같은 지점에 있다면 스칼라곱은 1이 되죠.

그게 어떻게 유용할까요? 3D 객체의 표면이 향하는 방향과 빛이 비추는 방향을 안다면 그 스칼라곱을 구할 수 있기 때문에, 빛이 표면을 직접 가리킨다면 1이 주어지고 정반대를 가리키면 -1이 주어집니다.

방향을 회전시켜보세요

색상을 해당 스칼라곱으로 곱할 수 있고 짜잔! 조명을 만들었습니다!

한 가지 문제는 3D 객체의 표면이 향하는 방향을 어떻게 알 수 있을까요?

법선 소개

법선은 라틴어 norma에서 유래한 것으로, 곱자라고 불리는데요. 곱자가 직각인 것처럼, 법선은 선이나 표면에 대해 직각을 이룹니다. 3D 그래픽에서 법선은 표면이 향하는 방향을 설명하는 단위 벡터를 의미합니다.

다음은 큐브와 구체에 대한 법선입니다.

객체에서 튀어나온 선들은 각 정점에 대한 법선을 나타냅니다.

큐브는 각 모서리에 3개의 법선을 가지는데요. 큐브의 각 면이 향하는 방향을 나타내려면 3개의 다른 법선이 필요하기 때문입니다.

또한 법선은 방향에 따라 +x는 빨강, 위쪽은 초록, +z는 파랑으로 채색됩니다.

그러면 이전 예제의 'F'에 법선을 추가해서 조명을 비춰봅시다. F가 박스형이고 면이 x, y, z축에 정렬되어 있기 때문에 굉장히 쉽습니다. 앞쪽을 향하고 있는 것은 법선 0, 0, 1을 가집니다. 뒤쪽을 향하는 것은 0, 0, -1입니다. 왼쪽을 향하는 것은 -1, 0, 0, 오른쪽을 향하는 것은 1, 0, 0입니다. 위쪽은 0, 1, 0이고 아래쪽은 0, -1, 0입니다.

function setNormals(gl) {
  var normals = new Float32Array([
    // 왼쪽 열 앞쪽
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,

    // 상단 획 앞쪽
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,

    // 중간 획 앞쪽
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,
    0, 0, 1,

    // 왼쪽 열 뒤쪽
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,

    // 상단 획 뒤쪽
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,

    // 중간 획 뒤쪽
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,
    0, 0, -1,

    // 상단
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,

    // 상단 획 오른쪽
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,

    // 상단 획 아래쪽
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,

    // 상단과 중간 획 사이
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,

    // 중간 획의 위쪽
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,
    0, 1, 0,

    // 중간 획의 오른쪽
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,

    // 중간 획의 아래쪽
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,

    // 하단의 오른쪽
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,
    1, 0, 0,

    // 하단
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,
    0, -1, 0,

    // 왼쪽 면
    -1, 0, 0,
    -1, 0, 0,
    -1, 0, 0,
    -1, 0, 0,
    -1, 0, 0,
    -1, 0, 0
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, normals, gl.STATIC_DRAW);
}

그리고 이것들을 설정하는데요. 동시에 정점 색상을 제거하여 조명을 보기 쉽게 합시다.

// 정점 데이터가 어디로 가야하는지 탐색
var positionLocation = gl.getAttribLocation(program, "a_position");
-var colorLocation = gl.getAttribLocation(program, "a_color");
+var normalLocation = gl.getAttribLocation(program, "a_normal");

...

-// 색상을 넣을 버퍼 생성
-var colorBuffer = gl.createBuffer();
-// ARRAY_BUFFER에 바인딩 (ARRAY_BUFFER = colorBuffer)
-gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
-// 버퍼에 지오메트리 데이터 넣기
-setColors(gl);

+// 법선 데이터를 넣을 버퍼 생성
+var normalBuffer = gl.createBuffer();
+// ARRAY_BUFFER에 바인딩 (ARRAY_BUFFER = normalBuffer)
+gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
+// 법선 데이터를 버퍼에 넣기
+setNormals(gl);

그리고 렌더링할 때 이렇게 합니다.

-// 색상 속성 활성화
-gl.enableVertexAttribArray(colorLocation);
-
-// 색상 버퍼 바인딩
-gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
-
// colorBuffer(ARRAY_BUFFER)에서 데이터 가져오는 방법을 속성에 지시
-var size = 3;                 // 반복마다 3개의 컴포넌트
-var type = gl.UNSIGNED_BYTE;  // 데이터는 부호없는 8비트 값
-var normalize = true;         // 데이터 정규화 (0-255에서 0-1로 전환)
-var stride = 0;               // 0 = 다음 위치를 가져오기 위해 반복마다 size * sizeof(type) 만큼 앞으로 이동
-var offset = 0;               // 버퍼의 처음부터 시작
-gl.vertexAttribPointer(colorLocation, size, type, normalize, stride, offset)

+// 법선 속성 활성화
+gl.enableVertexAttribArray(normalLocation);
+
+// 법선 버퍼 바인딩
+gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
+
+// normalBuffer(ARRAY_BUFFER)에서 데이터 가져오는 방법을 속성에 지시
+var size = 3;          // 반복마다 3개의 컴포넌트
+var type = gl.FLOAT;   // 데이터는 32비트 부동 소수점
+var normalize = false; // 데이터 정규화 (0-255에서 0-1로 전환)
+var stride = 0;        // 0 = 다음 위치를 가져오기 위해 반복마다 size * sizeof(type) 만큼 앞으로 이동
+var offset = 0;        // 버퍼의 처음부터 시작
+gl.vertexAttribPointer(normalLocation, size, type, normalize, stride, offset)

이제 셰이더가 이걸 사용하도록 만들어야 합니다.

먼저 정점 셰이더를 통해 프래그먼트 셰이더로 법선을 전달합니다.

attribute vec4 a_position;
-attribute vec4 a_color;
+attribute vec3 a_normal;

uniform mat4 u_matrix;

-varying vec4 v_color;
+varying vec3 v_normal;

void main() {
  // 위치에 행렬 곱하기
  gl_Position = u_matrix * a_position;

-  // 색상을 프래그먼트 셰이더로 전달
-  v_color = a_color;

+  // 법선을 프래그먼트 셰이더로 전달
+  v_normal = a_normal;
}

그리고 프래그먼트 셰이더는 빛의 방향과 법선의 스칼라곱을 이용한 수식을 수행할 겁니다.

precision mediump float;

// 정점 셰이더에서 전달됩니다.
-varying vec4 v_color;
+varying vec3 v_normal;

+uniform vec3 u_reverseLightDirection;
+uniform vec4 u_color;

void main() {
+   // v_normal이 varying이기 때문에 보간되므로 단위 벡터가 아닙니다.
+   // 정규화하면 다시 단위 벡터가 됩니다.
+   vec3 normal = normalize(v_normal);
+
+   float light = dot(normal, u_reverseLightDirection);

*   gl_FragColor = u_color;

+   // 색상 부분(알파 제외)에만 light 곱하기
+   gl_FragColor.rgb *= light;
}

그런 다음 u_coloru_reverseLightDirection의 위치를 찾아야 합니다.

  // 유니폼 탐색
  var matrixLocation = gl.getUniformLocation(program, "u_matrix");
+  var colorLocation = gl.getUniformLocation(program, "u_color");
+  var reverseLightDirectionLocation =
+      gl.getUniformLocation(program, "u_reverseLightDirection");

그리고 이것들을 설정해야 합니다.

  // 행렬 설정
  gl.uniformMatrix4fv(matrixLocation, false, worldViewProjectionMatrix);

+  // 사용할 색상 설정
+  gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]); // 초록색
+
+  // 빛의 방향 설정
+  gl.uniform3fv(reverseLightDirectionLocation, m4.normalize([0.5, 0.7, 1]));

앞서 살펴본 normalize는 우리가 넣은 값을 단위 벡터로 만듭니다. x = 0.5, +x는 오른쪽에 있는 빛이 왼쪽을 가리키는 것을 의미합니다. y = 0.7, +y는 위쪽에 있는 빛이 아래쪽을 가리키는 것을 의미합니다. z = 1, +z는 앞쪽에 있는 빛이 장면 쪽을 가리키는 것을 의미합니다. 상대적인 값은 방향이 대부분 장면을 가리키고 오른쪽보다 아래쪽을 더 가리키는 것을 의미합니다.

그리고 여기 결과입니다.

F를 회전시키셨다면 뭔가 눈치채셨을 겁니다. F는 회전하지만 조명은 변하지 않는데요. F가 회전함에 따라 빛이 향하는 부분이 가장 밝아지도록 해봅시다.

이걸 고치기 위해 객체의 방향이 변경될 때 법선의 방향을 변경해야 합니다. 위치에 했던 것처럼 법선에 어떤 행렬을 곱할 수 있는데 가장 확실한 행렬은 world 행렬입니다. 현재는 u_matrix라 불리는 행렬 하나만 전달하고 있는데 2개의 행렬을 전달하도록 바꿔봅시다. u_world로 명명한 것은 월드 행렬이 될 겁니다. u_worldViewProjection로 명명한 또 다른 행렬은 현재 u_matrix로 전달하고 있는 행렬입니다.

attribute vec4 a_position;
attribute vec3 a_normal;

*uniform mat4 u_worldViewProjection;
+uniform mat4 u_world;

varying vec3 v_normal;

void main() {
  // 위치에 행렬 곱하기
*  gl_Position = u_worldViewProjection * a_position;

*  // 법선의 방향을 정하고 프래그먼트 셰이더로 전달
*  v_normal = mat3(u_world) * a_normal;
}

a_normalmat3(u_world)로 곱한다는 점에 주목하세요. 이는 법선이 방향이어서 평행 이동을 고려하지 않기 때문입니다. 행렬의 오리엔테이션 부분은 행렬의 3x3 영역에만 있습니다.

이제 유니폼을 찾아야 합니다.

  // 유니폼 탐색
*  var worldViewProjectionLocation =
*      gl.getUniformLocation(program, "u_worldViewProjection");
+  var worldLocation = gl.getUniformLocation(program, "u_world");

그리고 유니폼 업데이트 코드를 수정해야 합니다.

*// 행렬 설정
*gl.uniformMatrix4fv(
*  worldViewProjectionLocation, false, worldViewProjectionMatrix);
*gl.uniformMatrix4fv(worldLocation, false, worldMatrix);

그리고 여기 결과입니다.

F를 회전시켜서 어느 쪽이 빛의 방향을 향하는지 확인해보세요.

어떻게 직접 보여줄 수 있을지 모르겠는 문제가 한 가지 있어 다이어그램으로 보여드리겠습니다. 우리는 normal을 법선의 방향을 변경하는 u_world 행렬로 곱하고 있는데요. 월드 행렬을 스케일링하면 무슨 일이 일어날까요? 잘못된 법선을 얻게 됩니다.

법선을 전환하려면 클릭

월드 행렬의 역을 구하고, 열을 행으로 바꾸는 전치를 해서, 이를 대신 사용하면 올바른 답을 구할 수 있습니다.

위 다이어그램에서 보라색 구는 크기가 바뀌지 않습니다. 왼쪽의 빨간색 구는 크기가 바뀌고 법선은 월드 행렬로 곱해집니다. 뭔가 잘못된 것을 확인할 수 있는데요. 오른쪽의 파란색 구는 월드 행렬의 역전치 행렬을 사용하고 있습니다.

다이어그램을 클릭하여 다른 표현들을 둘러보세요. 스케일이 극단적일 때 왼쪽의 법선(world)이 구의 표면에 수직으로 유지되지 않는 반면에 오른쪽에 있는 법선(worldInverseTranspose)은 구에 대해 수직이 유지되는 걸 쉽게 확인할 수 있습니다. 마지막 모드는 모두 빨간색으로 음영 처리되는데요. 어떤 행렬을 사용했는지에 따라 바깥쪽 구 2개의 조명이 매우 다르다는 것을 알 수 있습니다. 애매한 문제이기 때문에 어느 것이 맞다고 말하기는 힘들지만, 다른 시각화로 비교해봤을 때 worldInverseTranspose를 사용하는 것이 맞다는 건 분명합니다.

예제에서 이를 구현하기 위해 이렇게 코드를 수정해봅시다. 먼저 셰이더를 업데이트할 겁니다. 기술적으로는 u_world의 값만 업데이트할 수 있지만 혼란스러울 수 있기 때문에 실제 이름으로 바꿔주는 게 가장 좋습니다.

attribute vec4 a_position;
attribute vec3 a_normal;

uniform mat4 u_worldViewProjection;
*uniform mat4 u_worldInverseTranspose;

varying vec3 v_normal;

void main() {
  // 위치에 행렬 곱하기
  gl_Position = u_worldViewProjection * a_position;

  // 법선의 방향을 정하고 프래그먼트 셰이더로 전달
*  v_normal = mat3(u_worldInverseTranspose) * a_normal;
}

그런 다음 유니폼을 찾아야 합니다.

-  var worldLocation = gl.getUniformLocation(program, "u_world");
+  var worldInverseTransposeLocation =
+      gl.getUniformLocation(program, "u_worldInverseTranspose");

그리고 그걸 계산하고 설정해야 합니다.

var worldViewProjectionMatrix = m4.multiply(viewProjectionMatrix, worldMatrix);
var worldInverseMatrix = m4.inverse(worldMatrix);
var worldInverseTransposeMatrix = m4.transpose(worldInverseMatrix);

// 행렬 설정
gl.uniformMatrix4fv(
  worldViewProjectionLocation, false, worldViewProjectionMatrix);
-gl.uniformMatrix4fv(
  worldLocation, false, worldMatrix);
+gl.uniformMatrix4fv(
+  worldInverseTransposeLocation, false, worldInverseTransposeMatrix);

그리고 다음은 행렬을 전치하는 코드입니다.

var m4 = {
  transpose: function(m) {
    return [
      m[0], m[4], m[8], m[12],
      m[1], m[5], m[9], m[13],
      m[2], m[6], m[10], m[14],
      m[3], m[7], m[11], m[15],
    ];
  },

  ...

효과가 미묘하고 크기가 변경되고 있지 않기 때문에 눈에 띄는 차이는 없지만 적어도 지금은 스케일링에 대한 준비가 되어 있습니다.

조명에 대한 첫 걸음을 잘 내딛으셨길 바랍니다. 다음은 점 조명입니다.

mat3(u_worldInverseTranspose) * a_normal 대안

위 셰이더에는 이런 라인이 있습니다.

v_normal = mat3(u_worldInverseTranspose) * a_normal;

우리는 이걸 수행할 수 있었는데요.

v_normal = (u_worldInverseTranspose * vec4(a_normal, 0)).xyz;

곱하기 전에 w를 0으로 설정했기 때문에, 행렬에서 translation을 0으로 곱하면 사실상 제거됩니다. 저는 그게 더 일반적인 방법이라고 생각합니다. mat3 방식이 더 깔끔해 보였지만 저는 보통 이 방법으로 해왔습니다.

또 다른 해결책은 u_worldInverseTransposemat3로 만드는 겁니다. 이렇게 하지 않은 2가지 이유가 있는데요. 하나는 u_worldInverseTranspose 전체에 대한 다른 요구 사항이 있을 수 있으므로 mat4 전체를 전달하면 다른 요구 사항에 대해 사용할 수 있습니다. 또 다른 이유로 자바스크립트의 모든 행렬 함수는 4x4 행렬을 만듭니다. 3x3 행렬에 대해 완전히 다른 세트를 만들거나 4x4에서 3x3으로 변환하는 것은 더 설득력 있는 이유를 제외하면 안 하는 게 나은 작업입니다.

이슈/버그는? Github에 이슈를 만들어주세요.
코드 블록은 <pre><code>여기에 코드 입력</code></pre>를 사용해주세요
comments powered by Disqus