2.9 深度测试和裁剪 (Depth Test and Culling) WebGL 深度测试与裁剪 (Depth Test and Culling) 详解 引言:渲染管线中的关键环节 在构建复杂的3D场景时,我们不可避免地会遇到物体遮挡、面片朝向等问题。如果没有有效的处理机制,渲染结果可能会出现物体穿透、背面可见等错误,严重影响视觉效果和渲染效率。深度测试和裁剪正是为了解决这些问题而生的。 深度测试 (Depth Test),顾名思义,是用来判断像素深度(距离观察者的距离)的技术。它决定了在重叠的几何图形中,哪些像素应该被绘制在前面,哪些应该被遮挡在后面,从而实现正确的遮挡关系。 裁剪 (Culling),则是一种优化技术,用于剔除那些对最终渲染结果不可见的图元(通常是三角形)。
在构建复杂的3D场景时,我们不可避免地会遇到物体遮挡、面片朝向等问题。如果没有有效的处理机制,渲染结果可能会出现物体穿透、背面可见等错误,严重影响视觉效果和渲染效率。深度测试和裁剪正是为了解决这些问题而生的。
深度测试 (Depth Test),顾名思义,是用来判断像素深度(距离观察者的距离)的技术。它决定了在重叠的几何图形中,哪些像素应该被绘制在前面,哪些应该被遮挡在后面,从而实现正确的遮挡关系。
裁剪 (Culling),则是一种优化技术,用于剔除那些对最终渲染结果不可见的图元(通常是三角形)。最常见的裁剪方式是背面裁剪 (Back-face Culling),即剔除模型背对观察者的面片,因为这些面片在默认情况下是不可见的。
这两个技术通常协同工作,共同构建高效且正确的3D渲染场景。在深入细节之前,我们先通过mermaid图简单概括一下它们在WebGL渲染管线中的位置:
从图中可以看出,裁剪发生在图元装配之后、光栅化之前,而深度测试则发生在片元着色器之后、帧缓冲区写入之前。这表明裁剪在早期阶段就剔除了不可见图元,减少了后续处理的负担,而深度测试则确保了最终像素的正确性。
接下来,我们将分别详细探讨深度测试和裁剪。
想象一下,你在画一幅画,想要绘制一个球体遮挡住后面的立方体。如果你直接按照绘制顺序,先画立方体再画球体,那么球体就会覆盖在立方体之上,无论它们在3D空间中的实际深度关系如何。深度测试就是为了解决这个问题。
在WebGL中,每个像素都关联一个深度值,存储在深度缓冲区 (Depth Buffer) 中。深度值通常表示像素到观察者的距离,值越小表示距离越近。当WebGL准备绘制一个新的像素时,它会进行深度测试:
获取当前像素的深度值(来自片元着色器)。
从深度缓冲区读取对应位置已有的深度值。
将当前像素的深度值与已有的深度值进行比较,比较方式由深度测试函数 (Depth Function) 决定。
如果深度测试通过(根据深度测试函数和比较结果),则用当前像素的颜色和深度值更新帧缓冲区和深度缓冲区;否则,丢弃当前像素,不进行任何更新。
通过深度测试,WebGL确保了只有最靠近观察者的像素才会被最终绘制出来,从而实现了正确的遮挡效果。这对于渲染复杂的3D场景至关重要,例如包含多个重叠物体的场景、具有自遮挡的模型等。
更详细地描述深度测试的工作流程,可以用以下mermaid图表示:
深度缓冲区 (Depth Buffer) 是WebGL上下文中一块特殊的缓冲区,通常与颜色缓冲区(帧缓冲区的一部分)大小相同。在渲染之前,深度缓冲区通常需要被清空 (Clear),以确保每次渲染都是从一个干净的状态开始。清空深度缓冲区通常设置为一个最大的深度值(例如1.0),表示初始时所有像素都“无限远”。
深度测试函数 (Depth Function) 决定了深度值比较的具体规则。WebGL提供了多种深度测试函数,可以通过 gl.depthFunc() 函数设置。常见的深度测试函数包括:
NEVER: 永远不通过深度测试。
LESS: 如果当前像素深度值小于缓冲区深度值,则通过测试。(常用)
EQUAL: 如果当前像素深度值等于缓冲区深度值,则通过测试。
LEQUAL: 如果当前像素深度值小于或等于缓冲区深度值,则通过测试。(常用)
GREATER: 如果当前像素深度值大于缓冲区深度值,则通过测试。
NOTEQUAL: 如果当前像素深度值不等于缓冲区深度值,则通过测试。
GEQUAL: 如果当前像素深度值大于或等于缓冲区深度值,则通过测试。
ALWAYS: 永远通过深度测试。
最常用的深度测试函数是 LESS 和 LEQUAL。LESS 通常用于标准的不透明物体渲染,确保前面的物体遮挡后面的物体。 LEQUAL 在某些特殊情况下也很有用,例如处理精确的深度值或与透明度混合结合使用。
在WebGL中启用和配置深度测试非常简单。主要涉及以下几个步骤:
启用深度测试: 使用 gl.enable(gl.DEPTH_TEST) 启用深度测试功能。
设置深度测试函数: 使用 gl.depthFunc(func) 设置深度测试函数,例如 gl.depthFunc(gl.LEQUAL)。
清空深度缓冲区: 在每一帧渲染开始前,使用 gl.clear(gl.DEPTH_BUFFER_BIT) 清空深度缓冲区。
代码示例:启用深度测试并绘制两个重叠的立方体
以下代码示例展示了如何在WebGL中启用深度测试,并绘制两个颜色不同的立方体,其中一个立方体稍微偏移,以产生重叠效果。
<!DOCTYPE html> <html> <head> <title>WebGL Depth Test Example</title> <style> body { margin: 0; } canvas { display: block; } </style> </head> <body> <canvas id="glCanvas" width="640" height="480"></canvas> <script> const canvas = document.getElementById('glCanvas'); const gl = canvas.getContext('webgl'); if (!gl) { alert('Unable to initialize WebGL. Your browser may not support it.'); return; } // 顶点着色器 const vsSource = ` attribute vec4 aVertexPosition; attribute vec4 aVertexColor; uniform mat4 uModelViewMatrix; uniform mat4 uProjectionMatrix; varying lowp vec4 vColor; void main() { gl_Position = uProjectionMatrix * uModelViewMatrix * aVertexPosition; vColor = aVertexColor; } `; // 片元着色器 const fsSource = ` varying lowp vec4 vColor; void main() { gl_FragColor = vColor; } `; // 初始化着色器程序 const shaderProgram = initShaderProgram(gl, vsSource, fsSource); const programInfo = { program: shaderProgram, attribLocations: { vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'), vertexColor: gl.getAttribLocation(shaderProgram, 'aVertexColor'), }, uniformLocations: { projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix'), modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix'), }, }; // 立方体顶点数据 const cubeVertices = [ // Front face -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, // Back face -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0, // Top face -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0, // Bottom face -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0, // Right face 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, // Left face -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, ]; const cubeColors = [ [1.0, 0.0, 0.0, 1.0], // Front: red [0.0, 1.0, 0.0, 1.0], // Back: green [0.0, 0.0, 1.0, 1.0], // Top: blue [1.0, 1.0, 0.0, 1.0], // Bottom: yellow [1.0, 0.0, 1.0, 1.0], // Right: magenta [0.0, 1.0, 1.0, 1.0], // Left: cyan ]; const cubeIndices = [ 0, 1, 2, 0, 2, 3, // front 4, 5, 6, 4, 6, 7, // back 8, 9, 10, 8, 10, 11, // top 12, 13, 14, 12, 14, 15, // bottom 16, 17, 18, 16, 18, 19, // right 20, 21, 22, 20, 22, 23, // left ]; // 初始化缓冲区 const positionBuffer = initBuffer(gl, cubeVertices); const colorBuffer = initColorBuffer(gl, cubeColors); const indexBuffer = initIndexBuffer(gl, cubeIndices); let cubeRotation = 0.0; function render(now) { now *= 0.001; // convert to seconds const deltaTime = now - then; then = now; drawScene(gl, programInfo, positionBuffer, colorBuffer, indexBuffer, cubeRotation); cubeRotation += deltaTime; requestAnimationFrame(render); } let then = 0; requestAnimationFrame(render); // ------ 初始化着色器程序 ------ function initShaderProgram(gl, vsSource, fsSource) { const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); const shaderProgram = gl.createProgram(); gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) { alert('Unable to initialize the shader program: ' + gl.getProgramInfoLog(shaderProgram)); return null; } return shaderProgram; } // ------ 创建指定类型的着色器,上传源码并编译 ------ function loadShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } // ------ 初始化位置缓冲区 ------ function initBuffer(gl, vertices) { const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); return positionBuffer; } // ------ 初始化颜色缓冲区 ------ function initColorBuffer(gl, faceColors) { let colors = []; for (let j = 0; j < faceColors.length; ++j) { const c = faceColors[j]; colors = colors.concat(c, c, c, c); // 每个面4个顶点,颜色相同 } const colorBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STATIC_DRAW); return colorBuffer; } // ------ 初始化索引缓冲区 ------ function initIndexBuffer(gl, indices) { const indexBuffer = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); return indexBuffer; } // ------ 绘制场景 ------ function drawScene(gl, programInfo, positionBuffer, colorBuffer, indexBuffer, cubeRotation) { gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque gl.clearDepth(1.0); // Clear depth buffer to 1.0 gl.enable(gl.DEPTH_TEST); // Enable depth testing gl.depthFunc(gl.LEQUAL); // Pass depth test if <= current depth gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 创建透视投影矩阵 const fieldOfView = 45 * Math.PI / 180; // in radians const aspect = gl.canvas.clientWidth / gl.canvas.clientHeight; const zNear = 0.1; const zFar = 100.0; const projectionMatrix = mat4.create(); mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar); // 设置绘制位置 const modelViewMatrix = mat4.create(); mat4.translate(modelViewMatrix, // destination matrix modelViewMatrix, // matrix to translate [-0.0, 0.0, -6.0]); // amount to translate mat4.rotate(modelViewMatrix, // destination matrix modelViewMatrix, // matrix to rotate cubeRotation, // amount to rotate in radians [0, 1, 0]); // axis to rotate around (Y) // 绘制第一个立方体 (红色) { const numComponents = 3; const type = gl.FLOAT; const normalize = false; const stride = 0; const offset = 0; gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.vertexAttribPointer( programInfo.attribLocations.vertexPosition, numComponents, type, normalize, stride, offset); gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition); } { const numComponents = 4; const type = gl.FLOAT; const normalize = false; const stride = 0; const offset = 0; gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer); gl.vertexAttribPointer( programInfo.attribLocations.vertexColor, numComponents, type, normalize, stride, offset); gl.enableVertexAttribArray(programInfo.attribLocations.vertexColor); } gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); gl.useProgram(programInfo.program); gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix); gl.uniformMatrix4fv(programInfo.uniformLocations.modelViewMatrix, false, modelViewMatrix); const vertexCount = 36; const type = gl.UNSIGNED_SHORT; const offset = 0; gl.drawElements(gl.TRIANGLES, vertexCount, type, offset); // 绘制第二个立方体 (蓝色), 稍微偏移 const modelViewMatrix2 = mat4.create(); mat4.translate(modelViewMatrix2, modelViewMatrix2, [1.5, 0.0, -6.0]); // 偏移 X 轴 mat4.rotate(modelViewMatrix2, modelViewMatrix2, cubeRotation, [0, 1, 0]); // 保持旋转 // 颜色缓冲区需要重新绑定,因为我们绘制的是不同的颜色 const colorBuffer2 = initColorBuffer(gl, [ [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0], [0.0, 0.0, 1.0, 1.0] ]); gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer2); gl.vertexAttribPointer( programInfo.attribLocations.vertexColor, numComponents, type, normalize, stride, offset); gl.uniformMatrix4fv(programInfo.uniformLocations.modelViewMatrix, false, modelViewMatrix2); // 使用新的 ModelViewMatrix gl.drawElements(gl.TRIANGLES, vertexCount, type, offset); } </script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gl-matrix/2.8.1/gl-matrix-min.js"></script> </body> </html>
代码详解:
启用深度测试: 在 drawScene 函数中,我们使用 gl.enable(gl.DEPTH_TEST) 启用了深度测试。
设置深度测试函数: 使用 gl.depthFunc(gl.LEQUAL) 设置深度测试函数为 LEQUAL。
清空深度缓冲区: 在 gl.clear() 函数中,我们使用了 gl.DEPTH_BUFFER_BIT 来清空深度缓冲区。
绘制两个立方体: 代码绘制了两个立方体,一个红色,一个蓝色,蓝色立方体在X轴上稍微偏移。由于深度测试的启用,即使蓝色立方体在代码中后绘制,但如果它的某些部分在红色立方体的前面,也会正确地遮挡红色立方体。
运行结果分析:
运行上述代码,你会看到两个旋转的立方体,一个红色,一个蓝色。由于深度测试的启用,即使两个立方体在空间上有所重叠,它们的遮挡关系也是正确的:前面的立方体遮挡住了后面的立方体。如果注释掉 gl.enable(gl.DEPTH_TEST),你会发现遮挡关系错误,后绘制的蓝色立方体会覆盖在红色立方体之上,无论它们的实际深度关系如何。
深度冲突 (Z-fighting) 是深度测试中常见的问题。当两个或多个面片在非常接近的深度值处渲染时,深度缓冲区可能无法精确区分它们的前后关系,导致像素的深度测试结果不稳定,出现闪烁、噪点等视觉artifact。
原因:
深度缓冲区精度有限: 深度缓冲区通常使用有限的位数来存储深度值,例如24位或32位。当两个面片深度值非常接近时,它们在深度缓冲区中的表示可能非常接近甚至相同,导致比较结果不确定。
面片共面或几乎共面: 当两个面片完全或几乎共面时,它们的深度值在某些像素上会非常接近,更容易发生深度冲突。
缓解方法:
调整 zNear 和 zFar: 尽可能缩小近裁剪面 (zNear) 和远裁剪面 (zFar) 之间的距离。因为深度缓冲区的精度在 zNear 和 zFar 之间非线性分布,越靠近 zNear 精度越高。减小 zFar 可以提高整体的深度精度。
偏移面片深度 (Depth Bias): 在片元着色器中,可以对深度值进行微小的偏移,使得面片之间产生一定的深度差异,从而避免深度冲突。WebGL提供了 gl.polygonOffset() 函数来实现多边形偏移。
避免面片共面: 在建模时,尽量避免创建完全或几乎共面的面片。可以对面片进行微小的分离或调整。
理解深度冲突及其缓解方法,有助于我们在实际开发中避免或减轻深度冲突带来的视觉问题。
在3D模型中,通常包含大量的三角形面片。但并非所有的面片在渲染时都是可见的。例如,一个封闭模型的背面,在默认情况下是观察者看不到的。如果我们仍然渲染这些不可见的面片,就会浪费大量的计算资源,降低渲染效率。裁剪技术就是为了解决这个问题。
裁剪 (Culling) 的目的是剔除那些对最终渲染结果没有贡献的图元,从而减少渲染管线的负担,提高渲染性能。最常见的裁剪方式是背面裁剪 (Back-face Culling)。
背面裁剪 (Back-face Culling) 的原理是:对于封闭的3D模型,其朝向观察者相反的面片(背面)通常是不可见的。通过判断面片的朝向,我们可以剔除背面面片,只渲染朝向观察者的面片(正面)。
背面裁剪的判断依据是面片的顶点顺序 (Vertex Order)。在WebGL中,三角形面片的顶点顺序可以是顺时针 (Clockwise, CW) 或 逆时针 (Counter-Clockwise, CCW)。默认情况下,WebGL认为逆时针 (CCW) 顶点顺序的面片是正面 (Front Face),顺时针 (CW) 顶点顺序的面片是背面 (Back Face)。
当背面裁剪启用时,WebGL会根据以下流程判断是否裁剪一个三角形面片:
计算面片的法线方向: 根据三角形的三个顶点坐标,可以计算出面片的法线方向。
判断面片朝向: 将面片法线方向与观察方向(通常是相机到面片的向量)进行比较。如果法线方向与观察方向的点积为正,则面片朝向观察者(正面);如果点积为负,则面片背对观察者(背面)。
根据裁剪面模式 (Cull Face Mode) 进行裁剪: 根据设置的裁剪面模式(例如 BACK),决定是否裁剪背面面片。
可以用以下mermaid图表示背面裁剪的工作流程:
裁剪面模式 (Cull Face Mode) 决定了裁剪哪些面片。WebGL提供了以下裁剪面模式,可以通过 gl.cullFace() 函数设置:
FRONT: 裁剪正面面片。
BACK: 裁剪背面面片。(常用)
FRONT_AND_BACK: 裁剪正面和背面面片,即所有面片都将被裁剪,通常用于特殊效果或调试。
顶点顺序方向 (Front Face Winding) 决定了哪些顶点顺序被认为是正面。WebGL提供了两种顶点顺序方向,可以通过 gl.frontFace() 函数设置:
CW: 顺时针顶点顺序为正面。
CCW: 逆时针顶点顺序为正面。(默认)
默认情况下,WebGL使用 CCW 作为正面顶点顺序,并使用 BACK 作为裁剪面模式,即默认进行背面裁剪,并认为逆时针顶点顺序的面片是正面。
在WebGL中启用和配置裁剪也十分简单。主要涉及以下步骤:
启用裁剪: 使用 gl.enable(gl.CULL_FACE) 启用裁剪功能。
设置裁剪面模式: 使用 gl.cullFace(mode) 设置裁剪面模式,例如 gl.cullFace(gl.BACK)。
设置顶点顺序方向 (可选): 如果模型的顶点顺序与默认的 CCW 正面不一致,可以使用 gl.frontFace(dir) 设置顶点顺序方向,例如 gl.frontFace(gl.CW)。
代码示例:启用背面裁剪并绘制立方体
修改之前的立方体示例,启用背面裁剪。
function drawScene(gl, programInfo, positionBuffer, colorBuffer, indexBuffer, cubeRotation) { // ... (之前的深度测试相关代码) gl.enable(gl.CULL_FACE); // 启用裁剪 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // ... (投影矩阵和模型视图矩阵) // ... (顶点属性绑定和 uniform 设置) const vertexCount = 36; const type = gl.UNSIGNED_SHORT; const offset = 0; gl.drawElements(gl.TRIANGLES, vertexCount, type, offset); // ... (第二个立方体绘制代码,保持不变) }
代码详解:
启用裁剪: 在 drawScene 函数中,我们使用 gl.enable(gl.CULL_FACE) 启用了裁剪功能。
裁剪面模式: 默认情况下,WebGL的裁剪面模式是 BACK,即背面裁剪,所以我们没有显式地设置 gl.cullFace()。 如果需要裁剪正面,可以使用 gl.cullFace(gl.FRONT)。
运行结果分析:
运行修改后的代码,你会看到立方体的背面面片被裁剪掉了。如果你旋转视角,会发现立方体的内部是空心的,因为背面的面片已经被剔除,没有被渲染。如果注释掉 gl.enable(gl.CULL_FACE)