谈谈 图形学 的 从头建设发展之路
图形学 要 解决的 几个问题:
1 模型,
2 透视
3 边界判断 和 遮挡
4 光影 重力 和 机械运动 等 物理效果
模型的部分,我们先从 二维图形 开始说起, 比如 “如何判断一个点在 多边形 内?” , 这个问题我将之称为 “边界判断” 问题,
假设我们设计一个游戏的话, 就会考虑这个问题,不过对于 魂斗罗 超级玛丽 这样的 8 位机 游戏, 角色 道具 之间的 碰撞 应该是 直接比较 角色 道具 的 像素, 还没有用到图形学,因为 那个时代的 游戏 的 分辨率 不高, 角色 道具 的 像素数量 很少, 直接比较 像素 即可 。
对于 图形学 来说, 如何解决 边界判断 问题?
我想过一个办法,就是 把 多边形 分解为 若干个 三角形,然后 判断 点 是否在 这些 三角形 里, 但是 有 网友 说了一个 更直接 的 办法,就是 连线, 就是 让 点 和 多边形 的 每个端点 连线,如果这些连线和 多边形 的 边 有相交 则 点 在 多边形 外, 没有相交 则 点 在 多边形 内, 确实 这办法 很好, 但是只能处理 “没有凹陷” 的 多边形 。当然,分解为 三角形 的 方法 也只能 处理 “没有凹陷” 的 多边形 , 在 有 凹陷 的 情况下, 分解三角形 本身 就是个 困难的 工作, 需要 判断 多边形 的 边 的 “哪一侧” 是 “内侧”, 这个问题 是 边界判断 的 根本问题 。
如何 判断 多边形 的 边 的 “哪一侧” 是 “内侧”, 最一般 也是 最复杂 的 情况 是, 任意给定一些点 和 一个顺序, 按照这个顺序 把 点 连接起来 构成一个 多边形 。
这种情况是 最一般 也是 最复杂 的 。
比如, 给 A , B , C , …… X , Y , Z 这样一些点, 按照 A , B , C , …… X , Y , Z 的 顺序 连起来 。
这要 考虑 交叉 的 情况, 即使没有 交叉, 要 知道 边 的 哪一侧 是 内侧, 也是 很麻烦 的 。
这需要 判断 最后一点(Z) 和 第一点(A) 连接 的 闭合方向 是 顺时针 还是 逆时针,
这个 顺时针 逆时针 并不是 ZA 方向, 而是 要 判断 本次 “环绕” 是在 上次 “环绕” 的 内部 还是 外部 。
但是,有一种办法 可以让 问题 简化, 就是 人为 的 给出 某条边 的 内侧, 这样可以 递推 出 所有 边 的 内侧,
比如 , 指定 AB 的 “右边” 是 内侧, 即 A 到 B 的 方向 的 右边 为 内侧, 这样可以递推出 BC , CD , DE , …… XY , YZ 的 右边 是 内侧 。
这样,只要 判断 点 是否在 所有 边 的 内侧 就是 判断 点 是否在 多边形 内部 。
这是 可行 的, 因为 模型 是 人 建立 的, 所以 人 当然可以 指定 边 的 哪一侧 是 内侧 , 这样 问题 就 大大 简化 了 。
如果 把 边界判断 问题 推广到 三维 的 话, 情况 会 更复杂, 如果由 一些 三角形面 来组成一个 体 的话, 这类似与 二维 由 一些 线段 组成 多边形,
那么, 对于 这些 三角形面 是否 能 组成一个 封闭 体 , 这是一个问题, 二维 的 情况 只要 点 顺序 的 连接, 最后一点 和 第一点 相连, 就是一个 封闭多边形, 当然,这里面 存在 线段 交叉 的 情况,但 交叉了 也是 封闭 的,或者 可以 按 交叉 的 方式 另行 处理 。
但是 三维 的 情况 就 复杂 了, 空间 中的 面 是 会 扭曲 的, 这 不仅仅 发生在 连续曲面, 用 三角形面 组成 的 “组合面” 也 会 扭曲,
事实上, 三角形面 组成的 组合面 可以模拟 连续曲面 和 逼近, 这也是 3D 建模 的 基础 。
由于 面 的 扭曲, 所以 三维 中 要 判断 面 是否 构成一个 封闭体 就 很复杂, 我觉得 单纯 用 算法 大概 很难 实现 。
这里的 面 包括 了 连续曲面 和 组合面 。
面 的 扭曲, 我们可以 举个例子, 就以 拓扑学 的 经典例子 来看:
把 一条 纸带 扭一下 再 首尾 粘 起来, 这就 形成了一个 纸带圈, 因为 扭 了 一下, 这个 纸带圈 就有点 神奇,
我们把 一只 蚂蚁 放在 纸带 上, 它可以 从 纸带 的 内侧 爬到 外侧, 又从 外侧 爬到 内侧, 如此 循环 。
把 纸带 看作一个 面, 让 这个 面 延展 出去, 是 不能 组成一个 封闭体 的 ,
这是因为 有 那个 扭曲 的 存在, 因为这个 扭曲, 即使 延展 出去 的 面 封闭 了, 扭曲 那里 仍然 会 自然 的 形成 一个 “洞”,
或者说 一个 隧道, 两端 与 外界 相通, 感觉 好像 空间的 漏洞 ……
当然, 你可以用 一些 其它 的 面 把 隧道 的 两端 给 堵起来, 但 这就不是 纸带(面) 本身 延展 的 结果 了 。
所以, 我的看法 是 , 3D 建模 中, 一个 体 是否 封闭, 是由 建模 的 人 依靠 直观 来 实现 的,
在 体 封闭 的 前提 上, 和 二维 一样, 人为 的 指定 某个 单元面 的 哪一侧 是 内侧, 这样可以 递推 知道 所有 的 单元面 的 内侧,
这样 就可以 解决 三维 下 的 边界判断 问题 。
只要 判断 点 是否在 所有 单元面 的 内侧 就可以 判断 点 是否在 封闭体 内 。
这里说的 单元面 是 指 组成 封闭体 的 简单图形平面, 最基本 的 是 三角形面,
理论上, 所有的 多边形面 都可以由 三角形面 构成, 不过 对于一些 特定 的 模型, 可以 直接使用 矩形 梯形 或 其它 多边形 来 作为 单元面 ,
比如 长方体, 直接用 矩形面 组成就可以 。
三角形面 有一个 特点 是 可以 组成 扭曲 的 组合面 , 矩形面 不能 组成 扭曲 的 组合面 , 所以 三角形面 是 基本 的 单元面 。
接下来 说说 透视 , 透视 是 美术, 也就是 画画 里 的 术语, 透视 所指 的 就是 对于 人眼 来说, 远处 的 物体 变小 的 视觉效果, 以及 矩形 看起来是 平行四边形 、 圆形 看起来像 椭圆形 这样的效果 。
对于 3D 来说, 模型 最终 投影 并 渲染 到 屏幕 应该 满足 透视 效果, 这样看起来才符合人的视觉效果 。
那 如何 计算 透视 呢?
我们来看看 人眼(照相机) 的 成像示意图 :
如图, 线段 AB 经过 瞳孔(镜头) 在 视网膜(胶片) 上的 成像 是 ba, 是一个 倒立相反 的 像,
物理学 中, 我们知道, 凸透镜 的 成像 是 倒立相反 的, 眼睛(照相机) 的 成像 就是 凸透镜 原理 。
可以看到 abo 和 ABo 两个 三角形 是 相似三角形, ab 和 AB 之间 存在一个 比例关系: ab / oh = AB / oH ,
ab = AB * ( oh / oH ) , oH 是 AB 到 瞳孔(镜头) 的 距离, 所以, 像 的 大小 和 物体 离 瞳孔(镜头) 的 距离 成 反比 。
像 就是 有 透视效果 的 投影 , 我们也可以把 像 称为 透视投影 。
那么, 根据上面 这个 反比 关系,是不是可以来 计算 透视投影 了呢? 还不行, 因为 最关键 的 是 要 确定 组成 物体(模型) 的 单元面 的 端点 的 位置,
所以, 还是 需要 根据 成像 的 原理 来 计算 单元面 端点 的 位置 , 以此 得到 物体(模型) 在 视平面 上的 透视投影 。
上图 中 的 视网膜(胶片) 就是 视平面 。
因为 像 是 倒立相反 的,我们 还要 把 像 “反转” 过来, 才能得到 正立 的 像, 在 计算机 里 这 很简单, 像 是 一个 位图, 位图 是 一个 二维数组, 只要 以 倒序 的 方式 输出 数组 的 数据 得到的 就是 “反转” 的 像, 倒序 就是 以 从 最后一个 元素 到 第一个元素 的 顺序 访问 元素 。
虽然 3D 中 需要 根据 成像 原理 计算 像(透视投影), 但是, 在 2D 的 第一视角游戏 等 一些 模拟 3D 效果 的 场合(俗称 “假 3D”) 可以 使用 上述 的 反比关系 模拟 3D 透视效果, 比如 2D 下 的 第一视角 赛车游戏 什么的 。
接下来说 遮挡, 遮挡 是个 麻烦事, 遮挡 是 图形学 里的 一个 大问题, 我们先来看看 二维 里 的 遮挡 是 怎么 计算 的 ,
二维 里 的 遮挡 比如 浏览器 里的 元素 之间 的 叠放, 还有 Windows 操作系统 里 窗口 之间 的 叠放 。
只要 根据 元素(窗口) 的 叠放层次 来 渲染 就可以 。
不过 我们 先说说 “渲染” 这个词, 渲染 的 英文 是 Render , 但是 在 Windows 编程 里 ,通常说 “绘制” , 比如 绘制窗口, 所以, .Net 的 System.Windows.Forms 下 的 Control 基类 有 OnPaint() 和 OnPaintBackground() 虚方法, 而 窗口(Form) 和 所有的 控件 都是 继承 Control 基类 的,
Paint 就是 画画, 绘制 的 意思, 还有一个 近义词,就是 Draw, Draw 也有 画画 的 意思, 所以 .Net 提供 GDI+ 的 名字空间 System.Drawing 是用 Drawing 来命名 。 里面 那些 矢量图形 的 绘制方法 大概 也是 Draw() , 我猜的, 记不清了 。
当然, System.Drawing 名字空间 下 还有一个 重要的 角色 是 Brush , 刷子 , 又称 画刷 。
我觉得 Draw 主要是 画线条, Paint 则 偏重于 涂色,以及 整个 综合 的 绘画过程 。
以 浏览器 为例, 设 有 1 到 n 层 元素, 1 层 为 最低层, n 层 为 最高层, 则 从 最低层 开始 渲染, 逐层 渲染 至 最高层,
渲染 就是 输出 像素 。
这样 高层 会 覆盖 底层 的 内容(像素), 这样 就 实现 了 叠放(遮挡) 效果 。
再说说 半透明 的 问题, 假设 2 层 的 透明度 是 40% , 那 在 2 层 遮挡 1 层 的 区域, 应该 有 40% 的 像素 显示 1 层 的 像素, 这 40% 的 1 层 像素, 应该 均匀 的 分布 在 遮挡区域 内 , 这样 来 实现 半透明 的 效果 。
对于 多层叠放 和 多层半透明, 一样的方法, 逐层 计算 即可 。
当然 大家 会问, 能不能 不用 这种 笨办法, 能不能 计算出 每一层 被 上层 遮挡 后 “露出” 的 部分, 然后 每一层 只 渲染 露出 的 部分 ?
也可以 。 但是 遮挡 会产生 许多 不规则 的 图形, 虽然 浏览器 的 元素 和 Windows 窗口 都是 矩形, 即便如此, 遮挡 也会 产生 各种 不规则 图形,
就像 围棋盘 一样 。
不规则图形 是指 “露出” 的 部分, 因为 露出 部分 是 不规则图形, 所以计算起来 比较 麻烦, 如果 元素(窗口) 小 而 密集, 那 露出 部分 会 呈 不规则 和 “碎片化”, 这样 计算量 也不小 。
在 三维 下, 计算 露出 部分 就 更复杂, 因为 三维 模型 之间 遮挡 的 露出部分 的 投影 会是 各种 奇怪 的 不规则的 图形, 这个 投影 包括 几何投影 和 透视投影 。
所以,我放弃了 三维 下 计算 露出 部分 的 算法 。
退而求其次, 像 二维 那样, 根据 叠放(遮挡) 的 层级, 让 每个 模型 的 每个 单元面 一一 渲染, 如何?
这首先需要 知道 叠放(遮挡) 的 层级,
用通俗的话说, 叠放(遮挡) 的 层级 就是 单元面 谁在前 谁在后, 这个 单元面 包含 所有模型 的 所有单元面,
同一个 模型 的 单元面 也有 前后 遮挡 的 关系, 因为 一个 物体 有 “正面” 和 “背面” 。
要给 所有模型 的 所有单元面 计算出 遮挡层级, 也就是说, 要给 所有模型 的 所有单元面 排一个序, 看看 谁在前 谁在后,就像 给 幼儿园 的 小朋友 排队一样 。
假设 场景 里的 所有模型 由 n 个 单元面 组成, 每个 单元面 需要 和 其它 的 所有单元面 都 比较一次 谁在前 谁在后,
那么, 需要 比较 的 次数 是 (n - 1) + (n - 2) + (n - 3) + (n - 4) + …… + 1 。
比较 的 算法 是 看 2 个 单元面 在 视平面 上的 投影 有没有 相交, 如果没有 则 2 者 不存在 遮挡关系, 如果有, 则 取 投影 中的 任意一点, 比较 该 点 到 单元面 的 距离, 距离大的 单元面 靠后, 距离小 的 单元面 靠前, 距离大 的 单元面 被 距离小 的 单元面 遮挡 。
这里说的 投影 包括了 几何投影 和 透视投影 , 投影点 到 单元面 的 距离 就是 投影点 沿着 投影线 到 单元面 的 距离 。
当然, 几何投影 和 透视投影 的 投影方式 不同, 投影线 也 不一样 。
这个 方法 可以用于 计算 单元面 在 几何投影 上 是否有 遮挡, 也可以用于 计算 单元面 在 透视投影 上 是否有遮挡 。
可以看到, (n - 1) + (n - 2) + (n - 3) + (n - 4) + …… + 1 这个 比较 次数 不小 。
假设 组成 所有模型 的 单元面 是 10000 个, 那么 (n - 1) + (n - 2) + (n - 3) + (n - 4) + …… + 1 就等于 10000 * (9998 / 2) + 5555 = 49995555 , 差不多 5 千万, 或者说 接近 10000 * 10000 / 2 = 5 千万 。
10000 * (9998 / 2) + 5555 = 49995555 这是 高斯先生 的 算法, 高斯 小学 时候 老师 出了 一道题, 让 同学们 从 1 加 到 100, 然后 高斯 很快就算好了,他的算法是 100 + 1 = 101 , 99 + 2 = 101 , 99 + 3 = 101 , 一共有 50 个 101, 所以 1 + 2 + 3 + …… + 100 = 101 * 50 = 5050 。
一个 比较细腻 的 模型 ,比如 人体模型, 似乎 很容易 花费 1 万 个 单元面, 当然 这是 推测 。
所以, 一个 和 现实世界 比较 接近 的 场景 中 所有模型 的 单元面 数量 也是 可观 的, 计算这些 单元面 的 遮挡层次 的 计算量 也很 可观 。
所以 我 暂时 对 这种 做法 持 保留态度, 不过我们还是可以看看 如果 知道 所有 单元面 的 遮挡层级, 那么 如何 来 渲染 。
其实 和 二维 差不多, 区别 是 三维 下 要 先 计算 透视投影,
三维 的 渲染 是 先 计算 透视投影, 把 透视投影 输出像素 到 位图 , 位图 就是 渲染结果 。
和 二维一样, 从 最低层 的 单元面 开始 渲染, 逐层 渲染 至 最高层 , 这样 高层 会 覆盖 底层 的 内容(像素), 这样 就 实现 了 叠放(遮挡) 效果 。
对于 半透明 的 效果, 也 和 二维 一样, 在 渲染 中, 如果 单元面 遮挡了 某些 物体, 则 在 该 单元面 的 渲染结果 中 以 透明度 为 比例 显示 上层 渲染结果 的 像素 。 假设 单元面 的 透明度 是 40%,那么 在 单元面 的 渲染结果(输出像素)中 应该有 40% 的 像素 显示的是 上层 的 渲染结果 的 像素 。 这 40% 的 像素 是 均匀 的 分布在 单元面 的 渲染结果 里 的 。
显然, 这个做法 的 前提 是 计算出 所有 单元面 的 遮挡层次, 但是, 上文讨论了, 计算 所有 单元面 的 遮挡层次 的 计算量 是 很大的, 所以 3D 里的 半透明 效果 是 比较 麻烦 的 。 这里 还没有 考虑 半透明 介质 在 不同角度 上 的 透明度 的 变化 呢 。 比如, 一块 玻璃, 把 它 放 斜, 相当于 厚度 增加了, 透明度 会 降低 。
还有 折射 半反射 ,,,
那么, 能不能 不 计算出 所有 单元面 的 遮挡层次 而 实现 遮挡 效果 ? 有一个 办法, 是 个 笨办法 。
就是 每个 单元面 独立 渲染, 不需要 考虑 单元面 渲染 的 顺序, 在 输出 每个像素 的 时候, 比较 当前 位置 的 有没有 其它 单元面 已经 输出 的 像素, 如果没有, 则 直接 输出像素, 如果有, 则 比较 当前 像素 表示 的 投影点 到 单元面 的 距离 , 这个 距离 是 投影点 沿 投影线 到 单元面 的 距离 , 距离 大 的 表示 靠后, 距离 小 的 表示 靠前 , 靠前 覆盖 靠后 。 也就是说, 如果 当前 像素 的 距离 小,则 覆盖 已有像素, 否则 保持 原有像素 。
假设 位图 的 分辨率 是 1000 * 700 , 位图 的 像素 数 1000 * 700 = 70 万 , 那么 比较输出像素 的 次数 可能 小于 70 万, 也可能 远大于 70 万,
如果 模型 比较小 或者 离 “镜头” 比较远, 那么, 模型 的 像(透视投影) 就 很小, 像(透视投影) 的 像素 数量 也少, 这种情况下, 所有 模型 的 所有 单元面 的 像(透视投影) 的 像素 加起来 可能 小于 70 万, 当然 也可能 大于 。
这里要 提 一下 背景,
背景 也是 一个 或 多个 模型, 是 一种 特殊 的 模型, 它 的 特点 是 在 “最底层”, 不会 遮挡 其它 模型, 只会被 其它 模型 遮挡 。
所以, 背景 最先 被 渲染, 且 不需要 遮挡比较 计算 。
但 问题 是, 如果 背景 由 多个 模型 组成,这些 模型 之间 也可能 存在 遮挡, 这样 还是 需要 遮挡比较 计算 。
实际应用中 可能 存在 “固定背景” , 就是 把 背景 预先 渲染 好, 而 人物 道具 等 实时模型 直接在 背景 渲染好的 位图 上 继续 实时渲染 ,
这样可以 减少 计算量, 省时省力, 就好像 拍电影 在 演员 身后 用 一块 幕布 做 背景 那样, 就像 照相馆 一样 。
假设 场景 有 100 个 模型, 平均 每个 模型 的 像(透视投影) 的 像素 是 10 万 , 那么 , 100 个 模型 的 输出像素 数量 是 100 * 10 万 = 1000 万 , 也就是要 计算 1000 万 次 遮挡比较 。
把 每次 输出像素 时 的 比较 操作 的 时间复杂度 看作 O(1) , 那么, 对于 这 100 个 模型 的 场景, 渲染 时 计算 遮挡比较 时间复杂度 是 O( 1000 万 ),
假设 CPU 每次 比较输出 像素 的 操作 耗时 100 纳秒(ns), 1 秒钟 可以 比较输出 像素 1000 万 次 ,
那么, CPU 1 秒钟 可以 渲染 1000 万 / 1000 万 = 1 个 这样 的 100 个 模型 的 场景 。
当然 这些 是 推演 和 估算, 但 也 大概可以看出 3D 需要 密集 的 浮点计算 以及 把 计算 独立 到 GPU 里 进行 的 原因 了 吧 ~ !
同样 也可以看出, 分辨率 是 3D 的 一个 重要指标, 分辨率 的 增长 会 带来 显著的 计算量 增长, 随之带来 对 硬件 性能 的 要求 的 增长 。
当然 这里 的 渲染 仅仅 是 计算 遮挡, 没有 包括 皮肤材质 、光影 等 。
我们可以看看 3D 国漫 , 比如 《画江湖 之 不良人》, 不良人 第一季 第二季 的 最高 分辨率 是 720 P , 直到 第三季, 才变成 了 1080 P 。
第一季 大概是 2014 年 出的, 第三季 应该是 到了 2019 年, 从 720 P 到 1080 P , 走了 5 年, 不容易 啊 !
需要说明的是, 第 3 种 方法 (就是上面这种 每个 单元面 独立 渲染, 每个 像素 在 渲染 时 比较 到 单元面 的 距离) 不能 实现 半透明 效果 , 实现 半透明 必须 第 2 种 方法(计算出 所有 单元面 的 遮挡层次, 按 层次 渲染) 。
这是 因为 半透明 必须 考虑 一个 整体 的 效果, 即 在 上层 单元面 上 按比例 均匀 的 显示 下层内容 的 像素 , 比例 就是 透明度 。
这 需要 按 层次 渲染 和 按 单元面 呈现 半透明 效果 。
上面 我们 讨论 了 模型 边界判断 透视 遮挡 , 这 4 个 问题 是 图形学 的 基本问题, 这 4 个 问题 解决了, 皮肤 材质 光影 重力 机械运动 以及 其它 种种 问题 都是 添砖加瓦 的 工作量 问题 。
于是, 我们 可以 来 总结 一些 基本 的 库函数 :
1 求得 简单图形面(三角形面 矩形面) 在 任意 平面 上的 几何投影
2 求得 简单图形面(三角形面 矩形面) 的 透视投影, “摄像机” 镜头 和 简单图形面 的 距离 角度 可以 任意 设置
3 求得 多个 简单图形面(三角形面 矩形面) 之间 有 遮挡 效果 的 透视投影
以上 的 透视投影 不 包含 皮肤 材质 光影, 只 包含 简单图形面 的 边 , 实现了 这 3 个 库函数, 实际上 这 已经 是一个 简单 的 图形引擎(3D 引擎)了 。
这篇 文章 可以作为 图形学 的 理论基础 。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!