Tutorial 7

Zanim przejdziemy do omawaiania szczegółów, mam złe wiadomości. WebGL nie ma wbudowanej obsługi światła. Tak, tak wszystko trzeba robić ręcznie. W zasadzie jak się pozna trochę teorii to kwestia oświetlenia sceny nie jest aż taka trudna. Jeśli do tej pory nie miałeś problemów ze zrozumieniem rzeczy które się działy w shaderach to powinieneś dać radę.

Zacznijmy nasze rozważania od tego czego oczekujemy od oświetlenia. Celem jest symulacja kilku źródeł światła dających efekt jak najbardziej realistyczny. To co będziemy robić w tej lekcji to głównie pisać kod dla vertex shadera, który będzie obsługiwał oświetlenie.

Dobrym punktem startowym jest Model Phonga. Oto kilka punktów, które pomogą Ci go zrozumieć:

Model Phong'a realizuje to zakładając, że światło posiada właściwości:

  1. Wartość RGB dla światła rozproszonego jakie produkuje.
  2. Wartość RGB dla światła odbitego jakie produkuje.

Natomiast materiały posiadają cztery cechy:

  1. Wartość RGB dla światła otoczenia które odbijaja
  2. Wartość RGB dla światła rozproszonego które odbijają
  3. Wartość RGB dla światła odbitego
  4. Połysk obiektu.

Dla każdego punktu na scenie, kolor jest kombinacją koloru padającego na niego światła, koloru swojego własnego oraz efektów świetlnych. Jako że chcemy w tej lekcji zachować wszystko proste światło odbite zostanie pominięte. Ponadto zakładamy najprostszą metodę światła otoczenia, które oświetla wszystko pod jednym kątem. Pokazuje to obrazek:

Innym możliwym do zrealizowania światłem otoczenia jest światło pochodzęce z pewnego punktu na scenie:

Teraz już trochę wiemy. Wiemy, że światło będzie padać w jednym określonym kierunku i nie będzie się zmieniać dla różnych wierzchołków. Wiemy też że ilość odbitego światła dla każdego wierzchołka będzie zależeć od kąta padania. W grafice 3D najlepiej jest mierzyć kąt od wektora normalnego. Wektor normalny jest wektorem prostopadłym do danej powerzchni. Kąt będzie mierzony pomiędzy kątem padania światła a wektorem normalnym. Dla tego kąta liczony będzie cosinus. Dla zera jasność będzie maksymalna, natomiast dla dziewięćdziesięciu stopni i więcej będzie zerowa.

Powszechnie znaną prawdą wśród grafików 3D jest fakt że cosinusa można łatwo i wydajnie wyrazić za pomocą iloczynu skalarnego. (ang. dot product).

Teraz już wiemy dokładnie co musimy zrobić:

Przejdźmu do kodu. Zacznijmy od bufora:

    cubeVertexNormalBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    var vertexNormals = [
      // Front face
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,
       0.0,  0.0,  1.0,

      // Back face
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,
       0.0,  0.0, -1.0,

      // Top face
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,
       0.0,  1.0,  0.0,

      // Bottom face
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,
       0.0, -1.0,  0.0,

      // Right face
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,
       1.0,  0.0,  0.0,

      // Left face
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
      -1.0,  0.0,  0.0,
    ];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexNormals), gl.STATIC_DRAW);
    cubeVertexNormalBuffer.itemSize = 3;
    cubeVertexNormalBuffer.numItems = 24;

Kod jest wystarczająco prosty. Określane są tutaj wektory normalne. Przejdźmy dalej.

    gl.bindBuffer(gl.ARRAY_BUFFER, cubeVertexNormalBuffer);
    gl.vertexAttribPointer(shaderProgram.vertexNormalAttribute, cubeVertexNormalBuffer.itemSize, gl.FLOAT, false, 0, 0);

Następna zmiana ma miejsce w drawScene. Jest to przekazanie bufora do shadera. Dalej znajduje się obsługa pól formularza, który umożliwia zmiany parametrów sceny:

var lighting = document.getElementById("lighting").checked;
    gl.uniform1i(shaderProgram.useLightingUniform, lighting);
    if (lighting) {
        gl.uniform3f(
            shaderProgram.ambientColorUniform,
            parseFloat(document.getElementById("ambientR").value),
            parseFloat(document.getElementById("ambientG").value),
            parseFloat(document.getElementById("ambientB").value)
        );

        var lightingDirection = [
            parseFloat(document.getElementById("lightDirectionX").value),
            parseFloat(document.getElementById("lightDirectionY").value),
            parseFloat(document.getElementById("lightDirectionZ").value)
        ];
        var adjustedLD = vec3.create();
        vec3.normalize(lightingDirection, adjustedLD);
        vec3.scale(adjustedLD, -1);
        gl.uniform3fv(shaderProgram.lightingDirectionUniform, adjustedLD);

        gl.uniform3f(
            shaderProgram.directionalColorUniform,
            parseFloat(document.getElementById("directionalR").value),
            parseFloat(document.getElementById("directionalG").value),
            parseFloat(document.getElementById("directionalB").value)
        );
}

Kod znowu jest łatwy do zrozumienia. Pobierane są wartości oświetlenia i przekazywane dalej do shadera. Warto zwrócić uwagę na kod z lini 16-18. Jako, że iloczyn skalarny potrzebuje dwóch wektorów o tych samych rozmiarach, konieczne jest przeskalowanie danych wejściowych do odpowiedniej formy. Konieczne jest jeszcze zmienienie kierunku światła. Użytkownik definiuje gdzie światło ma świecić, podczas gdy do obliczeń są potrzebne dane skąd światło świeci. To wszystkie zmiany w drawScene

Następne zmiany są w naszej funkcji setMatrixUniforms, która jak pamiętasz kopiuje macierz projekcji i modelu do zmiennych typu uniform w szaderze. Dodaliśmy cztery linie, które teraz będą też kopiować naszą nową macierz.

    var normalMatrix = mat3.create();
    mat4.toInverseMat3(mvMatrix, normalMatrix);
    mat3.transpose(normalMatrix);
    gl.uniformMatrix3fv(shaderProgram.nMatrixUniform, false, normalMatrix);

Przejdźmy do shaderów. Zacznijmy od łatwiejszego fragment shadera.

  precision mediump float;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  uniform sampler2D uSampler;

  void main(void) {
     vec4 textureColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
     gl_FragColor = vec4(textureColor.rgb * vLightWeighting, textureColor.a);
  }

Kolor pobieramy tak jak w lekcji 6 z tekstury, ale pred zwróceniem zastosowujemy do niego skłądowe RGB ze światła. Spójrzmy na vertex shadera:

  attribute vec3 aVertexPosition;
  attribute vec3 aVertexNormal;
  attribute vec2 aTextureCoord;

  uniform mat4 uMVMatrix;
  uniform mat4 uPMatrix;
  uniform mat3 uNMatrix;

  uniform vec3 uAmbientColor;

  uniform vec3 uLightingDirection;
  uniform vec3 uDirectionalColor;

  uniform bool uUseLighting;

  varying vec2 vTextureCoord;
  varying vec3 vLightWeighting;

  void main(void) {
    gl_Position = uPMatrix * uMVMatrix * vec4(aVertexPosition, 1.0);
    vTextureCoord = aTextureCoord;

    if (!uUseLighting) {
      vLightWeighting = vec3(1.0, 1.0, 1.0);
    } else {
      vec3 transformedNormal = uNMatrix * aVertexNormal;
      float directionalLightWeighting = max(dot(transformedNormal, uLightingDirection), 0.0);
      vLightWeighting = uAmbientColor + uDirectionalColor * directionalLightWeighting;
    }
  }

Nowy atrybut aVertexNormal oczywiście przetrzymuje wekory normalne, które wcześniej zdefiniowaliśmy. uNMatrix jest macierzą normalną, uUseLighting określa czy światło jest włączone czy nie, a uAmbientColor, uDirectionalColor i uLightingDirection są wartościami definiowanymi przez użytkownika. Po naszym wstępie teoretycznym sposób obliczania wartości światła powinno być jasne.

W tej lekcji to już wszystko. Do zobaczenia następnym razem:)

Wynik końcowy

Włącz światło

Światło kierunkowe:

Kierunek: X: Y: Z:
Kolor: R: G: B:

Światło rozproszone:

Kolor: R: G: B: