C++小引擎中的Mipmap与LOD:从闪烁到流畅渲染

发布时间:2026/10/22 3:39:58
C++小引擎中的Mipmap与LOD:从闪烁到流畅渲染 做渲染的人一定遇到过这种场景一个明明贴图还挺清楚的远景模型镜头一拉远就开始疯狂闪烁地面上一片摩尔纹远处的草皮像爬满了蚂蚁。刚开始我还以为是贴图资源出了问题检查了 UV、检查了压缩格式问题依旧。后来才明白我压根就没给纹理生成 Mipmap更没做 LOD 切换。那次教训让我认了一件事在一个正经的引擎里Mipmap 和 LOD 不是后期优化的锦上添花而是从第一天就应该规划进去的基础设施。这篇文章就是围绕C 小引擎里的 Mipmaps LOD展开的总结。我会先讲清楚 Mipmap 是干什么的、应该怎么生成、采样器该怎么配然后再讲 LOD 从几何层面怎么做、切换条件怎么算、如何避免切换跳跃最后把我在实际开发里踩过的坑和排查经验整理成一个速查表。适合正在自己写渲染器、或者用图形 API 做实时渲染项目的开发者也适合那些想搞清楚为什么远处画面总在闪的朋友。1. 为什么 Mipmap 不是可选项一个闪烁问题的背后1.1 一个像素到底该取什么颜色要理解 Mipmap 存在的意义先得搞清楚实时渲染里纹理采样到底在做什么。一个远处的墙面投影到屏幕上可能只有几个像素那么宽。可原始纹理是一张 2048x2048 的大图GPU 在这个极小的范围内采样时纹理坐标的变化速度非常快——从一个像素到相邻像素横跨了原始纹理里几十个 texel。这就像你拿着一个非常低的采样率去记录一个高频信号。按采样定理的说法采样频率必须至少是信号最高频率的两倍才能无失真地重建。当纹理坐标的导数超过了屏幕像素所能表达的频率高频细节就会折叠成低频噪声。表现出来就是典型的摩尔纹、闪烁以及远处物体蠕动的现象。有个类比很好记你把一张报纸放到三米外肉眼看到的不应该是上面的字迹变成黑黑白白的脏点而应该是一个整体的灰白色块。想让屏幕表现符合直觉就得提前把细节做旧——这就是预过滤。1.2 Mipmap 本质上是空间上的预制滤波器Mipmap 的做法简单粗暴但极其有效生成一张长为原图一半、宽为原图一半、面积为原图四分之一的缩小图再在这个基础上继续缩一直缩到 1x1 为止。这就形成了一条纹理金字塔每一层都是上一层经过低通滤波后的结果。渲染时GPU 根据当前像素覆盖的纹理范围选择合适的层。层选得越靠上细节越少但采样结果越稳定。三线性过滤会让 GPU 在两层之间做插值确保层与层切换之间不会出现明显跳变。所以 Mipmap 本质上是在模板阶段就把各种频率的细节都预存好了运行时只需要挑选合适的那一层即可。这也引出了 LOD 这个概念。广义的 LOD 并不只是几何体的高模低模切换。纹理的 Mipmap 就是纹理层面的 LOD照着不同距离给渲染器提供不同精度的数据几何 LOD 则是另一条独立但思路相同的路径。后面我会把这两条线串起来讲。2. Mipmap 核心实现从生成到采样2.1 逐级降采样该怎么做平均最常见的生成方式是把 2x2 的 texel 做平均。假设原图分辨率是 2048x2048第一层 mip 就是 1024x1024第二层 512x512一直缩到 1x1。总层数用这个公式算floor(log2(max(width, height))) 1。比如 2048 的图就有 12 层。如果只是简单平均视觉效果其实不够理想。因为简单的盒式滤波器在频域上的旁瓣不够干净生成的 mip 链在远处还是容易出现轻微闪烁。更讲究的做法是用 Kaiser 窗或者 Lanczos 这类带权重的滤波器先把图像做一次高质量降采样再作为下一层的输入。这个细节在离线美术资源导出时尤其重要。还有个非常容易忽略的坑如果是在 sRGB 颜色空间直接做平均生成出来的低层 mip 会明显偏灰。原因是 sRGB 是非线性的简单平均的数值物理上并不对应真实亮度。正确的做法是把纹理数据先转到线性空间做卷积再转回 sRGB 存储。如果源数据本身是线性空间那就一路线性到底。我还建议在生成的 mipmap 链中保留最小的 1x1 层。它代表整张纹理的平均颜色在一些极远距离、甚至从当前 LOD 范围外靠近时可以作为兜底值避免采样器因层数不够而取到空数据。下面是一段早期我在自己的资源工具里写的链生成核心逻辑可以给你参考// 假设输入是一张 RGBA8 的线性空间像素数组 std::vectorunsigned char GenerateNextMip( const unsigned char* src, int srcWidth, int srcHeight, int channels) { int dstWidth std::max(1, srcWidth / 2); int dstHeight std::max(1, srcHeight / 2); std::vectorunsigned char dst(dstWidth * dstHeight * channels, 0); for (int y 0; y dstHeight; y) { for (int x 0; x dstWidth; x) { int sx0 std::min(x * 2, srcWidth - 1); int sy0 std::min(y * 2, srcHeight - 1); int sx1 std::min(x * 2 1, srcWidth - 1); int sy1 std::min(y * 2 1, srcHeight - 1); for (int c 0; c channels; c) { int sum 0; sum src[(sy0 * srcWidth sx0) * channels c]; sum src[(sy0 * srcWidth sx1) * channels c]; sum src[(sy1 * srcWidth sx0) * channels c]; sum src[(sy1 * srcWidth sx1) * channels c]; dst[(y * dstWidth x) * channels c] (unsigned char)(sum / 4); } } } return dst; } // 主流程 void BuildMipChain(const unsigned char* baseImage, int width, int height, int channels, std::vectorstd::vectorunsigned char outChain) { outChain.clear(); outChain.push_back(std::vectorunsigned char(baseImage, baseImage width * height * channels)); int curW width, curH height; while (curW 1 || curH 1) { auto next GenerateNextMip(outChain.back().data(), curW, curH, channels); outChain.push_back(std::move(next)); curW std::max(1, curW / 2); curH std::max(1, curH / 2); } }注意这段只是为了说明逻辑实际工程里你大概率会用一个成熟的图像库来生成 mip比如显卡厂商工具链里的离线工具或者第三方图像库。但滤波选择和线性空间处理是一致的。2.2 管线里的上传与采样配置生成完 mip 链之后上传到 GPU 时有一个很容易犯错的地方很多人创建纹理时只指定了宽高没有指定 mip 层级数量。在 Vulkan 里VkImageCreateInfo 的 mipLevels 字段如果填 1那就只有顶层即使后面上传了多层数据采样器也无法正确访问。正确的做法是创建 image 时把 mipLevels 设为完整链长度并且在每次上传时按层计算 offset 和 size。RGBA8 非压缩格式还比较简单尺寸减半字节数自然也是四分之一。但 BC 系压缩纹理就麻烦一些。因为 BC 格式按 4x4 像素块压缩每层 mip 的块数、每块字节数都要独立计算不能简单用总字节数除以层级。采样器这边同样需要配置。下面是一段 Vulkan 风格的创建示例关键字段可以参考VkSamplerCreateInfo samplerInfo {}; samplerInfo.sType VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; samplerInfo.magFilter VK_FILTER_LINEAR; samplerInfo.minFilter VK_FILTER_LINEAR; samplerInfo.mipmapMode VK_SAMPLER_MIPMAP_MODE_LINEAR; samplerInfo.addressModeU VK_SAMPLER_ADDRESS_MODE_REPEAT; samplerInfo.addressModeV VK_SAMPLER_ADDRESS_MODE_REPEAT; samplerInfo.mipLodBias 0.0f; samplerInfo.minLod 0.0f; samplerInfo.maxLod VK_LOD_CLAMP_NONE; // 允许访问完整链 samplerInfo.maxAnisotropy 4.0f; samplerInfo.anisotropyEnable VK_TRUE;这里最容易被忽略的是 mipmapMode。如果你忘了把它设成 VK_SAMPLER_MIPMAP_MODE_LINEAR即使有完整 mip 链采样器也只会用最高的第 0 层。这也是明明有 mip 却依然闪烁最常见的原因之一。关于各向异性过滤我建议不要盲目全开。各向异性采样在斜视角度下会沿屏幕方向多取纹理样本性能成本不低尤其在移动设备上。我自己的经验是地面、墙面这类大面积平面可以用 4x 或 8x普通角色和特效材质用 2x 就很稳透明材质反而要谨慎。2.3 生成时机离线还是运行时生成 mip 无非两种途径离线烘焙到纹理资源里或者运行时靠 API 自动生成。图形 API 提供的 generate mipmap 调用很方便但需要把完整纹理先 upload 一遍再做多次 blit时间和功耗未必划算。而且各厂商驱动在自动生成时使用的滤波器质量不一样结果也不可控。我的建议是正式项目里尽量离线生成并打包进资源比如做成 DDS 或 KTX 这类支持完整 mip 链的容器格式。这样资源管线还能顺便做压缩、预乘 alpha、sRGB 转线性等操作。这个小 Demo 或者调试工具里用运行时生成倒是没问题省事。如果你做的是大世界类项目纹理流送streaming是绕不开的。那时候 mip 链的加载优先级也很讲究优先流送低层小尺寸 mip先保证画面不闪高层大尺寸 mip 再陆续补进来。这也算是把 mip 能力和资源调度系统结合起来了。3. 从纹理到几何LOD 是怎么一回事3.1 几何 LOD 与Heirarchical LOD明白了 Mipmap 是纹理层面的预滤波后几何层面就好理解多了。一个几百万面的雕刻模型在远景里依然全量渲染GPU 顶点处理压力大还容易造成像素级的闪烁和覆盖误差。几何 LOD 的做法是在不同距离上更换不同精度的模型距离近用高模距离远用低模。最简单的离散 LOD 是美术手动或者 DCC 工具自动生成三到五个级别的模型每个级别三角形数量逐级减少。还有一种叫 CLOD连续 LOD的技术可以连续地增减网格复杂度但工程复杂度高移动端一般不用。大世界上还有一种思路叫 HLOD层次化 LOD把场景树内一个区域的所有物体合并后生成一个整体低模相当于把一堆物体当成一个纹理再为它构建 mip——这个思路和 mipmap 异曲同工非常优雅。选哪种取决于项目类型。我自己的引擎项目选择的是常规离散 LOD 加 HLOD 混合方案主角附近两三棵树用独立离散 LOD远景一片树林直接换成 HLOD 的合并 mesh。这样既能控制性能又能在中近距离保持较高的变形精度。3.2 距离阈值和屏幕空间误差LOD 切换最朴素的标准是距离。给每个 LOD 层级设定一个切换半径比如 LOD0 在 10 米内LOD1 在 10 到 30 米LOD2 在 30 米以外。用距离的好处是简单直观、容易调坏处是它对不同尺寸的物体不公平。一个 20 米高的建筑在 50 米外依然占屏幕很大面积如果这时已经切到极低模穿帮会非常明显。更合理的标准是屏幕空间误差SSEScreen Space Error。它衡量的是如果这个物体用了低模它的几何偏差在屏幕上相当于多少个像素。只要误差小于某个像素数就可以放心切换。计算思路是这样的假设高模相对低模的最大几何偏移是 d投影后这个偏移在屏幕上占据的像素数可以近似为pixels d * viewportHeight / (2 * distance * tan(verticalFov / 2))举个例子垂直视场角 60 度屏幕高度 1080 像素物体距离 50 米几何偏差是 0.05 米。代入算一下float projScale 1080.0f / (2.0f * tanf(glm::radians(60.0f) * 0.5f)); float pixels 0.05f * projScale / 50.0f;最后算出来大约是 0.9 像素。如果阈值设在 1 像素左右这个切换就是合理的。SSE 的好处是它把分辨率、视场角、物体尺寸、距离都统一进同一个度量不管什么设备调起来都有一致性。但纯 SSE 也有缺点计算几何偏移需要各层级之间的真实偏差数据美术不一定会提供。所以工程上常见的是基础距离 包围球尺寸修正的混合模式。给每个 LOD 设一个基准距离再乘以一个与包围球半径相关的缩放系数就能兼顾实现简单和效果合理。3.3 过渡怎么做到不跳变LOD 切换最让人头大的问题是跳变。高模和低模的边缘轮廓不可能完全一致切换瞬间物体就像抖了一下。要让切换尽量顺滑业界常用的做法是过渡带加 alpha 淡入淡出。具体实现是把两个 LOD 模型在切换半径附近同时渲染一小段时间新模型逐渐增加透明度或不透明度旧模型逐渐淡出。这种淡入淡出有两种常见做法一种是传统 alpha blend另一种是 alpha to coverage 配合 dither 图案后者不用排序适合草、树叶这类大量出现的物体。缺点是远景可能稍显颗粒感但对视线冲击不大。还有一个容易忽略的点静态烘焙光照下不同 LOD 因为法线和 UV 不同光照颜色可能有差异。淡入淡出期间如果两个模型明暗相差很大反而会出现忽明忽暗。解决办法是低模重新传递高模的法线信息或想办法共享同一套光照烘焙数据。这个过程往往比生成低模本身还耗时。4. 引擎里的落地操作资源管线与运行时管理4.1 资源导入阶段该做什么到了工程层面事情就不仅是算法问题而是流程问题。我在自己的引擎里把 LOD 和 mip 处理统一放进导入管线。模型文件进来之后管线会做这几件事第一步检查模型是否有多个 LOD 层级。如果没有就按预设简化率自动生成通常生成三个级别完整精度、半精度、低精度。简化过程必须在 DCC 工具里带纹理坐标和法线处理完成单纯在代码里做边折叠很容易破坏 UV。第二步为每个 LOD 层级计算包围球半径和几何偏差。这个几何偏差并不是引入新文件而是在简化前后做一次最大距离采样存成浮点数就行。这几组数据最终会写入自定义资源配置文件运行时 LOD 系统直接读取。第三步纹理资源导出时同步完成 mip 链生成、sRGB 线性处理以及压缩。如果项目使用 BC 纹理最好把 mip 链和压缩放在同一条工具链里处理否则不同层级之间的边界处可能出现明显接缝。提一句这里的做法同样适用于 ASTC 格式只是在算 mip 数据大小时要注意块尺寸。这套流程落地后运行时加载统一资源包只需要读配置、分配 GPU 资源、上传数据不需要再在游戏线程里做简化或者滤波运算。4.2 运行时 LOD 更新策略运行时 LOD 决策是个高频操作。如果一个场景里有几万个活跃对象每帧都逐个算 SSE、逐个切换层级性能会很难看。我的办法是分层处理每帧先做视锥剔除只对可见对象做 LOD 更新。可见对象再多时也不全部在单帧内更新而是按距离分桶每帧只处理一部分。例如近距离对象每帧更新中距离对象每两帧更新远距离每四帧一次。切换时有跃变感没关系决策滞后个一两帧对于远景物体肉眼根本无感。LOD 切换要加滞回区。比如 LOD1 切换到 LOD0 的阈值是 20 米但从 LOD0 回到 LOD1 的阈值设在 24 米而不是同一个 20 米。否则镜头稍微前后移动模型就会来回跳动看起来像呼吸。卸载那边也要注意。如果低模资源流送进内存后退场了高模才刚加载就会出现画面突然变粗糙。解决办法是给资源一个延迟回收周期比如 5 秒内不被再次使用的资源才真正卸载。这个经验来自一次大世界测试当时资源反复加载导致卡顿加了延迟回收后明显缓解。4.3 性能预算与调试手段有了资源管线性能预算是另一道关口。纹理内存的总量约等于所有层级面积之和再乘以每像素字节数非压缩格式下 mip 链大约增加 33% 的显存。压缩格式下内存少很多但带宽才是真正瓶颈。采样各向异性纹理的时候带宽开销会成倍增加所以要根据目标硬件封锁一大档。我习惯在引擎里留一个调试模式把当前采样到的 mip 层级和几何 LOD 层级直接显示成颜色。近处显示红色远处显示蓝色中间是渐变色。发现某个区域突然颜色跳变就说明阈值或过渡带没调好。这个手段比看帧率快得多强烈推荐你也做一套。还可以统计每帧的三角形数量和纹理采样次数。LOD 系统生效后三角形数量应该明显下降。如果曲线只在 LOD0 和 LOD2 之间反复跳动说明级间差距太大需要增加一个中间级或者把切换半径拉开。5. 常见问题与排查速查5.1 问题症状速查表我把过去踩过的坑整理成了下面的速查表方便你遇到类似症状时直接对照排查症状常见原因排查重点解决建议远景纹理闪成一片没有生成 mip 链或采样器 mipmapMode 仍是 Nearest检查资源 mip 层数和 VkSampler 配置补全 mip 生成mipmapMode 设为 LINEAR纹理整体发灰、颜色变淡mip 在 sRGB 空间做了平均查看生成工具的色彩空间设置改为在线性空间生成 mip压缩纹理出现接缝各 mip 层独立压缩时边界处理不当检查工具链是否统一压缩参数使用完整离线工具链生成压缩 mipLOD 切换时模型跳动切换阈值不区分进退观察镜头前后移动时层级切换点增加滞回区间进/出阈值错开远处模型边缘啃噬感淡出用 alpha blend 且排序问题检查渲染队列和 blend 状态换用 alpha-to-coverage 加 dither法线贴图远处发闪法线 mip 简单平均后未归一化检查法线纹理导出配置使用专门的法线 mip 生成或重归一化大量植被 LOD 切换突兀过渡带太短看 LOD 调试颜色是否急变拉长过渡距离并做交叉淡化资源反复加载卡顿卸载太激进查看资源流送日志延长资源延迟回收时间5.2 几个不是一眼能看出来的坑先说法线贴图。多数离线工具默认把法线纹理当普通颜色图来生成 mip每一层对 RGB 做平均。但法线贴图的 RGB 本质是向量的 XYZ 分量平均之后的向量长度会小于 1如果再直接采样光照结果就会发暗发灰。正确的做法是生成 mip 后再把向量归一化或者使用专门针对法线图的滤波方案。再说 alpha 测试的植被和铁丝网。它们的纹理常常带有硬边缘三线性过滤后 alpha 会逐渐变成灰色半透明一旦 alpha 测试值设高了边缘就会被裁剪得又细又碎。这个问题在移动端尤其明显。我当时的解决办法是改用 alpha-to-coverage同时把 mip 链的过渡层数集中在上部避免一个像素一个像素的闪烁。最后是 mip 层数计算错误。一个 1024x512 的非方形纹理最高层数应该是 floor(log2(max(1024,512)))1也就是 11 层。如果按短边算只生成 10 层最后一层会缺少 1x1 数据采样器 clamp 之后可能在极远距离取到错误结果。上传压缩纹理时尤其要注意大多数 API 对格式的最小尺寸有限制BC 格式下 4x4 块不能跨层共享。另外还有个小细节mipLodBias 尽量不要为了让画面更锐而设置负值。负的 bias 会让采样器偏向更精细的层级短时间看好像变锐了但远处该闪的地方还是会闪只是换了层干扰。需要提升远景清晰度时与其调 bias不如先检查各向异性采样是否开启、采样层是否选择正确或者从贴图本身的内容密度入手。我个人的体会是Mipmap 和 LOD 这两个东西单看一个原理都不复杂但把它们放进一个真实引擎里牵涉到资源格式、工具链、加载调度、调试手段是一条完整的技术链路。很多时候画面出问题不是缺了某一个函数调用而是整条链路上某个环节没有打通。把这条链路从导入到采样完整跑通一遍再回头看那些闪烁、跳动、卡顿基本都能找到准确的答案。希望这些经验能帮你少走一些弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询