目录

WebGLFundamentals.org

Fix, Fork, Contribute

WebGL 可视化相机

本文假设你已经读过 多个视角的文章 了。如果你还没有读过,请 先去阅读

本文还假设你已经读过 码少趣多,因为本文使用到了那里提到的库,以便使得本文的例子更整洁。如果你不明白 webglUtils.setBuffersAndAttributes 函数是设置 buffers 和 attributes 的,或者不明白 webglUtils.setUniforms 函数是设置 uniforms 的,等等之类的函数你都不能理解,那么你可能要往回 读读基础

将相机能看到什么进行可视化通常是非常有用的,即可视化相机的视椎体。这也是非常容易的。如 正交 投影和 透视 投影所说的那样,那些投影矩阵将某些空间的坐标转换为范围在 -1 到 +1 的裁剪空间。一个相机矩阵只是一个在世界空间中表示相机位置和方位的矩阵。

所以,如果要可视化相机的话,第一件要做的事就很显然了。如果我们只是使用相机矩阵去绘制某些东西,我们就可以得到一个代表相机的物体。复杂的地方在于相机并不能看到它本身,但是,如果使用 多个视角 中的技术,我们就可以拥有 2 个视角。对于每个视角,我们会使用不同的相机。第 2 个视角会看向第 1 个视角,因此第 2 个视角能够看到我们正在绘制的这个物体,这个物体表示的是第 1 个视角中的相机。

首先,让我们创建一些表示相机的数据。让我们创建一个立方体,然后在立方体的末端添加一个圆锥。我们会使用线段来绘制这个物体。我们会使用 索引 来连接顶点。

相机 看向的是 -Z 方向,所以,让我们把立方体和圆锥放到 +Z 这边,而圆锥的开口方向是 -Z 方向。

首先的是立方体的线框

// 为一个相机创建几何
function createCameraBufferInfo(gl) {
  // 首先,让我们添加一个立方体。它的范围是 1 到 3,
  // 因为相机看向的是 -Z 方向,所以我们想要相机在 Z = 0 处开始
  const positions = [
    -1, -1,  1,  // 立方体的顶点
     1, -1,  1,
    -1,  1,  1,
     1,  1,  1,
    -1, -1,  3,
     1, -1,  3,
    -1,  1,  3,
     1,  1,  3,
  ];
  const indices = [
    0, 1, 1, 3, 3, 2, 2, 0, // 立方体的索引
    4, 5, 5, 7, 7, 6, 6, 4,
    0, 4, 1, 5, 3, 7, 2, 6,
  ];
  return webglUtils.createBufferInfoFromArrays(gl, {
    position: positions,
    indices,
  });
}

然后,让我们添加圆锥的的线框

// 为一个相机创建几何
function createCameraBufferInfo(gl) {
  // 首先,让我们添加一个立方体。它的范围是 1 到 3,
  // 因为相机看向的是 -Z 方向,所以我们想要相机在 Z = 0 处开始。
+  // 我们会把一个圆锥放到该立方体的前面,
+  // 且该圆锥的开口方向朝 -Z 方向。
  const positions = [
    -1, -1,  1,  // 立方体的顶点
     1, -1,  1,
    -1,  1,  1,
     1,  1,  1,
    -1, -1,  3,
     1, -1,  3,
    -1,  1,  3,
     1,  1,  3,
+     0,  0,  1,  // 圆锥的尖头
  ];
  const indices = [
    0, 1, 1, 3, 3, 2, 2, 0, // 立方体的索引
    4, 5, 5, 7, 7, 6, 6, 4,
    0, 4, 1, 5, 3, 7, 2, 6,
  ];
+  // 添加圆锥的片段
+  const numSegments = 6;
+  const coneBaseIndex = positions.length / 3; 
+  const coneTipIndex =  coneBaseIndex - 1;
+  for (let i = 0; i < numSegments; ++i) {
+    const u = i / numSegments;
+    const angle = u * Math.PI * 2;
+    const x = Math.cos(angle);
+    const y = Math.sin(angle);
+    positions.push(x, y, 0);
+    // 从圆锥尖头到圆锥边缘的线段
+    indices.push(coneTipIndex, coneBaseIndex + i);
+    // 从圆锥边缘一点到圆锥边缘下一点的线段
+    indices.push(coneBaseIndex + i, coneBaseIndex + (i + 1) % numSegments);
+  }
  return webglUtils.createBufferInfoFromArrays(gl, {
    position: positions,
    indices,
  });
}

最后,让我们添加一个缩放比例,因为我们的 F 高 150 个单位长度,而这个相机的尺寸是 2 到 3 个单位长度,所以它在我们的 F 旁边会很小。当我们绘制它的时候,我们可以将它乘以一个缩放矩阵,使其缩放一定的比例,或者我们也可以像下面这样直接对数据本身进行缩放。

-function createCameraBufferInfo(gl) {
+function createCameraBufferInfo(gl, scale = 1) {
  // 首先,让我们添加一个立方体。它的范围是 1 到 3,
  // 因为相机看向的是 -Z 方向,所以我们想要相机在 Z = 0 处开始。
  // 我们会把一个圆锥放到该立方体的前面,
  // 且该圆锥的开口方向朝 -Z 方向。
  const positions = [
    -1, -1,  1,  // 立方体的顶点
     1, -1,  1,
    -1,  1,  1,
     1,  1,  1,
    -1, -1,  3,
     1, -1,  3,
    -1,  1,  3,
     1,  1,  3,
     0,  0,  1,  // 圆锥的尖头
  ];
  const indices = [
    0, 1, 1, 3, 3, 2, 2, 0, // 立方体的索引
    4, 5, 5, 7, 7, 6, 6, 4,
    0, 4, 1, 5, 3, 7, 2, 6,
  ];
  // 添加圆锥的片段
  const numSegments = 6;
  const coneBaseIndex = positions.length / 3; 
  const coneTipIndex =  coneBaseIndex - 1;
  for (let i = 0; i < numSegments; ++i) {
    const u = i / numSegments;
    const angle = u * Math.PI * 2;
    const x = Math.cos(angle);
    const y = Math.sin(angle);
    positions.push(x, y, 0);
    // 从圆锥尖头到圆锥边缘的线段
    indices.push(coneTipIndex, coneBaseIndex + i);
    // 从圆锥边缘一点到圆锥边缘下一点的线段
    indices.push(coneBaseIndex + i, coneBaseIndex + (i + 1) % numSegments);
  }
+  positions.forEach((v, ndx) => {
+    positions[ndx] *= scale;
+  });
  return webglUtils.createBufferInfoFromArrays(gl, {
    position: positions,
    indices,
  });
}

我们现在的着色器程序绘制的是顶点的颜色。让我们创建另一个绘制纯色的着色器程序。

<script id="solid-color-vertex-shader" type="x-shader/x-vertex">
attribute vec4 a_position;

uniform mat4 u_matrix;

void main() {
  // 将 position 乘以矩阵
  gl_Position = u_matrix * a_position;
}
</script>
<!-- fragment shader -->
<script id="solid-color-fragment-shader" type="x-shader/x-fragment">
precision mediump float;

uniform vec4 u_color;

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

现在,让我们使用它们来绘制一个场景,该场景内有一个相机看着另一个场景。

// 设置 GLSL 程序
// 编译着色器、链接程序、查找 locations
-const programInfo = webglUtils.createProgramInfo(gl, ['vertex-shader-3d', 'fragment-shader-3d']);
+const vertexColorProgramInfo = webglUtils.createProgramInfo(gl, ['vertex-shader-3d', 'fragment-shader-3d']);
+const solidColorProgramInfo = webglUtils.createProgramInfo(gl, ['solid-color-vertex-shader', 'solid-color-fragment-shader']);

// 为一个 3D 的 'F' 创建 buffers 并用数据来填充
const fBufferInfo = primitives.create3DFBufferInfo(gl);

...

+const cameraScale = 20;
+const cameraBufferInfo = createCameraBufferInfo(gl, cameraScale);

...

const settings = {
  rotation: 150,  // 以角度为单位
+  cam1FieldOfView: 60,  // 以角度为单位
+  cam1PosX: 0,
+  cam1PosY: 0,
+  cam1PosZ: -200,
};


function render() {
  webglUtils.resizeCanvasToDisplaySize(gl.canvas);

  gl.enable(gl.CULL_FACE);
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.SCISSOR_TEST);

  // 我们要把视角分成 2 个
  const effectiveWidth = gl.canvas.clientWidth / 2;
  const aspect = effectiveWidth / gl.canvas.clientHeight;
  const near = 1;
  const far = 2000;

  // 计算一个透视投影矩阵
  const perspectiveProjectionMatrix =
-      m4.perspective(fieldOfViewRadians), aspect, near, far);
+      m4.perspective(degToRad(settings.cam1FieldOfView), aspect, near, far);

  // 使用 look at 计算相机的矩阵
-  const cameraPosition = [0, 0, -75];
+  const cameraPosition = [
+      settings.cam1PosX, 
+      settings.cam1PosY,
+      settings.cam1PosZ,
+  ];
  const target = [0, 0, 0];
  const up = [0, 1, 0];
  const cameraMatrix = m4.lookAt(cameraPosition, target, up);

  let worldMatrix = m4.yRotation(degToRad(settings.rotation));
  worldMatrix = m4.xRotate(worldMatrix, degToRad(settings.rotation));
  // 使 F 围绕着它的原点
  worldMatrix = m4.translate(worldMatrix, -35, -75, -5);

  const {width, height} = gl.canvas;
  const leftWidth = width / 2 | 0;

  // draw on the left with orthographic camera
  // 使用正交相机绘制在左边
  gl.viewport(0, 0, leftWidth, height);
  gl.scissor(0, 0, leftWidth, height);
  gl.clearColor(1, 0.8, 0.8, 1);

  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);

  // 使用透视相机绘制在右边
  const rightWidth = width - leftWidth;
  gl.viewport(leftWidth, 0, rightWidth, height);
  gl.scissor(leftWidth, 0, rightWidth, height);
  gl.clearColor(0.8, 0.8, 1, 1);

  // 计算第二个投影矩阵和第二个相机
+  const perspectiveProjectionMatrix2 =
+      m4.perspective(degToRad(60), aspect, near, far);
+
+  // 使用 look at 计算相机的矩阵
+  const cameraPosition2 = [-600, 400, -400];
+  const target2 = [0, 0, 0];
+  const cameraMatrix2 = m4.lookAt(cameraPosition2, target2, up);

-  drawScene(perspectiveProjectionMatrix, cameraMatrix, worldMatrix);
+  drawScene(perspectiveProjectionMatrix2, cameraMatrix2, worldMatrix);

+  // 绘制代表第一个相机的物体
+  {
+    // 从第 2 个相机矩阵中创建一个视图矩阵
+    const viewMatrix = m4.inverse(cameraMatrix2);
+
+    let mat = m4.multiply(perspectiveProjectionMatrix2, viewMatrix);
+    // 使用第一个相机的矩阵作为表示相机的物体的世界矩阵
+    mat = m4.multiply(mat, cameraMatrix);
+
+    gl.useProgram(solidColorProgramInfo.program);
+
+    // ------ 绘制表示相机的物体 --------
+
+    // 设置所有需要的 attributes
+    webglUtils.setBuffersAndAttributes(gl, solidColorProgramInfo, cameraBufferInfo);
+
+    // 设置 uniforms
+    webglUtils.setUniforms(solidColorProgramInfo, {
+      u_matrix: mat,
+      u_color: [0, 0, 0, 1],
+    });
+
+    webglUtils.drawBufferInfo(gl, cameraBufferInfo, gl.LINES);
+  }
}
render();

现在,我们可以在右边的场景内看到用来渲染左边场景的相机了。

让我们再绘制一些东西来表示相机的视椎体。

因为视椎体表示的是将某一空间的坐标转换到裁剪空间的转换,这样我们就可以创建一个表示裁剪空间的立方体,然后使用投影矩阵的逆矩阵把该立方体放置到场景内。

首先,我们需要一个裁剪空间的立方体线框。

function createClipspaceCubeBufferInfo(gl) {
  // 首先,让我们添加一个立方体。它的范围是 1 到 3,
  // 因为相机看向的是 -Z 方向,所以我们想要相机在 Z = 0 处开始。
  // 我们会把一个圆锥放到该立方体的前面,
  // 且该圆锥的开口方向朝 -Z 方向。
  const positions = [
    -1, -1, -1,  // 立方体的顶点
     1, -1, -1,
    -1,  1, -1,
     1,  1, -1,
    -1, -1,  1,
     1, -1,  1,
    -1,  1,  1,
     1,  1,  1,
  ];
  const indices = [
    0, 1, 1, 3, 3, 2, 2, 0, // 立方体的索引
    4, 5, 5, 7, 7, 6, 6, 4,
    0, 4, 1, 5, 3, 7, 2, 6,
  ];
  return webglUtils.createBufferInfoFromArrays(gl, {
    position: positions,
    indices,
  });
}

然后我们创建一个立方体并绘制它

const cameraScale = 20;
const cameraBufferInfo = createCameraBufferInfo(gl, cameraScale);

+const clipspaceCubeBufferInfo = createClipspaceCubeBufferInfo(gl);

...

  // 绘制表示第一个相机的物体
  {
    // 从相机矩阵中创建一个视图矩阵
    const viewMatrix = m4.inverse(cameraMatrix2);

    let mat = m4.multiply(perspectiveProjectionMatrix2, viewMatrix);
    // 使用第一个相机的矩阵作为表示相机的物体的世界矩阵
    mat = m4.multiply(mat, cameraMatrix);

    gl.useProgram(solidColorProgramInfo.program);

    // ------ 绘制表示相机的物体 --------

    // 设置所有需要的 attributes
    webglUtils.setBuffersAndAttributes(gl, solidColorProgramInfo, cameraBufferInfo);

    // 设置 uniforms
    webglUtils.setUniforms(solidColorProgramInfo, {
      u_matrix: mat,
      u_color: [0, 0, 0, 1],
    });

    webglUtils.drawBufferInfo(gl, cameraBufferInfo, gl.LINES);

+    // ----- 绘制视椎体 -------
+
+    mat = m4.multiply(mat, m4.inverse(perspectiveProjectionMatrix));
+
+    // 设置所有需要的 attributes
+    webglUtils.setBuffersAndAttributes(gl, solidColorProgramInfo, clipspaceCubeBufferInfo);
+
+    // 设置 uniforms
+    webglUtils.setUniforms(solidColorProgramInfo, {
+      u_matrix: mat,
+      u_color: [0, 0, 0, 1],
+    });
+
+    webglUtils.drawBufferInfo(gl, clipspaceCubeBufferInfo, gl.LINES);
  }
}

让我们修改一下,以便我们可以调整第一个相机的近平面和远平面设置

const settings = {
  rotation: 150,  // 以角度为单位
  cam1FieldOfView: 60,  // 以角度为单位
  cam1PosX: 0,
  cam1PosY: 0,
  cam1PosZ: -200,
+  cam1Near: 30,
+  cam1Far: 500,
};

...

  // 计算一个透视投影矩阵
  const perspectiveProjectionMatrix =
      m4.perspective(degToRad(settings.cam1FieldOfView),
      aspect,
-      near,
-      far);
+      settings.cam1Near,
+      settings.cam1Far);

现在我们可以同时看到视椎体了。

如果你调整近平面或远平面或视场角,则它们会裁剪那个 F,你可以看到表示视椎体的物体匹配上了。

无论我们为左边的相机使用的是透视投影还是正交投影,它都能正常工作,因为一个投影矩阵总是会转换为裁剪空间,所以投影矩阵的逆矩阵总是会把我们传入的 +1 到 -1 立方体进行适当的扭曲。

const settings = {
  rotation: 150,  // 以角度为单位
  cam1FieldOfView: 60,  // 以角度为单位
  cam1PosX: 0,
  cam1PosY: 0,
  cam1PosZ: -200,
  cam1Near: 30,
  cam1Far: 500,
+  cam1Ortho: true,
+  cam1OrthoUnits: 120,
};

...

// 计算一个投影矩阵
const perspectiveProjectionMatrix = settings.cam1Ortho
    ? m4.orthographic(
        -settings.cam1OrthoUnits * aspect,  // left
         settings.cam1OrthoUnits * aspect,  // right
        -settings.cam1OrthoUnits,           // bottom
         settings.cam1OrthoUnits,           // top
         settings.cam1Near,
         settings.cam1Far)
    : m4.perspective(degToRad(settings.cam1FieldOfView),
        aspect,
        settings.cam1Near,
        settings.cam1Far);

那些使用 3D 建模软件(例如 Blender)或者带有场景编辑工具的游戏引擎(例如 UnityGodot)的人应该对这种可视化非常熟悉。

这种可视化对于 debugging 也非常有用。

有关于WebGL的疑问? 在Stackoverflow提问
有意见或建议? 在GitHub上提issue
comments powered by Disqus