みなさんこんにちは。泣く子も笑うWebGL芸人、emadurandalです。
ネイティブ3D APIの世界は、デプステストの精度を高めるためにデプスバッファの値を反転するReversed-Zというテクニックがよく用いられます。
Reversed-Zについては、以下の記事を参照してください。
Depth Precision Visualized | NVIDIA Developer
Outerra: Maximizing Depth Buffer Range and Precision
Reversed-Z in OpenGL | nlguillemot
Web3Dの世界はどうかというと、WebGPUではNDC(正規デバイス座標系)のZの値域がDirect3D, Vulkan, Metalなどと同じ[0,1]なので問題なくReversed-Zできます。しかし、OpenGLの系譜を持つWebGLではNDC(正規デバイス座標系)のZの値域が[-1,1]なので、Reversed-Zの精度が良くなく、これまでその利点を得られませんでした。
しかし、最近ではWebGLでもEXT_clip_controlという拡張をサポートする環境が増えつつあり、それらの環境ではNDC(正規デバイス座標系)のZの値域を[0,1]に変更することが可能です。
今回は、この拡張を使ってWebGLでもReversed-Zを試してみようという記事になります。
動作するコード
いきなりですが、完成系のコードです。
画面左側が非Reserved-Z、画面右側がReserved-Zです。非Reserved-Z版はデプスバッファの精度が足らず、遠い板ポリがZファイティングを起こしてしまっていますが、Reserved-Z版は問題ありませんね。
もうちょっとコードが複雑になりますが、精度エラーを可視化できるコードも追加したバージョンはこちらになります。
実行した時に、EXT_clip_controll拡張がサポートされていない環境ではその旨を伝えるダイアログが表示され、デモを実行できませんのでご了承ください(Firefoxとかはダメでした)
コード解説
このデモコードは、Khronosが公開するWebGPU SamplesのreversedZサンプルのソースコードをベースにほぼそのままWebGL向けに変更したものになっています。
頂点定義
まずは頂点定義です。赤と緑の2枚の矩形板ポリが、半分くらいの面積が重なり合うように配置されています。
変数dが重なり合っているところの互いの板ポリ同士の間隔ですね。この間隔をデプスバッファがうまく量子化できるか、というのがポイントなわけです。
  export const geometryVertexArray = new Float32Array([
  // float4 position, float4 color
  -1 - o, -1, d, 1, 1, 0, 0, 1,
  1 - o, -1, d, 1, 1, 0, 0, 1,
  -1 - o, 1, d, 1, 1, 0, 0, 1,
  1 - o, -1, d, 1, 1, 0, 0, 1,
  1 - o, 1, d, 1, 1, 0, 0, 1,
  -1 - o, 1, d, 1, 1, 0, 0, 1,
  -1 + o, -1, -d, 1, 0, 1, 0, 1,
  1 + o, -1, -d, 1, 0, 1, 0, 1,
  -1 + o, 1, -d, 1, 0, 1, 0, 1,
  1 + o, -1, -d, 1, 0, 1, 0, 1,
  1 + o, 1, -d, 1, 0, 1, 0, 1,
  -1 + o, 1, -d, 1, 0, 1, 0, 1,
]);
WebGLコンテキスト取得、EXT_clip_controll拡張取得、NDCのZの値域変更
以下のコードでWebGLコンテキストを取得し、次にEXT_clip_control拡張の取得を試みています。取得できない場合はダイアログを出し、デモを終了します。取得に成功した場合は、clipControlEXT関数を呼んで、NDCのZの値域を[0,1]に変更します。
  const canvas = document.querySelector("canvas") as HTMLCanvasElement;
  const gl = canvas.getContext("webgl2");
  // EXT_clip_control拡張機能の取得
  const extClipControl = gl.getExtension('EXT_clip_control');
  if (!extClipControl) {
      alert('EXT_clip_control extension is not available.');
      return;
  } else {
    console.log(extClipControl)
    // NDC(正規デバイス座標系)のZの値域をデフォルトの[-1,1]から[0,1]へ変更する
    extClipControl.clipControlEXT(extClipControl.LOWER_LEFT_EXT, extClipControl.ZERO_TO_ONE_EXT);
    console.log('EXT_clip_control extension is successfully enabled.');
  }
Framebufferの準備
デプスバッファを32bit浮動小数点フォーマットにしたいので、Framebufferを作成します。WebGLでは、デプスバッファだけ作成すればカラーは通常のフレームバッファに書き込めるのかと思いきや、それはダメらしいので、カラーレンダーターゲットテクスチャも作成します。後ほど、カラーレンダーターゲットテクスチャに描画した結果をフルスクリーン矩形テクスチャに貼って通常のフレームバッファに結果表示とします。
  // Framebufferの作成
  const framebuffer = gl.createFramebuffer();
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  // Create a color texture
  const colorTexture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, colorTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, canvas.width, canvas.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  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);
  // Attach the color texture to the framebuffer
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTexture, 0);
  // 32bit浮動小数点のデプステクスチャの作成
  const depthTexture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, depthTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.DEPTH_COMPONENT32F, canvas.width, canvas.height, 0, gl.DEPTH_COMPONENT, gl.FLOAT, null);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  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);
  // デプステクスチャをFramebufferに添付
  gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0);
  // フレームバッファの完成をチェック
  const framebufferStatus = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
  if (framebufferStatus !== gl.FRAMEBUFFER_COMPLETE) {
      console.error('Framebuffer is not complete: ' + framebufferStatus.toString());
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
シェーダーの準備
シェーダーを準備します。
  // 通常シェーダーの準備
  const vertexShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertexShader, vertexGLSL);
  gl.compileShader(vertexShader);
  if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(vertexShader));
  }
  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragmentShader, fragmentGLSL);
  gl.compileShader(fragmentShader);
  if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(fragmentShader));
  }
  const program = gl.createProgram();
  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program));
  }
  // フルスクリーンシェーダーの準備
  const vertexFullScreenShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertexFullScreenShader, vertexFullscreenGLSL);
  gl.compileShader(vertexFullScreenShader);
  if (!gl.getShaderParameter(vertexFullScreenShader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(vertexFullScreenShader));
  }
  const fragmentFullScreenShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragmentFullScreenShader, fragmentFullscreenGLSL);
  gl.compileShader(fragmentFullScreenShader);
  if (!gl.getShaderParameter(fragmentFullScreenShader, gl.COMPILE_STATUS)) {
    console.error(gl.getShaderInfoLog(fragmentFullScreenShader));
  }
  const programFullScreen = gl.createProgram();
  gl.attachShader(programFullScreen, vertexFullScreenShader);
  gl.attachShader(programFullScreen, fragmentFullScreenShader);
  gl.linkProgram(programFullScreen);
  if (!gl.getProgramParameter(programFullScreen, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(programFullScreen));
  }
以下は利用するシェーダーコードです。vertexGLSLとfragmentGLSLが板ポリを描画する本番のシェーダーコードです。板ポリはReversed-Z版、非Reversed-Z版それぞれで5つインスタンス描画するので、それに合わせたコードになっています。
vertexFullscreenGLSLとfragmentFullscreenGLSLは、前段の描画結果(オフスクリーン描画される)結果をフルスクリーンの矩形板ポリに貼って、我々が見える通常のフレームバッファに表示するためのものです。このコードは頂点バッファを使わずにフルスクリーン矩形を描画するテクニックです。詳しくはこちらの記事を参照ください。
export const vertexGLSL = `#version 300 es
precision highp float;
// Uniforms
uniform mat4 modelMatrix[5];
uniform mat4 viewProjectionMatrix;
// Vertex attributes
in vec4 position;
in vec4 color;
// Outputs to fragment shader
out vec4 fragColor;
void main() {
    // Retrieve the instance index from a custom attribute (in WebGL2, we usually use a separate buffer for instance IDs)
    int instanceIdx = gl_InstanceID;
    
    // Compute the final vertex position
    gl_Position = viewProjectionMatrix * modelMatrix[instanceIdx] * position;
    
    // Pass the color to the fragment shader
    fragColor = color;
}
`;
export const fragmentGLSL = `#version 300 es
precision highp float;
// Input from vertex shader
in vec4 fragColor;
// Output to framebuffer
out vec4 outColor;
void main() {
    outColor = fragColor;
}
`;
export const vertexFullscreenGLSL = `#version 300 es
precision highp float;
out vec2 texCoord;
void main() {
    float x = float((gl_VertexID & 1) << 2); // 0, 4, 0
    float y = float((gl_VertexID & 2) << 1); // 0, 0, 4
    texCoord.x = x * 0.5;
    texCoord.y = y * 0.5;
    gl_Position = vec4(x - 1.0, y - 1.0, 0, 1);
    // x, y, u, v
    // -1, -1, 0, 0
    // 3, -1, 2, 0
    // -1, 3, 0, 2
}
`;
export const fragmentFullscreenGLSL = `#version 300 es
precision highp float;
in vec2 texCoord;
// サンプラーの定義
uniform sampler2D colorTexture;
// Output to framebuffer
out vec4 outColor;
void main() {
    outColor = texture(colorTexture, texCoord);
}
`;
5枚の板ポリをテスト用の配置に持っていくためのモデル行列準備
以下のコードは、5枚の板ポリを、デプスバッファ精度評価のために、絶妙な配置を行うためのモデル行列を作成するためのコードです。遠くに配置された板ポリは、小さく見えないよう他の板ポリと同じ大きさに見えるように拡大もされています。
  let m = 0;
  for (let x = 0; x < xCount; x++) {
    for (let y = 0; y < yCount; y++) {
      const z = -800 * m;
      const s = 1 + 50 * m;
      const modelMatrix = mat4.create();
      mat4.translate(
        modelMatrix,
        modelMatrix,
        vec3.fromValues(
          x - xCount / 2 + 0.5,
          (4.0 - 0.2 * z) * (y - yCount / 2 + 1.0),
          z
        )
      );
      mat4.scale(modelMatrix, modelMatrix, vec3.fromValues(s, s, s));
      modelMatrices.push(modelMatrix);
      m++;
    }
  }
viewProjection行列の準備
非Reversed-Z、Reversed-Z用にviewProjection行列を準備します。
非Reversed-Z版のProjection行列は、WebGLにおけるごく普通のPerspectiveProjection行列を生成すればOKです。
Reversed-Z版では、2つやることがあります。
まず、NDCのZ値の値域が[0,1]になっているので、Projection行列もそれに合わせたものにする必要があるということです。行列ライブラリとして利用しているgl-matrixのバージョン3系では、値域[0,1]用の関数がなかったので(バージョン4系ではあったりします)、自分でProjection行列作成関数createPerspectiveMatrixZOを作成しました。
さらに、Reversed-Zというくらいですから、もちろんZを反転させなければなりません。そのためのdepthRangeRemapMatrix行列をさらに乗算します。これはこのデモの元となったWebGPU Samplesのコードと同じものを使用しています。
この辺りのProjection行列のことは、shikihuikuさんのページが詳しいです。
  const depthRangeRemapMatrix = mat4.create();
  depthRangeRemapMatrix[10] = -1;
  depthRangeRemapMatrix[14] = 1;
  // 非Reversed-Z向けのviewProjection行列の作成
  const viewMatrix = mat4.create();
  mat4.translate(viewMatrix, viewMatrix, vec3.fromValues(0, 0, -12));
  const aspect = (0.5 * canvas.width) / canvas.height;
  const projectionMatrix = mat4.create();
  mat4.perspective(projectionMatrix, (2 * Math.PI) / 5, aspect, 5, 9999);
  const viewProjectionMatrix = mat4.create();
  mat4.multiply(viewProjectionMatrix, projectionMatrix, viewMatrix);
  // Reversed-Z向けのviewProjection行列の作成
  function createPerspectiveMatrixZO(projectionMatrix: Float32Array, fov: number, aspect: number, zNear: number, zFar: number) {
    const f = 1.0 / Math.tan(fov / 2.0);
    const nf = 1.0 / (zNear - zFar);
    projectionMatrix[0] = f / aspect;
    projectionMatrix[1] = 0;
    projectionMatrix[2] = 0;
    projectionMatrix[3] = 0;
    projectionMatrix[4] = 0;
    projectionMatrix[5] = f;
    projectionMatrix[6] = 0;
    projectionMatrix[7] = 0;
    projectionMatrix[8] = 0;
    projectionMatrix[9] = 0;
    projectionMatrix[10] = zFar * nf;
    projectionMatrix[11] = -1;
    projectionMatrix[12] = 0;
    projectionMatrix[13] = 0;
    projectionMatrix[14] = zFar * zNear * nf;
    projectionMatrix[15] = 0;
  }
  const projectionMatrixZO = mat4.create();
  createPerspectiveMatrixZO(projectionMatrixZO, (2 * Math.PI) / 5, aspect, 5, 9999);
  const viewProjectionMatrixZO = mat4.create();
  mat4.multiply(viewProjectionMatrixZO, projectionMatrixZO, viewMatrix);
  const reversedRangeViewProjectionMatrix = mat4.create();
  mat4.multiply(
    reversedRangeViewProjectionMatrix,
    depthRangeRemapMatrix,
    viewProjectionMatrixZO
  );
描画
さて、ここまで準備ができたらいよいよ描画です。
updateTransformationMatrix関数は、5枚の板ポリをぐりぐり動かすためのものです。
frame関数の中では、前半のforループの中で、画面左側を非Reserved-Z用、画面右側をReserved-Z用に板ポリを描画します。この時、Reserved-Z版ではデプスバッファを1ではなく0でクリアし、さらにdepthFuncをgl.GREATERに設定しています。
後半のコードでは、フルスクリーン矩形描画テクニックを使って、前半でオフスクリーンに描画した結果をフルスクリーン矩形に貼って、画面に結果描画しています。
  function updateTransformationMatrix() {
    const now = Date.now() / 1000;
    for (let i = 0; i < numInstances; i++) {
      const tmpMat4 = mat4.create();
      mat4.rotate(
        tmpMat4,
        modelMatrices[i],
        (Math.PI / 180) * 30,
        vec3.fromValues(Math.sin(now), Math.cos(now), 0)
      );
      mvpMatricesData.set(tmpMat4, i * 16);
    }
  }
  gl.clearColor(0, 0, 0.5, 1);
  function frame() {
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.enable(gl.DEPTH_TEST);
    updateTransformationMatrix();
    for (const m of depthBufferModes) {
      gl.useProgram(program);
      const modelMatrixLocation = gl.getUniformLocation(program, "modelMatrix");
      const viewProjectionMatrixLocation = gl.getUniformLocation(
        program,
        "viewProjectionMatrix"
      );
      gl.uniformMatrix4fv(viewProjectionMatrixLocation, false, viewProjectionMatrixs[m]);
      gl.viewport((canvas.width * m) / 2, 0, canvas.width / 2, canvas.height);
      gl.depthFunc(depthCompareFuncs[m]);
      gl.clearDepth(depthClearValues[m]);
      gl.clear(gl.DEPTH_BUFFER_BIT);
      gl.bindVertexArray(vao);
      gl.uniformMatrix4fv(modelMatrixLocation, false, mvpMatricesData);
      gl.drawArraysInstanced(gl.TRIANGLES, 0, geometryVertexArray.length / 8, numInstances);  
    }
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    {
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.viewport(0, 0, canvas.width, canvas.height);
      gl.disable(gl.DEPTH_TEST);
      gl.useProgram(programFullScreen);
      const textureLocation = gl.getUniformLocation(programFullScreen, 'colorTexture');
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, colorTexture);
      gl.uniform1i(textureLocation, 0);
      gl.drawArrays(gl.TRIANGLES, 0, 3);
    }
    requestAnimationFrame(frame);
  }
  frame();
まとめ
WebGLでReversed-Zを行う主な手順をまとめます。
- EXT_clip_control拡張でNDCのZ値域を[0,1]に変更する
- Projection行列をReversed-Z用のものにする
- デプスバッファを1ではなく0で初期化する
- depthFuncをgl.LESSではなくgl.GREATERに設定する
最近はWebGLでもメタバースサービスのような、遠景を描画するようなコンテンツが増えてきたと思います。そういう中で、EXT_clip_control拡張がサポートされる環境が増えてきたことはReversed-ZがWebGLでも使えるという点で喜ばしいことです。
みなさんもぜひチャレンジしてみてね。
参考文献
- Depth Precision Visualized | NVIDIA Developer, https://developer.nvidia.com/content/depth-precision-visualized
- Outerra: Maximizing Depth Buffer Range and Precision, https://outerra.blogspot.com/2012/11/maximizing-depth-buffer-range-and.html
- Reversed-Z in OpenGL | nlguillemot, https://nlguillemot.wordpress.com/2016/12/07/reversed-z-in-opengl/
- EXT_clip_control Extension, (https://registry.khronos.org/webgl/extensions/EXT_clip_control/)
- WebGPU Samples, https://webgpu.github.io/webgpu-samples/?sample=reversedZ
- Projection Matrixについて, https://shikihuiku.github.io/post/projection_matrix/
- バッファレスレンダリングで25行から始めるWebGL2, https://qiita.com/emadurandal/items/c88d95aa321eea179ac2#%E7%95%AA%E5%A4%96%E7%B7%A8-%E7%94%BB%E9%9D%A2%E5%85%A8%E4%BD%93%E3%82%92%E8%A6%86%E3%81%86%E7%9F%A9%E5%BD%A2%E3%82%92%E9%A0%82%E7%82%B9%E3%83%90%E3%83%83%E3%83%95%E3%82%A1%E3%81%AA%E3%81%97%E3%81%A7%E6%8F%8F%E7%94%BB%E3%81%99%E3%82%8B


