From 6d06470849c501f0e4bbbac9b65ff3820e6c093c Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Wed, 1 Jul 2026 12:46:30 +0100 Subject: [PATCH 1/2] fix(font): disable mipmaps on MSDF font atlases Mipmapping an MSDF atlas averages the R/G/B distance channels, and median(average) != average(median), so minified text samples a corrupted median - faint ghost features (e.g. a thin flickering line below the close stems of "ll") that surface now the true-edge threshold no longer erodes them (#8990). Sample the base level only (mipmaps off, FILTER_LINEAR) for MSDF fonts; bitmap fonts are unaffected and keep their mipmaps. Co-Authored-By: Claude Opus 4.8 --- src/framework/handlers/font.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/framework/handlers/font.js b/src/framework/handlers/font.js index 3d74fdef037..9fe57b55dd1 100644 --- a/src/framework/handlers/font.js +++ b/src/framework/handlers/font.js @@ -1,6 +1,8 @@ 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 { FONT_MSDF } from '../font/constants.js'; import { Font } from '../font/font.js'; import { ResourceHandler } from './handler.js'; @@ -101,6 +103,10 @@ 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. + const isMsdf = !data.type || data.type === FONT_MSDF; + const loadTexture = function (index) { const onLoaded = function (err, texture) { if (error) return; @@ -111,6 +117,11 @@ class FontHandler extends ResourceHandler { return; } + if (isMsdf) { + texture.minFilter = FILTER_LINEAR; + texture.mipmaps = false; + } + texture.upload(); textures[index] = texture; numLoaded++; From b51fae7b3c6f0d2c5d3c2f6917ec858a3fdb8221 Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Wed, 1 Jul 2026 14:01:27 +0100 Subject: [PATCH 2/2] fix(font): create MSDF atlases without mipmaps at construction (WebGPU-safe) Mutating texture.mipmaps after load is a no-op on WebGPU: the setter warns and keeps the mip chain, and since the sampler has no LOD clamp it still minifies into those levels, so the artifact persisted on that backend. Request a non-mipmapped atlas up-front instead - thread the loader's existing `options` arg through to ResourceHandler.open, have the texture handler merge it into the texture creation options, and the font handler pass { mipmaps: false, minFilter: FILTER_LINEAR } for MSDF fonts. Adds a FontHandler regression test. Co-Authored-By: Claude Opus 4.8 --- src/framework/handlers/font.js | 16 ++++++------ src/framework/handlers/handler.js | 3 ++- src/framework/handlers/loader.js | 2 +- src/framework/handlers/texture.js | 6 +++-- test/framework/handlers/font-handler.test.mjs | 25 +++++++++++++++++++ 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/framework/handlers/font.js b/src/framework/handlers/font.js index 9fe57b55dd1..043c7addae4 100644 --- a/src/framework/handlers/font.js +++ b/src/framework/handlers/font.js @@ -103,9 +103,12 @@ 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. + // 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 + // a non-mipmapped texture at creation time (mipmaps can't be disabled after creation on + // WebGPU) rather than mutating the loaded texture. const isMsdf = !data.type || data.type === FONT_MSDF; + const textureOptions = isMsdf ? { mipmaps: false, minFilter: FILTER_LINEAR } : undefined; const loadTexture = function (index) { const onLoaded = function (err, texture) { @@ -117,11 +120,6 @@ class FontHandler extends ResourceHandler { return; } - if (isMsdf) { - texture.minFilter = FILTER_LINEAR; - texture.mipmaps = false; - } - texture.upload(); textures[index] = texture; numLoaded++; @@ -131,9 +129,9 @@ class FontHandler extends ResourceHandler { }; if (index === 0) { - loader.load(url, 'texture', onLoaded); + loader.load(url, 'texture', onLoaded, null, textureOptions); } else { - loader.load(url.replace('.png', `${index}.png`), 'texture', onLoaded); + loader.load(url.replace('.png', `${index}.png`), 'texture', onLoaded, null, textureOptions); } }; diff --git a/src/framework/handlers/handler.js b/src/framework/handlers/handler.js index 830db783fdb..9349536835b 100644 --- a/src/framework/handlers/handler.js +++ b/src/framework/handlers/handler.js @@ -80,9 +80,10 @@ class ResourceHandler { * @param {string} url - The URL of the resource to open. * @param {*} data - The raw resource data passed by callback from {@link load}. * @param {Asset} [asset] - Optional asset that is passed by ResourceLoader. + * @param {object} [options] - Optional resource-creation options passed by ResourceLoader. * @returns {*} The parsed resource data. */ - open(url, data, asset) { + open(url, data, asset, options) { return data; } diff --git a/src/framework/handlers/loader.js b/src/framework/handlers/loader.js index 97efc90f2f8..b5f6cffadd9 100644 --- a/src/framework/handlers/loader.js +++ b/src/framework/handlers/loader.js @@ -187,7 +187,7 @@ class ResourceLoader { } try { - self._onSuccess(key, handler.open(urlObj.original, data, asset), extra); + self._onSuccess(key, handler.open(urlObj.original, data, asset, options), extra); } catch (e) { self._onFailure(key, e); } diff --git a/src/framework/handlers/texture.js b/src/framework/handlers/texture.js index de75d53d215..4fa34e67bfd 100644 --- a/src/framework/handlers/texture.js +++ b/src/framework/handlers/texture.js @@ -250,12 +250,14 @@ class TextureHandler extends ResourceHandler { this._getParser(url.original).load(url, callback, asset); } - open(url, data, asset) { + open(url, data, asset, options) { if (!url) { return undefined; } - const textureOptions = this._getTextureOptions(asset); + // asset-derived options, with any per-load creation options (e.g. mipmaps: false for + // MSDF font atlases) taking precedence + const textureOptions = { ...this._getTextureOptions(asset), ...options }; let texture = this._getParser(url).open(url, data, this._device, textureOptions); if (texture === null) { diff --git a/test/framework/handlers/font-handler.test.mjs b/test/framework/handlers/font-handler.test.mjs index d3fff34a47c..177f09eb7da 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'; @@ -72,4 +73,28 @@ describe('FontHandler', function () { asset.on('error', err => done(new Error(err))); }); + // regression test: MSDF atlases must load without mipmaps and with a non-mip minFilter. Mip + // levels average the distance-field channels, corrupting the median under minification (which + // showed as a faint flickering line 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))); + }); + });