diff --git a/src/mol-geo/geometry/text/text-atlas.ts b/src/mol-geo/geometry/text/font-atlas.ts similarity index 50% rename from src/mol-geo/geometry/text/text-atlas.ts rename to src/mol-geo/geometry/text/font-atlas.ts index 997218e6191be89781860298481854591da3b6ba..7c58f276306cb31f1c24b8bd76b46d1762d2b778 100644 --- a/src/mol-geo/geometry/text/text-atlas.ts +++ b/src/mol-geo/geometry/text/font-atlas.ts @@ -6,53 +6,50 @@ import { ParamDefinition as PD } from 'mol-util/param-definition'; import { edt } from 'mol-math/geometry/distance-transform'; +import { createTextureImage } from 'mol-gl/renderable/util'; -const TextAtlasCache: { [k: string]: TextAtlas } = {} +const TextAtlasCache: { [k: string]: FontAtlas } = {} -export function getTextAtlas (props: Partial<TextAtlasProps>) { - const hash = JSON.stringify(props) - if (TextAtlasCache[ hash ] === undefined) { - TextAtlasCache[ hash ] = new TextAtlas(props) - } - return TextAtlasCache[ hash ] +export function getFontAtlas (props: Partial<FontAtlasProps>) { + const hash = JSON.stringify(props) + if (TextAtlasCache[hash] === undefined) { + TextAtlasCache[hash] = new FontAtlas(props) + } + return TextAtlasCache[hash] } -export type TextFonts = 'sans-serif' | 'monospace' | 'serif' | 'cursive' -export type TextStyles = 'normal' | 'italic' | 'oblique' -export type TextVariants = 'normal' | 'small-caps' -export type TextWeights = 'normal' | 'bold' +export type FontFamily = 'sans-serif' | 'monospace' | 'serif' | 'cursive' +export type FontStyle = 'normal' | 'italic' | 'oblique' +export type FontVariant = 'normal' | 'small-caps' +export type FontWeight = 'normal' | 'bold' -export const TextAtlasParams = { - fontFamily: PD.Select('sans-serif', [['sans-serif', 'Sans Serif'], ['monospace', 'Monospace'], ['serif', 'Serif'], ['cursive', 'Cursive']] as [TextFonts, string][]), +export const FontAtlasParams = { + fontFamily: PD.Select('sans-serif', [['sans-serif', 'Sans Serif'], ['monospace', 'Monospace'], ['serif', 'Serif'], ['cursive', 'Cursive']] as [FontFamily, string][]), fontSize: PD.Numeric(36, { min: 4, max: 96, step: 1 }), - fontStyle: PD.Select('normal', [['normal', 'Normal'], ['italic', 'Italic'], ['oblique', 'Oblique']] as [TextStyles, string][]), - fontVariant: PD.Select('normal', [['normal', 'Normal'], ['small-caps', 'Small Caps']] as [TextVariants, string][]), - fontWeight: PD.Select('normal', [['normal', 'Normal'], ['bold', 'Bold']] as [TextWeights, string][]), - - width: PD.Numeric(1024), - height: PD.Numeric(1024) + fontStyle: PD.Select('normal', [['normal', 'Normal'], ['italic', 'Italic'], ['oblique', 'Oblique']] as [FontStyle, string][]), + fontVariant: PD.Select('normal', [['normal', 'Normal'], ['small-caps', 'Small Caps']] as [FontVariant, string][]), + fontWeight: PD.Select('normal', [['normal', 'Normal'], ['bold', 'Bold']] as [FontWeight, string][]), } -export type TextAtlasParams = typeof TextAtlasParams -export type TextAtlasProps = PD.Values<TextAtlasParams> - -export type TextAtlasMap = { x: number, y: number, w: number, h: number } +export type FontAtlasParams = typeof FontAtlasParams +export type FontAtlasProps = PD.Values<FontAtlasParams> -export class TextAtlas { - readonly props: Readonly<TextAtlasProps> - readonly mapped: { [k: string]: TextAtlasMap } = {} - readonly placeholder: TextAtlasMap - readonly context: CanvasRenderingContext2D +export type FontAtlasMap = { x: number, y: number, w: number, h: number } - private canvas: HTMLCanvasElement +export class FontAtlas { + readonly props: Readonly<FontAtlasProps> + readonly mapped: { [k: string]: FontAtlasMap } = {} + readonly placeholder: FontAtlasMap + readonly texture = createTextureImage(4096 * 2048, 1) private scratchW = 0 private scratchH = 0 private currentX = 0 private currentY = 0 + private readonly scratchData: Uint8Array - private readonly cutoff = 0.25 - private padding: number - private radius: number + private readonly cutoff = 0.5 + readonly buffer: number + private readonly radius: number private gridOuter: Float64Array private gridInner: Float64Array @@ -64,17 +61,18 @@ export class TextAtlas { private scratchCanvas: HTMLCanvasElement private scratchContext: CanvasRenderingContext2D - private lineHeight: number - private maxWidth: number - private middle: number + readonly lineHeight: number - constructor (props: Partial<TextAtlasProps> = {}) { - const p = { ...PD.getDefaultValues(TextAtlasParams), ...props } + private readonly maxWidth: number + private readonly middle: number + + constructor (props: Partial<FontAtlasProps> = {}) { + const p = { ...PD.getDefaultValues(FontAtlasParams), ...props } this.props = p - this.padding = p.fontSize / 8 + this.buffer = p.fontSize / 8 this.radius = p.fontSize / 3 - this.lineHeight = Math.round(p.fontSize + 6 * this.padding) + this.lineHeight = Math.round(p.fontSize + 2 * this.buffer + this.radius) this.maxWidth = this.lineHeight * 1.5 // Prepare scratch canvas @@ -87,6 +85,9 @@ export class TextAtlas { this.scratchContext.fillStyle = 'black' this.scratchContext.textBaseline = 'middle' + // SDF scratch values + this.scratchData = new Uint8Array(this.lineHeight * this.maxWidth) + // temporary arrays for the distance transform this.gridOuter = new Float64Array(this.lineHeight * this.maxWidth) this.gridInner = new Float64Array(this.lineHeight * this.maxWidth) @@ -97,83 +98,56 @@ export class TextAtlas { this.middle = Math.ceil(this.lineHeight / 2) - // - - this.canvas = document.createElement('canvas') - this.canvas.width = p.width - this.canvas.height = p.height - this.context = this.canvas.getContext('2d')! - // Replacement Character - this.placeholder = this.map(String.fromCharCode(0xFFFD)) - - // Basic Latin (subset) - for (let i = 0x0020; i <= 0x007E; ++i) this.map(String.fromCharCode(i)) - - // TODO: to slow to always prepare them - // // Latin-1 Supplement (subset) - // for (let i = 0x00A1; i <= 0x00FF; ++i) this.map(String.fromCharCode(i)) - - // Degree sign - this.map(String.fromCharCode(0x00B0)) - - // // Greek and Coptic (subset) - // for (let i = 0x0391; i <= 0x03C9; ++i) this.map(String.fromCharCode(i)) - - // // Cyrillic (subset) - // for (let i = 0x0400; i <= 0x044F; ++i) this.map(String.fromCharCode(i)) - - // Angstrom Sign - this.map(String.fromCharCode(0x212B)) + this.placeholder = this.get(String.fromCharCode(0xFFFD)) } - map (text: string) { - if (this.mapped[text] === undefined) { - this.draw(text) + get (char: string) { + if (this.mapped[char] === undefined) { + this.draw(char) + + const { array, width, height } = this.texture + const data = this.scratchData - if (this.currentX + this.scratchW > this.props.width) { + if (this.currentX + this.scratchW > width) { this.currentX = 0 this.currentY += this.scratchH } - if (this.currentY + this.scratchH > this.props.height) { + if (this.currentY + this.scratchH > height) { console.warn('canvas to small') + return this.placeholder } - this.mapped[text] = { + this.mapped[char] = { x: this.currentX, y: this.currentY, w: this.scratchW, h: this.scratchH } - this.context.drawImage( - this.scratchCanvas, - 0, 0, this.scratchW, this.scratchH, - this.currentX, this.currentY, this.scratchW, this.scratchH - ) + for (let y = 0; y < this.scratchH; ++y) { + for (let x = 0; x < this.scratchW; ++x) { + array[width * (this.currentY + y) + this.currentX + x] = data[y * this.scratchW + x] + } + } this.currentX += this.scratchW } - return this.mapped[text] - } - - get (text: string) { - return this.mapped[text] || this.placeholder + return this.mapped[char] } - draw (text: string) { + draw (char: string) { const h = this.lineHeight const ctx = this.scratchContext + const data = this.scratchData // Measure text - const m = ctx.measureText(text) - const w = Math.min(this.maxWidth, Math.ceil(m.width + 2 * this.padding + this.radius / 2)) + const m = ctx.measureText(char) + const w = Math.min(this.maxWidth, Math.ceil(m.width + 2 * this.buffer)) const n = w * h ctx.clearRect(0, 0, w, h) // clear scratch area - ctx.fillText(text, this.padding + this.radius / 4, this.middle) // draw text - + ctx.fillText(char, this.buffer, this.middle) // draw text const imageData = ctx.getImageData(0, 0, w, h) - const data = imageData.data for (let i = 0; i < n; i++) { const a = imageData.data[i * 4 + 3] / 255 // alpha value @@ -186,10 +160,9 @@ export class TextAtlas { for (let i = 0; i < n; i++) { const d = this.gridOuter[i] - this.gridInner[i]; - data[i * 4 + 3] = Math.max(0, Math.min(255, Math.round(255 - 255 * (d / this.radius + this.cutoff)))); + data[i] = Math.max(0, Math.min(255, Math.round(255 - 255 * (d / this.radius + this.cutoff)))) } - ctx.putImageData(imageData, 0, 0) this.scratchW = w this.scratchH = h } diff --git a/src/mol-gl/renderable/util.ts b/src/mol-gl/renderable/util.ts index 73a60a38ae4e0c1809af9103fd2a4258370c18af..2d70433f1cd6c530806feae290fb6b96fd5a41bb 100644 --- a/src/mol-gl/renderable/util.ts +++ b/src/mol-gl/renderable/util.ts @@ -34,6 +34,24 @@ export function createTextureImage(n: number, itemSize: number): TextureImage<Ui return { array: new Uint8Array(length), width, height } } +export function printTextureImage(textureImage: TextureImage<any>, scale = 1) { + const { array, width, height } = textureImage + const itemSize = array.length / (width * height) + const data = new Uint8ClampedArray(width * height * 4) + if (itemSize === 1) { + for (let y = 0; y < height; ++y) { + for (let x = 0; x < width; ++x) { + data[(y * width + x) * 4 + 3] = array[y * width + x] + } + } + } else if (itemSize === 4) { + data.set(array) + } else { + console.warn(`itemSize '${itemSize}' not supported`) + } + return printImageData(new ImageData(data, width, height), scale) +} + export function printImageData(imageData: ImageData, scale = 1) { const canvas = document.createElement('canvas') canvas.width = imageData.width diff --git a/src/tests/browser/font-atlas.ts b/src/tests/browser/font-atlas.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dc00cd08aed378590b5e6708e31d3441053916a --- /dev/null +++ b/src/tests/browser/font-atlas.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + */ + +import './index.html' +import { FontAtlas } from 'mol-geo/geometry/text/font-atlas'; +import { printTextureImage } from 'mol-gl/renderable/util'; + +function test() { + console.time('FontAtlas init') + const fontAtlas = new FontAtlas({ fontSize: 96 }) + console.timeEnd('FontAtlas init') + + console.time('Basic Latin (subset)') + for (let i = 0x0020; i <= 0x007E; ++i) fontAtlas.get(String.fromCharCode(i)) + console.timeEnd('Basic Latin (subset)') + + console.time('Latin-1 Supplement (subset)') + for (let i = 0x00A1; i <= 0x00FF; ++i) fontAtlas.get(String.fromCharCode(i)) + console.timeEnd('Latin-1 Supplement (subset)') + + console.time('Greek and Coptic (subset)') + for (let i = 0x0391; i <= 0x03C9; ++i) fontAtlas.get(String.fromCharCode(i)) + console.timeEnd('Greek and Coptic (subset)') + + console.time('Cyrillic (subset)') + for (let i = 0x0400; i <= 0x044F; ++i) fontAtlas.get(String.fromCharCode(i)) + console.timeEnd('Cyrillic (subset)') + + console.time('Angstrom Sign') + fontAtlas.get(String.fromCharCode(0x212B)) + console.timeEnd('Angstrom Sign') + + printTextureImage(fontAtlas.texture, 0.5) +} + +test(); \ No newline at end of file diff --git a/src/tests/browser/text-atlas.ts b/src/tests/browser/text-atlas.ts deleted file mode 100644 index 60c0fd7169dc95b6789d2799e5d8d00ab71d7bb5..0000000000000000000000000000000000000000 --- a/src/tests/browser/text-atlas.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. - * - * @author Alexander Rose <alexander.rose@weirdbyte.de> - */ - -import './index.html' -import { TextAtlas } from 'mol-geo/geometry/text/text-atlas'; -import { printImageData } from 'mol-gl/renderable/util'; - -function test() { - console.time('TextAtlas') - const textAtlas = new TextAtlas() - console.timeEnd('TextAtlas') - const ctx = textAtlas.context - const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height) - printImageData(imageData) -} - -test(); \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index f4c1b4228046a93a2a19023e752823d9c53a0289..0599d63fdd03e070d5bfb7a0ddcee397bf5c757b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -65,7 +65,7 @@ module.exports = [ createApp('viewer'), createApp('model-server-query'), - createBrowserTest('text-atlas'), + createBrowserTest('font-atlas'), createBrowserTest('render-text'), createBrowserTest('render-spheres'), createBrowserTest('render-mesh')