首页 > 解决方案 > WebGL 并行性的 Hello World 示例

问题描述

WebGL 周围似乎有许多用于运行并行处理的抽象,例如:

但是我很难理解一个简单而完整的并行示例在 WebGL 的普通 GLSL 代码中是什么样子的。我对 WebGL 没有太多经验,但我知道有片段和顶点着色器以及如何从 JavaScript 将它们加载到 WebGL 上下文中。我不知道如何使用着色器或应该使用哪个着色器进行并行处理。

我想知道是否可以演示一个简单的并行添加操作的 hello world 示例,本质上是使用 GLSL/WebGL 着色器的并行形式/但是应该这样做。

var array = []
var size = 10000
while(size--) array.push(0)

for (var i = 0, n = 10000; i < n; i++) {
  array[i] += 10
}

我想我基本上不明白:

  1. 如果 WebGL 自动并行运行所有内容
  2. 或者如果有最大数量的东西并行运行,那么如果你有 10,000 个东西,但只有 1000 个并行运行,那么它将按顺序并行执行 1000 个 10 次。
  3. 或者,如果您必须手动指定所需的并行量。
  4. 如果并行性进入片段着色器或顶点着色器,或两者兼而有之。
  5. 如何实际实现并行示例。

标签: webgl

解决方案


首先,WebGL 只对点、线和三角形进行光栅化。使用 WebGL 进行非光栅化 ( GPGPU ) 基本上是意识到 WebGL 的输入是来自数组和输出的数据,像素的 2D 矩形实际上也只是 2D 数组,因此通过创造性地提供非图形数据和创造性地光栅化这些数据你可以做非图形数学。

WebGL 以两种方式并行。

  1. 它在不同的处理器 GPU 上运行,同时它正在计算您的 CPU 可以自由地做其他事情。

  2. GPU 本身并行计算。一个很好的例子,如果你用 100 个像素栅格化一个三角形,GPU 可以并行处理每个像素,直到该 GPU 的限制。如果不深入挖掘,它看起来像 NVidia 1080 GPU 有 2560 个内核,因此假设它们不是专门的,并假设其中一个可以并行计算 2560 个东西的最佳情况。

例如,所有 WebGL 应用程序都使用上面第 (1) 和 (2) 点的并行处理,而没有做任何特别的事情。

虽然在适当的位置添加 10 到 10000 个元素并不是 WebGL 擅长的,因为 WebGL 无法在一次操作中读取和写入相同的数据。换句话说,您的示例需要是

const size = 10000;
const srcArray = [];
const dstArray = [];
for (let i = 0; i < size; ++i) {
 srcArray[i] = 0;
}

for (var i = 0, i < size; ++i) {
  dstArray[i] = srcArray[i] + 10;
}

就像任何编程语言一样,有不止一种方法可以实现这一点。最快的可能是将所有值复制到纹理中,然后光栅化到另一个纹理中,从第一个纹理向上查找并将 +10 写入目的地。但是,其中有一个问题。将数据传入和传出 GPU 的速度很慢,因此您需要权衡在 GPU 上工作是否成功。

另一个就像你不能读取和写入同一个数组的限制一样,你也不能随机访问目标数组。GPU 正在栅格化一条线、点或三角形。它在绘制三角形方面是最快的,但这意味着它决定以什么顺序写入哪些像素,所以你的问题也必须忍受这些限制。您可以使用点来作为随机选择目的地的一种方式,但渲染点比渲染三角形要慢得多。

请注意,“计算着色器”(还不是 WebGL 的一部分)为 GPU 添加了随机访问写入能力。

例子:

const gl = document.createElement("canvas").getContext("webgl");

const vs = `
attribute vec4 position;
attribute vec2 texcoord;

varying vec2 v_texcoord;

void main() {
  gl_Position = position;
  v_texcoord = texcoord;
}
`;

const fs = `
precision highp float;
uniform sampler2D u_srcData;
uniform float u_add;

varying vec2 v_texcoord;

void main() {
  vec4 value = texture2D(u_srcData, v_texcoord);
  
  // We can't choose the destination here. 
  // It has already been decided by however
  // we asked WebGL to rasterize.
  gl_FragColor = value + u_add;
}
`;

// calls gl.createShader, gl.shaderSource,
// gl.compileShader, gl.createProgram, 
// gl.attachShaders, gl.linkProgram,
// gl.getAttributeLocation, gl.getUniformLocation
const programInfo = twgl.createProgramInfo(gl, [vs, fs]);


const size = 10000;
// Uint8Array values default to 0
const srcData = new Uint8Array(size);
// let's use slight more interesting numbers
for (let i = 0; i < size; ++i) {
  srcData[i] = i % 200;
}

// Put that data in a texture. NOTE: Textures
// are (generally) 2 dimensional and have a limit
// on their dimensions. That means you can't make
// a 1000000 by 1 texture. Most GPUs limit from
// between 2048 to 16384.
// In our case we're doing 10000 so we could use
// a 100x100 texture. Except that WebGL can
// process 4 values at a time (red, green, blue, alpha)
// so a 50x50 will give us 10000 values
const srcTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, srcTex);
const level = 0;
const width = Math.sqrt(size / 4);
if (width % 1 !== 0) {
  // we need some other technique to fit
  // our data into a texture.
  alert('size does not have integer square root');
}
const height = width;
const border = 0;
const internalFormat = gl.RGBA;
const format = gl.RGBA;
const type = gl.UNSIGNED_BYTE;
gl.texImage2D(
  gl.TEXTURE_2D, level, internalFormat,
  width, height, border, format, type, srcData);
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_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  
// create a destination texture
const dstTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, dstTex);
gl.texImage2D(
  gl.TEXTURE_2D, level, internalFormat,
  width, height, border, format, type, null);

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_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

// make a framebuffer so we can render to the
// destination texture
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
// and attach the destination texture
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, dstTex, level);

// calls gl.createBuffer, gl.bindBuffer, gl.bufferData
// to put a 2 unit quad (2 triangles) into
// a buffer with matching texture coords
// to process the entire quad
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
  position: {
    data: [
      -1, -1,
       1, -1,
      -1,  1,
      -1,  1,
       1, -1,
       1,  1,
    ],
    numComponents: 2,
  },
  texcoord: [
     0, 0,
     1, 0,
     0, 1,
     0, 1,
     1, 0, 
     1, 1,
  ],
});

gl.useProgram(programInfo.program);

// calls gl.bindBuffer, gl.enableVertexAttribArray, gl.vertexAttribPointer
twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);

// calls gl.activeTexture, gl.bindTexture, gl.uniformXXX
twgl.setUniforms(programInfo, {
  u_add: 10 / 255,  // because we're using Uint8
  u_srcData: srcTex,
});

// set the viewport to match the destination size
gl.viewport(0, 0, width, height);

// draw the quad (2 triangles)
const offset = 0;
const numVertices = 6;
gl.drawArrays(gl.TRIANGLES, offset, numVertices);

// pull out the result
const dstData = new Uint8Array(size);
gl.readPixels(0, 0, width, height, format, type, dstData);

console.log(dstData);
<script src="https://twgljs.org/dist/4.x/twgl-full.min.js"></script>

制作一个通用的数学处理器需要更多的工作。

问题:

纹理是 2D 数组,WebGL 仅对点、线和三角形进行光栅化,因此例如处理适合矩形的数据比不处理要容易得多。换句话说,如果您有 10001 个值,则没有适合整数个单位的矩形。最好填充您的数据并忽略末尾的部分。换句话说,100x101 纹理将是 10100 个值。所以只需忽略最后 99 个值。

上面的示例使用 8 位 4 通道纹理。使用 8 位 1 通道纹理(数学较少)会更容易,但效率也较低,因为 WebGL 每次操作可以处理 4 个值。

因为它使用 8 位纹理,它只能存储从 0 到 255 的整数值。我们可以将纹理切换为 32 位浮点纹理。浮点纹理是 WebGL 的可选功能(您需要启用扩展并检查它们是否成功)。光栅化为浮点纹理也是一个可选功能。截至 2018 年,大多数移动 GPU 不支持渲染到浮点纹理,因此如果您希望代码在这些 GPU 上运行,您必须找到创造性的方法将结果编码为他们支持的格式。

寻址源数据需要数学从一维索引转换为二维纹理坐标。在上面的示例中,因为我们直接从 srcData 转换为 dstData 1 到 1,所以不需要数学运算。如果您需要跳过 srcData ,则需要提供该数学

WebGL1

vec2 texcoordFromIndex(int ndx) {
  int column = int(mod(float(ndx),float(widthOfTexture)));
  int row = ndx / widthOfTexture;
  return (vec2(column, row) + 0.5) / vec2(widthOfTexture, heighOfTexture);
}

vec2 texcoord = texcoordFromIndex(someIndex);
vec4 value = texture2D(someTexture, texcoord);

WebGL2

ivec2 texcoordFromIndex(someIndex) {
  int column = ndx % widthOfTexture;
  int row = ndx / widthOfTexture;
  return ivec2(column, row);
}

int level = 0;
ivec2 texcoord = texcoordFromIndex(someIndex);
vec4 value = texelFetch(someTexture, texcoord, level);

假设我们要对每 2 个数字求和。我们可能会做这样的事情

const gl = document.createElement("canvas").getContext("webgl2");

const vs = `
#version 300 es
in vec4 position;

void main() {
  gl_Position = position;
}
`;

const fs = `
#version 300 es
precision highp float;
uniform sampler2D u_srcData;

uniform ivec2 u_destSize;  // x = width, y = height

out vec4 outColor;

ivec2 texcoordFromIndex(int ndx, ivec2 size) {
  int column = ndx % size.x;
  int row = ndx / size.x;
  return ivec2(column, row);
}

void main() {
  // compute index of destination
  ivec2 dstPixel = ivec2(gl_FragCoord.xy);
  int dstNdx = dstPixel.y * u_destSize.x + dstPixel.x; 

  ivec2 srcSize = textureSize(u_srcData, 0);

  int srcNdx = dstNdx * 2;
  ivec2 uv1 = texcoordFromIndex(srcNdx, srcSize);
  ivec2 uv2 = texcoordFromIndex(srcNdx + 1, srcSize);

  float value1 = texelFetch(u_srcData, uv1, 0).r;
  float value2 = texelFetch(u_srcData, uv2, 0).r;
  
  outColor = vec4(value1 + value2);
}
`;

// calls gl.createShader, gl.shaderSource,
// gl.compileShader, gl.createProgram, 
// gl.attachShaders, gl.linkProgram,
// gl.getAttributeLocation, gl.getUniformLocation
const programInfo = twgl.createProgramInfo(gl, [vs, fs]);


const size = 10000;
// Uint8Array values default to 0
const srcData = new Uint8Array(size);
// let's use slight more interesting numbers
for (let i = 0; i < size; ++i) {
  srcData[i] = i % 99;
}

const srcTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, srcTex);
const level = 0;
const srcWidth = Math.sqrt(size / 4);
if (srcWidth % 1 !== 0) {
  // we need some other technique to fit
  // our data into a texture.
  alert('size does not have integer square root');
}
const srcHeight = srcWidth;
const border = 0;
const internalFormat = gl.R8;
const format = gl.RED;
const type = gl.UNSIGNED_BYTE;
gl.texImage2D(
  gl.TEXTURE_2D, level, internalFormat,
  srcWidth, srcHeight, border, format, type, srcData);
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_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  
// create a destination texture
const dstTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, dstTex);
const dstWidth = srcWidth;
const dstHeight = srcHeight / 2;
// should check srcHeight is evenly
// divisible by 2
gl.texImage2D(
  gl.TEXTURE_2D, level, internalFormat,
  dstWidth, dstHeight, border, format, type, null);

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_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

// make a framebuffer so we can render to the
// destination texture
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
// and attach the destination texture
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, dstTex, level);

// calls gl.createBuffer, gl.bindBuffer, gl.bufferData
// to put a 2 unit quad (2 triangles) into
// a buffer
const bufferInfo = twgl.createBufferInfoFromArrays(gl, {
  position: {
    data: [
      -1, -1,
       1, -1,
      -1,  1,
      -1,  1,
       1, -1,
       1,  1,
    ],
    numComponents: 2,
  },
});

gl.useProgram(programInfo.program);

// calls gl.bindBuffer, gl.enableVertexAttribArray, gl.vertexAttribPointer
twgl.setBuffersAndAttributes(gl, programInfo, bufferInfo);

// calls gl.activeTexture, gl.bindTexture, gl.uniformXXX
twgl.setUniforms(programInfo, {
  u_srcData: srcTex,
  u_srcSize: [srcWidth, srcHeight],
  u_dstSize: [dstWidth, dstHeight],
});

// set the viewport to match the destination size
gl.viewport(0, 0, dstWidth, dstHeight);

// draw the quad (2 triangles)
const offset = 0;
const numVertices = 6;
gl.drawArrays(gl.TRIANGLES, offset, numVertices);

// pull out the result
const dstData = new Uint8Array(size / 2);
gl.readPixels(0, 0, dstWidth, dstHeight, format, type, dstData);

console.log(dstData);
<script src="https://twgljs.org/dist/4.x/twgl-full.min.js"></script>

注意上面的例子使用了 WebGL2。为什么?因为 WebGL2 支持渲染为 R8 格式的纹理,这使得计算变得容易。每个像素一个值,而不是像前面的例子那样每个像素有 4 个值。当然,这也意味着它更慢,但是让它与 4 个值一起工作会使计算索引的数学变得非常复杂,或者可能需要重新排列源数据以更好地匹配。例如,0, 1, 2, 3, 4, 5, 6, 7, 8, ...如果以0, 2, 4, 6, 1, 3, 5, 7, 8 ....这种方式一次拉出 4 个并将下一组 4 个值相加,则将每 2 个值相加而不是值索引会更容易。另一种方法是使用 2 个源纹理,将所有偶数索引值放在一个纹理中,将奇数索引值放在另一个纹理中。

WebGL1 提供 LUMINANCE 和 ALPHA 纹理,它们也是一个通道,但您是否可以渲染它们是一个可选功能,而在 WebGL2 中,渲染到 R8 纹理是一个必需功能。

WebGL2 还提供了一种叫做“转换反馈”的东西。这使您可以将顶点着色器的输出写入缓冲区。它的优点是您只需设置要处理的顶点数(无需将目标数据设为矩形)。这也意味着您可以输出浮点值(它不是可选的,就像渲染到纹理一样)。我相信(尽管我没有测试过)它比渲染到纹理要慢。

由于您是 WebGL 的新手,我建议您使用这些教程


推荐阅读