NGL@1.0.0-beta.7 Home Manual Reference Source Gallery

src/buffer/text-buffer.js

/**
 * @file Text Buffer
 * @author Alexander Rose <alexander.rose@weirdbyte.de>
 * @private
 */

import { Color, CanvasTexture } from '../../lib/three.es6.js'

import '../shader/SDFFont.vert'
import '../shader/SDFFont.frag'

import { Browser, BufferRegistry } from '../globals.js'
import { defaults } from '../utils.js'
import MappedQuadBuffer from './mappedquad-buffer.js'
import { IgnorePicker } from '../utils/picker.js'

const TextAtlasCache = {}

function getTextAtlas (params) {
  const hash = JSON.stringify(params)
  if (TextAtlasCache[ hash ] === undefined) {
    TextAtlasCache[ hash ] = new TextAtlas(params)
  }
  return TextAtlasCache[ hash ]
}

class TextAtlas {
  constructor (params) {
        // adapted from https://github.com/unconed/mathbox
        // MIT License Copyright (C) 2013+ Steven Wittens and contributors

    const p = Object.assign({}, params)

    this.font = defaults(p.font, [ 'sans-serif' ])
    this.size = defaults(p.size, 36)
    this.style = defaults(p.style, 'normal')
    this.variant = defaults(p.variant, 'normal')
    this.weight = defaults(p.weight, 'normal')
    this.outline = defaults(p.outline, 0)
    this.width = defaults(p.width, 2048)
    this.height = defaults(p.height, 2048)

    this.gamma = 1
    if (typeof navigator !== 'undefined') {
      const ua = navigator.userAgent
      if (ua.match(/Chrome/) && ua.match(/OS X/)) {
        this.gamma = 0.5
      }
    }

    this.mapped = {}
    this.scratchW = 0
    this.scratchH = 0
    this.currentX = 0
    this.currentY = 0

    this.build()
    this.populate()

    this.texture = new CanvasTexture(this.canvas2)
    this.texture.flipY = false
    this.texture.needsUpdate = true
  }

  build () {
        // Prepare line-height with room for outline and descenders/ascenders
    const lineHeight = this.size + 2 * this.outline + Math.round(this.size / 4)
    const maxWidth = this.width / 4

        // Prepare scratch canvas
    const canvas = document.createElement('canvas')
    canvas.width = maxWidth
    canvas.height = lineHeight

    const ctx = canvas.getContext('2d')
    ctx.font = this.style + ' ' + this.variant + ' ' + this.weight + ' ' + this.size + 'px ' + this.font
    ctx.fillStyle = '#FF0000'
    ctx.textAlign = 'left'
    ctx.textBaseline = 'bottom'
    ctx.lineJoin = 'round'

    const colors = []
    const dilate = this.outline * 3
    for (let i = 0; i < dilate; ++i) {
            // 8 rgb levels = 1 step = .5 pixel increase
      const val = Math.max(0, -i * 8 + 128 - (!i) * 8)
      const hex = ('00' + val.toString(16)).slice(-2)
      colors.push('#' + hex + hex + hex)
    }
    const scratch = new Uint8Array(maxWidth * lineHeight * 2)

    this.canvas = canvas
    this.context = ctx
    this.lineHeight = lineHeight
    this.maxWidth = maxWidth
    this.colors = colors
    this.scratch = scratch

    this.data = new Uint8Array(this.width * this.height * 4)

    this.canvas2 = document.createElement('canvas')
    this.canvas2.width = this.width
    this.canvas2.height = this.height
    this.context2 = this.canvas2.getContext('2d')
  }

  map (text) {
    if (this.mapped[ text ] === undefined) {
      this.draw(text)

      if (this.currentX + this.scratchW > this.width) {
        this.currentX = 0
        this.currentY += this.scratchH
      }
      if (this.currentY + this.scratchH > this.height) {
        console.warn('canvas to small')
      }

      this.mapped[ text ] = {
        x: this.currentX,
        y: this.currentY,
        w: this.scratchW,
        h: this.scratchH
      }

      this.context2.drawImage(
                this.canvas,
                0, 0,
                this.scratchW, this.scratchH,
                this.currentX, this.currentY,
                this.scratchW, this.scratchH
            )

      this.currentX += this.scratchW
    }

    return this.mapped[ text ]
  }

  get (text) {
    return this.mapped[ text ] || this.placeholder
  }

  draw (text) {
    const h = this.lineHeight
    const o = this.outline
    const ctx = this.context
    const dst = this.scratch
    const max = this.maxWidth
    const colors = this.colors

        // Bottom aligned, take outline into account
    const x = o
    const y = h - this.outline

        // Measure text
    const m = ctx.measureText(text)
    const w = Math.min(max, Math.ceil(m.width + 2 * x + 1))

        // Clear scratch area
    ctx.clearRect(0, 0, w, h)

    let i, il, j, imageData, data

    if (this.outline === 0) {
      ctx.fillText(text, x, y)
      imageData = ctx.getImageData(0, 0, w, h)
      data = imageData.data

      j = 3  // Skip to alpha channel
      for (i = 0, il = data.length / 4; i < il; ++i) {
        dst[ i ] = data[ j ]
        j += 4
      }
    } else {
      ctx.globalCompositeOperation = 'source-over'
            // Draw strokes of decreasing width to create
            // nested outlines (absolute distance)
      for (i = o + 1; i > 0; --i) {
                // Eliminate odd strokes once past > 1px,
                // don't need the detail
        j = i > 1 ? i * 2 - 2 : i
        ctx.strokeStyle = colors[ j - 1 ]
        ctx.lineWidth = j
        ctx.strokeText(text, x, y)
      }
      ctx.globalCompositeOperation = 'multiply'
      ctx.fillStyle = '#FF00FF'
      ctx.fillText(text, x, y)
      imageData = ctx.getImageData(0, 0, w, h)
      data = imageData.data

      j = 0
      const gamma = this.gamma
      for (i = 0, il = data.length / 4; i < il; ++i) {
                // Get value + mask
        const a = data[ j ]
        let mask = a ? data[ j + 1 ] / a : 1
        if (gamma === 0.5) {
          mask = Math.sqrt(mask)
        }
        mask = Math.min(1, Math.max(0, mask))

                // Blend between positive/outside and negative/inside
        const b = 256 - a
        const c = b + (a - b) * mask

                // Clamp (slight expansion to hide errors around the transition)
        dst[ i ] = Math.max(0, Math.min(255, c + 2))
        data[ j + 3 ] = dst[ i ]
        j += 4
      }
    }

    ctx.putImageData(imageData, 0, 0)
    this.scratchW = w
    this.scratchH = h
  }

  populate () {
        // Replacement Character
    this.placeholder = this.map(String.fromCharCode(0xFFFD))

        // Basic Latin
    for (let i = 0x0000; i < 0x007F; ++i) {
      this.map(String.fromCharCode(i))
    }

        // Latin-1 Supplement
    for (let i = 0x0080; i < 0x00FF; ++i) {
      this.map(String.fromCharCode(i))
    }

        // Greek and Coptic
    for (let i = 0x0370; i < 0x03FF; ++i) {
      this.map(String.fromCharCode(i))
    }

        // Cyrillic
    for (let i = 0x0400; i < 0x04FF; ++i) {
      this.map(String.fromCharCode(i))
    }

        // Angstrom Sign
    this.map(String.fromCharCode(0x212B))
  }
}

/**
 * Text buffer parameter object.
 * @typedef {Object} TextBufferParameters - text buffer parameters
 *
 * @property {Float} opacity - translucency: 1 is fully opaque, 0 is fully transparent
 * @property {Integer} clipNear - position of camera near/front clipping plane
 *                                in percent of scene bounding box
 * @property {String} labelType - type of the label, one of:
 *                                 "atomname", "atomindex", "occupancy", "bfactor",
 *                                 "serial", "element", "atom", "resname", "resno",
 *                                 "res", "text", "qualified". When set to "text", the
 *                                 `labelText` list is used.
 * @property {String[]} labelText - list of label strings, must set `labelType` to "text"
 *                                   to take effect
 * @property {String} fontFamily - font family, one of: "sans-serif", "monospace", "serif"
 * @property {String} fontStyle - font style, "normal" or "italic"
 * @property {String} fontWeight - font weight, "normal" or "bold"
 * @property {Boolean} sdf - use "signed distance field"-based rendering for sharper edges
 * @property {Float} xOffset - offset in x-direction
 * @property {Float} yOffset - offset in y-direction
 * @property {Float} zOffset - offset in z-direction (i.e. in camera direction)
 * @property {String} attachment - attachment of the label, one of:
 *                                 "bottom-left", "bottom-center", "bottom-right",
 *                                 "middle-left", "middle-center", "middle-right",
 *                                 "top-left", "top-center", "top-right"
 * @property {Boolean} showBorder - show border/outline
 * @property {Color} borderColor - color of the border/outline
 * @property {Float} borderWidth - width of the border/outline
 * @property {Boolean} showBackground - show background rectangle
 * @property {Color} backgroundColor - color of the background
 * @property {Float} backgroundMargin - width of the background
 * @property {Float} backgroundOpacity - opacity of the background
 */

/**
 * Text buffer. Renders screen-aligned text strings.
 *
 * @example
 * var textBuffer = new TextBuffer( {
 *     position: new Float32Array( [ 0, 0, 0 ] ),
 *     color: new Float32Array( [ 1, 0, 0 ] ),
 *     size: new Float32Array( [ 2 ] ),
 *     text: [ "Hello" ]
 * } );
 */
class TextBuffer extends MappedQuadBuffer {
    /**
     * @param  {Object} data - attribute object
     * @param  {Float32Array} data.position - positions
     * @param  {Float32Array} data.color - colors
     * @param  {Float32Array} data.size - sizes
     * @param  {String[]} data.text - text strings
     * @param  {TextBufferParameters} params - parameters object
     */
  constructor (data, params) {
    var d = data || {}
    var p = params || {}

    p.forceTransparent = true

    var n = d.position.length / 3
    var charCount = 0
    for (var i = 0; i < n; ++i) {
      charCount += d.text[ i ].length
    }

    var count = charCount
    if (p.showBackground) count += n

    super({
      position: new Float32Array(count * 3),
      color: new Float32Array(count * 3),
      picking: new IgnorePicker()
    }, p)

    this.fontFamily = defaults(p.fontFamily, 'sans-serif')
    this.fontStyle = defaults(p.fontStyle, 'normal')
    this.fontWeight = defaults(p.fontWeight, 'bold')
    this.fontSize = defaults(p.fontSize, 48)
    this.sdf = defaults(p.sdf, Browser === 'Chrome')
    this.xOffset = defaults(p.xOffset, 0.0)
    this.yOffset = defaults(p.yOffset, 0.0)
    this.zOffset = defaults(p.zOffset, 0.5)
    this.attachment = defaults(p.attachment, 'bottom-left')
    this.showBorder = defaults(p.showBorder, false)
    this.borderColor = defaults(p.borderColor, 'lightgrey')
    this.borderWidth = defaults(p.borderWidth, 0.15)
    this.showBackground = defaults(p.showBackground, false)
    this.backgroundColor = defaults(p.backgroundColor, 'lightgrey')
    this.backgroundMargin = defaults(p.backgroundMargin, 0.5)
    this.backgroundOpacity = defaults(p.backgroundOpacity, 1.0)

    this.text = d.text
    this.positionCount = n

    this.addUniforms({
      'fontTexture': { value: null },
      'xOffset': { value: this.xOffset },
      'yOffset': { value: this.yOffset },
      'zOffset': { value: this.zOffset },
      'ortho': { value: false },
      'showBorder': { value: this.showBorder },
      'borderColor': { value: new Color(this.borderColor) },
      'borderWidth': { value: this.borderWidth },
      'backgroundColor': { value: new Color(this.backgroundColor) },
      'backgroundOpacity': { value: this.backgroundOpacity }
    })

    this.addAttributes({
      'inputTexCoord': { type: 'v2', value: null },
      'inputSize': { type: 'f', value: null }
    })

    this.setAttributes(data)

    this.makeTexture()
    this.makeMapping()
  }

  get parameters () {
    return Object.assign({

      fontFamily: { uniform: true },
      fontStyle: { uniform: true },
      fontWeight: { uniform: true },
      fontSize: { uniform: true },
      sdf: { updateShader: true, uniform: true },
      xOffset: { uniform: true },
      yOffset: { uniform: true },
      zOffset: { uniform: true },
      showBorder: { uniform: true },
      borderColor: { uniform: true },
      borderWidth: { uniform: true },
      backgroundColor: { uniform: true },
      backgroundOpacity: { uniform: true }

    }, super.parameters, {

      flatShaded: undefined

    })
  }

  makeMaterial () {
    super.makeMaterial()

    const tex = this.texture

    const m = this.material
    m.extensions.derivatives = true
    m.lights = false
    m.uniforms.fontTexture.value = tex
    m.needsUpdate = true

    const wm = this.wireframeMaterial
    wm.extensions.derivatives = true
    wm.lights = false
    wm.uniforms.fontTexture.value = tex
    wm.needsUpdate = true

    const pm = this.pickingMaterial
    pm.extensions.derivatives = true
    pm.lights = false
    pm.uniforms.fontTexture.value = tex
    pm.needsUpdate = true
  }

  setAttributes (data) {
    let position, size, color
    let aPosition, inputSize, aColor

    const text = this.text
    const attributes = this.geometry.attributes

    if (data.position) {
      position = data.position
      aPosition = attributes.position.array
      attributes.position.needsUpdate = true
    }

    if (data.size) {
      size = data.size
      inputSize = attributes.inputSize.array
      attributes.inputSize.needsUpdate = true
    }

    if (data.color) {
      color = data.color
      aColor = attributes.color.array
      attributes.color.needsUpdate = true
    }

    const n = this.positionCount

    let j, o
    let iCharAll = 0
    let txt, iChar, nChar

    for (let v = 0; v < n; ++v) {
      o = 3 * v
      txt = text[ v ]
      nChar = txt.length
      if (this.showBackground) nChar += 1

      for (iChar = 0; iChar < nChar; ++iChar, ++iCharAll) {
        for (let m = 0; m < 4; m++) {
          j = iCharAll * 4 * 3 + (3 * m)

          if (position) {
            aPosition[ j ] = position[ o ]
            aPosition[ j + 1 ] = position[ o + 1 ]
            aPosition[ j + 2 ] = position[ o + 2 ]
          }

          if (size) {
            inputSize[ (iCharAll * 4) + m ] = size[ v ]
          }

          if (color) {
            aColor[ j ] = color[ o ]
            aColor[ j + 1 ] = color[ o + 1 ]
            aColor[ j + 2 ] = color[ o + 2 ]
          }
        }
      }
    }
  }

  makeTexture () {
    this.textAtlas = getTextAtlas({
      font: [ this.fontFamily ],
      style: this.fontStyle,
      weight: this.fontWeight,
      size: this.fontSize,
      outline: this.sdf ? 5 : 0
    })

    this.texture = this.textAtlas.texture
  }

  makeMapping () {
    const ta = this.textAtlas
    const text = this.text
    const attachment = this.attachment
    const margin = (ta.lineHeight * this.backgroundMargin * 0.1) - 10

    const inputTexCoord = this.geometry.attributes.inputTexCoord.array
    const inputMapping = this.geometry.attributes.mapping.array

    const n = this.positionCount
    let iCharAll = 0
    let c, i, txt, xadvance, iChar, nChar, xShift, yShift

    for (let v = 0; v < n; ++v) {
      txt = text[ v ]
      xadvance = 0
      nChar = txt.length

            // calculate width
      for (iChar = 0; iChar < nChar; ++iChar) {
        c = ta.get(txt[ iChar ])
        xadvance += c.w - 2 * ta.outline
      }

            // attachment
      if (attachment.startsWith('top')) {
        yShift = ta.lineHeight / 1.25
      } else if (attachment.startsWith('middle')) {
        yShift = ta.lineHeight / 2.5
      } else {
        yShift = 0  // "bottom"
      }
      if (attachment.endsWith('right')) {
        xShift = xadvance
      } else if (attachment.endsWith('center')) {
        xShift = xadvance / 2
      } else {
        xShift = 0  // "left"
      }
      xShift += ta.outline
      yShift += ta.outline

            // background
      if (this.showBackground) {
        i = iCharAll * 2 * 4
        inputMapping[ i + 0 ] = -ta.lineHeight / 6 - xShift - margin  // top left
        inputMapping[ i + 1 ] = ta.lineHeight - yShift + margin
        inputMapping[ i + 2 ] = -ta.lineHeight / 6 - xShift - margin  // bottom left
        inputMapping[ i + 3 ] = 0 - yShift - margin
        inputMapping[ i + 4 ] = xadvance + ta.lineHeight / 6 - xShift + 2 * ta.outline + margin  // top right
        inputMapping[ i + 5 ] = ta.lineHeight - yShift + margin
        inputMapping[ i + 6 ] = xadvance + ta.lineHeight / 6 - xShift + 2 * ta.outline + margin  // bottom right
        inputMapping[ i + 7 ] = 0 - yShift - margin
        inputTexCoord[ i + 0 ] = 10
        inputTexCoord[ i + 2 ] = 10
        inputTexCoord[ i + 4 ] = 10
        inputTexCoord[ i + 6 ] = 10
        iCharAll += 1
      }

      xadvance = 0

      for (iChar = 0; iChar < nChar; ++iChar, ++iCharAll) {
        c = ta.get(txt[ iChar ])
        i = iCharAll * 2 * 4

        inputMapping[ i + 0 ] = xadvance - xShift  // top left
        inputMapping[ i + 1 ] = c.h - yShift
        inputMapping[ i + 2 ] = xadvance - xShift  // bottom left
        inputMapping[ i + 3 ] = 0 - yShift
        inputMapping[ i + 4 ] = xadvance + c.w - xShift  // top right
        inputMapping[ i + 5 ] = c.h - yShift
        inputMapping[ i + 6 ] = xadvance + c.w - xShift  // bottom right
        inputMapping[ i + 7 ] = 0 - yShift

        const texWidth = ta.width
        const texHeight = ta.height

        const texCoords = [
          c.x / texWidth, c.y / texHeight,             // top left
          c.x / texWidth, (c.y + c.h) / texHeight,       // bottom left
          (c.x + c.w) / texWidth, c.y / texHeight,       // top right
          (c.x + c.w) / texWidth, (c.y + c.h) / texHeight  // bottom right
        ]
        inputTexCoord.set(texCoords, i)

        xadvance += c.w - 2 * ta.outline
      }
    }

    this.geometry.attributes.inputTexCoord.needsUpdate = true
    this.geometry.attributes.mapping.needsUpdate = true
  }

  getDefines (type) {
    const defines = super.getDefines(type)

    if (this.sdf) {
      defines.SDF = 1
    }

    return defines
  }

  setUniforms (data) {
    if (data && (
                data.fontFamily !== undefined ||
                data.fontStyle !== undefined ||
                data.fontWeight !== undefined ||
                data.fontSize !== undefined ||
                data.sdf !== undefined
            )
        ) {
      this.makeTexture()
      this.makeMapping()
      this.texture.needsUpdate = true
      data.fontTexture = this.texture
    }

    super.setUniforms(data)
  }

  set wireframe (value) {}
  get wireframe () { return false }

  get isText () { return true }
  get vertexShader () { return 'SDFFont.vert' }
  get fragmentShader () { return 'SDFFont.frag' }
}

BufferRegistry.add('text', TextBuffer)

export default TextBuffer