Skip to main content
GDN8 PATIO

Three.jsでの文字の描画について

このノートはWebGLで文字を描く方法をまとめたものです。方法は字形をテクスチャへ焼く、三角形にする、曲線のまま塗るの3つに分かれます。以下は描写方法と向いてる用途表です。

方法 向いている用途
Canvas Texture 短い見出し
ビットマップフォント 同じ大きさで出す文字
SDF 拡大する平面の文字
MSDF / MTSDF 角まで残す平面の文字
三角形化 短い見出しや立体のロゴ
曲線のまま塗る 曲線の輪郭を保って拡大する文字

Canvas Texture

fillText() で書いた文字を CanvasTexture にしてPlaneに貼ります。Webフォントを使用する場合は読み込みを待ってから描く必要があります。

const face = new FontFace( 'FontName', 'url(/fonts/FontName.woff)' );
await face.load();
document.fonts.add( face );

const canvas = document.createElement( 'canvas' );
canvas.width = 512;
canvas.height = 128;

const context = canvas.getContext( '2d' );
context.clearRect( 0, 0, canvas.width, canvas.height );
context.fillStyle = '#ffffff';
context.font = '64px FontName, sans-serif';
context.textBaseline = 'middle';
context.fillText( 'Ag', 24, canvas.height / 2 );

const texture = new THREE.CanvasTexture( canvas );
texture.colorSpace = THREE.SRGBColorSpace;

const material = new THREE.MeshBasicMaterial( {
	map: texture,
	transparent: true
} );

短い見出しを一度描くだけなら比較的簡単です。ただし画像なので拡大するとボケます。自動折り返しもないため長い文章では行の分割や配置を別途実装する必要があります。

ビットマップフォント

文字の形(グリフ)をあらかじめ1枚の画像へまとめ、文字ごとのPlaneに貼ります。必要なものは以下です。

ファイル名 内容
FontName.otf フォント
charset.txt 使用したい文字
FontName.fnt 配置の情報
FontName.png グリフの画像

生成方法に関して

生成には fontbm を使用します。実行ファイルはReleasesにあります。

fontbm \
	--font-file FontName.otf \
	--output FontName \
	--font-size 64 \
	--chars-file charset.txt \
	--data-format json \
	--texture-name-suffix none

charset.txt には使用したい文字を書きます。--font-size 64 は64pxでの生成、--data-format json は配置情報のJSON出力を指定します。出力は FontName.fnt と FontName.png です。

描画に関して

JSONに入っている文字の位置や幅を使って並べます。以下はアトラス1枚・横一行の例で、字間の調整や改行は省略しています。

const text = 'Ag';

// 読み込み
const jsonUrl = 'fonts/FontName.fnt';
const response = await fetch( jsonUrl );
if ( !response.ok ) throw new Error( jsonUrl );
const data = await response.json();
const directory = jsonUrl.slice( 0, jsonUrl.lastIndexOf( '/' ) + 1 );
const texture = await new THREE.TextureLoader().loadAsync( directory + data.pages[ 0 ] );
texture.colorSpace = THREE.SRGBColorSpace;

// マテリアル
const material = new THREE.MeshBasicMaterial( {
	map: texture,
	transparent: true,
	depthWrite: false
} );

// BMFont形式の配置。MSDFの例でも共用します。
function createTextGroup( text, data, material ) {
	const scale = 1 / data.info.size;
	const common = data.common;
	const group = new THREE.Group();
	let cursor = 0;
	for ( const char of text ) {
		const glyph = data.chars.find( ( item ) => item.id === char.codePointAt( 0 ) );
		const width = glyph.width * scale;
		const height = glyph.height * scale;
		const x = glyph.xoffset * scale;
		const y = ( common.base - glyph.yoffset - glyph.height / 2 ) * scale;
		const geometry = new THREE.PlaneGeometry( width, height );
		geometry.translate( x + width / 2, y, 0 );
		const u0 = glyph.x / common.scaleW;
		const u1 = ( glyph.x + glyph.width ) / common.scaleW;
		const v0 = 1 - ( glyph.y + glyph.height ) / common.scaleH;
		const v1 = 1 - glyph.y / common.scaleH;
		const uv = geometry.attributes.uv;
		uv.setXY( 0, u0, v1 );
		uv.setXY( 1, u1, v1 );
		uv.setXY( 2, u0, v0 );
		uv.setXY( 3, u1, v0 );
		const mesh = new THREE.Mesh( geometry, material );
		mesh.position.x = cursor;
		group.add( mesh );
		cursor += glyph.xadvance * scale;
	}
	return group;
}

const textGroup = createTextGroup( text, data, material );
scene.add( textGroup );

良い点は画像と配置情報を再利用できることです。ただし拡大には弱く含まれていない文字は画像の作り直しになります。大量の文字を描く場合はMeshをまとめる工夫が必要です。

SDF

SDF(Signed Distance Field:符号付き距離場)は輪郭までの距離をテクスチャの1チャンネルに入れ、シェーダーで縁を復元します。必要なものは以下です。

ファイル名 内容
FontName.woff フォント(.ttf/.otf/.woff)

Three.jsでは troika-three-text が使用できます。フォントから必要な文字のSDFを生成し、字間や改行などの配置も扱います。

import { Text } from 'troika-three-text';
const myText = new Text();
myText.text = 'Hello world!';
myText.font = '/fonts/FontName.woff';
myText.fontSize = 0.2;
myText.color = 0xffffff;
scene.add( myText );
myText.sync();

この方法の場合、拡大しても輪郭を保ちやすい一方で、低解像度では角が丸くなることがあります。細部が崩れる場合は sdfGlyphSize を既定の64から128へ上げます。

MSDF / MTSDF

個人的に一番使用する方法です。MSDF(Multi-channel Signed Distance Field:マルチチャンネル符号付き距離場)は距離をRGBの3チャンネルに分け、角を保って拡大します。必要なものは以下です。

ファイル名 内容
FontName.otf フォント
charset.txt 使用したい文字
FontName.json 配置の情報
FontName.0.png など 距離の画像(ページごと)

生成方法に関して

生成には msdf-bmfont-xml を使用します。中身は msdfgen です。

yarn global add msdf-bmfont-xml
msdf-bmfont \
	-f json \
	--texture-size 1024,1024 \
	--distance-range 8 \
	--pot \
	--smart-size \
	--charset-file charset.txt \
	-o FontName \
	FontName.otf

ビットマップフォントのときと同様に charset.txt に使用したい文字を書きます。

-f json は配置情報をJSONにします。--texture-size は画像サイズの上限、--pot と --smart-size は画像サイズの調整です。--distance-range 8 は記録する距離範囲の全幅で、シェーダーでも同じ値を使用します。

描画に関して

配置はビットマップフォント節の createTextGroup() を共用し、マテリアルを変更します。アトラスは色ではなく距離なので NoColorSpace にし、RGBの中央値から輪郭を復元します。この例もアトラス1枚を前提にしています。

const text = 'Ag';

// 読み込み
const jsonUrl = 'fonts/FontName.json';
const response = await fetch( jsonUrl );
if ( !response.ok ) throw new Error( jsonUrl );
const data = await response.json();
const directory = jsonUrl.slice( 0, jsonUrl.lastIndexOf( '/' ) + 1 );
const texture = await new THREE.TextureLoader().loadAsync( directory + data.pages[ 0 ] );
texture.colorSpace = THREE.NoColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;

// 距離から輪郭を復元するマテリアル
const material = new THREE.ShaderMaterial( {
	glslVersion: THREE.GLSL3,
	uniforms: {
		tMap: { value: texture },
		uDistanceRange: { value: data.distanceField.distanceRange },
		uAtlasSize: { value: new THREE.Vector2( data.common.scaleW, data.common.scaleH ) },
		uInk: { value: new THREE.Color( 0x222222 ) }
	},
	vertexShader: /* glsl */`
		out vec2 vUv;
		void main() {
			vUv = uv;
			gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
		}
	`,
	fragmentShader: /* glsl */`
		precision highp float;
		in vec2 vUv;
		out vec4 fragColor;
		uniform sampler2D tMap;
		uniform float uDistanceRange;
		uniform vec2 uAtlasSize;
		uniform vec3 uInk;
		float median( float r, float g, float b ) {
			return max( min( r, g ), min( max( r, g ), b ) );
		}
		void main() {
			vec3 sampleColor = texture( tMap, vUv ).rgb;
			float signedDistance = median( sampleColor.r, sampleColor.g, sampleColor.b );
			vec2 unitRange = vec2( uDistanceRange ) / uAtlasSize;
			vec2 screenTexSize = vec2( 1.0 ) / fwidth( vUv );
			float screenPxRange = max( 0.5 * dot( unitRange, screenTexSize ), 1.0 );
			float screenPxDistance = screenPxRange * ( signedDistance - 0.5 );
			float alpha = clamp( screenPxDistance + 0.5, 0.0, 1.0 );
			fragColor = linearToOutputTexel( vec4( uInk, alpha ) );
		}
	`,
	transparent: true,
	depthWrite: false
} );

const textGroup = createTextGroup( text, data, material );
scene.add( textGroup );

良い点はSDFより角を保って拡大しやすいことです。ただし細い線や小さな隙間の再現には生成解像度も影響します。

MTSDFはRGBのMSDFに真の距離をアルファチャンネルとして加えた形式で、発光や丸いアウトラインなどにも利用できます。上のコードとデモではMSDFだけを扱っています。

アウトラインを三角形にする

文字を立体にしたい場合はこの方法です。字形の輪郭を頂点にし必要なら厚みを付けます。必要なものは以下です。

ファイル名 内容
FontName.otf フォント
FontName.typeface.json 字形

生成方法に関して

facetype.js で FontName.typeface.json を書き出します。

描画に関して

Three.jsでは FontLoader と TextGeometry を使用します。

import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
const loader = new FontLoader();
const font = await loader.loadAsync( 'fonts/FontName.typeface.json' );
const geometry = new TextGeometry( 'Logo', {
	font: font,
	size: 1,
	depth: 0.15,
	curveSegments: 8,
	bevelEnabled: false
} );

良い点は立体として扱えることです。ただし文字数や輪郭の複雑さに応じて頂点数が増えるので長い文章には向きません。

曲線で塗る

ベジェ曲線の式で輪郭の内外を判定します。テクスチャは不要ですが、フォントから取り出した輪郭のデータが必要です。

LoopとBlinn

二次曲線では LoopとBlinnの方法 があります。頂点へ専用の座標を割り当て、次の値の正負で内外を判定します。

float k = vCurve.x * vCurve.x - vCurve.y;

良い点は拡大しても曲線を細かく分割し直す必要がないことです。ただし式だけで文字を描けるわけではなく、輪郭や頂点の準備に手間がかかります。

ステンシル

ステンシルは塗る領域を記録する仕組みです。比較デモでは曲線の判定と組み合わせ、字形の内側だけに色を付けています。曲線の扱いに加えて塗り分けや境界を滑らかにする処理も必要なので、実装は少し複雑です。

DOMテキストの扱い

WebGLへ描いただけでは文字を選択・検索したり、読み上げたりできません。本文や見出しはDOMにも残し、必要に応じて同じ位置へWebGLの文字を重ねます。

DOMとの位置合わせデモは固定の Ag を重ねる例です。DOMの位置と文字サイズを読み取り、スクロールに追従させています。

HTML-in-Canvas

また、HTML/CSSで組んだ要素を直接Canvasへ描画したり、WebGLやWebGPUのテクスチャへ渡したりする HTML-in-Canvas も提案されています。文字の折り返しや装飾をブラウザに任せたままシェーダーで加工する、といった使い方が想定されています。

2026年9月時点ではChromiumのフラグで有効にする実験段階ですが、DOMと描画位置を同期して文字選択や入力操作を扱う仕組みも含まれています。

参考