Skip to content

Commit

Permalink
Adjust renderer API
Browse files Browse the repository at this point in the history
  • Loading branch information
mate-h committed Jul 5, 2023
1 parent c19aa9b commit e9cfcd3
Show file tree
Hide file tree
Showing 12 changed files with 152 additions and 90 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
dist
dist
.vercel
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,63 @@
# WebGL Font Rendering

Demonstration of a font rendering on the GPU with glyph hinting and subpixel antialiasing.
Crisp font rendering on the GPU with glyph hinting and subpixel antialiasing. No external dependencies, very small (3.8kb gzipped).

## Usage

```bash
npm install webgl-fonts
```

Place your fonts in the `public/fonts` or `static/fonts` directory depending on your setup. Then use `loadFont` function to load the font and `createRenderer` to create a renderer.

```javascript
import { createRenderer, loadFont } from 'webgl-fonts';

// create a WebGL2 context
const canvas = document.getElementById('canvas');
const gl = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: false,
});

// thiis will load json and png files from
// public/fonts/roboto.json and public/fonts/roboto.png
const font = await loadFont(gl, 'roboto');
const renderer = createRenderer(gl);

// render loop
function loop() {
renderer.render({
font,
font_size: 32,
text: 'Hello, world!',
x: 0,
y: 0,
font_hinting: true,
subpixel: true,
font_color: [1, 1, 1, 1],
bg_color: [0, 0, 0, 1],
});
requestAnimationFrame(loop);
}
loop();

```

## Demo

The demo uses [signed distance field method](http://www.valvesoftware.com/publications/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf) for glyph rendering.

[Click here to see the demo](http://astiopin.github.io/webgl_fonts) (requires WebGL).
[Click here to see the demo](https://webgl-fonts.vercel.app/) (requires WebGL).

Font atlas generation tool is [here](https://github.com/astiopin/sdf_atlas).

## Hinting

The idea is pretty simple. First we're placing the text baseline exactly at the pixel boundary. Next we're using two different methods to place the glyphs. Lowcase characters are scaled in a such way that the [x-height](https://en.wikipedia.org/wiki/X-height) spans a whole number of pixels. All other characters are scaled to fit the [cap height](https://en.wikipedia.org/wiki/Cap_height) to the pixel boundary.

![Glyph hinting](http://astiopin.github.io/webgl_fonts/assets/scaling.png)
![Glyph hinting](./assets/scaling.png)

At the rasterisation stage we're modifying the antialiazing routine so that the antialiazed edge distance depends on a stroke direction, which makes horizontal strokes appear sharper than the vertical ones.

![Result](http://astiopin.github.io/webgl_fonts/assets/result.png)
![Result](./assets/result.png)
Binary file added assets/result.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/scaling.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
<div align="center">
<div>
<h1>WebGL Font Rendering</h1>
<a href="https://github.com/astiopin/webgl_fonts"
>Click for the source</a
<a href="https://github.com/mate-h/webgl-fonts">
Click for the source </a
><br /><br />
</div>
<canvas width="700" height="400" id="glcanvas"></canvas>
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"build": "vite build"
},
"author": "Anton Stepin <[email protected]>",
"contributors": [
"Máté Homolya <[email protected]>"
],
"license": "See LICENSE in LICENSE",
"devDependencies": {
"vite": "^4.3.9",
Expand Down
File renamed without changes.
28 changes: 19 additions & 9 deletions src/glutils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Attrib } from "./types";

import { Attrib, Font } from "./types";

export function createProgram(
gl: WebGL2RenderingContext,
Expand Down Expand Up @@ -228,13 +227,24 @@ export function loadTexture(
}

/**
* Loading SDF font images. Resulting textures should not be mipmapped.
*/
export async function loadFont(gl: WebGL2RenderingContext, name: string, path = "/fonts") {
const tex = loadTexture(gl, `${path}/${name}.png`, gl.LUMINANCE, false);
const font = await fetch(`${path}/${name}.json`).then((res) => res.json());

return { tex, font };
* Loading SDF font images. Resulting textures should not be mipmapped.
*/
export async function loadFont(
gl: WebGL2RenderingContext,
name: string,
path = "/fonts"
): Promise<Font> {
const font_texture = loadTexture(
gl,
`${path}/${name}.png`,
gl.LUMINANCE,
false
);
const font_bundle = await fetch(`${path}/${name}.json`).then((res) =>
res.json()
);

return { font_texture, font_bundle } as Font;
}

export function setTexImage(
Expand Down
30 changes: 4 additions & 26 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,51 +2,33 @@ import { colorFromString, loadFont } from "./glutils";
import { createRenderer } from "./render";
import "./style.css";

let do_update = true;

function update_text() {
do_update = true;
}

async function glMain() {
// Initializing input widgets
const fonts_select = document.getElementById("fonts") as HTMLSelectElement;
fonts_select.addEventListener("input", update_text, false);
fonts_select.onchange = async () => {
const font_name = fonts_select.value;
current_font = await loadFont(gl, font_name);
update_text();
};

const font_size_input = document.getElementById(
"font_size"
) as HTMLInputElement;
font_size_input.addEventListener("input", update_text, false);
font_size_input.onchange = update_text;

const font_hinting_input = document.getElementById(
"font_hinting"
) as HTMLInputElement;
font_hinting_input.addEventListener("input", update_text, false);
font_hinting_input.onchange = update_text;

const subpixel_input = document.getElementById(
"subpixel"
) as HTMLInputElement;
subpixel_input.addEventListener("input", update_text, false);
subpixel_input.onchange = update_text;

const font_color_input = document.getElementById(
"font_color"
) as HTMLInputElement;
font_color_input.addEventListener("input", update_text, false);
font_color_input.onchange = update_text;

const bg_color_input = document.getElementById(
"background_color"
) as HTMLInputElement;
bg_color_input.addEventListener("input", update_text, false);
bg_color_input.onchange = update_text;

const textarea = document.getElementById("text") as HTMLTextAreaElement;
textarea.value = `To be, or not to be--that is the question:
Expand All @@ -63,8 +45,6 @@ For in that sleep of death what dreams may come
When we have shuffled off this mortal coil,
Must give us pause. There's the respect
That makes calamity of so long life.`;
textarea.addEventListener("input", update_text, false);
textarea.onchange = update_text;

// GL stuff
const canvas = document.getElementById("glcanvas") as HTMLCanvasElement;
Expand All @@ -73,7 +53,7 @@ That makes calamity of so long life.`;
alpha: false,
})!;

const renderer = createRenderer({ gl, canvas });
const renderer = createRenderer(gl);

let current_font = await loadFont(gl, "roboto");
let font_color = [0.1, 0.1, 0.1];
Expand All @@ -85,12 +65,10 @@ That makes calamity of so long life.`;

renderer.render({
font_size: Number(font_size_input.value),
font: current_font.font,
tex: current_font.tex,
font_hinting: font_hinting_input.checked ? 1 : 0,
subpixel: subpixel_input.checked ? 1 : 0,
font: current_font,
font_hinting: font_hinting_input.checked,
subpixel: subpixel_input.checked,
text: textarea.value,
do_update,
font_color,
bg_color,
});
Expand Down
101 changes: 62 additions & 39 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { bindAttribs, createProgram, initAttribs } from "./glutils";
import { StringResult, fontMetrics, writeString } from "./textutils";
import { Attrib, RenderOptions, ImageTexture } from "./types";
import { Attrib, RenderOptions, ImageTexture, Font } from "./types";
import fragCode from "./shader.frag";
import vertCode from "./shader.vert";

export function createRenderer({
canvas,
gl,
}: {
canvas: HTMLCanvasElement;
gl: WebGL2RenderingContext;
}) {
export function createRenderer(gl: WebGL2RenderingContext) {
const canvas = gl.canvas as HTMLCanvasElement;
// Vertex attributes

const attribs: Attrib[] = [
Expand All @@ -32,53 +27,81 @@ export function createRenderer({

const prog = createProgram(gl, vertCode, fragCode, attribs);

let str_res: StringResult; // Result of a writeString function.
// Contains text bounding rectangle.
type Layout = {
font: Font;
font_size: number;
text: string;
};

// state variables
let pixel_ratio = window.devicePixelRatio || 1;
let str_res: StringResult; // Result of a writeString function, Contains text bounding rectangle.
let vcount = 0; // Text string vertex count
let canvas_width = canvas.clientWidth;
let canvas_height = canvas.clientHeight;
let prev_font: Font | null = null;
let prev_font_size = -1;
let prev_text = "";

function layout({ font, font_size, text }: Layout) {
const font_size_scaled = Math.round(font_size * pixel_ratio);
const fmetrics = fontMetrics(
font.font_bundle,
font_size_scaled,
font_size_scaled * 0.2
);

// Laying out the text
str_res = writeString(
text,
font.font_bundle,
fmetrics,
[0, 0],
vertex_array
);
vcount = str_res.array_pos / (attribs[0].stride! / 4) /*size of float*/;

const canvas_width = canvas.clientWidth;
const canvas_height = canvas.clientHeight;
let pixel_ratio = window.devicePixelRatio || 1;
gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertex_array);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

function render({
font,
do_update,
font_size,
text,
font_color,
bg_color,
font_hinting,
subpixel,
tex,
}: RenderOptions) {
if (do_update) {
const font_size_scaled = Math.round(font_size * pixel_ratio);
const fmetrics = fontMetrics(
font,
font_size_scaled,
font_size_scaled * 0.2
);

// Laying out the text
str_res = writeString(text, font, fmetrics, [0, 0], vertex_array);
vcount = str_res.array_pos / (attribs[0].stride! / 4) /*size of float*/;

gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertex_array);
gl.bindBuffer(gl.ARRAY_BUFFER, null);

do_update = false;
}

// Setting canvas size considering display DPI

const new_pixel_ratio = window.devicePixelRatio || 1;

let do_update = false;
if (pixel_ratio != new_pixel_ratio) {
do_update = true;
pixel_ratio = new_pixel_ratio;
}
if (prev_font != font) {
do_update = true;
prev_font = font;
}
if (prev_font_size != font_size) {
do_update = true;
prev_font_size = font_size;
}
if (prev_text != text) {
do_update = true;
prev_text = text;
}

if (do_update) {
// adjust the layout
layout({ font, font_size, text });
}

const tex = font.font_texture;

const cw = Math.round(pixel_ratio * canvas_width * 0.5) * 2.0;
const ch = Math.round(pixel_ratio * canvas_height * 0.5) * 2.0;
Expand Down Expand Up @@ -125,19 +148,19 @@ export function createRenderer({

prog.font_tex.set(0);
prog.sdf_tex_size.set(tex.image.width, tex.image.height);
prog.sdf_border_size.set(font.iy);
prog.sdf_border_size.set(font.font_bundle.iy);
prog.transform.setv(screen_mat);
prog.hint_amount.set(font_hinting);
prog.hint_amount.set(font_hinting ? 1.0 : 0.0);
prog.font_color.set(font_color[0], font_color[1], font_color[2], 1.0);
prog.subpixel_amount.set(subpixel);
prog.subpixel_amount.set(subpixel ? 1.0 : 0.0);

gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex.id);

gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer);
bindAttribs(gl, attribs);

if (subpixel == 1.0) {
if (subpixel) {
// Subpixel antialiasing.
// Method proposed by Radek Dutkiewicz @oomek
// Text color goes to constant blend factor and
Expand Down
Loading

0 comments on commit e9cfcd3

Please sign in to comment.