יסודות WebGL

Gregg Tavares
Gregg Tavares

WebGL מאפשר להציג גרפיקה מדהימה בתלת-ממד בזמן אמת בדפדפן שלך, אבל מה שהרבה אנשים לא יודעים הוא ש-WebGL הוא בעצם ממשק API דו-ממדי ולא ממשק API תלת-ממדי. הרשה לי להסביר.

ב-WebGL חשובים רק שני דברים. קואורדינטות של מרחב חיתוך ב-2D וצבעים. התפקיד שלכם כמתכנת שמשתמש ב-WebGL הוא לספק ל-WebGL את שני הדברים האלה. כדי לעשות זאת, צריך לספק 2 'מעבדי צללים'. Vertex shader שמספק את הקואורדינטות של מרחב החיתוך ו-fragment shader שמספק את הצבע. הקואורדינטות של מרחב החיתוך תמיד נעות בין -1 ל-1, ללא קשר לגודל הלוח. הנה דוגמה פשוטה ל-WebGL שמראה את WebGL בצורתו הפשוטה ביותר.

// Get A WebGL context
var canvas = document.getElementById("canvas");
var gl = canvas.getContext("experimental-webgl");

// setup a GLSL program
var vertexShader = createShaderFromScriptElement(gl, "2d-vertex-shader");
var fragmentShader = createShaderFromScriptElement(gl, "2d-fragment-shader");
var program = createProgram(gl, [vertexShader, fragmentShader]);
gl.useProgram(program);

// look up where the vertex data needs to go.
var positionLocation = gl.getAttribLocation(program, "a_position");

// Create a buffer and put a single clipspace rectangle in
// it (2 triangles)
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([
        -1.0, -1.0,
         1.0, -1.0,
        -1.0,  1.0,
        -1.0,  1.0,
         1.0, -1.0,
         1.0,  1.0]),
    gl.STATIC_DRAW);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

// draw
gl.drawArrays(gl.TRIANGLES, 0, 6);

אלה שני השיידררים

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

void main() {
  gl_Position = vec4(a_position, 0, 1);
}
</script>

<script id="2d-fragment-shader" type="x-shader/x-fragment">
void main() {
  gl_FragColor = vec4(0,1,0,1);  // green
}
</script>

שוב, הקואורדינטות במרחב החיתוך תמיד נעות בין -1 ל-1, ללא קשר לגודל הלוח. במקרה שלמעלה, אפשר לראות שאנחנו לא עושים שום דבר מלבד העברת נתוני המיקום שלנו ישירות. מאחר שנתוני המיקום כבר נמצאים בקליפ, אין צורך לבצע פעולה כלשהי. אם ברצונך ליצור תלת-ממד, באחריותך לספק תוכנות הצללה שממירות תלת-ממד לדו-ממד כי WebGL IS A 2D API! בדברים 2D, כנראה עדיף לעבוד בפיקסלים ולא במרחב חיתוך, אז נשנה את ה-shader כדי שנוכל לספק מלבנים בפיקסלים ולהמיר אותם למרחב חיתוך בשבילנו. הנה הכלי החדש להצללה (shader) של קודקוד

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;

void main() {
   // convert the rectangle from pixels to 0.0 to 1.0
   vec2 zeroToOne = a_position / u_resolution;

   // convert from 0->1 to 0->2
   vec2 zeroToTwo = zeroToOne * 2.0;

   // convert from 0->2 to -1->+1 (clipspace)
   vec2 clipSpace = zeroToTwo - 1.0;

   gl_Position = vec4(clipSpace, 0, 1);
}
</script>

עכשיו אפשר לשנות את הנתונים ממרחב חיתוך לתמונה (clipspace) לתמונה בפיקסלים

// set the resolution
var resolutionLocation = gl.getUniformLocation(program, "u_resolution");
gl.uniform2f(resolutionLocation, canvas.width, canvas.height);

// setup a rectangle from 10,20 to 80,30 in pixels
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
    10, 20,
    80, 20,
    10, 30,
    10, 30,
    80, 20,
    80, 30]), gl.STATIC_DRAW);

יכול להיות שהמלבן יופיע בחלק התחתון של האזור הזה. ב-WebGL, הפינה השמאלית התחתונה נחשבת ל-0,0. כדי להפוך אותה לפינה השמאלית העליונה המסורתית יותר שמשמשת עבור ממשקי API עם גרפיקה דו-ממדית, אנחנו פשוט הופכים את קואורדינטת ה-y.

gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

בואו נהפוך את הקוד שמגדיר מלבן לפונקציה כדי שנוכל לקרוא לו למלבנים בגדלים שונים. בזמן שאנחנו פה נגדיר את הצבע שלכם. קודם כול, אנחנו מגדירים לשבב ה-fragment shader לקבל קלט של צבע אחיד.

<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;

uniform vec4 u_color;

void main() {
   gl_FragColor = u_color;
}
</script>

זהו הקוד החדש שמשמש לציור של 50 מלבנים במקומות ובצבעים אקראיים.

...

  var colorLocation = gl.getUniformLocation(program, "u_color");
  ...
  // Create a buffer
  var buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

  // draw 50 random rectangles in random colors
  for (var ii = 0; ii < 50; ++ii) {
    // Setup a random rectangle
    setRectangle(
        gl, randomInt(300), randomInt(300), randomInt(300), randomInt(300));

    // Set a random color.
    gl.uniform4f(colorLocation, Math.random(), Math.random(), Math.random(), 1);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }
}

// Returns a random integer from 0 to range - 1.
function randomInt(range) {
  return Math.floor(Math.random() * range);
}

// Fills the buffer with the values that define a rectangle.
function setRectangle(gl, x, y, width, height) {
  var x1 = x;
  var x2 = x + width;
  var y1 = y;
  var y2 = y + height;
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
     x1, y1,
     x2, y1,
     x1, y2,
     x1, y2,
     x2, y1,
     x2, y2]), gl.STATIC_DRAW);
}

אני מקווה שהבנתם ש-WebGL הוא למעשה ממשק API פשוט למדי. אומנם יכול להיות קצת יותר מורכב לבצע תלת-ממד, אבל המתכנתים יכולים להוסיף את התכונה הזו, בצורת תוכנות הצללה מורכבות יותר. ה-WebGL API עצמו הוא דו-ממדי ופשוט.

מה המשמעות של type="x-shader/x-vertex" ושל type="x-shader/x-fragment"?

תגי <script> מכילים JavaScript כברירת מחדל. אפשר לא לציין סוג או לציין type="javascript" או type="text/javascript", והדפדפן יטפל בתוכן כ-JavaScript. אם תוסיפו משהו אחר, הדפדפן יתעלם מהתוכן של תג הסקריפט.

ניתן להשתמש בתכונה הזו כדי לאחסן תוכנות הצללה (shader) בתגי סקריפט. עדיף עוד יותר, אנחנו יכולים ליצור סוג משלהם ולחפש אותו ב-JavaScript כדי להחליט אם לקמפל את ה-shader כ-vertex shader או כ-fragment shader.

במקרה כזה, הפונקציה createShaderFromScriptElement מחפשת סקריפט עם id שצוין, ואז בודקת את type כדי להחליט איזה סוג של שַדְר (shader) ליצור.

עיבוד תמונות ב-WebGL

עיבוד תמונות קל ב-WebGL. כמה קל? מידע נוסף מפורט בהמשך.

כדי לצייר תמונות ב-WebGL, צריך להשתמש בטקסטורות. בדומה לאופן שבו WebGL מצפה לקואורדינטות של מרחב חיתוך בזמן עיבוד במקום פיקסלים, WebGL מצפה לקואורדינטות של טקסטורה בזמן קריאת טקסטורה. קואורדינטות הטקסטורה נעות מ-0.0 עד 1.0, ללא קשר לממדים של הטקסטורה. מכיוון שאנחנו מציירים רק מלבן אחד (טוב, 2 משולשים), אנחנו צריכים לומר ל-WebGL לאיזה מקום בטקסטורה כל נקודה במלבן תואמת. אנחנו מעבירים את המידע הזה מ-vertex shader ל-fragment shader באמצעות סוג מיוחד של משתנה שנקרא 'varying'. הוא נקרא 'משתנה' כי הוא משתנה. WebGL יבצע אינטרפולציה של הערכים שאנחנו מספקים ב-vertex shader בזמן שהוא מצייר כל פיקסל באמצעות fragment shader. באמצעות שפת שגיאת הקודקודים (vertex shader) מהקטע הקודם, צריך להוסיף מאפיין כדי להעביר קואורדינטות של טקסטורה, ולאחר מכן להעביר אותן לשפת שגיאת הפירור (fragment shader).

attribute vec2 a_texCoord;
...
varying vec2 v_texCoord;

void main() {
   ...
   // pass the texCoord to the fragment shader
   // The GPU will interpolate this value between points
   v_texCoord = a_texCoord;
}

לאחר מכן אנחנו מספקים תוכנת הצללה למקטעים כדי לחפש צבעים מהמרקם.

<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;

// our texture
uniform sampler2D u_image;

// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;

void main() {
   // Look up a color from the texture.
   gl_FragColor = texture2D(u_image, v_texCoord);
}
</script>

לבסוף, צריך לטעון תמונה, ליצור טקסטורה ולהעתיק את התמונה לטקסטורה. מכיוון שתמונות דפדפן נטענות באופן אסינכרוני, אנחנו צריכים לארגן מחדש את הקוד קצת כדי להמתין לטעינת המרקם. אחרי שהיא תיטען, נצייר אותה.

function main() {
  var image = new Image();
  image.src = "http://someimage/on/our/server";  // MUST BE SAME DOMAIN!!!
  image.onload = function() {
    render(image);
  }
}

function render(image) {
  ...
  // all the code we had before.
  ...
  // look up where the texture coordinates need to go.
  var texCoordLocation = gl.getAttribLocation(program, "a_texCoord");

  // provide texture coordinates for the rectangle.
  var texCoordBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
      0.0,  0.0,
      1.0,  0.0,
      0.0,  1.0,
      0.0,  1.0,
      1.0,  0.0,
      1.0,  1.0]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(texCoordLocation);
  gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);

  // Create a texture.
  var texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // Set the parameters so we can render any size image.
  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);

  // Upload the image into the texture.
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  ...
}

לא משהו מרגש במיוחד, אז נשנה את התמונה. מה דעתך להחליף את האדום בכחול?

...
gl_FragColor = texture2D(u_image, v_texCoord).bgra;
...

מה קורה אם רוצים לבצע עיבוד תמונה שמתבצע בפועל על פיקסלים אחרים? מכיוון ש-WebGL מפנה למרקמים בקואורדינטות של מרקמים שנעות מ-0.0 ל-1.0, אפשר לחשב כמה צריך לזוז בפיקסל אחד באמצעות החישוב הפשוט onePixel = 1.0 / textureSize. זהו שובר-פיסות שמחשב את הממוצע של הפיקסלים השמאלי והימני של כל פיקסל בטקסטורה.

<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;

// our texture
uniform sampler2D u_image;
uniform vec2 u_textureSize;

// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;

void main() {
   // compute 1 pixel in texture coordinates.
   vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;

   // average the left, middle, and right pixels.
   gl_FragColor = (
       texture2D(u_image, v_texCoord) +
       texture2D(u_image, v_texCoord + vec2(onePixel.x, 0.0)) +
       texture2D(u_image, v_texCoord + vec2(-onePixel.x, 0.0))) / 3.0;
}
</script>

לאחר מכן צריך להעביר את גודל המרקם מ-JavaScript.

...
var textureSizeLocation = gl.getUniformLocation(program, "u_textureSize");
...
// set the size of the image
gl.uniform2f(textureSizeLocation, image.width, image.height);
...

עכשיו, כשאנחנו יודעים איך להפנות לפיקסלים אחרים, נשתמש בליבה (kernel) של קונבולציה כדי לבצע כמה עיבודי תמונות נפוצים. במקרה הזה נשתמש בליבה (kernel) בגודל 3x3. ליבה של עיבוד נתונים (convolution) היא פשוט מטריצה 3x3 שבה כל רשומה במטריצה מייצגת את הערך שלפיו צריך להכפיל את 8 הפיקסלים שמסביב לפיקסל שאנחנו מבצעים עליו רינדור. לאחר מכן, מחלקים את התוצאה במשקל הליבה (הסכום של כל הערכים בליבה) או ב-1.0, הגבוה מביניהם. כאן יש מאמר טוב בנושא. במאמר הזה מופיע קוד בפועל שאפשר לכתוב ביד ב-C++‎. במקרה שלנו, נבצע את העבודה הזו בשיידר, כך שזהו השיידר החדש של הפירגמנט.

<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;

// our texture
uniform sampler2D u_image;
uniform vec2 u_textureSize;
uniform float u_kernel[9];

// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;

void main() {
   vec2 onePixel = vec2(1.0, 1.0) / u_textureSize;
   vec4 colorSum =
     texture2D(u_image, v_texCoord + onePixel * vec2(-1, -1)) * u_kernel[0] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 0, -1)) * u_kernel[1] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 1, -1)) * u_kernel[2] +
     texture2D(u_image, v_texCoord + onePixel * vec2(-1,  0)) * u_kernel[3] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 0,  0)) * u_kernel[4] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 1,  0)) * u_kernel[5] +
     texture2D(u_image, v_texCoord + onePixel * vec2(-1,  1)) * u_kernel[6] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 0,  1)) * u_kernel[7] +
     texture2D(u_image, v_texCoord + onePixel * vec2( 1,  1)) * u_kernel[8] ;
   float kernelWeight =
     u_kernel[0] +
     u_kernel[1] +
     u_kernel[2] +
     u_kernel[3] +
     u_kernel[4] +
     u_kernel[5] +
     u_kernel[6] +
     u_kernel[7] +
     u_kernel[8] ;

   if (kernelWeight <= 0.0) {
     kernelWeight = 1.0;
   }

   // Divide the sum by the weight but just use rgb
   // we'll set alpha to 1.0
   gl_FragColor = vec4((colorSum / kernelWeight).rgb, 1.0);
}
</script>

ב-JavaScript אנחנו צריכים לספק ליבה (kernel) של קונבולציה.

...
var kernelLocation = gl.getUniformLocation(program, "u_kernel[0]");
...
var edgeDetectKernel = [
    -1, -1, -1,
    -1,  8, -1,
    -1, -1, -1
];
gl.uniform1fv(kernelLocation, edgeDetectKernel);
...

אני מקווה שהצלחתי לשכנע אותך עיבוד תמונות ב-WebGL הוא פשוט למדי. בשלב הבא אסביר איך מחילים יותר מאפקט אחד על התמונה.

מה המשמעות של הקידומות a,‏ u ו-v_ לפני משתנים ב-GLSL?

זו רק מוסכמה למתן שמות. a_ למאפיינים, שהם הנתונים שסופקו על ידי מאגרי נתונים זמניים. u_ למשתני uniform שהם נתוני קלט לשיחרים, v_ למשתני varying שהם ערכים שמועברים משיחור קודקודים לשיחור פיקסלים ומשווים (או משתנים) בין הנקודות האלה לכל פיקסל שמצויר.

החלה של מספר אפקטים

השאלה החשובה הבאה בעיבוד תמונות היא איך משתמשים באפקטים מרובים?

אפשר לנסות ליצור שיבושים בזמן אמת. מספקים ממשק משתמש שמאפשר למשתמש לבחור את האפקטים שבהם הוא רוצה להשתמש, ולאחר מכן יוצרים שידרוג (shader) שמבצע את כל האפקטים. זה לא תמיד אפשרי, אבל השיטה הזו משמשת לעיתים קרובות ליצירת אפקטים לגרפיקה בזמן אמת. דרך גמישה יותר היא להשתמש ביותר שני מרקמים ולעבד כל טקסטורה בתורם, באמצעות פינג פונג הלוך ושוב והחלה של האפקט הבא בכל פעם.

Original Image -> [Blur]        -> Texture 1
Texture 1      -> [Sharpen]     -> Texture 2
Texture 2      -> [Edge Detect] -> Texture 1
Texture 1      -> [Blur]        -> Texture 2
Texture 2      -> [Normal]      -> Canvas

כדי לעשות זאת, צריך ליצור מאגרי framebuffer. ב-WebGL וב-OpenGL, השם Framebuffer הוא למעשה שם לא טוב. למעשה, Framebuffer של WebGL/OpenGL הוא רק אוסף של מצבים, ולא מאגר מכל סוג שהוא. עם זאת, על ידי צירוף טקסטורה למאגר הנתונים הזמני, נוכל לעבד את הטקסטורה הזו. קודם כול, נמיר את הקוד הישן ליצירת טקסטורה לפונקציה.

function createAndSetupTexture(gl) {
  var texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);

  // Set up texture so we can render any size image and so we are
  // working with pixels.
  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);

  return texture;
}

// Create a texture and put the image in it.
var originalImageTexture = createAndSetupTexture(gl);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

עכשיו נשתמש בפונקציה הזו כדי ליצור עוד 2 טקסטורות ונחבר אותם לשני מאגרים.

// create 2 textures and attach them to framebuffers.
var textures = [];
var framebuffers = [];
for (var ii = 0; ii < 2; ++ii) {
  var texture = createAndSetupTexture(gl);
  textures.push(texture);

  // make the texture the same size as the image
  gl.texImage2D(
      gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
      gl.RGBA, gl.UNSIGNED_BYTE, null);

  // Create a framebuffer
  var fbo = gl.createFramebuffer();
  framebuffers.push(fbo);
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);

  // Attach a texture to it.
  gl.framebufferTexture2D(
      gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
}

עכשיו ניצור סט של ליבה (kernel) ואז רשימה של כל אחת מהן לשימוש.

// Define several convolution kernels
var kernels = {
  normal: [
    0, 0, 0,
    0, 1, 0,
    0, 0, 0
  ],
  gaussianBlur: [
    0.045, 0.122, 0.045,
    0.122, 0.332, 0.122,
    0.045, 0.122, 0.045
  ],
  unsharpen: [
    -1, -1, -1,
    -1,  9, -1,
    -1, -1, -1
  ],
  emboss: [
     -2, -1,  0,
     -1,  1,  1,
      0,  1,  2
  ]
};

// List of effects to apply.
var effectsToApply = [
  "gaussianBlur",
  "emboss",
  "gaussianBlur",
  "unsharpen"
];

ולבסוף, נפעיל כל אחת מהן, ונעבור בין הטקסטורות שאנחנו מבצעים להן רינדור.

// start with the original image
gl.bindTexture(gl.TEXTURE_2D, originalImageTexture);

// don't y flip images while drawing to the textures
gl.uniform1f(flipYLocation, 1);

// loop through each effect we want to apply.
for (var ii = 0; ii < effectsToApply.length; ++ii) {
  // Setup to draw into one of the framebuffers.
  setFramebuffer(framebuffers[ii % 2], image.width, image.height);

  drawWithKernel(effectsToApply[ii]);

  // for the next draw, use the texture we just rendered to.
  gl.bindTexture(gl.TEXTURE_2D, textures[ii % 2]);
}

// finally draw the result to the canvas.
gl.uniform1f(flipYLocation, -1);  // need to y flip for canvas
setFramebuffer(null, canvas.width, canvas.height);
drawWithKernel("normal");

function setFramebuffer(fbo, width, height) {
  // make this the framebuffer we are rendering to.
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);

  // Tell the shader the resolution of the framebuffer.
  gl.uniform2f(resolutionLocation, width, height);

  // Tell webgl the viewport setting needed for framebuffer.
  gl.viewport(0, 0, width, height);
}

function drawWithKernel(name) {
  // set the kernel
  gl.uniform1fv(kernelLocation, kernels[name]);

  // Draw the rectangle.
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

כמה דברים שכדאי לי לעבור עליהם.

קריאה ל-gl.bindFramebuffer עם null מאפשרת ל-WebGL לדעת שאתם רוצים לבצע עיבוד (render) ב-canvas במקום באחד מ-framebuffers. WebGL צריך לבצע המרה ממרחב חיתוך בחזרה לפיקסלים. הוא עושה זאת על סמך ההגדרות של gl.viewport. ברירת המחדל של ההגדרות של gl.viewport היא גודל אזור העריכה כאשר אנחנו מאתחלים את WebGL. מאחר שפורמטים של framebuffer שאנחנו מבצעים בהם רינדור הם בגודל שונה מזה של הקנבס, צריך להגדיר את אזור התצוגה בהתאם. לסיום, בדוגמאות הבסיסיות של WebGL הפכנו את קואורדינטת ה-Y במהלך העיבוד, כי ב-WebGL הקנבס מוצג כאשר 0,0 היא הפינה הימנית התחתונה, במקום הפינה הימנית העליונה המסורתית יותר של 2D. אין צורך בכך כשמבצעים עיבוד (רנדר) ל-framebuffer. מכיוון ש-framebuffer אף פעם לא מוצג, לא משנה איזה חלק הוא החלק העליון ואיזה חלק הוא החלק התחתון. כל מה שחשוב הוא ש-pixel 0,0 ב-framebuffer תואם ל-0,0 בחישובים שלנו. כדי לטפל בבעיה הזו, הוספתי עוד קלט לשדרוג כדי שאפשר יהיה להגדיר אם להפוך את התמונה או לא.

<script id="2d-vertex-shader" type="x-shader/x-vertex">
...
uniform float u_flipY;
...

void main() {
   ...
   gl_Position = vec4(clipSpace * vec2(1, u_flipY), 0, 1);
   ...
}
</script>

ואז אנחנו יכולים להגדיר אותה כשמקודדים

...
var flipYLocation = gl.getUniformLocation(program, "u_flipY");
...
// don't flip
gl.uniform1f(flipYLocation, 1);
...
// flip
gl.uniform1f(flipYLocation, -1);

כדי שהדוגמה תהיה פשוטה, השתמשתי בתוכנית GLSL אחת שאפשר להשיג בעזרתה כמה אפקטים. אם רוצים לבצע עיבוד תמונה מלא, סביר להניח שיהיה צורך בהרבה תוכניות GLSL. תוכנית לכוונון הגוון, הרוויה והבהירות. עוד אחד לבהירות ולניגודיות. אחת להיפוך, אחת לשינוי רמות וכו'. צריך לשנות את הקוד כדי להחליף בין תוכניות GLSL ולעדכן את הפרמטרים של התוכנית הספציפית הזו. חשבתי לכתוב את הדוגמה הזו, אבל עדיף לכתוב זאת לקורא, כי כל אחת מתוכניות GLSL עם פרמטר משלהן דורשות כנראה ארגון מחדש משמעותי כדי למנוע בבלגן גדול של ספגטי. אני מקווה שהדוגמה הזו והדוגמאות הקודמות עזרו לכם להבין את WebGL קצת יותר, ושהתחלת העבודה עם 2D תעזור לכם להבין את WebGL קצת יותר בקלות. אם אמצא זמן אנסה לכתוב כמה מאמרים על איך לבצע תלת-ממד, ולספק פרטים נוספים על מה ש-WebGL עושה באמת.

WebGL ואלפא

שמתי לב שלמפתחים מסוימים של OpenGL יש בעיות עם האופן שבו WebGL מטפל ב-alpha ב-backbuffer (כלומר, בקנבס), ולכן חשבתי שיכול להיות שזה יהיה רעיון טוב להסביר על חלק מההבדלים בין WebGL ל-OpenGL שקשורים ל-alpha.

ההבדל הגדול ביותר בין OpenGL ל-WebGL הוא ש-OpenGL מבצע עיבוד למאגר נתונים זמני ברקע שאינו מורכב משום דבר, או שלמעשה לא מורכב מגורמים כלשהם ממנהל החלונות של מערכת ההפעלה, ולכן זה לא משנה מה האלפא שלכם. ה-WebGL מורכב על ידי הדפדפן עם דף האינטרנט, וברירת המחדל היא להשתמש בתגי אלפא מוכפלים מראש זהים לתגי <img> .png עם שקיפות ובתגי בד ציור דו-ממדי. ב-WebGL יש כמה דרכים לעשות את זה יותר כמו OpenGL.

#1) עליכם להודיע ל-WebGL שאתם רוצים שיצרו קומפוזיציה עם אלפא ללא הכפלה מראש

gl = canvas.getContext("experimental-webgl", {premultipliedAlpha: false});

ברירת המחדל היא true. כמובן שהתוצאה עדיין תורכב מעל הדף, כשצבע הרקע של הקנבס יוצג מתחת לאזור העריכה (צבע הרקע של הקנבס, צבע הרקע של אזור העריכה, צבע הרקע של הדף, החומר שמאחורי הקנבס, אם הערך של z-index > 0 וכו') במילים אחרות, ה-CSS צבעוני מגדיר את האזור הזה בדף האינטרנט. דרך טובה לבדוק אם יש בעיות שקשורות ל-alpha היא להגדיר את הרקע של הלוח לצבע בהיר כמו אדום. תוכלו לראות מיד מה קורה.

<canvas style="background: red;"></canvas>

אפשר גם להגדיר אותו לשחור, כדי להסתיר בעיות שקשורות לאלפא.

#2) מודיעים ל-WebGL שאתם לא רוצים אלפא ב-backbuffer

gl = canvas.getContext("experimental-webgl", {alpha: false});

כך הוא יפעל יותר כמו OpenGL, כי במאגר הווידאו האחורי יהיו רק צבעים מסוג RGB. זו כנראה האפשרות הטובה ביותר, כי דפדפן טוב יכול לראות שאין לכם אלפא ולבצע אופטימיזציה של האופן שבו מתבצע השילוב של WebGL. כמובן שגם זה אומר שלמעשה לא יהיה לו אלפא במאגר הנתונים הזמני, כך שאם אתם משתמשים באלפא בדרך למאגר העורפי למטרה כלשהי, יכול להיות שהיא לא תעבוד. ידוע לי על מעט אפליקציות שמשתמשות ב-alpha במאגר התמונות האחורי. לדעתי, זו הייתה צריכה להיות ברירת המחדל.

#3) ניקוי אלפא בסוף העיבוד

..
renderScene();
..
// Set the backbuffer's alpha to 1.0
gl.clearColor(1, 1, 1, 1);
gl.colorMask(false, false, false, true);
gl.clear(gl.COLOR_BUFFER_BIT);

בדרך כלל, הניקוי מהיר מאוד כי יש לו מקרה מיוחד ברוב החומרה. עשיתי זאת ברוב הדגמות שלי. אם הייתי חכם, הייתי עובר לשיטה 2 שמפורטת למעלה. אולי אעשה את זה מיד אחרי שאפרסם את הפוסט. נראה שרוב ספריות WebGL אמורות להשתמש בשיטה הזו כברירת מחדל. רק המפתחים הבודדים שמשתמשים בפועל ב-alpha ליצירת קומפוזיציות של אפקטים יכולים לבקש את זה. השאר יקבלו את הביצועים הכי טובים וגם כמה שפחות הפתעות.

#4) מוחקים את גרסת האלפא פעם אחת ואז לא מעבדים אותה יותר

// At init time. Clear the back buffer.
gl.clearColor(1,1,1,1);
gl.clear(gl.COLOR_BUFFER_BIT);

// Turn off rendering to alpha
gl.colorMask(true, true, true, false);

כמובן שאם אתם מבצעים רינדור לעיבוד של מעבדי הפריים, ייתכן שתצטרכו להפעיל שוב את הרינדור לאלפא ואז לכבות אותו שוב כשתעברו למצב העיבוד באזור העריכה.

#5) טיפול בתמונות

בנוסף, אם אתם מעמיסים קבצי PNG עם אלפא לתוך טקסטורות, ברירת המחדל היא שהאלפא שלהם מוכפלת מראש, וזו בדרך כלל לא הדרך שבה רוב המשחקים פועלים. כדי למנוע את ההתנהגות הזו, צריך לדווח ל-WebGL באמצעות

gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);

6) שימוש במשוואת מיזוג שפועלת עם אלפא שהוכפל מראש

כמעט בכל האפליקציות ל-OpenGL שכתבתי או שעבדתי עליהן נעשה שימוש

gl.blendFunc(gl.SRC_ALPHA, gl_ONE_MINUS_SRC_ALPHA);

הפתרון הזה מתאים למרקמי אלפא שלא הומרו מראש. אם אתם רוצים לעבוד עם טקסטורות אלפא שמולאו מראש, כדאי לכם

gl.blendFunc(gl.ONE, gl_ONE_MINUS_SRC_ALPHA);

אלה השיטות שאני מכיר. אם יש לך עוד מודעות כאלה, אפשר לפרסם אותן בהמשך.