Canvas 로 Sphere 구성 하기

WebGL 등에서 사용하는 구(Sphere) 만들기

Sphere 구성을 위한 확인사항

Canvas 를 활용하여 원을 그리는 방법을 이전 포스트 글에서 구성해 보았습니다. sin, cos 함수를 사용하면, 그리 어렵지 않게 원을 구성할 수 있었습니다.
구는 입체이기 때문에 x,y 좌표외에 z 좌표가 필요합니다. 반지름이 1인 구를 기준으로 생각해 보면 x,y 가 0,0 일때 z 좌표는 1 인 경우 WebGL 에서는 앞으로 튀어나오는 방향이고, -1 인 경우 뒤에 있는 구 지점으로 판단할 수 있습니다.
불 투명일 경우 뒤에 있는 영역은 그려지지 않을 겁니다. 이렇듯 x, y, z 좌표로 Sphere 를 구성하는 좌표만 정의 할 수 있으면, 2차원 화면에 표현할 수 있으니, 그 방법을 정리해 보려고 합니다.
WebGL 에서는 오른손 좌표계를 사용하니, 오른손 좌표계에 맞게 좌표의 순서를 반시계 방향으로 등록할 수 있도록 구성할 예정 입니다.

예제 사이트는 이곳을 클릭하여 확인해 보실 수 있습니다.

참조 사이트

구에 대해서 많은 분들이 자세한 설명, 구현방법을 구성해 놓았습니다. 여러 사이트가 있지만, 아무래도 대표적인 설명은 wiki를 참조하는게 좋을 듯 해서 두개의 위키 사이트를 참조하여 구성해 보았습니다.
하나는 Sphere 자체에 대한 부분이고, 다른 하나는 좌표구성 ( 경우에 따라 지도의 위도, 경도에 따른 좌표 등 )을 중심으로 설명하고 있는 사이트 입니다.

https://en.wikipedia.org/wiki/Sphere

https://en.wikipedia.org/wiki/Spherical_coordinate_system

Sphere 구현을 위한 수식

\begin{align} center ( x_9, y_0, z_0 ), points(x, y, z), r 은 반지름 &&\\ ( x-x_0)^2 + (y-y_0)^2 + (z-z_0)^2 = r^2 &&\\ x = x_0 + r\times sin\theta \times cos\phi &&\\ y = y_0 + r\times sin\theta \times sin\phi &&\\ z = z_0 + r\times cos\theta &&\\ x^2+y^2+z^2 = r^2 => center ( 0, 0, 0 )\quad 일 때 &&\\ r \geq 0, &&\\ 0° ≤ \theta ≤ 180° (π\quad rad) &&\\ 0° ≤ \phi < 360° (2π\quad rad) && \end{align}

프로그램 구현과정에서의 중복 사항

수식으로 정리는 하고 있으나, 크게 보면 theta 가 0 ~ 180 도 까지 변화 하는데 이는 위도와 매칭된다고 생각해도 좋을 것 같습니다.
phi 는 0 ~ 360 도 까지 인데 경도에 해당합니다. 위키의 설명에는 360 도를 포함하지 않습니다. 360도는 사실상 첫 시작점과 같기 때문이지요…
하지만 프로그램 구성에서는 360 도를 포함 시켰습니다. 이유는 GL에서 사용하는 texture 좌표에서 마지막을 처음으로 연결시키지 않고 독립적으로 구성하기 위해서 입니다.
texture 에서 0 ~ 1 사이인데 360도가 1이 되도록 구성하기 위해서 입니다. ( 물론 조금 낭비가 되겠네요 … )
동일한 의미에서 지도에서 극점인 북극과 남극은 단 하나의 포인트만 있어야 하지만, texture 좌표를 구성하기 위해서 동일 좌표지만, 중복된 좌표가 등록되도록 구성해 보았습니다.

Canvas 로 구성한 첫번째 Sphere

위의 수식을 활용하여, x, y, z 좌표를 구하고 있습니다. z 좌표가 음수 이면 현재 상태에서는 그릴 필요가 없는 부분이라 건너 뛰도록 구성해 놓았습니다.
포인트를 출력하는 방법으로 구성하였기 때문에 구라는 느낌이 조금 적을 수도 있습니다.
Canvas 로 구성된 내용에서 위의 수식을 단순하게 적용해 놓았다는 것을 확인하는 정도면 족할 것 같습니다.
이전에도 언급하였듯 canvas 좌표는 왼쪽 상단이 0, 0 이기 때문에 중앙지점으로 translate 하고 있습니다. y 좌표도 아래로 증가하는 방향이라, 뒤집어서 표현하고 있습니다.
한번이라도 계산을 덜 하게 하려면, Math.sin(theta), Math.sin(phi), Math.cos(theta), Math.cos(phi) 를 한번만 하고, 사용만 하게 하는게 조금이라도 계산을 줄이겠지만, 이해를 위해 위의 수식과 동일하게 구성해 보았습니다.


      const canvasWidth   = 500;
      const canvasHeight  = 500;

      function makeBasicCanvasSphere(xSize,ySize) {
         const roundValue    = 10000;
         const radious       = 200;
         const thetaDelta    = Math.floor(180/ySize * roundValue)/roundValue;
         const phiDelta      = Math.floor(360/xSize * roundValue)/roundValue;
         const canvas   = CanvasUtils.makeCanvasObject("basicCanvas", undefined, canvasWidth, canvasHeight);
         const ctx      = canvas.getContext("2d");

         ctx.save();
         ctx.translate(canvasWidth/2, canvasHeight/2);
         
         for ( let r = 0; r <= ySize; r++ ) {
             let thetaDeg = r*thetaDelta;
             if ( r == ySize ) {
                 thetaDeg = Math.round(thetaDeg);
             }
             
             let theta = Math.PI*thetaDeg/180;
             ctx.beginPath();
             ctx.strokeStyle = "#000080";
             for ( let c = 0; c <= xSize; c++ ) {
                 let phiDeg = c*phiDelta;
                 if ( c == xSize ) 
                     phiDeg = Math.round(phiDeg);

                 let phi = Math.PI*phiDeg/180;

                 let xPos = Math.sin(theta)*Math.cos(phi);
                 let yPos = Math.sin(theta)*Math.sin(phi);
                 let zPos = Math.cos(theta);

                 let trX = Math.round(xPos*radious);
                 let trY = -Math.round(yPos*radious); 
                 let trZ = Math.round(zPos*radious);

                 if ( zPos < 0 ) {
                     //continue;
                 }

                 ctx.moveTo(trX, trY);
                 ctx.arc(trX,trY , 2, 0, Math.PI*2 );
             }
             ctx.stroke();
             ctx.closePath();
         }
         ctx.restore();
      }

      makeBasicCanvasSphere(20,20);        

그 결과물 입니다.

Sphere 좌표표현

Canvas 로 Sphere 좌표를 연결해 보았습니다.

순차적으로 좌표를 계산하여 저장하고, 저장된 Index 를 기준으로 반시계 방향으로 순서가 이어지도록 구성해 보았습니다. WebGL 에서는 오른손 법칙이 적용되어 좌표가 반시계 방향 순서로 구성되어 있어야 정상 동작이 가능합니다.
Normal 등의 다른 항목도 있지만, 이 글에서는 좌표를 구성하고, 반시계 방향으로 인덱스를 구성하였다 정도만 정리하면 좋을 것 같습니다.

포지션과 인덱스를 구성하는 함수 입니다.


      export const makeSphereValues = ( xSize, ySize, defColors ) => {
         const roundValue    = 10000;
         const thetaDelta    = Math.floor(180/ySize * roundValue)/roundValue;
         const phiDelta      = Math.floor(360/xSize * roundValue)/roundValue;

         const positions             = [];
         const colors                = [];
         const vertexNormals         = [];
         const textureCoordinates    = [];
         const indices               = [];
         let defaultColors           = undefined;
         if ( defColors ) {
            defaultColors = defColors;
         } else {
            defaultColors = [0.5, 0.5, 1.0, 1.0];
         }
         
         for ( let r = 0; r <= ySize; r++ ) {
            let thetaDeg = r*thetaDelta;
            if ( r == ySize ) {
                  thetaDeg = Math.round(thetaDeg);
            }
            
            let uvy = r/ySize; 
            let theta = Math.PI*thetaDeg/180;
            for ( let c = 0; c <= xSize; c++ ) {
                  let phiDeg = c*phiDelta;
                  let uvx = c/xSize;
                  if ( c == xSize ) 
                     phiDeg = Math.round(phiDeg);

                  let phi = Math.PI*phiDeg/180;

                  let xPos = Math.sin(theta)*Math.cos(phi);
                  let yPos = Math.sin(theta)*Math.sin(phi);
                  let zPos = Math.cos(theta);

                  positions.push(xPos);
                  positions.push(yPos);
                  positions.push(zPos);

                  vertexNormals.push(xPos);
                  vertexNormals.push(yPos);
                  vertexNormals.push(zPos);

                  textureCoordinates.push(uvx);
                  textureCoordinates.push(uvy);

                  for ( let t = 0; t < 4; t++ ) {
                     colors.push(defaultColors[t]);
                  }

                  /*
                     idx01, idx02,
                     idx03, idx04
                  */
                  if ( r > 0 && c > 0 ) {
                     let idx01 = (r-1)*(xSize+1) + (c-1);
                     let idx02 = idx01+1;
                     let idx03 = idx01+xSize+1;
                     let idx04 = idx03+1; // r, c 의 현재 위치

                     //  GL 에서는 삼각형이 반시계 방향 
                     indices.push(idx01);
                     indices.push(idx03);
                     indices.push(idx04);

                     indices.push(idx01);
                     indices.push(idx04);
                     indices.push(idx02);
                  }
            }
         }

         return {
            positions : positions,
            colors : colors,
            normals : vertexNormals, 
            textures : textureCoordinates, 
            indices : indices,
         }
      }

해당 함수를 활용하여 그리는 소스입니다.


     function makeLineCanvasSphere(xSize,ySize) {
         const roundValue    = 10000;
         const radius       = 200;

         const pInfos = GLDataUtils.makeSphereValues(xSize, ySize);
         const canvas   = CanvasUtils.makeCanvasObject("lineCanvas", undefined, canvasWidth, canvasHeight);
         const ctx      = canvas.getContext("2d");

         ctx.save();
         ctx.translate(canvasWidth/2, canvasHeight/2);
         let indexSize = pInfos.indices.length;

         for ( let i = 0; i < indexSize; i += 6 ) {
             ctx.beginPath();
             ctx.strokeStyle = "blue";

             let idx = pInfos.indices[i];
             let xPos = Math.round(pInfos.positions[idx*3]*radius);
             let yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             let zPos = Math.round(pInfos.positions[idx*3+2]*radius);

             if ( zPos < 0 ) 
                 continue;
             ctx.moveTo(xPos,yPos);

             idx = pInfos.indices[i+1];
             xPos = Math.round(pInfos.positions[idx*3]*radius);
             yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             zPos = Math.round(pInfos.positions[idx*3+2]*radius);

             if ( zPos < 0 ) 
                 continue;

             ctx.lineTo(xPos,yPos);

             idx = pInfos.indices[i+2];
             xPos = Math.round(pInfos.positions[idx*3]*radius);
             yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             zPos = Math.round(pInfos.positions[idx*3+2]*radius);
             if ( zPos < 0 ) 
                 continue;

             ctx.lineTo(xPos,yPos);

             ctx.stroke();
             ctx.closePath();

             ctx.beginPath();
             ctx.strokeStyle = "red";

             idx = pInfos.indices[i+3];
             xPos = Math.round(pInfos.positions[idx*3]*radius);
             yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             zPos = Math.round(pInfos.positions[idx*3+2]*radius);
             if ( zPos < 0 ) 
                 continue;

             ctx.moveTo(xPos,yPos);

             idx = pInfos.indices[i+4];
             xPos = Math.round(pInfos.positions[idx*3]*radius);
             yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             zPos = Math.round(pInfos.positions[idx*3+2]*radius);
             if ( zPos < 0 ) 
                 continue;

             ctx.lineTo(xPos,yPos);

             idx = pInfos.indices[i+5];
             xPos = Math.round(pInfos.positions[idx*3]*radius);
             yPos = -Math.round(pInfos.positions[idx*3+1]*radius);
             zPos = Math.round(pInfos.positions[idx*3+2]*radius);
             if ( zPos < 0 ) 
                 continue;

             ctx.lineTo(xPos,yPos);

             ctx.stroke();
             ctx.closePath();
         }

        ctx.restore();
     }

     makeLineCanvasSphere(20,20);

중복되는 부분이 많지만, 가독성이 더 좋을 것 같아서 나열하듯이 구성해 보았습니다.

2개의 삼각형이 거의 한쌍 처럼 반복 되기 때문에 6개의 index를 기준으로 출력하도록 구성해 보았습니다.

그 결과물 입니다.

Sphere 라인연결

그림을 로딩하여 Sphere 에 표현해 보았습니다.

위의 형태를 조금 변형해서 지구 같은 지도를 구에 어떻게 표현할 수 있는지 간단히 정리해 보았습니다.
좌표값을 설정하는 부분에서 낭비가 많지만, 대략 어떤식으로 매핑할 수 있는지를 중심으로 구현해 보았습니다.

     function makeImageCanvasSphere(orgImageData) {
         const roundValue    = 10000;
         const radious       = 200;
         const xSize = orgImageData.width;
         const ySize = orgImageData.height;

         const thetaDelta    = Math.floor(180/ySize * roundValue)/roundValue;
         const phiDelta      = Math.floor(360/xSize * roundValue)/roundValue;
         const canvas   = CanvasUtils.makeCanvasObject("imageCanvas", undefined, canvasWidth, canvasHeight);
         const ctx      = canvas.getContext("2d");

         const imageData = ctx.createImageData(canvasWidth, canvasHeight);      
         

         ctx.save();

         const cx = canvasWidth/2;
         const cy = canvasHeight/2;

         const currentSet = new Set();
         let samePositions = 0;
         
         for ( let r = 0; r <= ySize; r++ ) {
             let thetaDeg = r*thetaDelta;
             if ( r == ySize ) {
                 thetaDeg = Math.round(thetaDeg);
             }
             
             let uvy = r/ySize; 
             //  만약 pixel 로 uv y 를 가져올려면 webgl 에서는 1-uvy
             uvy = 1-uvy;
             let theta = Math.PI*thetaDeg/180;
             for ( let c = 0; c <= xSize; c++ ) {
                 let phiDeg = c*phiDelta;
                 let uvx = c/xSize;
                 if ( c == xSize ) 
                     phiDeg = Math.round(phiDeg);

                 let phi = Math.PI*phiDeg/180;

                 let xPos = Math.sin(theta)*Math.cos(phi);
                 let yPos = Math.sin(theta)*Math.sin(phi);
                 let zPos = Math.cos(theta);

                 if ( zPos < 0 ) {
                     continue;
                 }


                 let trX = Math.round(xPos*radious);
                 let trY = -Math.round(yPos*radious); 
                 let trZ = Math.round(zPos*radious);

                 let keys = trX+"#"+trY;
                 if ( currentSet.has(keys) ) {
                     samePositions++;
                     continue;
                 }
                 currentSet.add(keys);
                 let imgIdx = (trY+cy)*500*4+(trX+cx)*4;
                 let pixels = CanvasUtils.getPixels(c,r,1,xSize,ySize,orgImageData);
                 for ( let t = 0; t < 4; t++ ) {
                     imageData.data[imgIdx+t] = pixels[t];
                 }
             }
         }
         ctx.putImageData(imageData,0,0);
         ctx.restore();
         //console.log ( currentSet.size + " : " + samePositions);
     }



     const url = "/imgs/8k_earth_daymap.jpg";
     CanvasUtils.makeCanvasImageData(url).then( imgData => {
         makeImageCanvasSphere(imgData);
     });

그 결과물 입니다.

Sphere 그림

예시된 파일 구성 페이지 입니다.

 Share!