Skip to content

Commit

Permalink
Port high resolution local SDFs (#10298)
Browse files Browse the repository at this point in the history
* Add 'localFontFamily' option to support rendering of all glyphs.

* Switch local glyph rendering to 2x SDF resolution.
Don't double-generate for glyphs with different fontstack but same local font.
Align baselines for glyphs generated with different local fonts.
Update tests.
  • Loading branch information
ChrisLoer authored Jan 25, 2021
1 parent 81978c8 commit 51510e8
Show file tree
Hide file tree
Showing 18 changed files with 273 additions and 67 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/mapbox-gl-supported": "^2.0.0",
"@mapbox/point-geometry": "^0.1.0",
"@mapbox/tiny-sdf": "^1.1.1",
"@mapbox/tiny-sdf": "^1.2.1",
"@mapbox/unitbezier": "^0.0.0",
"@mapbox/vector-tile": "^1.3.1",
"@mapbox/whoots-js": "^3.1.0",
Expand Down
13 changes: 12 additions & 1 deletion src/render/glyph_atlas.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
// @flow

import {SDF_SCALE} from '../render/glyph_manager';
import {AlphaImage} from '../util/image';
import {register} from '../util/web_worker_transfer';
import potpack from 'potpack';

import type {GlyphMetrics, StyleGlyph} from '../style/style_glyph';

const padding = 1;
const glyphPadding = 1;
/*
The glyph padding is just to prevent sampling errors at the boundaries between
glyphs in the atlas texture, and for that purpose there's no need to make it
bigger with high-res SDFs. However, layout is done based on the glyph size
including this padding, so scaling this padding is the easiest way to keep
layout exactly the same as the SDF_SCALE changes.
*/
const localGlyphPadding = glyphPadding * SDF_SCALE;

export type Rect = {
x: number,
Expand Down Expand Up @@ -38,6 +47,7 @@ export default class GlyphAtlas {
const src = glyphs[+id];
if (!src || src.bitmap.width === 0 || src.bitmap.height === 0) continue;

const padding = src.metrics.localGlyph ? localGlyphPadding : glyphPadding;
const bin = {
x: 0,
y: 0,
Expand All @@ -59,6 +69,7 @@ export default class GlyphAtlas {
const src = glyphs[+id];
if (!src || src.bitmap.width === 0 || src.bitmap.height === 0) continue;
const bin = positions[stack][id].rect;
const padding = src.metrics.localGlyph ? localGlyphPadding : glyphPadding;
AlphaImage.copy(src.bitmap, image, {x: 0, y: 0}, {x: bin.x + padding, y: bin.y + padding}, src.bitmap);
}
}
Expand Down
100 changes: 85 additions & 15 deletions src/render/glyph_manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ import type {StyleGlyph} from '../style/style_glyph';
import type {RequestManager} from '../util/mapbox';
import type {Callback} from '../types/callback';

/*
SDF_SCALE controls the pixel density of locally generated glyphs relative
to "normal" SDFs which are generated at 24pt font and a "pixel ratio" of 1.
The GlyphManager will generate glyphs SDF_SCALE times as large,
but with the same glyph metrics, and the quad generation code will scale them
back down so they display at the same size.
The choice of SDF_SCALE is a trade-off between performance and quality.
Glyph generation time grows quadratically with the the scale, while quality
improvements drop off rapidly when the scale is higher than the pixel ratio
of the device. The scale of 2 buys noticeable improvements on HDPI screens
at acceptable cost.
The scale can be any value, but in order to avoid small distortions, these
pixel-based values must come out to integers:
- "localGlyphPadding" in GlyphAtlas
- Font/Canvas/Buffer size for TinySDF
localGlyphPadding + buffer should equal 4 * SDF_SCALE. So if you wanted to
use an SDF_SCALE of 1.75, you could manually set localGlyphAdding to 2 and
buffer to 5.
*/
export const SDF_SCALE = 2;

type Entry = {
// null means we've requested the range, but the glyph wasn't included in the result.
glyphs: {[id: number]: StyleGlyph | null},
Expand All @@ -19,20 +42,38 @@ type Entry = {
tinySDF?: TinySDF
};

export const LocalGlyphMode = {
none: 0,
ideographs: 1,
all: 2
};

class GlyphManager {
requestManager: RequestManager;
localIdeographFontFamily: ?string;
localFontFamily: ?string;
localGlyphMode: number;
entries: {[_: string]: Entry};
// Multiple fontstacks may share the same local glyphs, so keep an index
// into the glyphs based soley on font weight
localGlyphs: {[_: string]: {[id: number]: StyleGlyph | null}};
url: ?string;

// exposed as statics to enable stubbing in unit tests
static loadGlyphRange: typeof loadGlyphRange;
static TinySDF: Class<TinySDF>;

constructor(requestManager: RequestManager, localIdeographFontFamily: ?string) {
constructor(requestManager: RequestManager, localGlyphMode: number, localFontFamily: ?string) {
this.requestManager = requestManager;
this.localIdeographFontFamily = localIdeographFontFamily;
this.localGlyphMode = localGlyphMode;
this.localFontFamily = localFontFamily;
this.entries = {};
this.localGlyphs = {
// Only these four font weights are supported
'200': {},
'400': {},
'500': {},
'900': {}
};
}

setURL(url: ?string) {
Expand Down Expand Up @@ -130,17 +171,23 @@ class GlyphManager {
}

_doesCharSupportLocalGlyph(id: number): boolean {
/* eslint-disable new-cap */
return !!this.localIdeographFontFamily &&
if (this.localGlyphMode === LocalGlyphMode.none) {
return false;
} else if (this.localGlyphMode === LocalGlyphMode.all) {
return !!this.localFontFamily;
} else {
/* eslint-disable new-cap */
return !!this.localFontFamily &&
(isChar['CJK Unified Ideographs'](id) ||
isChar['Hangul Syllables'](id) ||
isChar['Hiragana'](id) ||
isChar['Katakana'](id));
/* eslint-enable new-cap */
/* eslint-enable new-cap */
}
}

_tinySDF(entry: Entry, stack: string, id: number): ?StyleGlyph {
const family = this.localIdeographFontFamily;
const family = this.localFontFamily;
if (!family) {
return;
}
Expand All @@ -159,20 +206,43 @@ class GlyphManager {
} else if (/light/i.test(stack)) {
fontWeight = '200';
}
tinySDF = entry.tinySDF = new GlyphManager.TinySDF(24, 3, 8, .25, family, fontWeight);
tinySDF = entry.tinySDF = new GlyphManager.TinySDF(24 * SDF_SCALE, 3 * SDF_SCALE, 8 * SDF_SCALE, .25, family, fontWeight);
}

if (this.localGlyphs[tinySDF.fontWeight][id]) {
return this.localGlyphs[tinySDF.fontWeight][id];
}

return {
const {data, metrics} = tinySDF.drawWithMetrics(String.fromCharCode(id));
const {fontAscent, sdfWidth, sdfHeight, width, height, left, top, advance} = metrics;
// TinySDF's "top" is the distance from the top of the canvas to the top of the glyph,
// but drawn with `CanvasRenderingContext2D.textBaseline` = "middle", as opposed to
// the lower alphabetic baseline.
// Although we don't currently know the actual baseline of server supplied fonts
// (we might in the future, see: https://github.com/mapbox/node-fontnik/pull/160),
// we can guess the approximate difference between server-font baselines and this
// baseline using the "top" metric for DIN Office Pro's "A", which is -8.
// Modifying the adjustment based on the measured local font ascent ensures
// that local glyphs from different fonts will share the same baseline
const ascent = fontAscent ? (fontAscent / SDF_SCALE) : 17; // From SHAPING_DEFAULT_OFFSET
const baselineAdjustment = ascent - 9;

const glyph = this.localGlyphs[tinySDF.fontWeight][id] = {
id,
bitmap: new AlphaImage({width: 30, height: 30}, tinySDF.draw(String.fromCharCode(id))),
bitmap: new AlphaImage({
width: sdfWidth,
height: sdfHeight
}, data),
metrics: {
width: 24,
height: 24,
left: 0,
top: -8,
advance: 24
width: width / SDF_SCALE,
height: height / SDF_SCALE,
left: left / SDF_SCALE,
top: top / SDF_SCALE - baselineAdjustment,
advance: advance / SDF_SCALE,
localGlyph: true
}
};
return glyph;
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/style/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import StyleLayer from './style_layer';
import createStyleLayer from './create_style_layer';
import loadSprite from './load_sprite';
import ImageManager from '../render/image_manager';
import GlyphManager from '../render/glyph_manager';
import GlyphManager, {LocalGlyphMode} from '../render/glyph_manager';
import Light from './light';
import Terrain from './terrain';
import LineAtlas from '../render/line_atlas';
Expand Down Expand Up @@ -99,6 +99,7 @@ const empty = emptyStyle();

export type StyleOptions = {
validate?: boolean,
localFontFamily?: string,
localIdeographFontFamily?: string
};

Expand Down Expand Up @@ -163,7 +164,11 @@ class Style extends Evented {
this.dispatcher = new Dispatcher(getWorkerPool(), this);
this.imageManager = new ImageManager();
this.imageManager.setEventedParent(this);
this.glyphManager = new GlyphManager(map._requestManager, options.localIdeographFontFamily);
this.glyphManager = new GlyphManager(map._requestManager,
options.localFontFamily ?
LocalGlyphMode.all :
(options.localIdeographFontFamily ? LocalGlyphMode.ideographs : LocalGlyphMode.none),
options.localFontFamily || options.localIdeographFontFamily);
this.lineAtlas = new LineAtlas(256, 512);
this.crossTileSymbolIndex = new CrossTileSymbolIndex();

Expand Down
3 changes: 2 additions & 1 deletion src/style/style_glyph.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export type GlyphMetrics = {
height: number,
left: number,
top: number,
advance: number
advance: number,
localGlyph?: boolean
};

export type StyleGlyph = {
Expand Down
5 changes: 3 additions & 2 deletions src/symbol/quads.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type Anchor from './anchor';
import type {PositionedIcon, Shaping} from './shaping';
import {SHAPING_DEFAULT_OFFSET} from './shaping';
import {IMAGE_PADDING} from '../render/image_atlas';
import {SDF_SCALE} from '../render/glyph_manager';
import type SymbolStyleLayer from '../style/style_layer/symbol_style_layer';
import type {Feature} from '../style-spec/expression';
import type {StyleImage} from '../style/style_image';
Expand Down Expand Up @@ -278,8 +279,8 @@ export function getGlyphQuads(anchor: Anchor,

const x1 = (positionedGlyph.metrics.left - rectBuffer) * positionedGlyph.scale - halfAdvance + builtInOffset[0];
const y1 = (-positionedGlyph.metrics.top - rectBuffer) * positionedGlyph.scale + builtInOffset[1];
const x2 = x1 + textureRect.w * positionedGlyph.scale / pixelRatio;
const y2 = y1 + textureRect.h * positionedGlyph.scale / pixelRatio;
const x2 = x1 + textureRect.w * positionedGlyph.scale / (pixelRatio * (positionedGlyph.localGlyph ? SDF_SCALE : 1));
const y2 = y1 + textureRect.h * positionedGlyph.scale / (pixelRatio * (positionedGlyph.localGlyph ? SDF_SCALE : 1));

const tl = new Point(x1, y1);
const tr = new Point(x2, y1);
Expand Down
10 changes: 6 additions & 4 deletions src/symbol/shaping.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export type PositionedGlyph = {
fontStack: string,
sectionIndex: number,
metrics: GlyphMetrics,
rect: Rect | null
rect: Rect | null,
localGlyph?: boolean
};

export type PositionedLine = {
Expand Down Expand Up @@ -642,7 +643,8 @@ function shapeLines(shaping: Shaping,
height: size[1],
left: IMAGE_PADDING,
top: -GLYPH_PBF_BORDER,
advance: vertical ? size[1] : size[0]};
advance: vertical ? size[1] : size[0],
localGlyph: false};

// Difference between one EM and an image size.
// Aligns bottom of an image to a baseline level.
Expand All @@ -660,11 +662,11 @@ function shapeLines(shaping: Shaping,
}

if (!vertical) {
positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, fontStack: section.fontStack, sectionIndex, metrics, rect});
positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, localGlyph: metrics.localGlyph, fontStack: section.fontStack, sectionIndex, metrics, rect});
x += metrics.advance * section.scale + spacing;
} else {
shaping.verticalizable = true;
positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, fontStack: section.fontStack, sectionIndex, metrics, rect});
positionedGlyphs.push({glyph: codePoint, imageName, x, y: y + baselineOffset, vertical, scale: section.scale, localGlyph: metrics.localGlyph, fontStack: section.fontStack, sectionIndex, metrics, rect});
x += verticalAdvance * section.scale + spacing;
}
}
Expand Down
16 changes: 13 additions & 3 deletions src/ui/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const defaultOptions = {
refreshExpiredTiles: true,
maxTileCacheSize: null,
localIdeographFontFamily: 'sans-serif',
localFontFamily: null,
transformRequest: null,
accessToken: null,
fadeDuration: 300,
Expand Down Expand Up @@ -242,6 +243,9 @@ const defaultOptions = {
* In these ranges, font settings from the map's style will be ignored, except for font-weight keywords (light/regular/medium/bold).
* Set to `false`, to enable font settings from the map's style for these glyph ranges. Note that [Mapbox Studio](https://studio.mapbox.com/) sets this value to `false` by default.
* The purpose of this option is to avoid bandwidth-intensive glyph server requests. (See [Use locally generated ideographs](https://www.mapbox.com/mapbox-gl-js/example/local-ideographs).)
* @param {string} [options.localFontFamily=false] Defines a CSS
* font-family for locally overriding generation of all glyphs. Font settings from the map's style will be ignored, except for font-weight keywords (light/regular/medium/bold).
* If set, this option override the setting in localIdeographFontFamily
* @param {RequestTransformFunction} [options.transformRequest=null] A callback run before the Map makes a request for an external URL. The callback can be used to modify the url, set headers, or set the credentials property for cross-origin requests.
* Expected to return an object with a `url` property and optionally `headers` and `credentials` properties.
* @param {boolean} [options.collectResourceTiming=false] If `true`, Resource Timing API information will be collected for requests made by GeoJSON and Vector Tile web workers (this information is normally inaccessible from the main Javascript thread). Information will be returned in a `resourceTiming` property of relevant `data` events.
Expand Down Expand Up @@ -315,6 +319,7 @@ class Map extends Camera {
_logoControl: IControl;
_mapId: number;
_localIdeographFontFamily: string;
_localFontFamily: string;
_requestManager: RequestManager;
_locale: Object;
_removed: boolean;
Expand Down Expand Up @@ -476,8 +481,10 @@ class Map extends Camera {

this.resize();

this._localFontFamily = options.localFontFamily;
this._localIdeographFontFamily = options.localIdeographFontFamily;
if (options.style) this.setStyle(options.style, {localIdeographFontFamily: options.localIdeographFontFamily});

if (options.style) this.setStyle(options.style, {localFontFamily: this._localFontFamily, localIdeographFontFamily: this._localIdeographFontFamily});

if (options.attributionControl)
this.addControl(new AttributionControl({customAttribution: options.customAttribution}));
Expand Down Expand Up @@ -1349,13 +1356,16 @@ class Map extends Camera {
* @see [Change a map's style](https://www.mapbox.com/mapbox-gl-js/example/setstyle/)
*/
setStyle(style: StyleSpecification | string | null, options?: {diff?: boolean} & StyleOptions) {
options = extend({}, {localIdeographFontFamily: this._localIdeographFontFamily}, options);
options = extend({}, {localIdeographFontFamily: this._localIdeographFontFamily, localFontFamily: this._localFontFamily}, options);

if ((options.diff !== false && options.localIdeographFontFamily === this._localIdeographFontFamily) && this.style && style) {
if ((options.diff !== false &&
options.localIdeographFontFamily === this._localIdeographFontFamily &&
options.localFontFamily === this._localFontFamily) && this.style && style) {
this._diffStyle(style, options);
return this;
} else {
this._localIdeographFontFamily = options.localIdeographFontFamily;
this._localFontFamily = options.localFontFamily;
return this._updateStyle(style, options);
}
}
Expand Down
7 changes: 6 additions & 1 deletion test/expected/text-shaping-default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{
"glyph": 97,
"imageName": null,
"localGlyph": null,
"x": -32.5,
"y": -17,
"vertical": false,
Expand All @@ -28,6 +29,7 @@
{
"glyph": 98,
"imageName": null,
"localGlyph": null,
"x": -19.5,
"y": -17,
"vertical": false,
Expand All @@ -51,6 +53,7 @@
{
"glyph": 99,
"imageName": null,
"localGlyph": null,
"x": -5.5,
"y": -17,
"vertical": false,
Expand All @@ -74,6 +77,7 @@
{
"glyph": 100,
"imageName": null,
"localGlyph": null,
"x": 5.5,
"y": -17,
"vertical": false,
Expand All @@ -97,6 +101,7 @@
{
"glyph": 101,
"imageName": null,
"localGlyph": null,
"x": 19.5,
"y": -17,
"vertical": false,
Expand Down Expand Up @@ -129,4 +134,4 @@
"writingMode": 1,
"iconsInText": false,
"verticalizable": false
}
}
Loading

0 comments on commit 51510e8

Please sign in to comment.