목차

WebGLFundamentals.org

Fix, Fork, Contribute

WebGL 램프 텍스처

WebGL에서 중요한 사실은 텍스처가 텍스처에 대한 글에서 다룬 것처럼 삼각형에 직접 적용되는 게 아니라는 겁니다. 텍스처는 랜덤 접근 데이터 배열이며, 일반적으로 데이터의 2D 배열입니다. 따라서 데이터의 랜덤 접근 배열을 사용할 수 있는 솔루션은 텍스처를 사용할 수 있는 곳입니다.

방향성 조명에 대한 글에서 스칼라곱을 사용하여 두 벡터 사이의 각도를 계산하는 방법을 다뤘었습니다. 모델 표면의 법선에 대한 조명 방향의 스칼라곱을 계산했는데요. 이는 두 벡터 사이 각도의 코사인을 제공합니다. 코사인은 -1에서 +1사이의 값이며 색상에 직접 곱했습니다.

float light = dot(normal, u_reverseLightDirection);

gl_FragColor = u_color;
gl_FragColor.rgb *= light;

이렇게 하면 빛에서 멀어질수록 색상이 어두워집니다.

스칼라곱을 직접 사용하는 대신에 1차원 텍스처에서 값을 찾는 데 사용하면 어떨까요?

precision mediump float;

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

uniform vec3 u_reverseLightDirection;
uniform vec4 u_color;
+uniform sampler2D u_ramp;

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

-  float light = dot(normal, u_reverseLightDirection);
+  float cosAngle = dot(normal, u_reverseLightDirection);
+
+  // -1 <-> 1에서 0 <-> 1로 변환
+  float u = cosAngle * 0.5 + 0.5;
+
+  // 텍스처 좌표 만들기
+  vec2 uv = vec2(u, 0.5);
+
+  // 1차원 텍스처에서 값 조회
+  vec4 rampColor = texture2D(u_ramp, uv);
+
  gl_FragColor = u_color;
-  gl_FragColor.rgb *= light;
+  gl_FragColor *= rampColor;
}

텍스처를 만들어야 합니다. 2x1 텍스처로 시작해봅시다. 텍셀당 1바이트만 사용하여 모노크롬 텍스처를 제공하는 LUMINANCE 포맷을 사용할 겁니다.

var tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(
    gl.TEXTURE_2D,     // 대상
    0,                 // 밉 레벨
    gl.LUMINANCE,      // 내부 포맷
    2,                 // 너비
    1,                 // 높이
    0,                 // 테두리
    gl.LUMINANCE,      // 포맷
    gl.UNSIGNED_BYTE,  // 타입
    new Uint8Array([90, 255]));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

위에서 두 픽셀의 색상은 어두운 회색(90)과 흰색(255)입니다. 또한 필터링이 없도록 텍스처 매개변수를 설정합니다.

새로운 텍스처에 대한 샘플을 수정하려면 u_ramp 유니폼을 찾아야 합니다.

var worldViewProjectionLocation = gl.getUniformLocation(program, "u_worldViewProjection");
var worldInverseTransposeLocation = gl.getUniformLocation(program, "u_worldInverseTranspose");
var colorLocation = gl.getUniformLocation(program, "u_color");
+var rampLocation = gl.getUniformLocation(program, "u_ramp");
var reverseLightDirectionLocation =
    gl.getUniformLocation(program, "u_reverseLightDirection");

그리고 렌더링할 때 텍스처를 설정해야 합니다.

// 활성 텍스처 유닛 0에 텍스처 바인딩
gl.activeTexture(gl.TEXTURE0 + 0);
gl.bindTexture(gl.TEXTURE_2D, tex);
// u_ramp가 텍스처 유닛 0에서 텍스처를 사용해야 한다고 셰이더에 알림
gl.uniform1i(rampLocation, 0);

조명 샘플의 3D F 데이터를 저 폴리곤 머리 데이터로 교체했습니다. 실행하면 이렇게 됩니다.

모델을 회전해보면 툰 셰이딩과 비슷하게 보이는 걸 확인할 수 있습니다.

위 예제에서 텍스처 필터링을 NEAREST로 설정하여 텍스처에서 가장 가까운 텍셀을 색상으로 선택합니다. 단 2개의 텍셀만 있기 때문에 표면이 조명에서 멀어지면 첫 번째 색상(어두운 회색)을 얻고, 표면이 조명에서 가까워지면 두 번째 색상(흰색)을 얻게 됩니다. 색상은 light에 사용했던 것처럼 gl_FragColor로 곱해집니다.

생각해보니 LINEAR 필터링으로 전환하면 텍스처를 사용하기 전과 같은 결과가 나와야 합니다.

-gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
-gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

비슷하게 보이지만 실제로 나란히 놓고 비교해보면...

동일하지 않다는 것을 알 수 있습니다. 어떻게 된 걸까요?

LINEAR 필터링은 픽셀 사이를 혼합하는데요. 선형 필터링을 사용한 두 픽셀 텍스처를 확대해보면 문제가 뭔지 알게 됩니다.

램프의 텍스처 좌표 범위

보간없이 양쪽에 0.5픽셀이 있습니다. 텍스처에서 TEXTURE_WRAP_SREPEAT로 설정되었다고 상상해보세요. 그러면 초록색이 왼쪽으로 반복되는 것처럼 빨간색 픽셀이 초록색을 향해 선형적 혼합될 것이라 예상했는데요. 하지만 CLAMP_TO_EDGE를 사용하고 있기 때문에 왼쪽이 더 빨갛습니다.

램프를 실제로 얻으려면 해당 중심 범위에서 값을 선택하면 됩니다. 셰이더에서 약간의 수식으로 이를 수행할 수 있습니다.

precision mediump float;

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

uniform vec3 u_reverseLightDirection;
uniform vec4 u_color;
uniform sampler2D u_ramp;
+uniform vec2 u_rampSize;

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

  float cosAngle = dot(normal, u_reverseLightDirection);

  // -1 <-> 1에서 0 <-> 1로 변환
  float u = cosAngle * 0.5 + 0.5;

  // 텍스처 좌표 만들기
  vec2 uv = vec2(u, 0.5);

+  // 램프의 크기로 크기 조정
+  vec2 texelRange = uv * (u_rampSize - 1.0);
+
+  // 텍셀의 절반만큼 오프셋하고 텍스처 좌표로 변환
+  vec2 rampUV = (texelRange + 0.5) / u_rampSize;

-  vec4 rampColor = texture2D(u_ramp, uv);
+  vec4 rampColor = texture2D(u_ramp, rampUV);

  gl_FragColor = u_color;
  gl_FragColor *= rampColor;
}

위에서 우리는 기본적으로 UV 좌표의 크기를 조정하여 텍스처 너비보다 1만큼 작은 0에서 1사이가 됩니다. 그런 다음 픽셀에 0.5를 추가하고 다시 정규화된 텍스처 좌표로 변환합니다.

u_rampSize의 위치를 찾아야 합니다.

var colorLocation = gl.getUniformLocation(program, "u_color");
var rampLocation = gl.getUniformLocation(program, "u_ramp");
+var rampSizeLocation = gl.getUniformLocation(program, "u_rampSize");

그리고 렌더링할 때 이를 설정해야 합니다.

// 활성 텍스처 유닛 0에 텍스처 바인딩
gl.activeTexture(gl.TEXTURE0 + 0);
gl.bindTexture(gl.TEXTURE_2D, tex);
// u_ramp가 텍스처 유닛 0에서 텍스처를 사용해야 한다고 셰이더에 알림
gl.uniform1i(rampLocation, 0);
+gl.uniform2fv(rampSizeLocation, [2, 1]);

실행하기 전에 램프 텍스처가 있을 때와 없을 때를 비교할 수 있도록 플래그를 추가해봅시다.

precision mediump float;

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

uniform vec3 u_reverseLightDirection;
uniform vec4 u_color;
uniform sampler2D u_ramp;
uniform vec2 u_rampSize;
+uniform bool u_useRampTexture;

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

  float cosAngle = dot(normal, u_reverseLightDirection);

  // -1 <-> 1에서 0 <-> 1로 변환
  float u = cosAngle * 0.5 + 0.5;

  // 텍스처 좌표 만들기
  vec2 uv = vec2(u, 0.5);

  // 램프의 크기로 크기 조정
  vec2 texelRange = uv * (u_rampSize - 1.0);

  // 텍셀의 절반만큼 오프셋하고 텍스처 좌표로 변환
  vec2 rampUV = (texelRange + 0.5) / u_rampSize;

  vec4 rampColor = texture2D(u_ramp, rampUV);

+  if (!u_useRampTexture) {
+    rampColor = vec4(u, u, u, 1);
+  }

  gl_FragColor = u_color;
  gl_FragColor *= rampColor;
}

유니폼의 위치도 찾을 겁니다.

var rampLocation = gl.getUniformLocation(program, "u_ramp");
var rampSizeLocation = gl.getUniformLocation(program, "u_rampSize");
+var useRampTextureLocation = gl.getUniformLocation(program, "u_useRampTexture");

그리고 그걸 설정합니다.

var data = {
  useRampTexture: true,
};

...

// 액티브 텍스처 유닛 0에 텍스처 바인딩
gl.activeTexture(gl.TEXTURE0 + 0);
gl.bindTexture(gl.TEXTURE_2D, tex);
// u_ramp가 텍스처 유닛 0의 텍스처를 사용해야 한다고 셰이더에 알림
gl.uniform1i(rampLocation, 0);
gl.uniform2fv(rampSizeLocation, [2, 1]);

+gl.uniform1i(useRampTextureLocation, data.useRampTexture);

이를 통해 기존 조명 방식과 새로운 램프 텍스처 방식이 일치하는 것을 볼 수 있습니다.

"useRampTexture" 체크 박스를 클릭하면 현재 두 기술이 일치하기 때문에 변화가 없습니다.

참고: 일반적으로 셰이더에서 u_useRampTexture와 같은 조건문을 사용하는 것은 권장되지 않는데요. 대신에 일반 조명과 램프 텍스처를 사용하는 셰이더 프로그램을 각각 만드는 게 좋습니다. 안타깝지만 코드는 외부 도우미 라이브러리를 사용하고 있지 않기 때문에 2개의 셰이더 프로그램을 지원하기 위해 꽤 많이 바뀔 겁니다. 각 프로그램은 고유한 위치 세트가 필요합니다. 하지만 그렇게 많이 변경하면 이 글의 요점에서 벗어날 수 있기 때문에 여기서는 조건문을 사용하기로 결정했습니다. 일반적으로 저는 셰이더에서 기능을 선택하는 조건문을 피하고 대신에 다른 기능을 위한 다른 셰이더를 생성합니다.

참고: 이 수식은 LINEAR 필터링을 사용하는 경우에만 중요합니다. NEAREST 필터링을 사용하고 있다면 원래 수식이 필요합니다.

이제 램프 수식이 맞다는 것을 알았으니 다양한 램프 텍스처를 만들어 보겠습니다.

+// 0 ~ 127 요소는 64 ~ 191이고 128 ~ 255 요소는 모두 255인 배열을 만듭니다.
+const smoothSolid = new Array(256).fill(255);
+for (let i = 0; i < 128; ++i) {
+  smoothSolid[i] = 64 + i;
+}
+
+const ramps = [
+  { name: 'dark-white',          color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 255] },
+  { name: 'dark-white-skewed',   color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 80, 80, 255, 255] },
+  { name: 'normal',              color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: true,
+    data: [0, 255] },
+  { name: '3-step',              color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 160, 255] },
+  { name: '4-step',              color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 140, 200, 255] },
+  { name: '4-step skewed',       color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 80, 80, 80, 140, 200, 255] },
+  { name: 'black-white-black',   color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 255, 80] },
+  { name: 'stripes',             color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255, 80, 255] },
+  { name: 'stripe',              color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: [80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 80, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] },
+  { name: 'smooth-solid',        color: [0.2, 1, 0.2, 1], format: gl.LUMINANCE, filter: false,
+    data: smoothSolid },
+  { name: 'rgb',                 color: [  1, 1,   1, 1], format: gl.RGB,       filter: true,
+    data: [255, 0, 0, 0, 255, 0, 0, 0, 255] },
+];
+
+var elementsForFormat = {};
+elementsForFormat[gl.LUMINANCE] = 1;
+elementsForFormat[gl.RGB      ] = 3;
+
+ramps.forEach((ramp) => {
+  const {name, format, filter, data} = ramp;
  var tex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, tex);
+  gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+  const width = data.length / elementsForFormat[format];
  gl.texImage2D(
      gl.TEXTURE_2D,     // 대상
      0,                 // 밉 레벨
*      format,            // 내부 포맷
*      width,             // 너비
      1,                 // 높이
      0,                 // 테두리
*     format,            // 포맷
      gl.UNSIGNED_BYTE,  // 타입
*      new Uint8Array(data));
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
*  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter ? gl.LINEAR : gl.NEAREST);
*  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter ? gl.LINEAR : gl.NEAREST);
+  ramp.texture = tex;
+  ramp.size = [width, 1];
+});

NEARESTLINEAR를 모두 처리할 수 있도록 셰이더를 만들어 보겠습니다. 위에서 언급한 것처럼 일반적으로 셰이더에서 조건문을 사용하진 않지만, 차이점이 간단하고 조건문 없이 할 수 있다면 셰이더 하나만 사용하는 것을 고려할 겁니다. 그렇게 하기 위해 0.0이나 1.0으로 설정할 부동 소수점 유니폼 u_linearAdjust를 추가할 수 있습니다.

precision mediump float;

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

uniform vec3 u_reverseLightDirection;
uniform vec4 u_color;
uniform sampler2D u_ramp;
uniform vec2 u_rampSize;
-uniform bool u_useRampTexture;
-uniform float u_linearAdjust;  // "linear"인 경우 1.0, "nearest"인 경우 0.0

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

  float cosAngle = dot(normal, u_reverseLightDirection);

  // -1 <-> 1에서 0 <-> 1로 변환
  float u = cosAngle * 0.5 + 0.5;

  // 텍스처 좌표 만들기
  vec2 uv = vec2(u, 0.5);

  // 램프의 크기로 크기 조정
-  vec2 texelRange = uv * (u_rampSize - 1.0);
+  vec2 texelRange = uv * (u_rampSize - u_linearAdjust);

-  // 텍셀의 절반만큼 오프셋하고 텍스처 좌표로 변환
-  vec2 rampUV = (texelRange + 0.5) / u_rampSize;
+  // "linear"인 경우 텍셀의 절반만큼 오프셋하고 텍스처 좌표로 변환
+  vec2 rampUV = (texelRange + 0.5 * u_linearAdjust) / u_rampSize;

  vec4 rampColor = texture2D(u_ramp, rampUV);

-  if (!u_useRampTexture) {
-    rampColor = vec4(u, u, u, 1);
-  }

  gl_FragColor = u_color;
  gl_FragColor *= rampColor;
}

초기화할 때 위치를 찾습니다.

var colorLocation = gl.getUniformLocation(program, "u_color");
var rampLocation = gl.getUniformLocation(program, "u_ramp");
var rampSizeLocation = gl.getUniformLocation(program, "u_rampSize");
+var linearAdjustLocation = gl.getUniformLocation(program, "u_linearAdjust");

그리고 렌더링할 때 텍스처 중 하나를 선택합니다.

var data = {
  ramp: 0,
};

...
+const {texture, color, size, filter} = ramps[data.ramp];

// 사용할 색상 설정
-gl.uniform4fv(colorLocation, [0.2, 1, 0.2, 1]);
+gl.uniform4fv(colorLocation, color);

// 조명 방향 설정
gl.uniform3fv(reverseLightDirectionLocation, m4.normalize([-1.75, 0.7, 1]));

// 액티브 텍스처 유닛 0에 텍스처 바인딩
gl.activeTexture(gl.TEXTURE0 + 0);
-gl.bindTexture(gl.TEXTURE_2D, tex);
+gl.bindTexture(gl.TEXTURE_2D, texture);
// u_ramp가 텍스처 유닛 0의 텍스처를 사용해야 한다고 셰이더에 알림
gl.uniform1i(rampLocation, 0);
-gl.uniform2fv(rampSizeLocation, [2, 1]);
+gl.uniform2fv(rampSizeLocation, size);

+// "linear"인 경우 조정
+gl.uniform1f(linearAdjustLocation, filter ? 1 : 0);

다른 램프 텍스처를 시도하면 이상한 효과를 많이 볼 수 있습니다. 이게 일반적인 조정 셰이더를 만드는 하나의 방법입니다. 다음과 같이 2개의 색상과 임계값을 설정하여 2색 툰 셰이딩 셰이더를 만들 수 있습니다.

uniform vec4 color1;
uniform vec4 color2;
uniform float threshold;

...

  float cosAngle = dot(normal, u_reverseLightDirection);

  // -1 <-> 1에서 0 <-> 1로 변환
  float u = cosAngle * 0.5 + 0.5;

  gl_FragColor = mix(color1, color2, step(cosAngle, threshold));

그리고 이것은 잘 동작할 겁니다. 하지만 3단계 혹은 4단계 버전을 하고 싶다면 또 다른 셰이더를 작성해야 합니다. 램프 텍스처로 다른 텍스처를 제공할 수 있습니다. 2단계 툰 셰이더를 하려는 경우에도 텍스처에 데이터를 더 많이 혹은 더 적게 넣어서 단계가 발생하는 위치를 조정할 수 있는데요.

[dark, light]

예를 들어 위와 같은 텍스처가 있으면 빛을 향하는 방향과 반대하는 방향 사이의 중간에서 나뉘는 2단계 텍스처를 제공합니다.

[dark, dark, dark, light, light]

하지만 위와 같은 텍스처의 경우 셰이더를 바꾸지 않고도 빛을 향하는 방향과 반대하는 방향 사이의 60% 지점으로 분할을 이동합니다.

툰 셰이딩이나 이상한 효과를 위해 램프 텍스처를 사용하는 이 예제는 여러분에게 유용할 수도 있고 아닐 수도 있지만 더 중요한 점은 텍스처에서 데이터를 찾기 위해 어떤 값을 사용하는 기본 개념입니다. 이렇게 텍스처를 사용하는 것은 단순히 빛 계산을 변환하기 위한 게 아닙니다. 포토샵의 그레이디언트 맵과 동일한 효과를 내기 위해 후처리에 램프 텍스처를 사용할 수 있습니다.

GPU 기반의 애니메이션에 램프 텍스처를 사용할 수도 있습니다. 텍스처에 키/값을 저장하고 "시간"을 값으로 사용하여 텍스처로 이동합니다. 이 기술에는 많은 용도가 있습니다.

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