Skip to content

第53章 3D图形编程

学习目标

完成本章学习后,你将能够:

  1. 理解3D图形基础:坐标系、变换、投影、光照
  2. 使用OpenGL渲染:顶点缓冲、着色器、纹理映射
  3. 实现3D变换:平移、旋转、缩放、矩阵运算
  4. 创建3D模型:网格生成、模型加载、骨骼动画
  5. 实现光照效果:环境光、漫反射、镜面反射、阴影
  6. 开发游戏引擎:场景管理、相机控制、碰撞检测
  7. 实现物理模拟:刚体动力学、碰撞响应、粒子系统
  8. 优化渲染性能:批处理、遮挡剔除、LOD技术

53.1 3D图形基础

53.1.1 数学基础

python
from dataclasses import dataclass
from typing import List, Tuple, Optional
import math


@dataclass
class Vector3:
    x: float = 0.0
    y: float = 0.0
    z: float = 0.0

    def __add__(self, other: "Vector3") -> "Vector3":
        return Vector3(self.x + other.x, self.y + other.y, self.z + other.z)

    def __sub__(self, other: "Vector3") -> "Vector3":
        return Vector3(self.x - other.x, self.y - other.y, self.z - other.z)

    def __mul__(self, scalar: float) -> "Vector3":
        return Vector3(self.x * scalar, self.y * scalar, self.z * scalar)

    def __truediv__(self, scalar: float) -> "Vector3":
        return Vector3(self.x / scalar, self.y / scalar, self.z / scalar)

    def __neg__(self) -> "Vector3":
        return Vector3(-self.x, -self.y, -self.z)

    def dot(self, other: "Vector3") -> float:
        return self.x * other.x + self.y * other.y + self.z * other.z

    def cross(self, other: "Vector3") -> "Vector3":
        return Vector3(
            self.y * other.z - self.z * other.y,
            self.z * other.x - self.x * other.z,
            self.x * other.y - self.y * other.x
        )

    def length(self) -> float:
        return math.sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2)

    def length_squared(self) -> float:
        return self.x ** 2 + self.y ** 2 + self.z ** 2

    def normalized(self) -> "Vector3":
        length = self.length()
        if length == 0:
            return Vector3(0, 0, 0)
        return self / length

    def normalize(self) -> None:
        length = self.length()
        if length > 0:
            self.x /= length
            self.y /= length
            self.z /= length

    def distance_to(self, other: "Vector3") -> float:
        return (self - other).length()

    def angle_to(self, other: "Vector3") -> float:
        dot = self.dot(other)
        lengths = self.length() * other.length()
        if lengths == 0:
            return 0
        return math.acos(max(-1, min(1, dot / lengths)))

    def lerp(self, other: "Vector3", t: float) -> "Vector3":
        return self + (other - self) * t

    def reflect(self, normal: "Vector3") -> "Vector3":
        return self - normal * (2 * self.dot(normal))

    @staticmethod
    def zero() -> "Vector3":
        return Vector3(0, 0, 0)

    @staticmethod
    def one() -> "Vector3":
        return Vector3(1, 1, 1)

    @staticmethod
    def up() -> "Vector3":
        return Vector3(0, 1, 0)

    @staticmethod
    def forward() -> "Vector3":
        return Vector3(0, 0, -1)

    @staticmethod
    def right() -> "Vector3":
        return Vector3(1, 0, 0)


@dataclass
class Matrix4:
    elements: List[float] = None

    def __post_init__(self):
        if self.elements is None:
            self.elements = [0.0] * 16

    @staticmethod
    def identity() -> "Matrix4":
        return Matrix4([
            1, 0, 0, 0,
            0, 1, 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def translation(x: float, y: float, z: float) -> "Matrix4":
        return Matrix4([
            1, 0, 0, 0,
            0, 1, 0, 0,
            0, 0, 1, 0,
            x, y, z, 1
        ])

    @staticmethod
    def scale(x: float, y: float, z: float) -> "Matrix4":
        return Matrix4([
            x, 0, 0, 0,
            0, y, 0, 0,
            0, 0, z, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def rotation_x(angle: float) -> "Matrix4":
        c = math.cos(angle)
        s = math.sin(angle)
        return Matrix4([
            1, 0, 0, 0,
            0, c, s, 0,
            0, -s, c, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def rotation_y(angle: float) -> "Matrix4":
        c = math.cos(angle)
        s = math.sin(angle)
        return Matrix4([
            c, 0, -s, 0,
            0, 1, 0, 0,
            s, 0, c, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def rotation_z(angle: float) -> "Matrix4":
        c = math.cos(angle)
        s = math.sin(angle)
        return Matrix4([
            c, s, 0, 0,
            -s, c, 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def rotation_axis(axis: Vector3, angle: float) -> "Matrix4":
        c = math.cos(angle)
        s = math.sin(angle)
        t = 1 - c
        x, y, z = axis.x, axis.y, axis.z

        return Matrix4([
            t * x * x + c, t * x * y + s * z, t * x * z - s * y, 0,
            t * x * y - s * z, t * y * y + c, t * y * z + s * x, 0,
            t * x * z + s * y, t * y * z - s * x, t * z * z + c, 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def look_at(
        eye: Vector3,
        target: Vector3,
        up: Vector3
    ) -> "Matrix4":
        forward = (target - eye).normalized()
        right = forward.cross(up).normalized()
        new_up = right.cross(forward)

        return Matrix4([
            right.x, new_up.x, -forward.x, 0,
            right.y, new_up.y, -forward.y, 0,
            right.z, new_up.z, -forward.z, 0,
            -right.dot(eye), -new_up.dot(eye), forward.dot(eye), 1
        ])

    @staticmethod
    def perspective(
        fov: float,
        aspect: float,
        near: float,
        far: float
    ) -> "Matrix4":
        f = 1.0 / math.tan(fov / 2)
        range_inv = 1.0 / (near - far)

        return Matrix4([
            f / aspect, 0, 0, 0,
            0, f, 0, 0,
            0, 0, (near + far) * range_inv, -1,
            0, 0, 2 * near * far * range_inv, 0
        ])

    @staticmethod
    def orthographic(
        left: float,
        right: float,
        bottom: float,
        top: float,
        near: float,
        far: float
    ) -> "Matrix4":
        return Matrix4([
            2 / (right - left), 0, 0, 0,
            0, 2 / (top - bottom), 0, 0,
            0, 0, -2 / (far - near), 0,
            -(right + left) / (right - left),
            -(top + bottom) / (top - bottom),
            -(far + near) / (far - near), 1
        ])

    def __mul__(self, other: "Matrix4") -> "Matrix4":
        result = [0.0] * 16

        for i in range(4):
            for j in range(4):
                for k in range(4):
                    result[i * 4 + j] += self.elements[i * 4 + k] * other.elements[k * 4 + j]

        return Matrix4(result)

    def transform_point(self, point: Vector3) -> Vector3:
        e = self.elements
        w = e[12] * point.x + e[13] * point.y + e[14] * point.z + e[15]

        return Vector3(
            (e[0] * point.x + e[1] * point.y + e[2] * point.z + e[3]) / w,
            (e[4] * point.x + e[5] * point.y + e[6] * point.z + e[7]) / w,
            (e[8] * point.x + e[9] * point.y + e[10] * point.z + e[11]) / w
        )

    def transform_direction(self, direction: Vector3) -> Vector3:
        e = self.elements
        return Vector3(
            e[0] * direction.x + e[1] * direction.y + e[2] * direction.z,
            e[4] * direction.x + e[5] * direction.y + e[6] * direction.z,
            e[8] * direction.x + e[9] * direction.y + e[10] * direction.z
        )

    def transpose(self) -> "Matrix4":
        e = self.elements
        return Matrix4([
            e[0], e[4], e[8], e[12],
            e[1], e[5], e[9], e[13],
            e[2], e[6], e[10], e[14],
            e[3], e[7], e[11], e[15]
        ])

    def inverse(self) -> "Matrix4":
        e = self.elements

        inv = [0.0] * 16

        inv[0] = e[5] * e[10] * e[15] - e[5] * e[11] * e[14] - e[9] * e[6] * e[15] + e[9] * e[7] * e[14] + e[13] * e[6] * e[11] - e[13] * e[7] * e[10]
        inv[4] = -e[4] * e[10] * e[15] + e[4] * e[11] * e[14] + e[8] * e[6] * e[15] - e[8] * e[7] * e[14] - e[12] * e[6] * e[11] + e[12] * e[7] * e[10]
        inv[8] = e[4] * e[9] * e[15] - e[4] * e[11] * e[13] - e[8] * e[5] * e[15] + e[8] * e[7] * e[13] + e[12] * e[5] * e[11] - e[12] * e[7] * e[9]
        inv[12] = -e[4] * e[9] * e[14] + e[4] * e[10] * e[13] + e[8] * e[5] * e[14] - e[8] * e[6] * e[13] - e[12] * e[5] * e[10] + e[12] * e[6] * e[9]
        inv[1] = -e[1] * e[10] * e[15] + e[1] * e[11] * e[14] + e[9] * e[2] * e[15] - e[9] * e[3] * e[14] - e[13] * e[2] * e[11] + e[13] * e[3] * e[10]
        inv[5] = e[0] * e[10] * e[15] - e[0] * e[11] * e[14] - e[8] * e[2] * e[15] + e[8] * e[3] * e[14] + e[12] * e[2] * e[11] - e[12] * e[3] * e[10]
        inv[9] = -e[0] * e[9] * e[15] + e[0] * e[11] * e[13] + e[8] * e[1] * e[15] - e[8] * e[3] * e[13] - e[12] * e[1] * e[11] + e[12] * e[3] * e[9]
        inv[13] = e[0] * e[9] * e[14] - e[0] * e[10] * e[13] - e[8] * e[1] * e[14] + e[8] * e[2] * e[13] + e[12] * e[1] * e[10] - e[12] * e[2] * e[9]
        inv[2] = e[1] * e[6] * e[15] - e[1] * e[7] * e[14] - e[5] * e[2] * e[15] + e[5] * e[3] * e[14] + e[13] * e[2] * e[7] - e[13] * e[3] * e[6]
        inv[6] = -e[0] * e[6] * e[15] + e[0] * e[7] * e[14] + e[4] * e[2] * e[15] - e[4] * e[3] * e[14] - e[12] * e[2] * e[7] + e[12] * e[3] * e[6]
        inv[10] = e[0] * e[5] * e[15] - e[0] * e[7] * e[13] - e[4] * e[1] * e[15] + e[4] * e[3] * e[13] + e[12] * e[1] * e[7] - e[12] * e[3] * e[5]
        inv[14] = -e[0] * e[5] * e[14] + e[0] * e[6] * e[13] + e[4] * e[1] * e[14] - e[4] * e[2] * e[13] - e[12] * e[1] * e[6] + e[12] * e[2] * e[5]
        inv[3] = -e[1] * e[6] * e[11] + e[1] * e[7] * e[10] + e[5] * e[2] * e[11] - e[5] * e[3] * e[10] - e[9] * e[2] * e[7] + e[9] * e[3] * e[6]
        inv[7] = e[0] * e[6] * e[11] - e[0] * e[7] * e[10] - e[4] * e[2] * e[11] + e[4] * e[3] * e[10] + e[8] * e[2] * e[7] - e[8] * e[3] * e[6]
        inv[11] = -e[0] * e[5] * e[11] + e[0] * e[7] * e[9] + e[4] * e[1] * e[11] - e[4] * e[3] * e[9] - e[8] * e[1] * e[7] + e[8] * e[3] * e[5]
        inv[15] = e[0] * e[5] * e[10] - e[0] * e[6] * e[9] - e[4] * e[1] * e[10] + e[4] * e[2] * e[9] + e[8] * e[1] * e[6] - e[8] * e[2] * e[5]

        det = e[0] * inv[0] + e[1] * inv[4] + e[2] * inv[8] + e[3] * inv[12]

        if det == 0:
            return Matrix4.identity()

        det = 1.0 / det

        for i in range(16):
            inv[i] *= det

        return Matrix4(inv)


class Quaternion:
    def __init__(self, x: float = 0, y: float = 0, z: float = 0, w: float = 1):
        self.x = x
        self.y = y
        self.z = z
        self.w = w

    @staticmethod
    def from_axis_angle(axis: Vector3, angle: float) -> "Quaternion":
        half_angle = angle / 2
        s = math.sin(half_angle)
        return Quaternion(
            axis.x * s,
            axis.y * s,
            axis.z * s,
            math.cos(half_angle)
        )

    @staticmethod
    def from_euler(x: float, y: float, z: float) -> "Quaternion":
        c1 = math.cos(x / 2)
        c2 = math.cos(y / 2)
        c3 = math.cos(z / 2)
        s1 = math.sin(x / 2)
        s2 = math.sin(y / 2)
        s3 = math.sin(z / 2)

        return Quaternion(
            s1 * c2 * c3 + c1 * s2 * s3,
            c1 * s2 * c3 - s1 * c2 * s3,
            c1 * c2 * s3 + s1 * s2 * c3,
            c1 * c2 * c3 - s1 * s2 * s3
        )

    def normalized(self) -> "Quaternion":
        length = math.sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2 + self.w ** 2)
        if length == 0:
            return Quaternion()
        return Quaternion(
            self.x / length,
            self.y / length,
            self.z / length,
            self.w / length
        )

    def conjugate(self) -> "Quaternion":
        return Quaternion(-self.x, -self.y, -self.z, self.w)

    def __mul__(self, other: "Quaternion") -> "Quaternion":
        return Quaternion(
            self.x * other.w + self.w * other.x + self.y * other.z - self.z * other.y,
            self.y * other.w + self.w * other.y + self.z * other.x - self.x * other.z,
            self.z * other.w + self.w * other.z + self.x * other.y - self.y * other.x,
            self.w * other.w - self.x * other.x - self.y * other.y - self.z * other.z
        )

    def rotate_vector(self, v: Vector3) -> Vector3:
        qv = Quaternion(v.x, v.y, v.z, 0)
        result = self * qv * self.conjugate()
        return Vector3(result.x, result.y, result.z)

    def to_matrix(self) -> Matrix4:
        xx = self.x * self.x
        yy = self.y * self.y
        zz = self.z * self.z
        xy = self.x * self.y
        xz = self.x * self.z
        yz = self.y * self.z
        wx = self.w * self.x
        wy = self.w * self.y
        wz = self.w * self.z

        return Matrix4([
            1 - 2 * (yy + zz), 2 * (xy + wz), 2 * (xz - wy), 0,
            2 * (xy - wz), 1 - 2 * (xx + zz), 2 * (yz + wx), 0,
            2 * (xz + wy), 2 * (yz - wx), 1 - 2 * (xx + yy), 0,
            0, 0, 0, 1
        ])

    @staticmethod
    def slerp(q1: "Quaternion", q2: "Quaternion", t: float) -> "Quaternion":
        dot = q1.x * q2.x + q1.y * q2.y + q1.z * q2.z + q1.w * q2.w

        if dot < 0:
            q2 = Quaternion(-q2.x, -q2.y, -q2.z, -q2.w)
            dot = -dot

        if dot > 0.9995:
            return Quaternion(
                q1.x + t * (q2.x - q1.x),
                q1.y + t * (q2.y - q1.y),
                q1.z + t * (q2.z - q1.z),
                q1.w + t * (q2.w - q1.w)
            ).normalized()

        theta = math.acos(dot)
        sin_theta = math.sin(theta)

        w1 = math.sin((1 - t) * theta) / sin_theta
        w2 = math.sin(t * theta) / sin_theta

        return Quaternion(
            q1.x * w1 + q2.x * w2,
            q1.y * w1 + q2.y * w2,
            q1.z * w1 + q2.z * w2,
            q1.w * w1 + q2.w * w2
        )

53.1.2 3D变换

python
from dataclasses import dataclass, field
from typing import Optional, List


@dataclass
class Transform:
    position: Vector3 = field(default_factory=Vector3.zero)
    rotation: Quaternion = field(default_factory=Quaternion)
    scale: Vector3 = field(default_factory=lambda: Vector3(1, 1, 1))
    parent: Optional["Transform"] = None
    children: List["Transform"] = field(default_factory=list)

    def get_model_matrix(self) -> Matrix4:
        t = Matrix4.translation(self.position.x, self.position.y, self.position.z)
        r = self.rotation.to_matrix()
        s = Matrix4.scale(self.scale.x, self.scale.y, self.scale.z)

        local_matrix = t * r * s

        if self.parent:
            return self.parent.get_model_matrix() * local_matrix

        return local_matrix

    def get_world_position(self) -> Vector3:
        if self.parent:
            return self.parent.get_model_matrix().transform_point(self.position)
        return self.position

    def get_world_rotation(self) -> Quaternion:
        if self.parent:
            return self.parent.get_world_rotation() * self.rotation
        return self.rotation

    def translate(self, delta: Vector3) -> None:
        self.position = self.position + delta

    def rotate(self, axis: Vector3, angle: float) -> None:
        rotation_delta = Quaternion.from_axis_angle(axis, angle)
        self.rotation = rotation_delta * self.rotation

    def rotate_around(self, point: Vector3, axis: Vector3, angle: float) -> None:
        rotation = Quaternion.from_axis_angle(axis, angle)

        offset = self.position - point
        rotated_offset = rotation.rotate_vector(offset)

        self.position = point + rotated_offset
        self.rotation = rotation * self.rotation

    def look_at(self, target: Vector3, up: Vector3 = None) -> None:
        if up is None:
            up = Vector3.up()

        forward = (target - self.position).normalized()
        if forward.length() == 0:
            return

        right = forward.cross(up).normalized()
        if right.length() == 0:
            return

        new_up = right.cross(forward).normalized()

        rotation_matrix = Matrix4([
            right.x, new_up.x, -forward.x, 0,
            right.y, new_up.y, -forward.y, 0,
            right.z, new_up.z, -forward.z, 0,
            0, 0, 0, 1
        ])

        self.rotation = self._matrix_to_quaternion(rotation_matrix)

    def _matrix_to_quaternion(self, m: Matrix4) -> Quaternion:
        e = m.elements

        trace = e[0] + e[5] + e[10]

        if trace > 0:
            s = 0.5 / math.sqrt(trace + 1.0)
            w = 0.25 / s
            x = (e[6] - e[9]) * s
            y = (e[8] - e[2]) * s
            z = (e[1] - e[4]) * s
        elif e[0] > e[5] and e[0] > e[10]:
            s = 2.0 * math.sqrt(1.0 + e[0] - e[5] - e[10])
            w = (e[6] - e[9]) / s
            x = 0.25 * s
            y = (e[4] + e[1]) / s
            z = (e[8] + e[2]) / s
        elif e[5] > e[10]:
            s = 2.0 * math.sqrt(1.0 + e[5] - e[0] - e[10])
            w = (e[8] - e[2]) / s
            x = (e[4] + e[1]) / s
            y = 0.25 * s
            z = (e[9] + e[6]) / s
        else:
            s = 2.0 * math.sqrt(1.0 + e[10] - e[0] - e[5])
            w = (e[1] - e[4]) / s
            x = (e[8] + e[2]) / s
            y = (e[9] + e[6]) / s
            z = 0.25 * s

        return Quaternion(x, y, z, w).normalized()

    def add_child(self, child: "Transform") -> None:
        child.parent = self
        self.children.append(child)

    def remove_child(self, child: "Transform") -> None:
        if child in self.children:
            child.parent = None
            self.children.remove(child)

    def get_forward(self) -> Vector3:
        return self.rotation.rotate_vector(Vector3.forward())

    def get_up(self) -> Vector3:
        return self.rotation.rotate_vector(Vector3.up())

    def get_right(self) -> Vector3:
        return self.rotation.rotate_vector(Vector3.right())

53.2 3D模型

53.2.1 网格数据

python
from dataclasses import dataclass, field
from typing import List, Tuple, Optional
import struct


@dataclass
class Vertex:
    position: Vector3
    normal: Vector3 = field(default_factory=Vector3.zero)
    tex_coord: Tuple[float, float] = (0.0, 0.0)
    tangent: Vector3 = field(default_factory=Vector3.zero)
    color: Tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)


@dataclass
class Mesh:
    vertices: List[Vertex] = field(default_factory=list)
    indices: List[int] = field(default_factory=list)

    def calculate_normals(self) -> None:
        for vertex in self.vertices:
            vertex.normal = Vector3.zero()

        for i in range(0, len(self.indices), 3):
            i0, i1, i2 = self.indices[i], self.indices[i + 1], self.indices[i + 2]

            v0 = self.vertices[i0].position
            v1 = self.vertices[i1].position
            v2 = self.vertices[i2].position

            edge1 = v1 - v0
            edge2 = v2 - v0
            normal = edge1.cross(edge2).normalized()

            self.vertices[i0].normal = self.vertices[i0].normal + normal
            self.vertices[i1].normal = self.vertices[i1].normal + normal
            self.vertices[i2].normal = self.vertices[i2].normal + normal

        for vertex in self.vertices:
            vertex.normal.normalize()

    def calculate_tangents(self) -> None:
        for i in range(0, len(self.indices), 3):
            i0, i1, i2 = self.indices[i], self.indices[i + 1], self.indices[i + 2]

            v0 = self.vertices[i0]
            v1 = self.vertices[i1]
            v2 = self.vertices[i2]

            edge1 = v1.position - v0.position
            edge2 = v2.position - v0.position

            delta_uv1 = (v1.tex_coord[0] - v0.tex_coord[0], v1.tex_coord[1] - v0.tex_coord[1])
            delta_uv2 = (v2.tex_coord[0] - v0.tex_coord[0], v2.tex_coord[1] - v0.tex_coord[1])

            f = 1.0 / (delta_uv1[0] * delta_uv2[1] - delta_uv2[0] * delta_uv1[1])

            tangent = Vector3(
                f * (delta_uv2[1] * edge1.x - delta_uv1[1] * edge2.x),
                f * (delta_uv2[1] * edge1.y - delta_uv1[1] * edge2.y),
                f * (delta_uv2[1] * edge1.z - delta_uv1[1] * edge2.z)
            )

            self.vertices[i0].tangent = tangent
            self.vertices[i1].tangent = tangent
            self.vertices[i2].tangent = tangent

    def get_bounds(self) -> Tuple[Vector3, Vector3]:
        if not self.vertices:
            return Vector3.zero(), Vector3.zero()

        min_pos = Vector3(float('inf'), float('inf'), float('inf'))
        max_pos = Vector3(float('-inf'), float('-inf'), float('-inf'))

        for vertex in self.vertices:
            min_pos.x = min(min_pos.x, vertex.position.x)
            min_pos.y = min(min_pos.y, vertex.position.y)
            min_pos.z = min(min_pos.z, vertex.position.z)
            max_pos.x = max(max_pos.x, vertex.position.x)
            max_pos.y = max(max_pos.y, vertex.position.y)
            max_pos.z = max(max_pos.z, vertex.position.z)

        return min_pos, max_pos

    def to_bytes(self) -> bytes:
        data = bytearray()

        data.extend(struct.pack('<I', len(self.vertices)))
        for v in self.vertices:
            data.extend(struct.pack('<fff', v.position.x, v.position.y, v.position.z))
            data.extend(struct.pack('<fff', v.normal.x, v.normal.y, v.normal.z))
            data.extend(struct.pack('<ff', v.tex_coord[0], v.tex_coord[1]))

        data.extend(struct.pack('<I', len(self.indices)))
        for idx in self.indices:
            data.extend(struct.pack('<I', idx))

        return bytes(data)


class MeshGenerator:
    @staticmethod
    def create_cube(size: float = 1.0) -> Mesh:
        half = size / 2
        vertices = []
        indices = []

        faces = [
            (Vector3(0, 0, 1), [(Vector3(-half, -half, half), (0, 0)),
                                (Vector3(half, -half, half), (1, 0)),
                                (Vector3(half, half, half), (1, 1)),
                                (Vector3(-half, half, half), (0, 1))]),
            (Vector3(0, 0, -1), [(Vector3(half, -half, -half), (0, 0)),
                                 (Vector3(-half, -half, -half), (1, 0)),
                                 (Vector3(-half, half, -half), (1, 1)),
                                 (Vector3(half, half, -half), (0, 1))]),
            (Vector3(0, 1, 0), [(Vector3(-half, half, half), (0, 0)),
                                (Vector3(half, half, half), (1, 0)),
                                (Vector3(half, half, -half), (1, 1)),
                                (Vector3(-half, half, -half), (0, 1))]),
            (Vector3(0, -1, 0), [(Vector3(-half, -half, -half), (0, 0)),
                                 (Vector3(half, -half, -half), (1, 0)),
                                 (Vector3(half, -half, half), (1, 1)),
                                 (Vector3(-half, -half, half), (0, 1))]),
            (Vector3(1, 0, 0), [(Vector3(half, -half, half), (0, 0)),
                                (Vector3(half, -half, -half), (1, 0)),
                                (Vector3(half, half, -half), (1, 1)),
                                (Vector3(half, half, half), (0, 1))]),
            (Vector3(-1, 0, 0), [(Vector3(-half, -half, -half), (0, 0)),
                                 (Vector3(-half, -half, half), (1, 0)),
                                 (Vector3(-half, half, half), (1, 1)),
                                 (Vector3(-half, half, -half), (0, 1))])
        ]

        vertex_offset = 0
        for normal, face_vertices in faces:
            for pos, tex in face_vertices:
                vertices.append(Vertex(
                    position=pos,
                    normal=normal,
                    tex_coord=tex
                ))

            indices.extend([
                vertex_offset, vertex_offset + 1, vertex_offset + 2,
                vertex_offset, vertex_offset + 2, vertex_offset + 3
            ])
            vertex_offset += 4

        return Mesh(vertices=vertices, indices=indices)

    @staticmethod
    def create_sphere(radius: float = 1.0, segments: int = 32, rings: int = 16) -> Mesh:
        vertices = []
        indices = []

        for ring in range(rings + 1):
            phi = math.pi * ring / rings
            for seg in range(segments + 1):
                theta = 2 * math.pi * seg / segments

                x = radius * math.sin(phi) * math.cos(theta)
                y = radius * math.cos(phi)
                z = radius * math.sin(phi) * math.sin(theta)

                position = Vector3(x, y, z)
                normal = position.normalized()
                tex_coord = (seg / segments, ring / rings)

                vertices.append(Vertex(
                    position=position,
                    normal=normal,
                    tex_coord=tex_coord
                ))

        for ring in range(rings):
            for seg in range(segments):
                current = ring * (segments + 1) + seg
                next_ring = current + segments + 1

                indices.extend([
                    current, next_ring, current + 1,
                    current + 1, next_ring, next_ring + 1
                ])

        return Mesh(vertices=vertices, indices=indices)

    @staticmethod
    def create_plane(width: float = 1.0, height: float = 1.0,
                     segments_x: int = 1, segments_y: int = 1) -> Mesh:
        vertices = []
        indices = []

        half_width = width / 2
        half_height = height / 2

        for y in range(segments_y + 1):
            for x in range(segments_x + 1):
                pos_x = -half_width + (x / segments_x) * width
                pos_z = -half_height + (y / segments_y) * height

                vertices.append(Vertex(
                    position=Vector3(pos_x, 0, pos_z),
                    normal=Vector3.up(),
                    tex_coord=(x / segments_x, y / segments_y)
                ))

        for y in range(segments_y):
            for x in range(segments_x):
                current = y * (segments_x + 1) + x
                next_row = current + segments_x + 1

                indices.extend([
                    current, next_row, current + 1,
                    current + 1, next_row, next_row + 1
                ])

        return Mesh(vertices=vertices, indices=indices)

    @staticmethod
    def create_cylinder(radius: float = 1.0, height: float = 2.0,
                        segments: int = 32) -> Mesh:
        vertices = []
        indices = []

        half_height = height / 2

        for i in range(segments):
            angle = 2 * math.pi * i / segments
            x = radius * math.cos(angle)
            z = radius * math.sin(angle)

            normal = Vector3(math.cos(angle), 0, math.sin(angle))

            vertices.append(Vertex(
                position=Vector3(x, -half_height, z),
                normal=normal,
                tex_coord=(i / segments, 0)
            ))
            vertices.append(Vertex(
                position=Vector3(x, half_height, z),
                normal=normal,
                tex_coord=(i / segments, 1)
            ))

        for i in range(segments):
            current = i * 2
            next_v = ((i + 1) % segments) * 2

            indices.extend([
                current, next_v, current + 1,
                current + 1, next_v, next_v + 1
            ])

        return Mesh(vertices=vertices, indices=indices)

53.3 光照系统

53.3.1 光源类型

python
from dataclasses import dataclass, field
from typing import Tuple, Optional
from enum import Enum


class LightType(Enum):
    DIRECTIONAL = "directional"
    POINT = "point"
    SPOT = "spot"
    AMBIENT = "ambient"


@dataclass
class Light:
    light_type: LightType
    color: Tuple[float, float, float] = (1.0, 1.0, 1.0)
    intensity: float = 1.0
    position: Vector3 = field(default_factory=Vector3.zero)
    direction: Vector3 = field(default_factory=lambda: Vector3(0, -1, 0))
    range: float = 10.0
    inner_angle: float = 30.0
    outer_angle: float = 45.0
    cast_shadows: bool = True

    def calculate_contribution(
        self,
        surface_pos: Vector3,
        surface_normal: Vector3
    ) -> Tuple[float, Tuple[float, float, float]]:
        if self.light_type == LightType.AMBIENT:
            return self.intensity, self.color

        elif self.light_type == LightType.DIRECTIONAL:
            light_dir = self.direction.normalized() * -1
            ndotl = max(0, surface_normal.dot(light_dir))
            return ndotl * self.intensity, self.color

        elif self.light_type == LightType.POINT:
            light_vec = self.position - surface_pos
            distance = light_vec.length()

            if distance > self.range:
                return 0.0, (0.0, 0.0, 0.0)

            light_dir = light_vec.normalized()
            ndotl = max(0, surface_normal.dot(light_dir))

            attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance)

            return ndotl * self.intensity * attenuation, self.color

        elif self.light_type == LightType.SPOT:
            light_vec = self.position - surface_pos
            distance = light_vec.length()

            if distance > self.range:
                return 0.0, (0.0, 0.0, 0.0)

            light_dir = light_vec.normalized()
            spot_dir = self.direction.normalized() * -1

            theta = math.degrees(math.acos(max(-1, min(1, light_dir.dot(spot_dir) * -1))))

            if theta > self.outer_angle:
                return 0.0, (0.0, 0.0, 0.0)

            ndotl = max(0, surface_normal.dot(light_dir))

            if theta < self.inner_angle:
                spot_factor = 1.0
            else:
                spot_factor = 1.0 - (theta - self.inner_angle) / (self.outer_angle - self.inner_angle)

            attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance)

            return ndotl * self.intensity * attenuation * spot_factor, self.color

        return 0.0, (0.0, 0.0, 0.0)


@dataclass
class Material:
    albedo: Tuple[float, float, float] = (1.0, 1.0, 1.0)
    metallic: float = 0.0
    roughness: float = 0.5
    ambient_occlusion: float = 1.0
    emissive: Tuple[float, float, float] = (0.0, 0.0, 0.0)

    def calculate_color(
        self,
        lights: list,
        surface_pos: Vector3,
        surface_normal: Vector3,
        view_pos: Vector3
    ) -> Tuple[float, float, float]:
        result = list(self.emissive)

        for light in lights:
            intensity, color = light.calculate_contribution(surface_pos, surface_normal)

            if intensity <= 0:
                continue

            for i in range(3):
                result[i] += self.albedo[i] * color[i] * intensity

        return (
            min(1.0, result[0]),
            min(1.0, result[1]),
            min(1.0, result[2])
        )


class LightingSystem:
    def __init__(self):
        self.lights: list = []
        self.ambient_color: Tuple[float, float, float] = (0.1, 0.1, 0.1)
        self.ambient_intensity: float = 1.0

    def add_light(self, light: Light) -> None:
        self.lights.append(light)

    def remove_light(self, light: Light) -> None:
        if light in self.lights:
            self.lights.remove(light)

    def calculate_lighting(
        self,
        surface_pos: Vector3,
        surface_normal: Vector3,
        material: Material,
        view_pos: Vector3
    ) -> Tuple[float, float, float]:
        ambient_light = Light(
            light_type=LightType.AMBIENT,
            color=self.ambient_color,
            intensity=self.ambient_intensity
        )

        all_lights = [ambient_light] + self.lights

        return material.calculate_color(
            all_lights,
            surface_pos,
            surface_normal,
            view_pos
        )

53.3.2 相机系统

python
from dataclasses import dataclass, field
from typing import Tuple


class ProjectionType(Enum):
    PERSPECTIVE = "perspective"
    ORTHOGRAPHIC = "orthographic"


@dataclass
class Camera:
    position: Vector3 = field(default_factory=Vector3.zero)
    rotation: Quaternion = field(default_factory=Quaternion.identity)
    projection_type: ProjectionType = ProjectionType.PERSPECTIVE

    fov: float = 60.0
    near_plane: float = 0.1
    far_plane: float = 1000.0
    aspect_ratio: float = 16.0 / 9.0

    ortho_size: float = 5.0

    def get_view_matrix(self) -> Matrix4:
        forward = self.rotation.rotate_vector(Vector3.forward())
        up = self.rotation.rotate_vector(Vector3.up())

        target = self.position + forward

        return Matrix4.look_at(self.position, target, up)

    def get_projection_matrix(self) -> Matrix4:
        if self.projection_type == ProjectionType.PERSPECTIVE:
            return Matrix4.perspective(
                self.fov,
                self.aspect_ratio,
                self.near_plane,
                self.far_plane
            )
        else:
            half_width = self.ortho_size * self.aspect_ratio
            half_height = self.ortho_size
            return Matrix4.orthographic(
                -half_width, half_width,
                -half_height, half_height,
                self.near_plane, self.far_plane
            )

    def get_view_projection_matrix(self) -> Matrix4:
        return self.get_projection_matrix() * self.get_view_matrix()

    def screen_to_world(
        self,
        screen_pos: Tuple[float, float],
        screen_size: Tuple[int, int]
    ) -> Vector3:
        ndc_x = (2.0 * screen_pos[0]) / screen_size[0] - 1.0
        ndc_y = 1.0 - (2.0 * screen_pos[1]) / screen_size[1]

        inv_proj = self.get_projection_matrix().inverse()
        inv_view = self.get_view_matrix().inverse()

        ray_ndc = Vector4(ndc_x, ndc_y, -1.0, 1.0)
        ray_eye = inv_proj * ray_ndc
        ray_eye = Vector4(ray_eye.x, ray_eye.y, -1.0, 0.0)
        ray_world = inv_view * ray_eye

        direction = Vector3(ray_world.x, ray_world.y, ray_world.z).normalized()

        return self.position + direction * self.near_plane

    def world_to_screen(
        self,
        world_pos: Vector3,
        screen_size: Tuple[int, int]
    ) -> Tuple[float, float]:
        vp_matrix = self.get_view_projection_matrix()

        clip_pos = vp_matrix * Vector4(world_pos.x, world_pos.y, world_pos.z, 1.0)

        ndc_x = clip_pos.x / clip_pos.w
        ndc_y = clip_pos.y / clip_pos.w

        screen_x = (ndc_x + 1.0) * 0.5 * screen_size[0]
        screen_y = (1.0 - ndc_y) * 0.5 * screen_size[1]

        return (screen_x, screen_y)

    def look_at(self, target: Vector3) -> None:
        forward = (target - self.position).normalized()
        self.rotation = Quaternion.look_rotation(forward, Vector3.up())

    def orbit_around(
        self,
        target: Vector3,
        horizontal_angle: float,
        vertical_angle: float,
        distance: float
    ) -> None:
        x = distance * math.sin(horizontal_angle) * math.cos(vertical_angle)
        y = distance * math.sin(vertical_angle)
        z = distance * math.cos(horizontal_angle) * math.cos(vertical_angle)

        self.position = target + Vector3(x, y, z)
        self.look_at(target)


class CameraController:
    def __init__(self, camera: Camera):
        self.camera = camera
        self.target = Vector3.zero()
        self.distance = 10.0
        self.horizontal_angle = 0.0
        self.vertical_angle = 0.0
        self.min_vertical = -89.0
        self.max_vertical = 89.0
        self.pan_speed = 0.01
        self.rotate_speed = 0.5
        self.zoom_speed = 1.0

    def rotate(self, delta_x: float, delta_y: float) -> None:
        self.horizontal_angle += delta_x * self.rotate_speed
        self.vertical_angle = max(
            self.min_vertical,
            min(self.max_vertical, self.vertical_angle + delta_y * self.rotate_speed)
        )

        self._update_camera()

    def pan(self, delta_x: float, delta_y: float) -> None:
        right = self.camera.rotation.rotate_vector(Vector3.right())
        up = self.camera.rotation.rotate_vector(Vector3.up())

        self.target = self.target - right * delta_x * self.pan_speed
        self.target = self.target + up * delta_y * self.pan_speed

        self._update_camera()

    def zoom(self, delta: float) -> None:
        self.distance = max(1.0, min(100.0, self.distance - delta * self.zoom_speed))
        self._update_camera()

    def _update_camera(self) -> None:
        h_rad = math.radians(self.horizontal_angle)
        v_rad = math.radians(self.vertical_angle)

        self.camera.orbit_around(self.target, h_rad, v_rad, self.distance)

学术注记:3D图形学的核心是渲染管线(Rendering Pipeline),它将3D场景转换为2D图像。现代图形API(OpenGL、DirectX、Vulkan)都遵循类似的管线架构:顶点处理→图元装配→光栅化→片段处理→输出合并。理解渲染管线对于优化渲染性能和实现高级视觉效果至关重要。


53.4 本章小结

本章详细介绍了Python 3D图形编程的核心概念和实践:

  1. 数学基础:向量运算、矩阵变换、四元数旋转
  2. 3D变换:平移、旋转、缩放、父子层级
  3. 3D模型:顶点数据、网格生成、法线计算
  4. 基本几何体:立方体、球体、平面、圆柱体
  5. 光照系统:方向光、点光源、聚光灯、材质
  6. 相机系统:透视投影、正交投影、相机控制

53.4.1 知识图谱

3D图形编程
├── 数学基础
│   ├── 向量运算(加减、点积、叉积)
│   ├── 矩阵运算(乘法、逆矩阵、转置)
│   ├── 四元数(旋转、插值、转换)
│   └── 变换(平移、旋转、缩放)
├── 渲染管线
│   ├── 顶点处理(变换、光照)
│   ├── 图元装配(三角形、线段)
│   ├── 光栅化(片段生成)
│   ├── 片段处理(着色、纹理)
│   └── 输出合并(深度测试、混合)
├── 3D模型
│   ├── 顶点数据(位置、法线、UV)
│   ├── 索引缓冲(三角形列表)
│   ├── 网格生成(程序化建模)
│   └── 模型加载(OBJ、FBX、glTF)
├── 光照系统
│   ├── 光源类型(方向、点、聚光)
│   ├── 光照模型(Phong、PBR)
│   ├── 材质系统(反照率、金属度)
│   └── 阴影映射(阴影贴图)
└── 相机系统
    ├── 投影类型(透视、正交)
    ├── 视图矩阵(观察变换)
    ├── 相机控制(轨道、飞行)
    └── 视锥剔除(可见性判断)

53.4.2 最佳实践清单

场景推荐做法避免做法
向量归一化使用前检查长度是否为0直接除以长度
矩阵乘法注意顺序(右乘 vs 左乘)忽略矩阵顺序
四元数旋转用于平滑插值欧拉角插值
法线计算使用叉积计算面法线忽略顶点法线
相机控制使用四元数避免万向锁直接操作欧拉角
性能优化使用实例化渲染每帧重建网格
坐标系统统一使用右手坐标系混用不同坐标系

53.4.3 技术选型指南

需求场景推荐方案备选方案
学习入门PyGame + PyOpenGLModernGL
游戏开发Panda3D、UrsinaGodot(Python绑定)
科学可视化VTK、MayaviPyVista
数据可视化PyVista、Plotly 3DMatplotlib 3D
CAD/建模Blender Python APIFreeCAD
实时渲染ModernGLPyOpenGL
物理模拟PyBulletPanda3D Physics

练习题

基础题

  1. 实现一个完整的Vector3类,支持所有基本运算(加减、点积、叉积、归一化)。

  2. 创建一个程序化网格生成器,生成一个圆环体(Torus)。

  3. 实现一个简单的相机系统,支持WASD键移动和鼠标旋转。

进阶题

  1. 实现一个OBJ文件加载器,支持顶点、法线和纹理坐标。

  2. 开发一个简单的Phong光照着色器,支持环境光、漫反射和镜面反射。

  3. 实现一个射线拾取系统,能够检测鼠标点击的3D物体。

项目实践

  1. 3D场景编辑器:构建一个简单的3D场景编辑器,要求:
    • 支持多种几何体的创建(立方体、球体、圆柱)
    • 实现物体的选择、移动、旋转、缩放
    • 支持多光源场景
    • 提供相机控制(轨道、飞行模式)
    • 实现基本的材质编辑

思考题

  1. 四元数相比欧拉角有哪些优势?在什么情况下会出现万向锁问题?

  2. 透视投影和正交投影的区别是什么?它们分别适用于什么场景?

  3. PBR(基于物理的渲染)与传统Phong光照模型相比有哪些优势?为什么现代游戏引擎普遍采用PBR?

扩展阅读

53.5.1 图形学基础

53.5.2 Python图形库

53.5.3 游戏引擎

53.5.4 进阶书籍

  • 《游戏引擎架构》 (Jason Gregory) — 游戏引擎设计经典
  • 《计算机图形学原理与实践》 — 图形学权威教材
  • 《GPU Gems》系列 — GPU编程技巧合集
  • 《Real-Time Shadows》 — 实时阴影技术

下一章:第54章 编译器与解释器

Python技术丛书 - 江苏省宿城中等专业学校