buildMipChain function

List<Rgba8Image> buildMipChain(
  1. Rgba8Image base, {
  2. bool srgb = false,
  3. bool isNormalMap = false,
  4. double? alphaTestThreshold,
})

Builds a full mip chain from base down to a 1×1 level, each level a proper Kaiser-windowed resample of the one above rather than a box average — ap-08 in doc/asset-pipeline-plan.md.

Three different channels, three different filters, because averaging bytes is only ever correct for one of them.

  • srgb — a base-colour or emissive map's bytes are gamma-encoded, and averaging them in that space darkens every edge a mip crosses (the textbook mipmapping artifact: two texels at 0 and 255 average to 128 in byte space, but a display expects the linear-light average, which decodes back to about 188). RGB is decoded to linear, filtered, and re-encoded; alpha is never gamma data and is filtered as-is.
  • isNormalMap — R, G, B decode to a -1..1 component each, are filtered as vectors (never gamma), and every output texel is renormalized to unit length: averaging unit vectors does not produce one, and a mip whose normals have drifted off unit length makes a surface's lighting dimmer exactly where the mip is coarsest.
  • alphaTestThreshold — set for a mask this engine cuts holes with, not blends: an ordinary filtered alpha shrinks the fraction of texels above the threshold at every level down (a leaf card's coverage drops each mip and the foliage visibly thins with distance), so alpha here is rescaled per level to hold the same fraction above the threshold the base level has — see _preserveCoverage.

base's dimensions need not be a power of two or even square; each level halves both dimensions, rounding up, down to 1×1 — the same halving a GPU's own mip chain performs, so a device that reads this chain samples exactly the level count MipChain.levelsFor in texture_upload.dart already expects it to.

Clamp-to-edge only. A tiling texture wants its mip filter to wrap, the same way its sampler does, so a seam at the U/V wrap does not darken at distance the way an edge that clamped would. Nothing in this repository's three games currently generates mips for a texture whose sampler wraps — every tiled surface here samples a single level — so this is a real gap named rather than a silent wrong answer for whichever texture hits it first.

Implementation

List<Rgba8Image> buildMipChain(
  Rgba8Image base, {
  bool srgb = false,
  bool isNormalMap = false,
  double? alphaTestThreshold,
}) {
  final baseCoverage = alphaTestThreshold == null
      ? null
      : _coverage(base, alphaTestThreshold);

  final levels = <Rgba8Image>[base];
  var current = base;
  while (current.width > 1 || current.height > 1) {
    final nextWidth = math.max(1, (current.width / 2).ceil());
    final nextHeight = math.max(1, (current.height / 2).ceil());
    current = _downsample(
      current,
      nextWidth,
      nextHeight,
      srgb: srgb,
      isNormalMap: isNormalMap,
    );
    if (baseCoverage != null) {
      current = _preserveCoverage(current, alphaTestThreshold!, baseCoverage);
    }
    levels.add(current);
  }
  return levels;
}