3D 그래픽: WebGL 튜토리얼
게시 됨: 2022-03-113D 그래픽의 세계는 들어가기가 매우 겁이 날 수 있습니다. 인터랙티브한 3D 로고를 만들든 완전한 게임을 디자인하든 3D 렌더링의 원리를 모른다면 많은 것을 추상화하는 라이브러리를 사용해야 합니다.
라이브러리를 사용하는 것은 올바른 도구일 수 있으며 JavaScript에는 three.js 형식의 놀라운 오픈 소스가 있습니다. 그러나 미리 만들어진 솔루션을 사용하는 데에는 몇 가지 단점이 있습니다.
- 사용하지 않을 기능이 많이 있을 수 있습니다. 축소된 기본 three.js 기능의 크기는 약 500kB이며 추가 기능(실제 모델 파일 로드가 그 중 하나임)은 페이로드를 훨씬 더 크게 만듭니다. 웹 사이트에 회전하는 로고를 표시하기 위해 많은 데이터를 전송하는 것은 낭비입니다.
- 추상화의 추가 계층은 그렇지 않으면 쉬운 수정을 어렵게 만들 수 있습니다. 화면에 개체를 음영 처리하는 창의적인 방법은 구현하기 간단하거나 라이브러리의 추상화에 통합하는 데 수십 시간의 작업이 필요할 수 있습니다.
- 라이브러리는 대부분의 시나리오에서 매우 잘 최적화되어 있지만 사용 사례에 따라 많은 부분이 생략될 수 있습니다. 렌더러로 인해 특정 절차가 그래픽 카드에서 수백만 번 실행될 수 있습니다. 이러한 절차에서 제거된 모든 명령은 약한 그래픽 카드가 문제 없이 콘텐츠를 처리할 수 있음을 의미합니다.
고급 그래픽 라이브러리를 사용하기로 결정했더라도 내부에 대한 기본 지식이 있으면 더 효과적으로 사용할 수 있습니다. 라이브러리에는 three.js
의 ShaderMaterial
과 같은 고급 기능도 있을 수 있습니다. 그래픽 렌더링의 원리를 알면 이러한 기능을 사용할 수 있습니다.
우리의 목표는 3D 그래픽을 렌더링하고 WebGL을 사용하여 구현하는 이면의 모든 핵심 개념을 간략하게 소개하는 것입니다. 빈 공간에서 3D 개체를 표시하고 이동하는 가장 일반적인 작업을 볼 수 있습니다.
최종 코드는 포크하고 가지고 놀 수 있습니다.
3D 모델 표현
가장 먼저 이해해야 할 것은 3D 모델이 표현되는 방식입니다. 모델은 삼각형 메쉬로 만들어집니다. 각 삼각형은 삼각형의 각 모서리에 대해 세 개의 꼭짓점으로 표시됩니다. 꼭짓점에 연결된 세 가지 가장 일반적인 속성이 있습니다.
정점 위치
위치는 정점의 가장 직관적인 속성입니다. 좌표의 3D 벡터로 표현되는 3D 공간에서의 위치입니다. 공간에서 세 점의 정확한 좌표를 알고 있다면 그 사이에 간단한 삼각형을 그리는 데 필요한 모든 정보를 갖게 됩니다. 모델을 렌더링할 때 실제로 보기 좋게 만들려면 렌더러에 제공해야 하는 몇 가지 사항이 더 있습니다.
정점 법선
위의 두 모델을 고려하십시오. 그것들은 동일한 정점 위치로 구성되지만 렌더링될 때 완전히 다르게 보입니다. 어떻게 그게 가능합니까?
정점이 어디에 위치하기를 원하는지 렌더러에게 알려주는 것 외에도 우리는 표면이 그 정확한 위치에서 어떻게 기울어져 있는지에 대한 힌트를 줄 수도 있습니다. 힌트는 3D 벡터로 표현되는 모델의 특정 지점에서 표면의 법선 형태입니다. 다음 이미지는 처리 방법을 보다 자세히 설명합니다.
왼쪽 및 오른쪽 표면은 각각 이전 이미지의 왼쪽 및 오른쪽 공에 해당합니다. 빨간색 화살표는 정점에 대해 지정된 법선을 나타내고 파란색 화살표는 법선이 정점 사이의 모든 점을 찾는 방법에 대한 렌더러의 계산을 나타냅니다. 이미지는 2D 공간에 대한 데모를 보여주지만 동일한 원리가 3D에도 적용됩니다.
법선은 조명이 표면을 비추는 방법에 대한 힌트입니다. 광선의 방향이 법선에 가까울수록 점이 더 밝습니다. 법선 방향으로 점진적인 변화가 있으면 라이트 그라디언트가 발생하는 반면, 중간에 변화가 없는 급격한 변화는 표면 전체에 일정한 조명이 있는 표면과 표면 사이의 조명 변화가 갑자기 발생합니다.
텍스처 좌표
마지막으로 중요한 속성은 일반적으로 UV 매핑이라고 하는 텍스처 좌표입니다. 모델과 적용하려는 텍스처가 있습니다. 텍스처에는 모델의 다른 부분에 적용하려는 이미지를 나타내는 다양한 영역이 있습니다. 어떤 삼각형이 텍스처의 어느 부분으로 표현되어야 하는지 표시하는 방법이 있어야 합니다. 텍스처 매핑이 필요한 곳입니다.
각 정점에 대해 U와 V의 두 좌표를 표시합니다. 이 좌표는 텍스처의 위치를 나타내며 U는 수평 축을, V는 수직 축을 나타냅니다. 값은 픽셀 단위가 아니라 이미지 내의 백분율 위치입니다. 이미지의 왼쪽 하단 모서리는 두 개의 0으로 표시되고 오른쪽 상단은 두 개의 0으로 표시됩니다.
삼각형은 삼각형에 있는 각 꼭짓점의 UV 좌표를 가져오고 그 좌표 사이에 캡처된 이미지를 텍스처에 적용하여 그려집니다.
위 이미지에서 UV 매핑의 데모를 볼 수 있습니다. 구형 모델을 가져와서 2D 표면에 평평하게 할 만큼 충분히 작은 부품으로 절단했습니다. 절단된 이음새는 더 두꺼운 선으로 표시됩니다. 패치 중 하나가 강조 표시되어 항목이 어떻게 일치하는지 잘 볼 수 있습니다. 또한 미소의 중앙을 관통하는 이음새가 입의 일부를 두 개의 다른 패치로 배치하는 방법을 볼 수 있습니다.
와이어프레임은 텍스처의 일부가 아니라 이미지 위에 오버레이되어 사물이 어떻게 함께 매핑되는지 확인할 수 있습니다.
OBJ 모델 로드
믿거나 말거나, 이것이 자신만의 간단한 모델 로더를 만들기 위해 알아야 할 전부입니다. OBJ 파일 형식은 몇 줄의 코드로 파서를 구현하기에 충분히 간단합니다.
이 파일은 v <float> <float> <float>
형식으로 정점 위치를 나열하며, 간단하게 유지하기 위해 무시할 옵션인 네 번째 부동 소수점과 함께 나열합니다. 정점 법선은 vn <float> <float> <float>
로 유사하게 표현됩니다. 마지막으로 텍스처 좌표는 vt <float> <float>
로 표시되며 선택적으로 무시할 세 번째 float와 함께 표시됩니다. 세 가지 경우 모두에서 float는 각각의 좌표를 나타냅니다. 이 세 가지 속성은 세 개의 배열에 누적됩니다.
면은 정점 그룹으로 표시됩니다. 각 꼭짓점은 각 속성의 인덱스로 표시되며 인덱스는 1에서 시작합니다. 이를 표현하는 방법은 다양하지만 f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3
형식을 고수합니다. , 세 가지 속성을 모두 제공해야 하고 면당 정점 수를 3으로 제한합니다. 이러한 모든 제한 사항은 로더를 가능한 한 단순하게 유지하기 위해 수행되고 있습니다. 다른 모든 옵션은 WebGL이 좋아하는 형식이 되기 전에 약간의 추가적인 간단한 처리가 필요하기 때문입니다.
우리는 파일 로더에 대해 많은 요구 사항을 입력했습니다. 제한적으로 들릴 수 있지만 3D 모델링 응용 프로그램은 모델을 OBJ 파일로 내보낼 때 이러한 제한을 설정할 수 있는 기능을 제공하는 경향이 있습니다.
다음 코드는 OBJ 파일을 나타내는 문자열을 구문 분석하고 면 배열 형태의 모델을 생성합니다.
function Geometry (faces) { this.faces = faces || [] } // Parses an OBJ file, passed as a string Geometry.parseOBJ = function (src) { var POSITION = /^v\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var NORMAL = /^vn\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var UV = /^vt\s+([\d\.\+\-eE]+)\s+([\d\.\+\-eE]+)/ var FACE = /^f\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)\s+(-?\d+)\/(-?\d+)\/(-?\d+)(?:\s+(-?\d+)\/(-?\d+)\/(-?\d+))?/ lines = src.split('\n') var positions = [] var uvs = [] var normals = [] var faces = [] lines.forEach(function (line) { // Match each line of the file against various RegEx-es var result if ((result = POSITION.exec(line)) != null) { // Add new vertex position positions.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = NORMAL.exec(line)) != null) { // Add new vertex normal normals.push(new Vector3(parseFloat(result[1]), parseFloat(result[2]), parseFloat(result[3]))) } else if ((result = UV.exec(line)) != null) { // Add new texture mapping point uvs.push(new Vector2(parseFloat(result[1]), 1 - parseFloat(result[2]))) } else if ((result = FACE.exec(line)) != null) { // Add new face var vertices = [] // Create three vertices from the passed one-indexed indices for (var i = 1; i < 10; i += 3) { var part = result.slice(i, i + 3) var position = positions[parseInt(part[0]) - 1] var uv = uvs[parseInt(part[1]) - 1] var normal = normals[parseInt(part[2]) - 1] vertices.push(new Vertex(position, normal, uv)) } faces.push(new Face(vertices)) } }) return new Geometry(faces) } // Loads an OBJ file from the given URL, and returns it as a promise Geometry.loadOBJ = function (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(Geometry.parseOBJ(xhr.responseText)) } } xhr.open('GET', url, true) xhr.send(null) }) } function Face (vertices) { this.vertices = vertices || [] } function Vertex (position, normal, uv) { this.position = position || new Vector3() this.normal = normal || new Vector3() this.uv = uv || new Vector2() } function Vector3 (x, y, z) { this.x = Number(x) || 0 this.y = Number(y) || 0 this.z = Number(z) || 0 } function Vector2 (x, y) { this.x = Number(x) || 0 this.y = Number(y) || 0 }
Geometry
구조는 처리하기 위해 그래픽 카드에 모델을 보내는 데 필요한 정확한 데이터를 보유합니다. 하지만 그렇게 하기 전에 화면에서 모델을 이동할 수 있는 기능을 원할 것입니다.
공간 변환 수행
로드한 모델의 모든 점은 좌표계에 상대적입니다. 모델을 변환, 회전 및 크기 조정하려면 좌표계에서 해당 작업을 수행하기만 하면 됩니다. 좌표계 B를 기준으로 한 좌표계 A는 중심 위치를 벡터 p_ab
로 정의하고 각 축에 대한 벡터 x_ab
, y_ab
및 z_ab
는 해당 축의 방향을 나타냅니다. 따라서 좌표계 A의 x
축에서 점이 10만큼 이동하면 좌표계 B에서 x_ab
방향으로 이동하고 10을 곱합니다.
이 모든 정보는 다음 매트릭스 형식으로 저장됩니다.
x_ab.x y_ab.x z_ab.x p_ab.x x_ab.y y_ab.y z_ab.y p_ab.y x_ab.z y_ab.z z_ab.z p_ab.z 0 0 0 1
3D 벡터 q
를 변환하려면 변환 행렬에 벡터를 곱하기만 하면 됩니다.
qx qy qz 1
이렇게 하면 점이 새 x
축을 따라 qx
만큼, 새 y
축을 따라 qy
, 새 z
축을 따라 qz
만큼 이동합니다. 마지막으로 p
벡터만큼 점이 추가로 이동하게 하므로 곱셈의 마지막 요소로 1을 사용합니다.
이 행렬을 사용하는 가장 큰 장점은 꼭짓점에 대해 수행할 여러 변환이 있는 경우 꼭짓점 자체를 변환하기 전에 행렬을 곱하여 하나의 변환으로 병합할 수 있다는 사실입니다.
수행할 수 있는 다양한 변환이 있으며 주요 변환을 살펴보겠습니다.
변환 없음
변환이 발생하지 않으면 p
벡터는 0 벡터이고 x
벡터는 [1, 0, 0]
, y
는 [0, 1, 0]
, z
는 [0, 0, 1]
입니다. 이제부터 이러한 값을 이러한 벡터의 기본값으로 참조합니다. 이 값을 적용하면 단위 행렬이 제공됩니다.
1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
이것은 체인 변환을 위한 좋은 시작점입니다.
번역
번역을 수행하면 p
벡터를 제외한 모든 벡터가 기본값을 갖습니다. 그 결과 다음과 같은 행렬이 생성됩니다.
1 0 0 px 0 1 0 py 0 0 1 pz 0 0 0 1
스케일링
모델의 크기를 조정한다는 것은 각 좌표가 점의 위치에 기여하는 양을 줄이는 것을 의미합니다. 스케일링으로 인한 균일한 오프셋이 없으므로 p
벡터는 기본값을 유지합니다. 기본 축 벡터에 각각의 배율 인수를 곱해야 다음 행렬이 생성됩니다.
s_x 0 0 0 0 s_y 0 0 0 0 s_z 0 0 0 0 1
여기서 s_x
, s_y
, s_z
는 각 축에 적용된 스케일링을 나타냅니다.
회전
위의 이미지는 Z축을 중심으로 좌표 프레임을 회전할 때 어떤 일이 발생하는지 보여줍니다.
회전은 균일한 오프셋을 생성하지 않으므로 p
벡터는 기본값을 유지합니다. 이제 상황이 조금 더 복잡해집니다. 회전으로 인해 원래 좌표계의 특정 축을 따라 이동이 다른 방향으로 이동합니다. 따라서 Z축을 중심으로 좌표계를 45도 회전하면 원래 좌표계의 x
축을 따라 이동하면 새 좌표계에서 x
축과 y
축 사이의 대각선 방향으로 이동합니다.
일을 단순하게 유지하기 위해 변환 행렬이 주축을 중심으로 회전을 찾는 방법을 보여드리겠습니다.
Around X: 1 0 0 0 0 cos(phi) sin(phi) 0 0 -sin(phi) cos(phi) 0 0 0 0 1 Around Y: cos(phi) 0 sin(phi) 0 0 1 0 0 -sin(phi) 0 cos(phi) 0 0 0 0 1 Around Z: cos(phi) -sin(phi) 0 0 sin(phi) cos(phi) 0 0 0 0 1 0 0 0 0 1
구현
이 모든 것은 16개의 숫자를 저장하고 열 우선 순위로 행렬을 저장하는 클래스로 구현될 수 있습니다.
function Transformation () { // Create an identity transformation this.fields = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } // Multiply matrices, to chain transformations Transformation.prototype.mult = function (t) { var output = new Transformation() for (var row = 0; row < 4; ++row) { for (var col = 0; col < 4; ++col) { var sum = 0 for (var k = 0; k < 4; ++k) { sum += this.fields[k * 4 + row] * t.fields[col * 4 + k] } output.fields[col * 4 + row] = sum } } return output } // Multiply by translation matrix Transformation.prototype.translate = function (x, y, z) { var mat = new Transformation() mat.fields[12] = Number(x) || 0 mat.fields[13] = Number(y) || 0 mat.fields[14] = Number(z) || 0 return this.mult(mat) } // Multiply by scaling matrix Transformation.prototype.scale = function (x, y, z) { var mat = new Transformation() mat.fields[0] = Number(x) || 0 mat.fields[5] = Number(y) || 0 mat.fields[10] = Number(z) || 0 return this.mult(mat) } // Multiply by rotation matrix around X axis Transformation.prototype.rotateX = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[5] = c mat.fields[10] = c mat.fields[9] = -s mat.fields[6] = s return this.mult(mat) } // Multiply by rotation matrix around Y axis Transformation.prototype.rotateY = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[10] = c mat.fields[2] = -s mat.fields[8] = s return this.mult(mat) } // Multiply by rotation matrix around Z axis Transformation.prototype.rotateZ = function (angle) { angle = Number(angle) || 0 var c = Math.cos(angle) var s = Math.sin(angle) var mat = new Transformation() mat.fields[0] = c mat.fields[5] = c mat.fields[4] = -s mat.fields[1] = s return this.mult(mat) }
카메라를 통해보고
다음은 화면에 개체를 표시하는 핵심 부분인 카메라입니다. 카메라에는 두 가지 주요 구성 요소가 있습니다. 즉, 위치와 관찰 대상을 화면에 투영하는 방법입니다.
카메라 위치는 하나의 간단한 트릭으로 처리됩니다. 카메라를 앞으로 1미터 움직이는 것과 전 세계를 1미터 뒤로 움직이는 것 사이에는 시각적인 차이가 없습니다. 그래서 자연스럽게 우리는 행렬의 역행렬을 변환으로 적용하여 후자를 수행합니다.
두 번째 핵심 구성 요소는 관찰 대상이 렌즈에 투영되는 방식입니다. WebGL에서 화면에 보이는 모든 것은 상자에 있습니다. 상자는 각 축에서 -1과 1 사이에 있습니다. 보이는 모든 것이 그 상자 안에 있습니다. 변환 행렬의 동일한 접근 방식을 사용하여 투영 행렬을 만들 수 있습니다.
직교 투영
가장 간단한 투영법은 직교 투영법입니다. 중심이 0 위치에 있다고 가정하고 너비, 높이 및 깊이를 나타내는 공간에서 상자를 가져옵니다. 그런 다음 투영은 WebGL이 개체를 관찰하는 이전에 설명한 상자에 맞도록 상자의 크기를 조정합니다. 각 차원의 크기를 2로 조정하고 싶기 때문에 각 축의 크기를 2/size
로 조정합니다. 여기서 size
는 각 축의 차원입니다. 작은 주의 사항은 Z축에 음수를 곱한다는 사실입니다. 이는 해당 차원의 방향을 뒤집고 싶기 때문에 수행됩니다. 최종 행렬의 형식은 다음과 같습니다.
2/width 0 0 0 0 2/height 0 0 0 0 -2/depth 0 0 0 0 1
원근 투영
이 투영법이 어떻게 설계되었는지에 대한 자세한 내용은 다루지 않고 현재 거의 표준인 최종 공식을 사용합니다. 투영을 x 및 y 축의 0 위치에 놓고 오른쪽/왼쪽 및 위쪽/아래쪽 제한을 각각 width/2
및 height/2
2와 같게 하여 단순화할 수 있습니다. 매개변수 n
과 f
는 카메라가 캡처할 수 있는 가장 작은 점과 가장 큰 거리인 near
및 far
클리핑 평면을 나타냅니다. 위 이미지에서 절두체의 평행한 면으로 표시됩니다.
투시 투영은 일반적으로 시야(수직을 사용함), 종횡비, 근거리 및 원거리 평면 거리로 표시됩니다. 이 정보는 width
와 height
를 계산하는 데 사용할 수 있으며 다음 템플릿에서 행렬을 만들 수 있습니다.
2*n/width 0 0 0 0 2*n/height 0 0 0 0 (f+n)/(nf) 2*f*n/(nf) 0 0 -1 0
너비와 높이를 계산하기 위해 다음 공식을 사용할 수 있습니다.
height = 2 * near * Math.tan(fov * Math.PI / 360) width = aspectRatio * height
FOV(시야)는 카메라가 렌즈로 포착하는 수직 각도를 나타냅니다. 종횡비는 이미지 너비와 높이 사이의 비율을 나타내며 렌더링 대상 화면의 크기를 기반으로 합니다.
구현
이제 카메라 위치와 투영 행렬을 저장하는 클래스로 카메라를 나타낼 수 있습니다. 역변환을 계산하는 방법도 알아야 합니다. 일반적인 행렬 역전을 푸는 것은 문제가 될 수 있지만 우리의 특별한 경우에는 단순화된 접근 방식이 있습니다.
function Camera () { this.position = new Transformation() this.projection = new Transformation() } Camera.prototype.setOrthographic = function (width, height, depth) { this.projection = new Transformation() this.projection.fields[0] = 2 / width this.projection.fields[5] = 2 / height this.projection.fields[10] = -2 / depth } Camera.prototype.setPerspective = function (verticalFov, aspectRatio, near, far) { var height_div_2n = Math.tan(verticalFov * Math.PI / 360) var width_div_2n = aspectRatio * height_div_2n this.projection = new Transformation() this.projection.fields[0] = 1 / height_div_2n this.projection.fields[5] = 1 / width_div_2n this.projection.fields[10] = (far + near) / (near - far) this.projection.fields[10] = -1 this.projection.fields[14] = 2 * far * near / (near - far) this.projection.fields[15] = 0 } Camera.prototype.getInversePosition = function () { var orig = this.position.fields var dest = new Transformation() var x = orig[12] var y = orig[13] var z = orig[14] // Transpose the rotation matrix for (var i = 0; i < 3; ++i) { for (var j = 0; j < 3; ++j) { dest.fields[i * 4 + j] = orig[i + j * 4] } } // Translation by -p will apply R^T, which is equal to R^-1 return dest.translate(-x, -y, -z) }
이것은 화면에 무언가를 그리기 시작하기 전에 필요한 마지막 조각입니다.
WebGL 그래픽 파이프라인으로 개체 그리기
그릴 수 있는 가장 단순한 표면은 삼각형입니다. 사실 3D 공간에서 그리는 대부분의 것들은 많은 수의 삼각형으로 구성되어 있습니다.
가장 먼저 이해해야 할 것은 화면이 WebGL에서 어떻게 표현되는지입니다. x , y 및 z 축에서 -1과 1 사이에 있는 3D 공간입니다. 기본적으로 이 z 축은 사용되지 않지만 3D 그래픽에 관심이 있으므로 즉시 활성화하는 것이 좋습니다.
이를 염두에 두고 이 표면에 삼각형을 그리는 데 필요한 세 단계는 다음과 같습니다.
그리려는 삼각형을 나타내는 세 개의 꼭짓점을 정의할 수 있습니다. 해당 데이터를 직렬화하여 GPU(그래픽 처리 장치)로 보냅니다. 전체 모델을 사용할 수 있으면 모델의 모든 삼각형에 대해 그렇게 할 수 있습니다. 제공한 정점 위치는 로드한 모델의 로컬 좌표 공간에 있습니다. 간단히 말해서, 제공하는 위치는 파일의 정확한 위치이며 행렬 변환을 수행한 후 얻은 위치가 아닙니다.
이제 GPU에 정점을 지정했으므로 화면에 정점을 배치할 때 사용할 논리를 GPU에 알려줍니다. 이 단계는 행렬 변환을 적용하는 데 사용됩니다. GPU는 많은 4x4 행렬을 곱하는 데 매우 능숙하므로 그 능력을 잘 사용할 것입니다.
마지막 단계에서 GPU는 해당 삼각형을 래스터화합니다. 래스터화는 벡터 그래픽을 가져와서 해당 벡터 그래픽 개체를 표시하기 위해 화면의 어떤 픽셀을 그려야 하는지 결정하는 프로세스입니다. 우리의 경우 GPU는 각 삼각형 내에 어떤 픽셀이 있는지 확인하려고 합니다. 각 픽셀에 대해 GPU는 페인트할 색상을 묻습니다.
이것은 원하는 것을 그리는 데 필요한 네 가지 요소이며 그래픽 파이프라인의 가장 간단한 예입니다. 다음은 각각을 살펴보고 간단한 구현입니다.
기본 프레임 버퍼
WebGL 응용 프로그램에서 가장 중요한 요소는 WebGL 컨텍스트입니다. gl = canvas.getContext('webgl')
를 사용하여 액세스하거나 현재 사용되는 브라우저가 아직 모든 WebGL 기능을 지원하지 않는 경우 'experimental-webgl'
을 대체 수단으로 사용할 수 있습니다. 우리가 참조한 canvas
는 우리가 그릴 캔버스의 DOM 요소입니다. 컨텍스트에는 기본 프레임 버퍼가 포함된 많은 항목이 포함되어 있습니다.
프레임 버퍼를 그릴 수 있는 버퍼(객체)로 느슨하게 설명할 수 있습니다. 기본적으로 기본 프레임 버퍼는 WebGL 컨텍스트가 바인딩된 캔버스의 각 픽셀에 대한 색상을 저장합니다. 이전 섹션에서 설명했듯이 프레임 버퍼에 그릴 때 각 픽셀은 x 및 y 축에서 -1과 1 사이에 위치합니다. 우리가 또한 언급한 것은 기본적으로 WebGL이 z 축을 사용하지 않는다는 사실입니다. 해당 기능은 gl.enable(gl.DEPTH_TEST)
를 실행하여 활성화할 수 있습니다. 좋습니다. 하지만 깊이 테스트가 무엇입니까?
깊이 테스트를 활성화하면 픽셀이 색상과 깊이를 모두 저장할 수 있습니다. 깊이는 해당 픽셀의 z 좌표입니다. 특정 깊이 z 의 픽셀에 그린 후 해당 픽셀의 색상을 업데이트하려면 카메라에 더 가까운 z 위치에 그려야 합니다. 그렇지 않으면 추첨 시도가 무시됩니다. 이것은 3D의 착시를 허용합니다. 다른 객체 뒤에 있는 객체를 그리면 그 객체가 앞에 있는 객체에 의해 가려지기 때문입니다.
당신이 수행하는 모든 무승부는 당신이 지우라고 말할 때까지 화면에 남아 있습니다. 그렇게 하려면 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
를 호출해야 합니다. 이렇게 하면 색상 및 깊이 버퍼가 모두 지워집니다. 지워진 픽셀이 설정된 색상을 선택하려면 gl.clearColor(red, green, blue, alpha)
사용하십시오.
캔버스를 사용하고 요청 시 지우는 렌더러를 만들어 보겠습니다.
function Renderer (canvas) { var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') gl.enable(gl.DEPTH_TEST) this.gl = gl } Renderer.prototype.setClearColor = function (red, green, blue) { gl.clearColor(red / 255, green / 255, blue / 255, 1) } Renderer.prototype.getContext = function () { return this.gl } Renderer.prototype.render = function () { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) } var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) loop() function loop () { renderer.render() requestAnimationFrame(loop) }
이 스크립트를 다음 HTML에 첨부하면 화면에 밝은 파란색 사각형이 표시됩니다.
<!DOCTYPE html> <html> <head> </head> <body> <canvas width="800" height="500"></canvas> <script src="script.js"></script> </body> </html>
requestAnimationFrame
호출은 이전 프레임이 렌더링되고 모든 이벤트 처리가 완료되는 즉시 루프가 다시 호출되도록 합니다.
정점 버퍼 객체
가장 먼저 해야 할 일은 그리고 싶은 정점을 정의하는 것입니다. 3D 공간에서 벡터를 통해 설명함으로써 그렇게 할 수 있습니다. 그런 다음 새 VBO( Vertex Buffer Object )를 만들어 해당 데이터를 GPU RAM으로 이동하려고 합니다.
일반적으로 버퍼 객체 는 GPU에 메모리 청크 배열을 저장하는 객체입니다. VBO라는 것은 GPU가 메모리를 사용할 수 있는 용도를 나타냅니다. 대부분의 경우 생성하는 버퍼 개체는 VBO가 됩니다.
우리가 가지고 있는 모든 N
정점을 취하고 정점 위치와 정점 법선 VBO에 대해 3N
요소와 텍스처 좌표 VBO에 대해 2N
요소가 있는 부동 소수점 배열을 만들어 VBO를 채울 수 있습니다. 세 개의 부동 소수점 또는 UV 좌표의 경우 두 개의 부동 소수점 각 그룹은 정점의 개별 좌표를 나타냅니다. 그런 다음 이 배열을 GPU에 전달하고 나머지 파이프라인에 대해 정점이 준비됩니다.
데이터는 이제 GPU RAM에 있으므로 범용 RAM에서 삭제할 수 있습니다. 즉, 나중에 수정하고 다시 업로드하지 않으려는 경우입니다. JS 어레이의 수정 사항은 실제 GPU RAM의 VBO에 적용되지 않기 때문에 각 수정 후에는 업로드가 필요합니다.
아래는 설명된 모든 기능을 제공하는 코드 예제입니다. 중요한 점은 GPU에 저장된 변수는 가비지 수집되지 않는다는 사실입니다. 즉, 더 이상 사용하지 않으려면 수동으로 삭제해야 합니다. 여기에서 수행하는 방법에 대한 예를 제공할 뿐이며 개념에 대해서는 더 이상 집중하지 않을 것입니다. GPU에서 변수를 삭제하는 것은 프로그램 전체에서 특정 지오메트리 사용을 중단하려는 경우에만 필요합니다.
또한 Geometry
클래스와 그 안의 요소에 직렬화를 추가했습니다.
Geometry.prototype.vertexCount = function () { return this.faces.length * 3 } Geometry.prototype.positions = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.position answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.normals = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.normal answer.push(vx, vy, vz) }) }) return answer } Geometry.prototype.uvs = function () { var answer = [] this.faces.forEach(function (face) { face.vertices.forEach(function (vertex) { var v = vertex.uv answer.push(vx, vy) }) }) return answer } //////////////////////////////// function VBO (gl, data, count) { // Creates buffer object in GPU RAM where we can store anything var bufferObject = gl.createBuffer() // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, bufferObject) // Write the data, and set the flag to optimize // for rare changes to the data we're writing gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW) this.gl = gl this.size = data.length / count this.count = count this.data = bufferObject } VBO.prototype.destroy = function () { // Free memory that is occupied by our buffer object this.gl.deleteBuffer(this.data) }
VBO
데이터 유형은 두 번째 매개변수로 전달된 배열을 기반으로 전달된 WebGL 컨텍스트에서 VBO를 생성합니다.
gl
컨텍스트에 대한 세 번의 호출을 볼 수 있습니다. createBuffer()
호출은 버퍼를 생성합니다. bindBuffer()
호출은 WebGL 상태 시스템에 달리 지시될 때까지 이 특정 메모리를 모든 향후 작업에 대해 현재 VBO( ARRAY_BUFFER
)로 사용하도록 지시합니다. 그런 다음 bufferData()
를 사용하여 현재 VBO의 값을 제공된 데이터로 설정합니다.
또한 deleteBuffer()
를 사용하여 GPU RAM에서 버퍼 객체를 삭제하는 destroy 메소드를 제공합니다.
3개의 VBO와 변환을 사용하여 위치와 함께 메쉬의 모든 속성을 설명할 수 있습니다.
function Mesh (gl, geometry) { var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() }
예를 들어, 다음은 모델을 로드하고 메시에 속성을 저장한 다음 파괴하는 방법입니다.
Geometry.loadOBJ('/assets/model.obj').then(function (geometry) { var mesh = new Mesh(gl, geometry) console.log(mesh) mesh.destroy() })
셰이더
다음은 점을 원하는 위치로 이동하고 모든 개별 픽셀을 페인팅하는 이전에 설명한 2단계 프로세스입니다. 이를 위해 그래픽 카드에서 여러 번 실행되는 프로그램을 작성합니다. 이 프로그램은 일반적으로 적어도 두 부분으로 구성됩니다. 첫 번째 부분은 각 정점에 대해 실행되는 정점 셰이더 이며 무엇보다도 화면에서 정점을 배치해야 하는 위치를 출력합니다. 두 번째 부분은 화면에서 삼각형이 덮는 각 픽셀에 대해 실행되고 픽셀이 그려져야 하는 색상을 출력하는 조각 셰이더 입니다.
정점 셰이더
화면에서 좌우로 움직이는 모델을 갖고 싶다고 가정해 봅시다. 순진한 접근 방식에서는 각 정점의 위치를 업데이트하고 GPU로 다시 보낼 수 있습니다. 그 과정은 비싸고 느립니다. 또는 GPU가 각 꼭짓점에 대해 실행할 프로그램을 제공하고 모든 작업을 정확히 해당 작업을 수행하도록 구축된 프로세서와 병렬로 수행합니다. 그것이 정점 셰이더 의 역할입니다.

정점 셰이더는 개별 정점을 처리하는 렌더링 파이프라인의 일부입니다. 정점 셰이더에 대한 호출은 단일 정점을 수신하고 정점에 대한 모든 가능한 변환이 적용된 후 단일 정점을 출력합니다.
셰이더는 GLSL로 작성됩니다. 이 언어에는 고유한 요소가 많이 있지만 대부분의 구문은 C와 매우 유사하므로 대부분의 사람들이 이해할 수 있어야 합니다.
정점 셰이더에 들어오고 나가는 세 가지 유형의 변수가 있으며 모두 특정 용도로 사용됩니다.
-
attribute
— 정점의 특정 속성을 보유하는 입력입니다. 이전에 정점의 위치를 3요소 벡터의 형태로 속성으로 설명했습니다. 속성은 하나의 정점을 설명하는 값으로 볼 수 있습니다. -
uniform
— 동일한 렌더링 호출 내의 모든 정점에 대해 동일한 입력입니다. 변환 행렬을 정의하여 모델을 이동할 수 있기를 원한다고 가정해 보겠습니다.uniform
변수를 사용하여 이를 설명할 수 있습니다. 텍스처와 같은 GPU의 리소스도 가리킬 수 있습니다. 유니폼을 모델 또는 모델의 일부를 설명하는 값으로 볼 수 있습니다. -
varying
— 프래그먼트 셰이더에 전달하는 출력입니다. 꼭짓점 삼각형에 대해 잠재적으로 수천 개의 픽셀이 있기 때문에 각 픽셀은 위치에 따라 이 변수에 대해 보간된 값을 받습니다. 따라서 한 정점이 500을 출력으로 보내고 다른 정점이 100을 보내면 그 사이에 있는 픽셀은 해당 변수에 대한 입력으로 300을 받습니다. 변이는 정점 사이의 표면을 설명하는 값으로 볼 수 있습니다.
So, let's say you want to create a vertex shader that receives a position, normal, and uv coordinates for each vertex, and a position, view (inverse camera position), and projection matrix for each rendered object. Let's say you also want to paint individual pixels based on their uv coordinates and their normals. “How would that code look?” 라고 물을 수도 있습니다.
attribute vec3 position; attribute vec3 normal; attribute vec2 uv; uniform mat4 model; uniform mat4 view; uniform mat4 projection; varying vec3 vNormal; varying vec2 vUv; void main() { vUv = uv; vNormal = (model * vec4(normal, 0.)).xyz; gl_Position = projection * view * model * vec4(position, 1.); }
Most of the elements here should be self-explanatory. The key thing to notice is the fact that there are no return values in the main
function. All values that we would want to return are assigned, either to varying
variables, or to special variables. Here we assign to gl_Position
, which is a four-dimensional vector, whereby the last dimension should always be set to one. Another strange thing you might notice is the way we construct a vec4
out of the position vector. You can construct a vec4
by using four float
s, two vec2
s, or any other combination that results in four elements. There are a lot of seemingly strange type castings which make perfect sense once you're familiar with transformation matrices.
You can also see that here we can perform matrix transformations extremely easily. GLSL is specifically made for this kind of work. The output position is calculated by multiplying the projection, view, and model matrix and applying it onto the position. The output normal is just transformed to the world space. We'll explain later why we've stopped there with the normal transformations.
For now, we will keep it simple, and move on to painting individual pixels.
조각 셰이더
A fragment shader is the step after rasterization in the graphics pipeline. It generates color, depth, and other data for every pixel of the object that is being painted.
The principles behind implementing fragment shaders are very similar to vertex shaders. There are three major differences, though:
- There are no more
varying
outputs, andattribute
inputs have been replaced withvarying
inputs. We have just moved on in our pipeline, and things that are the output in the vertex shader are now inputs in the fragment shader. - Our only output now is
gl_FragColor
, which is avec4
. The elements represent red, green, blue, and alpha (RGBA), respectively, with variables in the 0 to 1 range. You should keep alpha at 1, unless you're doing transparency. Transparency is a fairly advanced concept though, so we'll stick to opaque objects. - At the beginning of the fragment shader, you need to set the float precision, which is important for interpolations. In almost all cases, just stick to the lines from the following shader.
With that in mind, you can easily write a shader that paints the red channel based on the U position, green channel based on the V position, and sets the blue channel to maximum.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec2 clampedUv = clamp(vUv, 0., 1.); gl_FragColor = vec4(clampedUv, 1., 1.); }
The function clamp
just limits all floats in an object to be within the given limits. The rest of the code should be pretty straightforward.
With all of this in mind, all that is left is to implement this in WebGL.
Combining Shaders into a Program
The next step is to combine the shaders into a program:
function ShaderProgram (gl, vertSrc, fragSrc) { var vert = gl.createShader(gl.VERTEX_SHADER) gl.shaderSource(vert, vertSrc) gl.compileShader(vert) if (!gl.getShaderParameter(vert, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(vert)) throw new Error('Failed to compile shader') } var frag = gl.createShader(gl.FRAGMENT_SHADER) gl.shaderSource(frag, fragSrc) gl.compileShader(frag) if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(frag)) throw new Error('Failed to compile shader') } var program = gl.createProgram() gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) throw new Error('Failed to link program') } this.gl = gl this.position = gl.getAttribLocation(program, 'position') this.normal = gl.getAttribLocation(program, 'normal') this.uv = gl.getAttribLocation(program, 'uv') this.model = gl.getUniformLocation(program, 'model') this.view = gl.getUniformLocation(program, 'view') this.projection = gl.getUniformLocation(program, 'projection') this.vert = vert this.frag = frag this.program = program } // Loads shader files from the given URLs, and returns a program as a promise ShaderProgram.load = function (gl, vertUrl, fragUrl) { return Promise.all([loadFile(vertUrl), loadFile(fragUrl)]).then(function (files) { return new ShaderProgram(gl, files[0], files[1]) }) function loadFile (url) { return new Promise(function (resolve) { var xhr = new XMLHttpRequest() xhr.onreadystatechange = function () { if (xhr.readyState == XMLHttpRequest.DONE) { resolve(xhr.responseText) } } xhr.open('GET', url, true) xhr.send(null) }) } }
There isn't much to say about what's happening here. Each shader gets assigned a string as a source and compiled, after which we check to see if there were compilation errors. Then, we create a program by linking these two shaders. Finally, we store pointers to all relevant attributes and uniforms for posterity.
Actually Drawing the Model
Last, but not least, you draw the model.
First you pick the shader program you want to use.
ShaderProgram.prototype.use = function () { this.gl.useProgram(this.program) }
Then you send all the camera related uniforms to the GPU. These uniforms change only once per camera change or movement.
Transformation.prototype.sendToGpu = function (gl, uniform, transpose) { gl.uniformMatrix4fv(uniform, transpose || false, new Float32Array(this.fields)) } Camera.prototype.use = function (shaderProgram) { this.projection.sendToGpu(shaderProgram.gl, shaderProgram.projection) this.getInversePosition().sendToGpu(shaderProgram.gl, shaderProgram.view) }
Finally, you take the transformations and VBOs and assign them to uniforms and attributes, respectively. Since this has to be done to each VBO, you can create its data binding as a method.
VBO.prototype.bindToAttribute = function (attribute) { var gl = this.gl // Tell which buffer object we want to operate on as a VBO gl.bindBuffer(gl.ARRAY_BUFFER, this.data) // Enable this attribute in the shader gl.enableVertexAttribArray(attribute) // Define format of the attribute array. Must match parameters in shader gl.vertexAttribPointer(attribute, this.size, gl.FLOAT, false, 0, 0) }
Then you assign an array of three floats to the uniform. Each uniform type has a different signature, so documentation and more documentation are your friends here. Finally, you draw the triangle array on the screen. You tell the drawing call drawArrays()
from which vertex to start, and how many vertices to draw. The first parameter passed tells WebGL how it shall interpret the array of vertices. Using TRIANGLES
takes three by three vertices and draws a triangle for each triplet. Using POINTS
would just draw a point for each passed vertex. There are many more options, but there is no need to discover everything at once. Below is the code for drawing an object:
Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) }
The renderer needs to be extended a bit to accommodate all the extra elements that need to be handled. It should be possible to attach a shader program, and to render an array of objects based on the current camera position.
Renderer.prototype.setShader = function (shader) { this.shader = shader } Renderer.prototype.render = function (camera, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
We can combine all the elements that we have to finally draw something on the screen:
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Geometry.loadOBJ('/assets/sphere.obj').then(function (data) { objects.push(new Mesh(gl, data)) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) loop() function loop () { renderer.render(camera, objects) requestAnimationFrame(loop) }
This looks a bit random, but you can see the different patches of the sphere, based on where they are on the UV map. You can change the shader to paint the object brown. Just set the color for each pixel to be the RGBA for brown:
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); gl_FragColor = vec4(brown, 1.); }
It doesn't look very convincing. It looks like the scene needs some shading effects.
Adding Light
Lights and shadows are the tools that allow us to perceive the shape of objects. Lights come in many shapes and sizes: spotlights that shine in one cone, light bulbs that spread light in all directions, and most interestingly, the sun, which is so far away that all the light it shines on us radiates, for all intents and purposes, in the same direction.
Sunlight sounds like it's the simplest to implement, since all you need to provide is the direction in which all rays spread. For each pixel that you draw on the screen, you check the angle under which the light hits the object. This is where the surface normals come in.
You can see all the light rays flowing in the same direction, and hitting the surface under different angles, which are based on the angle between the light ray and the surface normal. The more they coincide, the stronger the light is.
If you perform a dot product between the normalized vectors for the light ray and the surface normal, you will get -1 if the ray hits the surface perfectly perpendicularly, 0 if the ray is parallel to the surface, and 1 if it illuminates it from the opposite side. So anything between 0 and 1 should add no light, while numbers between 0 and -1 should gradually increase the amount of light hitting the object. You can test this by adding a fixed light in the shader code.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); gl_FragColor = vec4(brown * lightness, 1.); }
우리는 태양이 전방-좌-하 방향으로 빛나도록 설정했습니다. 모델이 매우 들쭉날쭉한데도 음영이 얼마나 부드러운지 알 수 있습니다. 또한 왼쪽 하단이 얼마나 어두운지 알 수 있습니다. 그림자 영역을 더 밝게 만드는 주변광 수준을 추가할 수 있습니다.
#ifdef GL_ES precision highp float; #endif varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); vec3 sunlightDirection = vec3(-1., -1., -1.); float lightness = -clamp(dot(normalize(vNormal), normalize(sunlightDirection)), -1., 0.); float ambientLight = 0.3; lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
조명 방향과 주변광 강도를 저장하는 조명 클래스를 도입하여 이와 동일한 효과를 얻을 수 있습니다. 그런 다음 해당 추가를 수용하도록 프래그먼트 셰이더를 변경할 수 있습니다.
이제 셰이더는 다음과 같이 됩니다.
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; varying vec3 vNormal; varying vec2 vUv; void main() { vec3 brown = vec3(.54, .27, .07); float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(brown * lightness, 1.); }
그런 다음 조명을 정의할 수 있습니다.
function Light () { this.lightDirection = new Vector3(-1, -1, -1) this.ambientLight = 0.3 } Light.prototype.use = function (shaderProgram) { var dir = this.lightDirection var gl = shaderProgram.gl gl.uniform3f(shaderProgram.lightDirection, dir.x, dir.y, dir.z) gl.uniform1f(shaderProgram.ambientLight, this.ambientLight) }
셰이더 프로그램 클래스에서 필요한 유니폼을 추가합니다.
this.ambientLight = gl.getUniformLocation(program, 'ambientLight') this.lightDirection = gl.getUniformLocation(program, 'lightDirection')
프로그램에서 렌더러의 새 조명에 대한 호출을 추가합니다.
Renderer.prototype.render = function (camera, light, objects) { this.gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) var shader = this.shader if (!shader) { return } shader.use() light.use(shader) camera.use(shader) objects.forEach(function (mesh) { mesh.draw(shader) }) }
그러면 루프가 약간 변경됩니다.
var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
모든 작업을 올바르게 수행했다면 렌더링된 이미지가 마지막 이미지와 같아야 합니다.
고려해야 할 마지막 단계는 실제 텍스처를 모델에 추가하는 것입니다. 이제 해보자.
텍스처 추가
HTML5는 이미지 로드를 훌륭하게 지원하므로 미친 이미지 구문 분석을 수행할 필요가 없습니다. 이미지는 바운드 텍스처 중 샘플링할 것을 셰이더에 sampler2D
로 GLSL에 전달됩니다. 바인딩할 수 있는 텍스처의 수에는 제한이 있으며 제한은 사용된 하드웨어에 따라 다릅니다. sampler2D
는 특정 위치에서 색상을 쿼리할 수 있습니다. 여기에서 UV 좌표가 입력됩니다. 다음은 갈색을 샘플링된 색상으로 대체한 예입니다.
#ifdef GL_ES precision highp float; #endif uniform vec3 lightDirection; uniform float ambientLight; uniform sampler2D diffuse; varying vec3 vNormal; varying vec2 vUv; void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
새 유니폼은 셰이더 프로그램의 목록에 추가되어야 합니다.
this.diffuse = gl.getUniformLocation(program, 'diffuse')
마지막으로 텍스처 로딩을 구현합니다. 이전에 말했듯이 HTML5는 이미지를 로드하기 위한 기능을 제공합니다. GPU에 이미지를 보내기만 하면 됩니다.
function Texture (gl, image) { var texture = gl.createTexture() // Set the newly created texture context as active texture gl.bindTexture(gl.TEXTURE_2D, texture) // Set texture parameters, and pass the image that the texture is based on gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) // Set filtering methods // Very often shaders will query the texture value between pixels, // and this is instructing how that value shall be calculated gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) this.data = texture this.gl = gl } Texture.prototype.use = function (uniform, binding) { binding = Number(binding) || 0 var gl = this.gl // We can bind multiple textures, and here we pick which of the bindings // we're setting right now gl.activeTexture(gl['TEXTURE' + binding]) // After picking the binding, we set the texture gl.bindTexture(gl.TEXTURE_2D, this.data) // Finally, we pass to the uniform the binding ID we've used gl.uniform1i(uniform, binding) // The previous 3 lines are equivalent to: // texture[i] = this.data // uniform = i } Texture.load = function (gl, url) { return new Promise(function (resolve) { var image = new Image() image.onload = function () { resolve(new Texture(gl, image)) } image.src = url }) }
이 프로세스는 VBO를 로드하고 바인딩하는 데 사용되는 프로세스와 크게 다르지 않습니다. 주요 차이점은 더 이상 속성에 바인딩하지 않고 텍스처 인덱스를 정수 유니폼에 바인딩한다는 것입니다. sampler2D
유형은 텍스처에 대한 포인터 오프셋에 불과합니다.
이제 텍스처를 처리하기 위해 Mesh
클래스를 확장하기만 하면 됩니다.
function Mesh (gl, geometry, texture) { // added texture var vertexCount = geometry.vertexCount() this.positions = new VBO(gl, geometry.positions(), vertexCount) this.normals = new VBO(gl, geometry.normals(), vertexCount) this.uvs = new VBO(gl, geometry.uvs(), vertexCount) this.texture = texture // new this.vertexCount = vertexCount this.position = new Transformation() this.gl = gl } Mesh.prototype.destroy = function () { this.positions.destroy() this.normals.destroy() this.uvs.destroy() } Mesh.prototype.draw = function (shaderProgram) { this.positions.bindToAttribute(shaderProgram.position) this.normals.bindToAttribute(shaderProgram.normal) this.uvs.bindToAttribute(shaderProgram.uv) this.position.sendToGpu(this.gl, shaderProgram.model) this.texture.use(shaderProgram.diffuse, 0) // new this.gl.drawArrays(this.gl.TRIANGLES, 0, this.vertexCount) } Mesh.load = function (gl, modelUrl, textureUrl) { // new var geometry = Geometry.loadOBJ(modelUrl) var texture = Texture.load(gl, textureUrl) return Promise.all([geometry, texture]).then(function (params) { return new Mesh(gl, params[0], params[1]) }) }
최종 메인 스크립트는 다음과 같습니다.
var renderer = new Renderer(document.getElementById('webgl-canvas')) renderer.setClearColor(100, 149, 237) var gl = renderer.getContext() var objects = [] Mesh.load(gl, '/assets/sphere.obj', '/assets/diffuse.png') .then(function (mesh) { objects.push(mesh) }) ShaderProgram.load(gl, '/shaders/basic.vert', '/shaders/basic.frag') .then(function (shader) { renderer.setShader(shader) }) var camera = new Camera() camera.setOrthographic(16, 10, 10) var light = new Light() loop() function loop () { renderer.render(camera, light, objects) requestAnimationFrame(loop) }
애니메이션 작업도 이 시점에서 쉽게 이루어집니다. 카메라가 객체 주위를 회전하도록 하려면 한 줄의 코드만 추가하면 됩니다.
function loop () { renderer.render(camera, light, objects) camera.position = camera.position.rotateY(Math.PI / 120) requestAnimationFrame(loop) }
셰이더를 자유롭게 사용하십시오. 한 줄의 코드를 추가하면 이 사실적인 조명이 만화 같은 것으로 바뀝니다.
void main() { float lightness = -clamp(dot(normalize(vNormal), normalize(lightDirection)), -1., 0.); lightness = lightness > 0.1 ? 1. : 0.; // new lightness = ambientLight + (1. - ambientLight) * lightness; gl_FragColor = vec4(texture2D(diffuse, vUv).rgb * lightness, 1.); }
조명이 설정된 임계값을 초과했는지 여부에 따라 극한으로 진행하도록 지시하는 것만 큼 간단합니다.
다음에 갈 곳
WebGL의 모든 트릭과 복잡성을 배울 수 있는 많은 정보 소스가 있습니다. 그리고 가장 좋은 점은 WebGL과 관련된 답변을 찾을 수 없는 경우 OpenGL에서 찾을 수 있다는 것입니다. WebGL은 OpenGL의 일부에 기반을 두고 있고 일부 이름은 변경되기 때문입니다.
특별한 순서 없이 WebGL과 OpenGL 모두에 대한 더 자세한 정보를 얻을 수 있는 몇 가지 훌륭한 소스가 있습니다.
- WebGL 기초
- WebGL 배우기
- 여기에 설명된 모든 기본 원칙을 매우 느리고 상세한 방식으로 안내하는 매우 상세한 OpenGL 자습서입니다.
- 그리고 컴퓨터 그래픽의 원리를 가르치는 데 전념하는 다른 많은 사이트가 있습니다.
- WebGL용 MDN 문서
- 모든 경우에 WebGL API가 작동하는 방식에 대한 보다 기술적인 세부 사항을 이해하는 데 관심이 있는 경우 Khronos WebGL 1.0 사양을 참조하세요.