Repository: playcanvas/engine (2026-07-02T09:28:00Z)

Issue:
MSDF font atlases are mipmapped, corrupting the median on minified text
### Summary

MSDF font atlas textures are loaded with mipmaps enabled (`mipmaps: true`, `minFilter: FILTER_LINEAR_MIPMAP_LINEAR`). Mipmapping is wrong for MSDF: each mip level box-averages the R/G/B channels, but `median(average) != average(median)`, so under minification the shader samples a corrupted median. This shows as faint ghost/flicker artifacts on small text — e.g. a thin horizontal line that flickers on and off just below closely-spaced stems (the `ll` in "Hello") as the text moves. Most visible at small `fontSize` and DPR 1 / non-HiDPI.

### Why it's visible now

Latent for a long time (the atlas has always been mipmapped), but surfaced by #8990, which renders MSDF text at the true glyph edge (threshold `0.5`). The previous inward erosion (~`0.525` plus the `(m-0.05)/0.95` remap) had kept the faint corrupted-median values below the render threshold. Related to #8984.

### Repro

- MSDF font (e.g. store Roboto), `ElementComponent` text, small `fontSize` (~6), `useDevicePixelRatio: false` (DPR 1 makes it much easier to see), text translating sub-pixel.
- A faint horizontal line flickers below the `ll`. Forcing the atlas to sample the base level only (no mipmaps) eliminates it, without making small text look worse.

### Fix direction / constraints

Load MSDF atlases at the base level only — `mipmaps: false` + `minFilter: FILTER_LINEAR` (bitmap fonts should keep their mipmaps). Two things make this non-trivial:

1. **Must be set at texture *creation*, not mutated after load.** On WebGPU `Texture#mipmaps` can't be changed after creation (the setter warns and keeps the mip chain), and the WebGPU sampler sets no `lodMaxClamp`, so even a non-mip `minFilter` still minifies into the chain — the artifact persists unless the mip chain doesn't exist.
2. **No clean creation-options channel.** Font atlas textures load via `loader.load(url, 'texture', cb)` with no asset, so there's currently no tidy way to pass creation options (mipmaps/minFilter) through to the texture handler. #8996 attempted to add one by threading an `options` arg through `ResourceHandler.open` but that was rejected (too broad / public-API surface). The clean channel is the open design question.

Closed attempt: #8996.


Discussion:

Patch:
diff --git a/src/framework/handlers/font.js b/src/framework/handlers/font.js
index 3d74fdef037..4b387a5bb2e 100644
--- a/src/framework/handlers/font.js
+++ b/src/framework/handlers/font.js
@@ -1,6 +1,9 @@
 import { path } from '../../core/path.js';
 import { string } from '../../core/string.js';
+import { FILTER_LINEAR } from '../../platform/graphics/constants.js';
 import { http } from '../../platform/net/http.js';
+import { Asset } from '../asset/asset.js';
+import { FONT_MSDF } from '../font/constants.js';
 import { Font } from '../font/font.js';
 import { ResourceHandler } from './handler.js';
 
@@ -101,6 +104,14 @@ class FontHandler extends ResourceHandler {
         const textures = new Array(numTextures);
         const loader = this._loader;
 
+        // MSDF atlases must not be mipmapped: mip levels average the distance-field channels, and
+        // median(average) != average(median), so minified text samples a corrupted median. Request
+        // non-mipmapped textures at creation time (on WebGPU mipmaps cannot be disabled after
+        // creation), using per-load texture options on an ephemeral asset. Bitmap fonts keep
+        // their mipmaps.
+        const isMsdf = !data.type || data.type === FONT_MSDF;
+        const textureOptions = isMsdf ? { texture: { mipmaps: false, minFilter: FILTER_LINEAR } } : null;
+
         const loadTexture = function (index) {
             const onLoaded = function (err, texture) {
                 if (error) return;
@@ -119,11 +130,9 @@ class FontHandler extends ResourceHandler {
                 }
             };
 
-            if (index === 0) {
-                loader.load(url, 'texture', onLoaded);
-            } else {
-                loader.load(url.replace('.png', `${index}.png`), 'texture', onLoaded);
-            }
+            const pageUrl = index === 0 ? url : url.replace('.png', `${index}.png`);
+            const pageAsset = textureOptions ? new Asset(pageUrl, 'texture', { url: pageUrl }, null, textureOptions) : null;
+            loader.load(pageUrl, 'texture', onLoaded, pageAsset);
         };
 
         for (let i = 0; i < numTextures; i++) {
diff --git a/src/framework/handlers/texture.js b/src/framework/handlers/texture.js
index 8685286d578..d2b3483ab55 100644
--- a/src/framework/handlers/texture.js
+++ b/src/framework/handlers/texture.js
@@ -208,6 +208,12 @@ class TextureHandler extends ResourceHandler {
                 // basis normalmaps flag the variant as swizzled
                 options.type = TEXTURETYPE_SWIZZLEGGGR;
             }
+
+            // per-load creation options (raw Texture constructor options, for example
+            // { mipmaps: false, minFilter: FILTER_LINEAR }) override the asset-derived options
+            if (asset.options?.texture) {
+                Object.assign(options, asset.options.texture);
+            }
         }
 
         return options;


Test patch:
diff --git a/test/framework/handlers/font-handler.test.mjs b/test/framework/handlers/font-handler.test.mjs
index d3fff34a47c..1e55e4efbb3 100644
--- a/test/framework/handlers/font-handler.test.mjs
+++ b/test/framework/handlers/font-handler.test.mjs
@@ -1,6 +1,7 @@
 import { expect } from 'chai';
 
 import { Asset } from '../../../src/framework/asset/asset.js';
+import { FILTER_LINEAR } from '../../../src/platform/graphics/constants.js';
 import { createApp } from '../../app.mjs';
 import { jsdomSetup, jsdomTeardown } from '../../jsdom.mjs';
 
@@ -37,6 +38,31 @@ describe('FontHandler', function () {
         asset.on('error', err => done(new Error(err)));
     });
 
+    // regression test for https://github.com/playcanvas/engine/issues/8997 - MSDF atlases must
+    // load without mipmaps and with a non-mip minFilter: mip levels average the distance-field
+    // channels, corrupting the median under minification (visible as faint flickering artifacts
+    // below thin strokes on small text)
+    it('loads MSDF atlas textures without mipmaps', function (done) {
+        const asset = new Asset('arial', 'font', {
+            url: 'http://localhost:3210/test/assets/fonts/arial.json'
+        });
+
+        app.assets.add(asset);
+        app.assets.load(asset);
+
+        asset.ready(function () {
+            const textures = asset.resource.textures;
+            expect(textures).to.have.lengthOf(1);
+            textures.forEach((texture) => {
+                expect(texture.mipmaps).to.equal(false);
+                expect(texture.minFilter).to.equal(FILTER_LINEAR);
+            });
+            done();
+        });
+
+        asset.on('error', err => done(new Error(err)));
+    });
+
     // regression test for https://github.com/playcanvas/engine/issues/7033 - unloading a font
     // asset must destroy its textures and remove them from the resource loader cache
     it('destroys its textures and clears the loader cache on unload', function (done) {