Skip to content

第8章:可见性与遮挡

8.1 深度缓冲

8.1.1 可见性问题

在渲染 3D 场景时,一个关键问题是确定哪些物体对观察者可见。当多个物体在屏幕上的投影重叠时,需要正确处理遮挡关系。

可见性问题

从相机视角看,物体 A 遮挡了物体 B:

        相机

         ╱│╲
        ╱ │ ╲
       ╱  │  ╲
      ╱   │   ╲
     ╱    │    ╲
    ┌─────┼─────┐   ← A(近)
    │     │     │
    └─────┼─────┘

    ┌─────┼─────┐   ← B(远)
    │     │     │
    └─────┼─────┘

问题:如何确定哪个像素显示 A,哪个显示 B?

8.1.2 Z-Buffer 算法

Z-Buffer(深度缓冲)

最广泛使用的可见性算法。

原理:
1. 为每个像素维护一个深度值
2. 渲染时,比较新片段与缓冲区中的深度
3. 只有更近的片段才会更新颜色缓冲

初始状态:
┌───┬───┬───┬───┐
│ ∞ │ ∞ │ ∞ │ ∞ │   ← 深度缓冲(初始化为最大值)
├───┼───┼───┼───┤
│ ∞ │ ∞ │ ∞ │ ∞ │
├───┼───┼───┼───┤
│ ∞ │ ∞ │ ∞ │ ∞ │
└───┴───┴───┴───┘

渲染物体 A(深度 5):
┌───┬───┬───┬───┐
│ 5 │ 5 │ ∞ │ ∞ │
├───┼───┼───┼───┤
│ 5 │ 5 │ ∞ │ ∞ │
├───┼───┼───┼───┤
│ ∞ │ ∞ │ ∞ │ ∞ │
└───┴───┴───┴───┘

渲染物体 B(深度 10):
┌───┬───┬───┬───┐
│ 5 │ 5 │ 10│ 10│   ← 左上角保持 5(比 10 近)
├───┼───┼───┼───┤
│ 5 │ 5 │ 10│ 10│
├───┼───┼───┼───┤
│ 10│ 10│ 10│ 10│   ← 下方没有 A,显示 B
└───┴───┴───┴───┘

8.1.3 Z-Buffer 实现

javascript
/**
 * 软件 Z-Buffer 实现
 */
class ZBuffer {
    constructor(width, height) {
        this.width = width;
        this.height = height;
        
        // 深度缓冲
        this.depthBuffer = new Float32Array(width * height);
        
        // 颜色缓冲
        this.colorBuffer = new Uint8ClampedArray(width * height * 4);
        
        this.clear();
    }
    
    /**
     * 清除缓冲区
     */
    clear(clearColor = [0, 0, 0, 255], clearDepth = Infinity) {
        for (let i = 0; i < this.depthBuffer.length; i++) {
            this.depthBuffer[i] = clearDepth;
        }
        
        for (let i = 0; i < this.colorBuffer.length; i += 4) {
            this.colorBuffer[i] = clearColor[0];
            this.colorBuffer[i + 1] = clearColor[1];
            this.colorBuffer[i + 2] = clearColor[2];
            this.colorBuffer[i + 3] = clearColor[3];
        }
    }
    
    /**
     * 测试并写入像素
     * @returns true 如果通过深度测试
     */
    testAndSet(x, y, depth, color) {
        x = Math.floor(x);
        y = Math.floor(y);
        
        if (x < 0 || x >= this.width || y < 0 || y >= this.height) {
            return false;
        }
        
        const index = y * this.width + x;
        
        // 深度测试:小于等于则通过
        if (depth <= this.depthBuffer[index]) {
            // 更新深度缓冲
            this.depthBuffer[index] = depth;
            
            // 更新颜色缓冲
            const colorIndex = index * 4;
            this.colorBuffer[colorIndex] = color[0];
            this.colorBuffer[colorIndex + 1] = color[1];
            this.colorBuffer[colorIndex + 2] = color[2];
            this.colorBuffer[colorIndex + 3] = color[3] ?? 255;
            
            return true;
        }
        
        return false;
    }
    
    /**
     * 渲染三角形
     */
    renderTriangle(v0, v1, v2, color) {
        // 计算包围盒
        const minX = Math.floor(Math.min(v0.x, v1.x, v2.x));
        const maxX = Math.ceil(Math.max(v0.x, v1.x, v2.x));
        const minY = Math.floor(Math.min(v0.y, v1.y, v2.y));
        const maxY = Math.ceil(Math.max(v0.y, v1.y, v2.y));
        
        // 边函数系数(用于判断点是否在三角形内)
        const edge = (ax, ay, bx, by, px, py) => {
            return (px - ax) * (by - ay) - (py - ay) * (bx - ax);
        };
        
        const area = edge(v0.x, v0.y, v1.x, v1.y, v2.x, v2.y);
        if (area === 0) return; // 退化三角形
        
        // 遍历包围盒内的所有像素
        for (let y = minY; y <= maxY; y++) {
            for (let x = minX; x <= maxX; x++) {
                // 计算重心坐标
                const px = x + 0.5;
                const py = y + 0.5;
                
                const w0 = edge(v1.x, v1.y, v2.x, v2.y, px, py) / area;
                const w1 = edge(v2.x, v2.y, v0.x, v0.y, px, py) / area;
                const w2 = edge(v0.x, v0.y, v1.x, v1.y, px, py) / area;
                
                // 判断是否在三角形内
                if (w0 >= 0 && w1 >= 0 && w2 >= 0) {
                    // 插值深度
                    const depth = w0 * v0.z + w1 * v1.z + w2 * v2.z;
                    
                    // 深度测试并写入
                    this.testAndSet(x, y, depth, color);
                }
            }
        }
    }
    
    /**
     * 获取 ImageData
     */
    getImageData() {
        return new ImageData(this.colorBuffer, this.width, this.height);
    }
}

8.1.4 深度精度问题

深度缓冲的精度问题

非线性深度分布:

透视投影后,深度值是非线性的:
z_ndc = (f + n) / (f - n) + (2fn) / ((f - n) * z_eye)

这导致近处深度精度高,远处精度低。

              精度高                  精度低
    ┌──────────┬────────────────────────────────┐
近平面        10%             90% 的范围          远平面


Z-Fighting(深度冲突):

当两个面距离非常近时,可能出现闪烁:

    ┌─────────────┐   ← 面 A(z = 10.0001)
    ├─────────────┤   ← 面 B(z = 10.0000)
    └─────────────┘
    
由于精度限制,两个面可能交替显示。


解决方案:

1. 减小 far/near 比例
2. 使用反向深度(Reversed-Z)
3. 使用对数深度(Logarithmic Depth)
4. 避免共面几何

8.2 画家算法

8.2.1 画家算法原理

画家算法(Painter's Algorithm)

原理:像画家作画一样,先画远处的物体,再画近处的物体。

步骤:
1. 按深度对所有多边形排序(从远到近)
2. 依次渲染每个多边形

示例:

远                         近
┌─────┐                 ┌───┐
│     │                 │   │
│  C  │                 │ A │
│     │     ┌────┐      │   │
└─────┘     │ B  │      └───┘
            └────┘

渲染顺序:C → B → A

优点:
- 简单
- 支持透明物体

缺点:
- 需要排序 O(n log n)
- 无法处理循环遮挡

8.2.2 循环遮挡问题

循环遮挡

三个物体互相遮挡,无法确定绘制顺序:

        ┌─────┐
        │  A  │
    ┌───┴─┐   │
    │  B  │───┘
    │   ┌─┴───┐
    └───┤  C  │
        └─────┘

A 遮挡 B
B 遮挡 C
C 遮挡 A

这种情况画家算法无法处理!

解决方案:
1. 切割多边形
2. 使用 BSP 树
3. 使用 Z-Buffer

8.2.3 画家算法实现

javascript
/**
 * 画家算法实现
 */
class PaintersAlgorithm {
    constructor(ctx) {
        this.ctx = ctx;
        this.polygons = [];
    }
    
    /**
     * 添加多边形
     */
    addPolygon(vertices, color, depth = null) {
        // 如果没有提供深度,计算中心点深度
        if (depth === null) {
            depth = vertices.reduce((sum, v) => sum + v.z, 0) / vertices.length;
        }
        
        this.polygons.push({ vertices, color, depth });
    }
    
    /**
     * 渲染
     */
    render() {
        // 按深度排序(从远到近)
        this.polygons.sort((a, b) => b.depth - a.depth);
        
        // 依次绘制
        for (const polygon of this.polygons) {
            this.ctx.fillStyle = polygon.color;
            this.ctx.beginPath();
            
            const v = polygon.vertices;
            this.ctx.moveTo(v[0].x, v[0].y);
            
            for (let i = 1; i < v.length; i++) {
                this.ctx.lineTo(v[i].x, v[i].y);
            }
            
            this.ctx.closePath();
            this.ctx.fill();
        }
    }
    
    /**
     * 清除
     */
    clear() {
        this.polygons = [];
    }
}

8.3 BSP 树

8.3.1 BSP 树原理

BSP 树(Binary Space Partitioning)

通过递归地用平面分割空间,构建二叉树。

                  空间

        ┌───────────┼───────────┐
        │           │           │
        │  平面 P   │           │
        │           │           │
        ├───────────┴───────────┤
        │                       │
     前方                      后方


构建示例:

原始多边形:
    ┌───┐
    │ A │
    └───┘     ┌───┐
              │ B │
    ┌───┐     └───┘
    │ C │
    └───┘

选择 A 作为分割平面:

              A
            ╱   ╲
           ╱     ╲
         前      后
         │         │
         C         B


遍历顺序(从相机看):
1. 如果相机在节点平面前方:先后、再节点、最后前
2. 如果相机在节点平面后方:先前、再节点、最后后

8.3.2 BSP 树实现

javascript
/**
 * BSP 树节点
 */
class BSPNode {
    constructor(polygon) {
        this.polygon = polygon;     // 分割平面上的多边形
        this.front = null;          // 前方子树
        this.back = null;           // 后方子树
    }
}

/**
 * BSP 树
 */
class BSPTree {
    constructor() {
        this.root = null;
    }
    
    /**
     * 计算多边形的平面方程
     */
    computePlane(polygon) {
        const v = polygon.vertices;
        
        // 使用前三个顶点计算法向量
        const e1 = {
            x: v[1].x - v[0].x,
            y: v[1].y - v[0].y,
            z: v[1].z - v[0].z
        };
        const e2 = {
            x: v[2].x - v[0].x,
            y: v[2].y - v[0].y,
            z: v[2].z - v[0].z
        };
        
        // 法向量 = e1 × e2
        const normal = {
            x: e1.y * e2.z - e1.z * e2.y,
            y: e1.z * e2.x - e1.x * e2.z,
            z: e1.x * e2.y - e1.y * e2.x
        };
        
        // d = -n · v0
        const d = -(normal.x * v[0].x + normal.y * v[0].y + normal.z * v[0].z);
        
        return { normal, d };
    }
    
    /**
     * 判断点在平面的哪一侧
     */
    classifyPoint(point, plane) {
        const dist = 
            plane.normal.x * point.x + 
            plane.normal.y * point.y + 
            plane.normal.z * point.z + 
            plane.d;
        
        const epsilon = 0.0001;
        
        if (dist > epsilon) return 'front';
        if (dist < -epsilon) return 'back';
        return 'on';
    }
    
    /**
     * 分类多边形
     */
    classifyPolygon(polygon, plane) {
        let frontCount = 0;
        let backCount = 0;
        
        for (const v of polygon.vertices) {
            const side = this.classifyPoint(v, plane);
            if (side === 'front') frontCount++;
            if (side === 'back') backCount++;
        }
        
        if (frontCount > 0 && backCount === 0) return 'front';
        if (backCount > 0 && frontCount === 0) return 'back';
        if (frontCount === 0 && backCount === 0) return 'coplanar';
        return 'spanning'; // 跨越平面,需要分割
    }
    
    /**
     * 分割多边形
     */
    splitPolygon(polygon, plane) {
        const frontVertices = [];
        const backVertices = [];
        const vertices = polygon.vertices;
        
        for (let i = 0; i < vertices.length; i++) {
            const current = vertices[i];
            const next = vertices[(i + 1) % vertices.length];
            
            const currentSide = this.classifyPoint(current, plane);
            const nextSide = this.classifyPoint(next, plane);
            
            if (currentSide !== 'back') {
                frontVertices.push(current);
            }
            if (currentSide !== 'front') {
                backVertices.push(current);
            }
            
            // 如果边跨越平面,计算交点
            if ((currentSide === 'front' && nextSide === 'back') ||
                (currentSide === 'back' && nextSide === 'front')) {
                const intersection = this.computeIntersection(current, next, plane);
                frontVertices.push(intersection);
                backVertices.push({ ...intersection });
            }
        }
        
        return {
            front: frontVertices.length >= 3 
                ? { vertices: frontVertices, color: polygon.color } 
                : null,
            back: backVertices.length >= 3 
                ? { vertices: backVertices, color: polygon.color } 
                : null
        };
    }
    
    /**
     * 计算线段与平面的交点
     */
    computeIntersection(p1, p2, plane) {
        const d1 = plane.normal.x * p1.x + plane.normal.y * p1.y + 
                   plane.normal.z * p1.z + plane.d;
        const d2 = plane.normal.x * p2.x + plane.normal.y * p2.y + 
                   plane.normal.z * p2.z + plane.d;
        
        const t = d1 / (d1 - d2);
        
        return {
            x: p1.x + t * (p2.x - p1.x),
            y: p1.y + t * (p2.y - p1.y),
            z: p1.z + t * (p2.z - p1.z)
        };
    }
    
    /**
     * 构建 BSP 树
     */
    build(polygons) {
        if (polygons.length === 0) return null;
        
        // 选择第一个多边形作为分割平面
        const splitter = polygons[0];
        const plane = this.computePlane(splitter);
        
        const node = new BSPNode(splitter);
        const frontPolygons = [];
        const backPolygons = [];
        
        // 分类其余多边形
        for (let i = 1; i < polygons.length; i++) {
            const polygon = polygons[i];
            const classification = this.classifyPolygon(polygon, plane);
            
            switch (classification) {
                case 'front':
                    frontPolygons.push(polygon);
                    break;
                case 'back':
                    backPolygons.push(polygon);
                    break;
                case 'coplanar':
                    // 与分割面共面,可以放在任一侧
                    frontPolygons.push(polygon);
                    break;
                case 'spanning':
                    // 需要分割
                    const split = this.splitPolygon(polygon, plane);
                    if (split.front) frontPolygons.push(split.front);
                    if (split.back) backPolygons.push(split.back);
                    break;
            }
        }
        
        // 递归构建子树
        node.front = this.build(frontPolygons);
        node.back = this.build(backPolygons);
        
        return node;
    }
    
    /**
     * 按正确顺序遍历(画家算法)
     */
    traverse(cameraPosition, callback) {
        this.traverseNode(this.root, cameraPosition, callback);
    }
    
    traverseNode(node, cameraPosition, callback) {
        if (!node) return;
        
        const plane = this.computePlane(node.polygon);
        const side = this.classifyPoint(cameraPosition, plane);
        
        if (side === 'front') {
            // 相机在前:先后、再节点、最后前
            this.traverseNode(node.back, cameraPosition, callback);
            callback(node.polygon);
            this.traverseNode(node.front, cameraPosition, callback);
        } else {
            // 相机在后:先前、再节点、最后后
            this.traverseNode(node.front, cameraPosition, callback);
            callback(node.polygon);
            this.traverseNode(node.back, cameraPosition, callback);
        }
    }
}

8.4 背面剔除

8.4.1 背面剔除原理

背面剔除(Back-face Culling)

不渲染背对相机的面。

对于封闭物体,背面总是被前面遮挡,可以安全剔除。


判断方法:

计算面法向量与视线向量的点积:

    法向量 N


    ────┼────  面


        ● 相机
        
如果 N · V > 0(法向量朝向相机):正面,渲染
如果 N · V < 0(法向量背离相机):背面,剔除


顶点顺序(Winding Order):

正面通常定义为顶点逆时针排列(从外部看):

正面(CCW):          背面(CW):
    V2                     V2
   ╱  ╲                   ╱  ╲
  ╱    ╲                 ╱    ╲
 ╱──────╲               ╱──────╲
V0      V1             V1      V0

8.4.2 背面剔除实现

javascript
/**
 * 背面剔除
 */
class BackfaceCulling {
    /**
     * 判断三角形是否为背面
     * @param v0, v1, v2 屏幕空间顶点
     * @returns true 如果是背面
     */
    static isBackface(v0, v1, v2) {
        // 计算屏幕空间的有符号面积(叉积的 z 分量)
        // 正值 = CCW(正面),负值 = CW(背面)
        const area = (v1.x - v0.x) * (v2.y - v0.y) - 
                     (v2.x - v0.x) * (v1.y - v0.y);
        
        return area < 0;
    }
    
    /**
     * 使用视线向量判断(3D 空间)
     */
    static isBackface3D(v0, v1, v2, cameraPosition) {
        // 计算面法向量
        const e1 = {
            x: v1.x - v0.x,
            y: v1.y - v0.y,
            z: v1.z - v0.z
        };
        const e2 = {
            x: v2.x - v0.x,
            y: v2.y - v0.y,
            z: v2.z - v0.z
        };
        
        const normal = {
            x: e1.y * e2.z - e1.z * e2.y,
            y: e1.z * e2.x - e1.x * e2.z,
            z: e1.x * e2.y - e1.y * e2.x
        };
        
        // 计算视线向量(从面中心到相机)
        const center = {
            x: (v0.x + v1.x + v2.x) / 3,
            y: (v0.y + v1.y + v2.y) / 3,
            z: (v0.z + v1.z + v2.z) / 3
        };
        
        const viewDir = {
            x: cameraPosition.x - center.x,
            y: cameraPosition.y - center.y,
            z: cameraPosition.z - center.z
        };
        
        // 点积
        const dot = normal.x * viewDir.x + 
                    normal.y * viewDir.y + 
                    normal.z * viewDir.z;
        
        return dot < 0;
    }
}

// WebGL 中启用背面剔除
// gl.enable(gl.CULL_FACE);
// gl.cullFace(gl.BACK);      // 剔除背面
// gl.frontFace(gl.CCW);      // 逆时针为正面

8.5 视锥剔除

8.5.1 视锥剔除原理

视锥剔除(Frustum Culling)

不渲染视锥体外的物体。

          视锥体
        ╱─────────╲
       ╱           ╲
      ╱   ●         ╲   ← 可见
     ╱     渲染      ╲
    ╱─────────────────╲

    ● ←────── │ ──────→ ●
   不渲染            不渲染


判断步骤:

1. 为每个物体计算包围体(球或 AABB)
2. 测试包围体与视锥体 6 个平面的关系
3. 完全在外:剔除
4. 完全在内或相交:渲染

8.5.2 视锥剔除实现

javascript
/**
 * 视锥剔除
 */
class FrustumCuller {
    constructor() {
        // 6 个平面:near, far, left, right, top, bottom
        this.planes = [];
    }
    
    /**
     * 从视图投影矩阵提取平面
     */
    extractPlanes(viewProjectionMatrix) {
        const m = viewProjectionMatrix.elements;
        
        // 左平面: row4 + row1
        this.planes[0] = this.normalizePlane(
            m[3] + m[0], m[7] + m[4], m[11] + m[8], m[15] + m[12]
        );
        
        // 右平面: row4 - row1
        this.planes[1] = this.normalizePlane(
            m[3] - m[0], m[7] - m[4], m[11] - m[8], m[15] - m[12]
        );
        
        // 底平面: row4 + row2
        this.planes[2] = this.normalizePlane(
            m[3] + m[1], m[7] + m[5], m[11] + m[9], m[15] + m[13]
        );
        
        // 顶平面: row4 - row2
        this.planes[3] = this.normalizePlane(
            m[3] - m[1], m[7] - m[5], m[11] - m[9], m[15] - m[13]
        );
        
        // 近平面: row4 + row3
        this.planes[4] = this.normalizePlane(
            m[3] + m[2], m[7] + m[6], m[11] + m[10], m[15] + m[14]
        );
        
        // 远平面: row4 - row3
        this.planes[5] = this.normalizePlane(
            m[3] - m[2], m[7] - m[6], m[11] - m[10], m[15] - m[14]
        );
    }
    
    normalizePlane(a, b, c, d) {
        const len = Math.sqrt(a * a + b * b + c * c);
        return { a: a / len, b: b / len, c: c / len, d: d / len };
    }
    
    /**
     * 测试球体
     */
    testSphere(center, radius) {
        for (const plane of this.planes) {
            const dist = plane.a * center.x + 
                         plane.b * center.y + 
                         plane.c * center.z + 
                         plane.d;
            
            if (dist < -radius) {
                return 'outside'; // 完全在平面外
            }
        }
        return 'inside'; // 至少部分在内
    }
    
    /**
     * 测试 AABB
     */
    testAABB(min, max) {
        for (const plane of this.planes) {
            // 找到最正方向的顶点
            const px = plane.a > 0 ? max.x : min.x;
            const py = plane.b > 0 ? max.y : min.y;
            const pz = plane.c > 0 ? max.z : min.z;
            
            const dist = plane.a * px + plane.b * py + plane.c * pz + plane.d;
            
            if (dist < 0) {
                return 'outside';
            }
        }
        return 'inside';
    }
    
    /**
     * 剔除物体列表
     */
    cull(objects) {
        return objects.filter(obj => {
            if (obj.boundingSphere) {
                return this.testSphere(
                    obj.boundingSphere.center, 
                    obj.boundingSphere.radius
                ) !== 'outside';
            }
            if (obj.boundingBox) {
                return this.testAABB(
                    obj.boundingBox.min, 
                    obj.boundingBox.max
                ) !== 'outside';
            }
            return true; // 没有包围体,不剔除
        });
    }
}

8.6 遮挡剔除

8.6.1 遮挡剔除原理

遮挡剔除(Occlusion Culling)

不渲染被其他物体遮挡的物体。


场景示例:

        相机

         ╱│╲
        ╱ │ ╲
       ╱  │  ╲
      ╱ ┌─┼─┐ ╲
     ╱  │ A │  ╲   ← 遮挡物 A
    ╱   └───┘   ╲
   ╱      │      ╲
  ╱    ┌──┼──┐    ╲
 ╱     │ B  │     ╲   ← B 被 A 完全遮挡,可以剔除
╱      └────┘      ╲
        
通过视锥剔除后,B 仍会被渲染。
但遮挡剔除可以识别并跳过 B。


常用技术:

1. 硬件遮挡查询(Occlusion Query)
2. 层次化 Z 缓冲(Hierarchical Z-Buffer)
3. 软件光栅化预判(Software Rasterization)
4. Portal/Cell 划分

8.6.2 遮挡查询实现

javascript
/**
 * WebGL 遮挡查询
 */
class OcclusionQuery {
    constructor(gl) {
        this.gl = gl;
        this.queries = new Map();
    }
    
    /**
     * 开始查询
     */
    begin(objectId) {
        const gl = this.gl;
        const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2') || 
                    gl.getExtension('EXT_occlusion_query_boolean');
        
        if (!ext) {
            console.warn('Occlusion query not supported');
            return null;
        }
        
        const query = gl.createQuery();
        gl.beginQuery(gl.ANY_SAMPLES_PASSED, query);
        
        this.queries.set(objectId, query);
        return query;
    }
    
    /**
     * 结束查询
     */
    end() {
        const gl = this.gl;
        gl.endQuery(gl.ANY_SAMPLES_PASSED);
    }
    
    /**
     * 获取查询结果
     */
    getResult(objectId) {
        const gl = this.gl;
        const query = this.queries.get(objectId);
        
        if (!query) return true; // 默认可见
        
        // 检查结果是否可用
        const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE);
        
        if (!available) {
            return null; // 结果还没准备好
        }
        
        // 获取结果
        const visible = gl.getQueryParameter(query, gl.QUERY_RESULT);
        
        // 清理
        gl.deleteQuery(query);
        this.queries.delete(objectId);
        
        return visible > 0;
    }
}

/**
 * 层次化遮挡剔除
 */
class HierarchicalOcclusionCulling {
    constructor() {
        this.hiZ = null; // 层次化 Z 缓冲
    }
    
    /**
     * 构建层次化 Z 缓冲
     */
    buildHiZ(depthBuffer, width, height) {
        // 创建多级 mipmap
        const levels = [];
        levels.push(depthBuffer);
        
        let w = width;
        let h = height;
        let current = depthBuffer;
        
        while (w > 1 || h > 1) {
            const newW = Math.max(1, Math.floor(w / 2));
            const newH = Math.max(1, Math.floor(h / 2));
            const next = new Float32Array(newW * newH);
            
            // 下采样:取 4 个像素的最大深度
            for (let y = 0; y < newH; y++) {
                for (let x = 0; x < newW; x++) {
                    const x0 = x * 2;
                    const y0 = y * 2;
                    const x1 = Math.min(x0 + 1, w - 1);
                    const y1 = Math.min(y0 + 1, h - 1);
                    
                    const d00 = current[y0 * w + x0];
                    const d01 = current[y0 * w + x1];
                    const d10 = current[y1 * w + x0];
                    const d11 = current[y1 * w + x1];
                    
                    next[y * newW + x] = Math.max(d00, d01, d10, d11);
                }
            }
            
            levels.push(next);
            w = newW;
            h = newH;
            current = next;
        }
        
        this.hiZ = levels;
    }
    
    /**
     * 测试包围盒是否被遮挡
     */
    isOccluded(screenBounds, minDepth) {
        if (!this.hiZ) return false;
        
        // 选择合适的 mipmap 级别
        const boxWidth = screenBounds.maxX - screenBounds.minX;
        const boxHeight = screenBounds.maxY - screenBounds.minY;
        const level = Math.floor(Math.log2(Math.max(boxWidth, boxHeight)));
        
        const safeLevel = Math.min(level, this.hiZ.length - 1);
        const hiZLevel = this.hiZ[safeLevel];
        
        // 采样 Hi-Z
        const scale = 1 / Math.pow(2, safeLevel);
        const x = Math.floor(screenBounds.minX * scale);
        const y = Math.floor(screenBounds.minY * scale);
        
        const hiZDepth = hiZLevel[y * Math.floor(this.width * scale) + x];
        
        // 如果包围盒的最近深度比 Hi-Z 更远,则被遮挡
        return minDepth > hiZDepth;
    }
}

8.7 本章小结

剔除技术对比

技术剔除内容复杂度效果
背面剔除背面三角形O(1)约 50%
视锥剔除视锥外物体O(n)场景相关
遮挡剔除被遮挡物体O(n log n)场景相关
Z-Buffer被遮挡像素O(pixels)像素级

剔除流水线

场景物体


┌─────────┐
│视锥剔除  │ ← 粗粒度,物体级
└────┬────┘


┌─────────┐
│遮挡剔除  │ ← 中粒度,物体级
└────┬────┘


┌─────────┐
│背面剔除  │ ← 细粒度,三角形级
└────┬────┘


┌─────────┐
│深度测试  │ ← 最细粒度,像素级
└────┬────┘


  最终图像

关键要点

  1. Z-Buffer 是最通用的可见性解决方案
  2. 背面剔除简单高效,应始终启用
  3. 视锥剔除是基本的场景管理技术
  4. 遮挡剔除在复杂场景中效果显著
  5. 多种技术结合使用效果最佳

下一章预告:在第9章中,我们将学习光照与着色模型,包括 Phong 模型、BRDF 和全局光照。


文档版本:v1.0
字数统计:约 11,000 字

如有转载或 CV 的请标注本站原文地址