WebGL 번역(Multiple Views, Multiple Canvases)

Minji·2022년 3월 21일
0

해당 포스트는 WebGL_multiple-views를 번역한 글입니다. 틀린 부분이 있다면, 언제든지 알려주시면 감사하겠습니다!

제목: WebGL 여러 관점, 여러 캔버스
Description: 여러 관점 그리기
TOC: 여러 관점, 여러 캔버스

이 글은 더 적은 코드로 즐겁게 코딩하기 에서 언급된 라이브러리를 사용하여 예시를 정리하기 때문에 해당 글을 읽었다는 전제 하에 작성되어 있습니다.
만약에 webglUtils.setBuffersAndAttributes 라는 함수가 버퍼와 속성을 설정한다는 의미를 모르거나, webglUtils.setUniforms 라는 함수가 uniforms, 등등을 설정하는 것을 모른다면 read the fundamentals 를 다시 보고 읽고 오시기 바랍니다.

같은 장면의 여러 관점을 그리고 싶다고 가정해보겠습니다. 어떻게 하면 우리는 이것을 할 수 있을까요?
한 가지 방법은 텍스처에 렌더링하기 한 후에 캔버스에 해당 텍스처를 그리는 방법입니다.
이 방법은 확실한 방법이고 이 방법이 옳은 방법일 수 있습니다. 그러나, 이 방법은 텍스처를 할당하고 이 텍스처에 렌더링 한 후에 해당 텍스처를 캔버스에 렌더링 해야 합니다.
이는 우리가 실제로 이중 렌더링을 하고 있음을 뜻합니다. 이 방법은 레이싱 게임에서 우리가 백미러로 view를 렌더링할 때 차량 뒤에 있는 것을 텍스처로 렌더링하고 그 후에 백미러를 그 텍스처에 그릴 때 사용한다면 적절한 방법이라고 할 수 있습니다.

다른 방법은 viewport를 설정하고 scissor test를 수행하는 것이다.
이 방법은 우리의 관점이 겹치지 않는 상황에 좋습니다. 또한, 위의 해결방법처럼 이중 렌더링이 없어서 더 낫습니다.

첫 번째 글에서 WebGL을 clip 공간에서 pixel 공간으로 바꾸려면 다음과 같은 방법으로 설정할 수 있다고 했습니다.

gl.viewport(left, bottom, width, height);

가장 일반적인 것은 캔버스 전체를 다루기 위해서 0, 0, gl.canvas.width, gl.canvas.height 를 각각 설정하는 것입니다.
대신 우리는 캔버스의 일부로 설정할 수 있고 캔버스의 일부에 그릴 수 있도록 할 수 있습니다.
WebGL 클립 공간에서 정점들을 자릅니다. 이전에 언급했듯이, 우리가 vertex shader에서 gl_Position을 x,y,z의 값을 -1에서 +1 사이로 설정했습니다.
WebGL은 이 범위를 지나는 삼각형과 선들을 자릅니다. clipping이 일어난 후, 아래의 예시처럼 gl.viewport 설정들이 적용됩니다.

gl.viewport(
   10,   // left(왼쪽)
   20,   // bottom(아래)
   30,   // width(넓이)
   40,   // height(높이)
);

그 후, 클립 공간에서 x=-1의 값은 픽셀 x=10과 동일하고 클립 공간에서 값 +1은 픽셀 공간에서 x=40(왼쪽 10과 넓이 30)에 해당합니다.
(사실 이것은 좀 지나치게 단순화 한 것으로, 아래의 내용을 보시면 됩니다.

따라서, 우리가 삼각형을 그릴 때 clipping 후에는 viewport내부에 알맞게 보입니다.

이제 이전 글 의 'F'를 그려봅시다.

vertex, fragment shader들은 직교 투영원근 투영 의 글에서 사용된 것과 동일합니다.

// vertex shader
attribute vec4 a_position;
attribute vec4 a_color;

uniform mat4 u_matrix;

varying vec4 v_color;

void main() {
  // 위치에 행렬을 곱합니다.
  gl_Position = u_matrix * a_position;

  // 정점 색상을 fragment shader에 전달합니다.
  v_color = a_color;
}
// fragment shader
precision mediump float;

// vertex shader로부터 전달 받습니다.
varying vec4 v_color;

void main() {
  gl_FragColor = v_color;
}

그 후, 초기화 할 때 우리는 program을 만들고 F를 위한 버퍼들을 만듭니다.

// GLSL programs 설정
// shaders 컴파일, program 연결, 위치 파악
const programInfo = webglUtils.createProgramInfo(gl, ["vertex-shader-3d", "fragment-shader-3d"]);

// 버퍼를 만들고 3D 'F'에 대한 데이터로 버퍼를 채웁니다.
const bufferInfo = primitives.create3DFBufferInfo(gl);

그리고 그리기를 위해서 투영 행렬, 카메라 행렬, world 행렬을 전달할 수 있는 함수들을 만들어 보겠습니다.

function drawScene(projectionMatrix, cameraMatrix, worldMatrix) {
  // 카메라 행렬로부터 뷰 행렬을 만듭니다
  const viewMatrix = m4.inverse(cameraMatrix);
 
  // worldViewProjection 헹렬을 만들기 위해서 이들을 곱합니다.
  let mat = m4.multiply(projectionMatrix, viewMatrix);
  mat = m4.multiply(mat, worldMatrix);
 
  gl.useProgram(programInfo.program);
 
  // ------ F 그리기 --------
 
  // 필요한 모든 속성들을 설정합니다.
  webglUtils.setBuffersAndAttributes(gl, programInfo, bufferInfo);
 
  // uniforms을 설정합니다
  webglUtils.setUniforms(programInfo, {
    u_matrix: mat,
  });
 
  //  gl.drawArrays 또는 gl.drawElements를 불러옵니다
  webglUtils.drawBufferInfo(gl, bufferInfo);
}

그리고나서 F를 그리기 위한 함수들을 불러오겠습니다.

function degToRad(d) {
  return d * Math.PI / 180;
}

const settings = {
  rotation: 150,  // 도 다
};
const fieldOfViewRadians = degToRad(120);

function render() {
  webglUtils.resizeCanvasToDisplaySize(gl.canvas);

  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

  gl.enable(gl.CULL_FACE);
  gl.enable(gl.DEPTH_TEST);

  const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
  const near = 1;
  const far = 2000;

  //원근 투영 행렬 계산
  const perspectiveProjectionMatrix =
      m4.perspective(fieldOfViewRadians, aspect, near, far);

  // lookAt을 사용하여 카메라의 행렬을 계산
  const cameraPosition = [0, 0, -75];
  const target = [0, 0, 0];
  const up = [0, 1, 0];
  const cameraMatrix = m4.lookAt(cameraPosition, target, up);

  // world space에서 F 회전
  let worldMatrix = m4.yRotation(degToRad(settings.rotation));
  worldMatrix = m4.xRotate(worldMatrix, degToRad(settings.rotation));
  // F의 원점을 중심으로 합니다
  worldMatrix = m4.translate(worldMatrix, -35, -75, -5);

  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}
render();

이것은 기본적으로 코드를 단순화시키기 위해서 our library를 사용하는 것을 제외하고 the article on perspective 의 마지막 예시와 동일합니다.
{{{example url="../webgl-multiple-views-one-view.html"}}}

이제 gl.viewport를 사용하여 'F'의 2가지 뷰를 나란히 그려보겠습니다.

function render() {
  webglUtils.resizeCanvasToDisplaySize(gl.canvas);

-  gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

  gl.enable(gl.CULL_FACE);
  gl.enable(gl.DEPTH_TEST);

  // 뷰를 2개로 나누겠습니다
-  const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight;
+  const effectiveWidth = gl.canvas.clientWidth / 2;
+  const aspect = effectiveWidth / gl.canvas.clientHeight;
  const near = 1;
  const far = 2000;

  // 원근 투영 행렬 계산
  const perspectiveProjectionMatrix =
      m4.perspective(fieldOfViewRadians, aspect, near, far);

+  // 직교 투영 행렬 계산
+  const halfHeightUnits = 120;
+  const orthographicProjectionMatrix = m4.orthographic(
+      -halfHeightUnits * aspect,  // left
+       halfHeightUnits * aspect,  // right
+      -halfHeightUnits,           // bottom
+       halfHeightUnits,           // top
+       -75,                       // near
+       2000);                     // far

  // lookAt을 사용하여 카메라의 행렬 계산
  const cameraPosition = [0, 0, -75];
  const target = [0, 0, 0];
  const up = [0, 1, 0];
  const cameraMatrix = m4.lookAt(cameraPosition, target, up);

  let worldMatrix = m4.yRotation(degToRad(settings.rotation));
  worldMatrix = m4.xRotate(worldMatrix, degToRad(settings.rotation));
  // 'F'의 원점을 중심으로 합니다.
  worldMatrix = m4.translate(worldMatrix, -35, -75, -5);

+  const {width, height} = gl.canvas;
+  const leftWidth = width / 2 | 0;
+
+  // 직교 카메라로 왼쪽 그리기
+  gl.viewport(0, 0, leftWidth, height);
+
+  drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);

+  // 투시 카메라로 오른쪽 그리기
+  const rightWidth = width - leftWidth;
+  gl.viewport(leftWidth, 0, rightWidth, height);

  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}

위에서 볼 수 있듯이먼저 우리는 캔버스의 왼쪽 반을 덮도록 viewport를 설정하고 그린 후에 오른쪽 반을 덮도록 설정하고 그립니다.
그렇지 않으면, 우리는 투영 행렬을 변경하는 것 외에는 양쪽에 동일한 것을 그릴 수도 있습니다.

{{{example url="../webgl-multiple-views.html"}}}

양쪽을 다른 색으로 구분 짓겠습니다.

우선, drawScene에서 gl.clear를 호출합니다.

  function drawScene(projectionMatrix, cameraMatrix, worldMatrix) {
+    // 캔버스와 depth 버퍼를 지웁니다
+    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    ...

이제 drawScene을 부르기 전에 선명한 색으로 설정해 보겠습니다.

  const {width, height} = gl.canvas;
  const leftWidth = width / 2 | 0;

  // 직교카메라로 왼쪽 그리기
  gl.viewport(0, 0, leftWidth, height);
+  gl.clearColor(1, 0, 0, 1);  // red

  drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);

  // 직교카메라로 왼쪽 그리기
  const rightWidth = width - leftWidth;
  gl.viewport(leftWidth, 0, rightWidth, height);
  gl.clearColor(0, 0, 1, 1);  // blue

+  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);

{{{example url="../webgl-multiple-views-clear-issue.html"}}}

무슨 일이 발생했나요? 왜 왼쪽에 아무것도 없을까요?

gl.clearviewport 설정을 확인하지 않는 것으로 밝혀졌습니다.
이걸 고치기 위해서 우리는 scissor test 를 사용할 수 있습니다.
scissor test는 직사각형을 정의할 때 사용할 수 있습니다. scissor test가 활성화 되어 있다면, 그 직사각형의 외부에 있는 어떤 것도 영향을 받지 않습니다.

scissor test는 꺼져있는 것이 default 입니다. 우리는 다음을 호출하여 활성화 시킬 수 있습니다.

gl.enable(gl.SCISSOR_TEST);

viewport와 마찬가지로 이것은 캔버스의 초기 크기가 default이지만 아래와 같이 gl.scissor을 호출하여 viewport과 같은 parameter로 설정할 수 있습니다.

gl.scissor(
   10,   // left
   20,   // bottom
   30,   // width
   40,   // height
);

그럼 아래에 이들을 추가해보겠습니다.

function render() {
  webglUtils.resizeCanvasToDisplaySize(gl.canvas);

  gl.enable(gl.CULL_FACE);
  gl.enable(gl.DEPTH_TEST);
+  gl.enable(gl.SCISSOR_TEST);

  ...

  const {width, height} = gl.canvas;
  const leftWidth = width / 2 | 0;

  // 직교 카메라로 왼쪽 그리기
  gl.viewport(0, 0, leftWidth, height);
+  gl.scissor(0, 0, leftWidth, height);
  gl.clearColor(1, 0, 0, 1);  // red

  drawScene(orthographicProjectionMatrix, cameraMatrix, worldMatrix);

  // 직교 카메라로 왼쪽 그리기
  const rightWidth = width - leftWidth;
  gl.viewport(leftWidth, 0, rightWidth, height);
+  gl.scissor(leftWidth, 0, rightWidth, height);
  gl.clearColor(0, 0, 1, 1);  // blue

  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
}

이제 작동할 것 입니다.

{{{example url="../webgl-multiple-views-clear-fixed.html"}}}

물론 같은 장면만 그리는 게 아니라 원하는 각각의 뷰를 그릴 수 있습니다.

다중 캔버스 그리기

이것은 여러 캔버스를 시뮬레이션하기에 좋은 방법입니다.
게임에서 캐릭터 선택 화면을 만들고 사용자가 선택할 수 있도록 목록에서 3d 모델의 각 헤드를 표시한다고 가정해봅시다.
또는 전자상거래 사이트를 만들고 동시에 각 제품의 3d 모델을 페이지 아래에 표시한다고 가정해봅시다.
가장 확실한 방법은 대상을 표시하고 싶은 각각의 부분에 <canvas>를 붙이는 것입니다.
그렇게하면 당신은 여러 문제들을 마주하게 될 것 입니다.

우선, 각 캔버스는 다른 WebGl context가 필요합니다.
WebGL context들은 자원을 공유할 수 없고 각 캔버스에 shader를 컴파일 해야 하고 각 캔버스에 texture를 로드해야하고 각 캔버스에 형상을 업로드해야합니다.

다른 문제는 대부분의 브라우저가 동시에 지원할 수 있는 캔버스의 수에 제한이 있다는 것입니다.
대부분의 경우 8개 이하 입니다. 즉 9번째 캔버스에 WebGL context를 만드는 순간 첫 번째 캔버스에 있는 context를 잃게 된다는 것입니다.

우리는 window전체를 덮는 1개의 큰 캔버스를 만들어서 이 문제를 해결할 수 있습니다.
그런 다음에 그리고 싶은 각 위치에 <div> placeholder를 넣습니다.
우리는 element.getBoundingClientRect를 사용하여 그 공간에 그리기 위해 viewport와 scissor 둘 위치를 찾습니다.

이것은 위에 언급된 문제점들을 해결할 수 있습니다. 우리는 하나의 webgl context를 갖게 되므로 자원을 공유할 수 있고 context 제한에 부딪히지 않습니다.

예시를 들어봅시다.

우선 앞쪽으로 가는 일부 content를 가지고 배경으로 가는 canvas를 만들어보겠습니다.
우선 HTML은 다음과 같습니다.

<body>
  <canvas id="canvas"></canvas>
  <div id="content"></div>
</body>

그리고 나서 CSS는 다음과 같습니다.

body {
  margin: 0;
}
#content {
  margin: 10px;
}
#canvas {
  position: absolute;
  top: 0;
  width: 100%;
  height: 100vh;
  z-index: -1;
  display: block;
}

이제 그릴 몇가지를 만들어 봅시다.
BufferInfo는 이름별 버퍼 목록일 뿐이고 해당 속성들을 설정해야 한다는 것을 기억해야합니다.

// 버퍼를 만들고 다양한 것들에 대한 데이터를 채웁니다.
const bufferInfos = [
  primitives.createCubeBufferInfo(
      gl,
      1,  // width
      1,  // height
      1,  // depth
  ),
  primitives.createSphereBufferInfo(
      gl,
      0.5,  // 반지름
      8,    // 세분화된 주변 부분(subdivisions around)
      6,    // 세분화된 아래 부분(subdivisions down)
  ),
  primitives.createTruncatedConeBufferInfo(
      gl,
      0.5,  // bottom radius
      0,    // top radius
      1,    // height
      6,    // subdivisions around
      1,    // subdivisions down
  ),
];

이제 100개의 html 항목을 만들어보겠습니다. 각각에 대해서 div 컨테이너를 만들고 내부에는 view와 label이 있습니다.
view는 그리려고 하는 위치의 빈 div 입니다.

function createElem(type, parent, className) {
  const elem = document.createElement(type);
  parent.appendChild(elem);
  if (className) {
    elem.className = className;
  }
  return elem;
}

function randArrayElement(array) {
  return array[Math.random() * array.length | 0];
}

function rand(min, max) {
  if (max === undefined) {
    max = min;
    min = 0;
  }
  return Math.random() * (max - min) + min;
}

const contentElem = document.querySelector('#content');
const items = [];
const numItems = 100;
for (let i = 0; i < numItems; ++i) {
  const outerElem = createElem('div', contentElem, 'item');
  const viewElem = createElem('div', outerElem, 'view');
  const labelElem = createElem('div', outerElem, 'label');
  labelElem.textContent = `Item ${i + 1}`;
  const bufferInfo = randArrayElement(bufferInfos);
  const color = [rand(1), rand(1), rand(1), 1];
  items.push({
    bufferInfo,
    color,
    element: viewElem,
  });
}

이러한 항목을 다음과 같이 스타일 지정해 보겠습니다.

.item {
  display: inline-block;
  margin: 1em;
  padding: 1em;
}
.label {
  margin-top: 0.5em;
}
.view {
  width: 250px;
  height: 250px;
  border: 1px solid black;
}

items 배열에는 각 항목에 대해 bufferInfo, colorelement가 있습니다.
우리는 모든 항목을 한번에 하나씩 반복하고 element.getBoundingClientRect를 호출할 수 있고 반환된 직사각형을 사용해서 해당 요소와 캔버스가 교차하는지 확인할 수 있습니다.
이 경우 viewport와 scissor를 일치하도록 설정하고 그 다음 객체를 그립니다.

function render(time) {
  time *= 0.001;  // convert to seconds

  webglUtils.resizeCanvasToDisplaySize(gl.canvas);

  gl.enable(gl.CULL_FACE);
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.SCISSOR_TEST);

  // 캔버스를 현재 스크롤 위치의 맨 위로 이동
  gl.canvas.style.transform = `translateY(${window.scrollY}px)`;

  for (const {bufferInfo, element, color} of items) {
    const rect = element.getBoundingClientRect();
    if (rect.bottom < 0 || rect.top  > gl.canvas.clientHeight ||
        rect.right  < 0 || rect.left > gl.canvas.clientWidth) {
      continue;  // it's off screen
    }

    const width  = rect.right - rect.left;
    const height = rect.bottom - rect.top;
    const left   = rect.left;
    const bottom = gl.canvas.clientHeight - rect.bottom;

    gl.viewport(left, bottom, width, height);
    gl.scissor(left, bottom, width, height);
    gl.clearColor(...color);

    const aspect = width / height;
    const near = 1;
    const far = 2000;

    // 원근 투영 행렬 계산
    const perspectiveProjectionMatrix =
        m4.perspective(fieldOfViewRadians, aspect, near, far);

    // look at을 사용하여 카메라의 행렬을 계산
    const cameraPosition = [0, 0, -2];
    const target = [0, 0, 0];
    const up = [0, 1, 0];
    const cameraMatrix = m4.lookAt(cameraPosition, target, up);

    // 회전
    const rTime = time * 0.2;
    const worldMatrix = m4.xRotate(m4.yRotation(rTime), rTime);

    drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix, bufferInfo);
  }
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

requestAnimationFrame loop를 사용하여 위의 코드를 짰고 이제 객체를 애니메이션할수 있게 되었습니다.
또한 그릴 버퍼 정보를 drawScene에 전달했습니다. shader는 shader를 단순하게 유지하기 위해서 법선을 색상으로 사용하는 것일 뿐입니다.
만일 lighting코드를 추가했다면 더 복잡해질 것입니다.

{{{example url="../webgl-multiple-views-items.html"}}}

물론 3D 장면 전체를 그리거나 각각의 아이템 무엇이든지 그릴 수 있습니다. vieport와 scissor를 올바르게 설정한 다음 영역의 측면과 일치하게 projection matrix를 설정한다면 잘 작동할 것입니다.
코드에서 주목해야할 또 다른 것은 이 라인으로 캔버스를 옮긴다는 점 입니다.

gl.canvas.style.transform = `translateY(${window.scrollY}px)`;

왜 그럴까요? 이것 대신 position: fixed;로 설정할 경우 페이지를 스크롤 할 수 없습니다.
이 차이는 알아차리기는 어려울 수 있습니다. 브라우저가 가능한 한 부드럽게 페이지를 스크롤하려고 할 것입니다. 그건 우리가 물체를 그리는 것보다 빠를 수 있습니다.
이 때문에 2가지 옵션이 존재합니다.

  1. 고정된 위치의 캔버스를 사용하기
    이 경우 빠르게 업데이트 할 수 없다면 캔버스 앞에 있는 HTML은 스크롤되더라도 캔버스 자체는 잠시동안 동기화 되지 않을 것입니다.

  2. content아래의 캔버스를 움직이기
    이 경우 빠르게 업데이트 할 수 없다면 캔버스는 HTML과 동기화된채로 스크롤 되겠지만 그릴 기회를 얻을 떄까지 무언가가 그려지기 원하는 새로운 공간은 비어 있게 됩니다.

    이것이 위에서 사용된 해결방안 입니다.

가로 스크롤을 다루고 싶다면 이 줄을

gl.canvas.style.transform = `translateY(${window.scrollY}px)`;

아래처럼 바꾸면 됩니다.

gl.canvas.style.transform = `translateX(${window.scrollX}px) translateY(${window.scrollY}px)`;

{{{example url="../webgl-multiple-views-items-horizontal-scrolling.html"}}}

이 글이 다중 뷰를 그리기 위한 아이디어를 주는데 도움이 되었으면 좋겠습니다.
우리는 다중 뷰를 보는 것이 이해에 도움이 되는 가까운 미래의 글에서 이 기술들을 사용할 것입니다.

픽셀 좌표

WebGL의 픽셀 좌표는 가장자리에 의해 참조됩니다. 예를 들어 3x2 픽셀 크기의 캔버스가 있고 viewport를 다음과 같이 설정했습니다.


gl.viewport(
  0, // left
  0, // bottom
  3, // width
  2, // height
);

그리고나서 3x2 픽셀을 둘러싸는 이 직사각형을 정의합니다.

즉, 클립 공간에서 X = -1.0는 이 직사각형의 왼쪽 가장자리에 해당하고 X = 1.0은 오른쪽에 해당합니다. 위에서 X = -1.0이 가장 왼쪽 픽셀에 해당한다고 말했지만 실제로는 왼쪽 가장자리에 해당합니다.

profile
매일매일 성장하기 : )

0개의 댓글