【WebGL】ビルボードで球を描く

WebGLでビルボードを使って球を描いてみます。
実用性は特にないです(本当はここから発展的なことをしようと思っていたのですが、難しそうなのであきらめました)。

デモはここから見られます。
https://aadebdeb.github.io/Sample_WebGL_BillboardSphere/
違和感のない3球が表示されることを確認できます。
![screenshot.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61569/f3b3093a-ef60-5c05-5c14-faf895297236.gif)

ソースコードはGitHubに置いてあります。
https://github.com/aadebdeb/Sample_WebGL_BillboardSphere

---

ビルボードはざっくり書くと常にカメラに正面を向ける平面メッシュです。
これを実現するために平面メッシュのモデル行列にカメラの方向を向かせる回転行列を含めるようにします。

行列の作り方が大事になってくるので、そこを見ていきます。

```js
  // 球の中心位置
  const spherePos = new Vector3(0.0, 0.0, 0.0);
  // カメラの位置(マウスの位置で制御できるようにしている)
  const cameraPos = new Vector3(10.0 * (2.0 * mouse[0] - 1.0), 10.0 * (2.0 * mouse[1] - 1.0), 5.0);
  // カメラの注視点
  const cameraTarget = new Vector3(0.0, 0.0, 0.0);
  // カメラの上方向
  const cameraUp = new Vector3(0.0, 1.0, 0.0);
  
  // 球のモデル行列の平行移動成分
  const modelTransMatrix = Matrix4.translate(spherePos.x, spherePos.y, spherePos.z);
  // 球のモデル行列の回転成分(これでカメラの平面メッシュをカメラの方向に向かせる)
  const modelRotMatrix = Matrix4.lookTo(Vector3.sub(spherePos, cameraPos).norm(), cameraUp);
  // 球のモデル行列 = (平行移動成分の行列) x (回転成分の行列)
  const modelMatrix = Matrix4.mul(modelRotMatrix, modelTransMatrix);

  const cameraMatrix = Matrix4.lookAt(cameraPos, cameraTarget, cameraUp);  
  // ビュー行列
  const viewMatrix = Matrix4.inverse(cameraMatrix);
```

ここでビルボードを実現するために大事な箇所は`modelRotMatrix`になります。`Matrix4.lookTo(front, up)`は`up`を上にして`front`方向を正面にするような回転行列を作成するメソッドです。`Vector3.sub(spherePos, cameraPos).norm()`を`front`として渡すことでカメラの方向を向くような回転行列を作成しています。

(補足: Matrix4クラスではデフォルトの正面方向(=デフォルトのカメラの方向)を`(0, 0, -1)`としています。平面メッシュはカメラと正対するのでメッシュの正面はデフォルトで`(0, 0, 1)`になります。そのためメッシュの背面がカメラと同じ向きになっているときにメッシュの正面はカメラに正対するので`front`は`Vector3.sub(cameraPos, spherePos).norm()`ではなく`Vector3.sub(spherePos, cameraPos).norm()`になります。)

行列の作成方法を確認したので、次にシェーダーを見ていきます。
頂点シェーダーでは特に特別なことはしておらず、オブジェクト座標にMVP行列をかけて求めたクリップ座標を`gl_Position`に代入しています。(平面メッシュのVBOを作るのがめんどくさかったので頂点シェーダー内にオブジェクト座標の頂点位置を持たせています。)

```glsl:頂点シェーダー
#version 300 es

precision highp float;

out vec2 v_uv;

uniform float u_size;
uniform mat4 u_mvpMatrix;

const vec3[4] POSITIONS = vec3[](
  vec3(-1.0, -1.0, 0.0),
  vec3(1.0, -1.0, 0.0),
  vec3(-1.0, 1.0, 0.0),
  vec3(1.0, 1.0, 0.0)
);

const vec2[4] UVS = vec2[](
  vec2(0.0, 0.0),
  vec2(1.0, 0.0),
  vec2(0.0, 1.0),
  vec2(1.0, 1.0)
);

const int[6] INDICES = int[](
  0, 1, 2,
  3, 2, 1
);

void main(void) {
  int index = INDICES[gl_VertexID];
  gl_Position = u_mvpMatrix * vec4(u_size * POSITIONS[index], 1.0);
  v_uv = UVS[index];
}
```

フラグメントシェーダーではワールド座標系における法線を求めてライティングしています。平面メッシュのうち球の範囲外になる部分は`discard`しています。オブジェクト座標系のzの値は球が$x^2+y^2+z^2=r^2$を満たすことから求めています。

```glsl:フラグメントシェーダー
#version 300 es

precision highp float;

in vec2 v_uv;

out vec4 o_color;

uniform float u_size;
uniform mat4 u_modelMatrix;
uniform mat4 u_vpMatrix;

vec3 lightDir = normalize(vec3(0.0, 1.0, 1.0));

void main(void) {
  vec2 st = v_uv * 2.0 - 1.0;

  // 球の範囲外は描画しない
  if (length(st) > 1.0) discard;

  // オブジェクト座標系での位置
  vec3 objPos = u_size * vec3(st.x, st.y, sqrt(1.0 - st.x * st.x - st.y * st.y));
  // オブジェクト座標系での法線
  vec3 objNormal = normalize(objPos);
  // ワールド座標系での法線
  vec3 worldNormal = (u_modelMatrix * vec4(objNormal, 0.0)).xyz;

  vec3 ambient = vec3(0.2);
  vec3 diffuse = vec3(0.3, 0.5, 0.8) * clamp(dot(worldNormal, lightDir), 0.0, 1.0);

  o_color = vec4(ambient + diffuse, 1.0);
}
```

---

以下、ソースコード全文です。

```js
const renderVertex = `#version 300 es

precision highp float;

out vec2 v_uv;

uniform float u_size;
uniform mat4 u_mvpMatrix;

const vec3[4] POSITIONS = vec3[](
  vec3(-1.0, -1.0, 0.0),
  vec3(1.0, -1.0, 0.0),
  vec3(-1.0, 1.0, 0.0),
  vec3(1.0, 1.0, 0.0)
);

const vec2[4] UVS = vec2[](
  vec2(0.0, 0.0),
  vec2(1.0, 0.0),
  vec2(0.0, 1.0),
  vec2(1.0, 1.0)
);

const int[6] INDICES = int[](
  0, 1, 2,
  3, 2, 1
);

void main(void) {
  int index = INDICES[gl_VertexID];
  gl_Position = u_mvpMatrix * vec4(u_size * POSITIONS[index], 1.0);
  v_uv = UVS[index];
}
`

const renderFragment = `#version 300 es

precision highp float;

in vec2 v_uv;

out vec4 o_color;

uniform float u_size;
uniform mat4 u_modelMatrix;
uniform mat4 u_vpMatrix;

vec3 lightDir = normalize(vec3(0.0, 1.0, 1.0));

void main(void) {
  vec2 st = v_uv * 2.0 - 1.0;

  if (length(st) > 1.0) discard;

  vec3 objPos = u_size * vec3(st.x, st.y, sqrt(1.0 - st.x * st.x - st.y * st.y));
  vec3 objNormal = normalize(objPos);
  vec3 worldNormal = (u_modelMatrix * vec4(objNormal, 0.0)).xyz;

  vec3 ambient = vec3(0.2);
  vec3 diffuse = vec3(0.3, 0.5, 0.8) * clamp(dot(worldNormal, lightDir), 0.0, 1.0);

  o_color = vec4(ambient + diffuse, 1.0);
}
`;

function createShader(gl, source, type) {
  const shader = gl.createShader(type);
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw new Error(gl.getShaderInfoLog(shader) + source);
  }
  return shader;
}

function createProgram(gl, vertShader, fragShader) {
  const program = gl.createProgram();
  gl.attachShader(program, vertShader);
  gl.attachShader(program, fragShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    throw new Error(gl.getProgramInfoLog(program));
  }
  return program;
}

function getUniformLocs(gl, program, names) {
  const map = new Map();
  names.forEach((name) => map.set(name, gl.getUniformLocation(program, name)));
  return map;
}

const canvas = document.getElementById('canvas');
canvas.width = innerWidth;
canvas.height = innerHeight;
const gl = canvas.getContext('webgl2');
gl.clearColor(0.5, 0.5, 0.5, 1.0);

const program = createProgram(gl,
  createShader(gl, renderVertex, gl.VERTEX_SHADER),
  createShader(gl, renderFragment, gl.FRAGMENT_SHADER));
const uniformLocs = getUniformLocs(gl, program, ['u_size', 'u_modelMatrix', 'u_vpMatrix', 'u_mvpMatrix']);

const mouse = [0.0, 0.0];
canvas.addEventListener('mousemove', e => {
  mouse[0] = e.clientX / canvas.width;
  mouse[1] = (canvas.height - e.clientY) / canvas.height;
});

let projMatrix = Matrix4.perspective(canvas.width / canvas.height, 60.0, 0.1, 1000.0);
let requestId = null;
const render = () => {
  const spherePos = new Vector3(0.0, 0.0, 0.0);
  const cameraPos = new Vector3(10.0 * (2.0 * mouse[0] - 1.0), 10.0 * (2.0 * mouse[1] - 1.0), 5.0);
  const cameraTarget = new Vector3(0.0, 0.0, 0.0);
  const cameraUp = new Vector3(0.0, 1.0, 0.0);
  const cameraMatrix = Matrix4.lookAt(cameraPos, cameraTarget, cameraUp);
  const modelTransMatrix = Matrix4.translate(spherePos.x, spherePos.y, spherePos.z);
  const modelRotMatrix = Matrix4.lookTo(Vector3.sub(spherePos, cameraPos).norm(), cameraUp);

  const modelMatrix = Matrix4.mul(modelRotMatrix, modelTransMatrix);
  const viewMatrix = Matrix4.inverse(cameraMatrix);
  const vpMatrix = Matrix4.mul(viewMatrix, projMatrix);
  const mvpMatrix = Matrix4.mul(modelMatrix, vpMatrix);

  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.useProgram(program);
  gl.uniform1f(uniformLocs.get('u_size'), 1.0);
  gl.uniformMatrix4fv(uniformLocs.get('u_modelMatrix'), false, modelMatrix.elements);
  gl.uniformMatrix4fv(uniformLocs.get('u_vpMatrix'), false, vpMatrix.elements);
  gl.uniformMatrix4fv(uniformLocs.get('u_mvpMatrix'), false, mvpMatrix.elements);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
 
  requestId = requestAnimationFrame(render);
};

addEventListener('resize', () => {
  if (requestId !== null) {
    cancelAnimationFrame(requestId);
  }
  canvas.width = innerWidth;
  canvas.height = innerHeight;
  projMatrix = Matrix4.perspective(canvas.width / canvas.height, 60.0, 0.1, 100.0);
  gl.viewport(0.0, 0.0, canvas.width, canvas.height);
  requestId = requestAnimationFrame(render);
});

requestId = requestAnimationFrame(render);
```