Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

如何用GPU来实现一个抽奖程序 #27

Open
akira-cn opened this issue Jun 9, 2020 · 0 comments
Open

如何用GPU来实现一个抽奖程序 #27

akira-cn opened this issue Jun 9, 2020 · 0 comments

Comments

@akira-cn
Copy link
Owner

akira-cn commented Jun 9, 2020

如何写一个用GPU来抽奖的程序

我们奇舞团有一个传统,那就是每年年会时,会由我给大家现场写一个抽奖程序,所有在场的人共同review代码,确认没有问题后,开启这一年愉快的年会抽奖活动。

写抽奖程序,核心无非就是将数据按照随机的规则进行抽取,确保每个人抽中奖品的概率是公平的。

今年,我写了一个比较另类的抽奖程序——使用GPU而不是CPU进行抽奖。

那用GPU抽奖究竟是怎么一回事?

我们具体一步一步来看一下。

首先,我们创建了一个基础的页面:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>抽奖</title>
  <style>
    div, button {
      font-size: 3rem;
    }
    canvas {
      background: #000;
    }
    span {
      margin-left: 20px;
    }
  </style>
</head>
<body>
  <div><button id="updateBtn">抽奖</button><span id="user"></span></div>
  <canvas id="glDoodle" width="512" height="512"></canvas>
</body>
</html>

这个页面上现在只有一个抽奖按钮,一个显示人名的span元素,和一个canvas元素 —— 既然是用GPU抽奖,我们肯定需要使用canvas元素。

这是页面运行后的样式,除了一个黑色的方块区域外,什么都没有。

接下来,我们开始写抽奖的JavaScript代码,这是一个WebGL的程序。原生WebGL的API比较复杂,为了简化操作,我写了一个叫 gl-renderer 的开源库。

<script type="module">
  import GLRenderer from './lib/gl-renderer.js';
  const container = document.getElementById('glDoodle');
  const doodle = new GLRenderer(container, {autoUpdate: false});
  doodle.render();
</script>

在这里,我们使用GLRenderer从canvas元素创建一个WebGL的上下文环境,并执行渲染。

这时候,界面上没有任何变化,这是因为,我们没有给WebGL渲染定义对应的着色器。

接下来我们写一个简单的片元着色器:

#ifdef GL_ES
precision mediump float;
#endif

void main() {
  gl_FragColor = vec4(0, 0, 1.0, 1.0);
}

这个着色器主要代码只有一行:gl_FragColor = vec4(0, 0, 1.0, 1.0);

这个代码的作用是将纯蓝色输出到屏幕上,赋给gl_FragColor的是一个四维向量,代表一个RGBA色值,不过与Web标准的RGBA色值不同,着色器中的RGBA四个通道的取值都是0到1之间,所以vec4(0, 0, 1.0, 1.0)相当于rgba(0,0,255,1)

我们将这个着色器读取并加载到 renderer 中:

import GLRenderer from './lib/gl-renderer.js';
const container = document.getElementById('glDoodle');
const doodle = new GLRenderer(container, {autoUpdate: false});
(async function() {
  const program = await doodle.load('./lib/fragment.glsl');
  doodle.useProgram(program);
  doodle.render();
}());

现在我们的UI界面由原来的黑色变成了蓝色:

为什么这段着色器代码能让整个Canvas输出为蓝色呢?很重要的一点是GPU渲染是并行的,片元着色器操作的是像素,gl_FragColor = vec4(0, 0, 1.0, 1.0);将当前像素设为蓝色,而实际执行绘制的时候,画布上的每一个像素都会同时被执行这段着色器代码,所以我们看到的就是每个点都被绘制成蓝色,于是整个画布就呈现蓝色了。

在这里,我们忽略了另一个着色器——顶点着色器(vertex shader),但是没有关系,我们创建的renderer会启用默认的顶点着色器,关于顶点着色器的问题,我们在专栏后续的文章中会有深入的探讨。

我们只是改变画布颜色,显然没法完成我们期待的抽奖功能。接下来我们要做的事情,是必须要让画布的不同位置呈现不同的颜色。换句话说,我们要在画布上创建不同的区块,创建多少个区块,取决于多少人参与抽奖。假设我们有100人,那么我们可以创建一个10X10的区块。

我们可以通过修改shader来做到:

#ifdef GL_ES
precision mediump float;
#endif

uniform vec2 resolution;

void main() {
  vec2 st = gl_FragCoord.xy / resolution;
  st = floor(10.0 * st);
  gl_FragColor = vec4(0, 0, 1.0, 1.0);
}

在这里,我们先声明一个resolution的变量,我们会在JavaScript中将画布的宽高传入进来。

然后,我们通过gl_FragCoord.xy / resolution,将当前渲染像素点的x、y坐标对应到0~1的范围,然后我们将它乘10并向下取整,这样我们就可以得到[0,0] [9,9]的100块不同的区域。

import GLRenderer from './lib/gl-renderer.js';
const container = document.getElementById('glDoodle');
const doodle = new GLRenderer(container, {autoUpdate: false});
const width = 512,
  height = 512;

(async function() {
  const program = await doodle.load('./lib/fragment.glsl');
  doodle.useProgram(program);
  doodle.uniforms.resolution = [width, height];
  doodle.render();
}());

我们修改JS代码将[width, height]通过doodle.uniforms传入shader中。

不过这时候,我们的页面还没有变化,因为虽然我们划分了10X10的区域,但是每个区域显示的颜色还是相同的,都是蓝色。

我们可以修改gl_FragColor让每一块根据st显示不同的颜色,比如:

gl_FragColor = vec4(st / 10.0, 1.0, 1.0);

现在我们可以对区块呈现不同的颜色,也就意味着我们可以来通过随机数让区块呈现为我们想要的颜色,或者保持为黑色。

#ifdef GL_ES
precision mediump float;
#endif

highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt= dot(co.xy ,vec2(a,b));
  highp float sn= mod(dt,3.14);
  return fract(sin(sn) * c);
}

uniform vec2 resolution;
uniform float rate;
uniform float seed;

void main() {
  vec2 st = gl_FragCoord.xy / resolution;
  st = floor(10.0 * st);
  float p = random(st + seed);
  p = step(p, rate);
  gl_FragColor = vec4(0, 0, 1.0, 1.0) * p;
}

我们修改shader,使用一个比较简单的伪随机函数,我们需要增加两个变量,rate和seed,rate控制中奖概率,seed保证随机。

p = step(p, rate);,step函数当rate不小于p的时候,返回1.0,否则返回0。

这样,p只会是0或1,因此,gl_FragColor = vec4(0, 0, 1.0, 1.0) * p; 要么是 vec4(0, 0, 1.0, 1.0)即蓝色,要么是 vec4(0, 0, 0, 0) 是透明的。而出现蓝色块和透明块的几率是由rate控制的。

import GLRenderer from './lib/gl-renderer.js';
const container = document.getElementById('glDoodle');
const doodle = new GLRenderer(container, {autoUpdate: false});
const width = 512,
  height = 512;

(async function() {
  const program = await doodle.load('./lib/fragment.glsl');
  doodle.useProgram(program);
  doodle.uniforms.resolution = [width, height];
  doodle.uniforms.rate = 0.3; // 30% 中奖概率
  doodle.uniforms.seed = Math.random(); // 随机种子
  doodle.render();
}());

这样,我们就让画布随机呈现出不同的色块:

蓝色区域的块表示中奖,黑色区域的块表示未中奖,中奖的概率是rate控制,现在的设置是30%。

最后我们还要做的一件事情是,如果要多次抽奖,我们要让已中奖的人不能再次中奖。

由于GPU是并行渲染,我们并不能在shader中拿到当前像素以外的其他像素的情况,也就是说,我们没法直接获得已中奖区域的信息。不过,我们可以将上一次输出的结果,以图片纹理的方式输入回shader中:

#ifdef GL_ES
precision mediump float;
#endif

highp float random(vec2 co) {
  highp float a = 12.9898;
  highp float b = 78.233;
  highp float c = 43758.5453;
  highp float dt= dot(co.xy ,vec2(a,b));
  highp float sn= mod(dt,3.14);
  return fract(sin(sn) * c);
}

uniform vec2 resolution;
uniform float rate;
uniform float seed;

uniform sampler2D texture;
varying vec2 vTextureCoord;

void main() {
  vec2 st = gl_FragCoord.xy / resolution;
  st = floor(10.0 * st);
  float p = random(st + seed);
  p = step(p, rate);

  vec2 texCoord = vec2(vTextureCoord.x, 1.0 - vTextureCoord.y);
  vec4 texColor = texture2D(texture, texCoord);

  gl_FragColor = texColor + vec4(0, 0, 1.0, 1.0) * (1.0 - sign(length(texColor))) * p;
}

我们声明一个texture变量,vTextureCoord是它的图片纹理坐标,因为我们的texture变量对应的纹理图片是Bitmap图片格式,所以对应的坐标的y轴是要反转一下的。

然后我们修改设置像素颜色代码:

  gl_FragColor = texColor + vec4(0, 0, 1.0, 1.0) * (1.0 - sign(length(texColor.rgb))) * p;

如果当前的texColor有色值,那么sign(length(texColor))的值肯定是1,1.0 - sign(length(texColor.rgb))就会是0,这时候呈现的颜色就是texColor + 0,即texColor本身,否则,因为texColor是vec4(0),所以最终显示的颜色就是vec4(0, 0, 1.0, 1.0) * 1.0 * p

import GLRenderer from './lib/gl-renderer.js';
const container = document.getElementById('glDoodle');
const doodle = new GLRenderer(container, {autoUpdate: false});
const width = 512,
  height = 512;

const button = document.getElementById('updateBtn');

(async function() {
  const textureCanvas = new OffscreenCanvas(width, height);
  const ctx = textureCanvas.getContext('2d');
  const program = await doodle.load('./lib/fragment.glsl');
  doodle.useProgram(program);

  button.addEventListener('click', () => {
    const texture = doodle.createTexture(textureCanvas.transferToImageBitmap());
    doodle.uniforms.resolution = [width, height];
    doodle.uniforms.rate = 0.2; // 20% 中奖概率
    doodle.uniforms.seed = Math.random(); // 随机种子
    doodle.uniforms.texture = texture;
    doodle.render();
    doodle.deleteTexture(texture);
    ctx.drawImage(doodle.canvas, 0, 0, width, height);
  });
}());

在JS代码中,我们创建一个离屏Canvas,然后将它的内容输出为Bitmap,作为纹理传给shader,我们把绘制的步骤给移到button的click事件中,这样我们就能在前一次中奖的基础上继续抽奖了。

至此,我们最核心的抽奖代码就写完了。当然我们还有很多细节要处理,比如每次抽奖之后,因为要把已中奖的人排除在总人数之外,所以rate需要做修正。我们还要把区块对应到具体的人名上,这样才能真正完成抽奖。还有很多交互细节也需要修改。

最终完成的代码,详细见GitHub仓库

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant