RSS2.0

LWJGL で日本語フォントを使ってみる

今回は LWJGL でのフォントの使い方について書いてみたいと思いますー。
日本語表示もばっちり、こんな感じです。
screenshot.png
PSP 版だーりんも発売されたということでシュタゲネタ(笑

テキストメッセージの表示はゲームには欠かせない要素になりますが、OpenGL としてはあまりサポートされていない分野なので、LWJGL でも扱うのは少し手間がかかります。

基本的には、予め必要な文字をすべて書き込んでおいた画像をテクスチャーとして読み込み、そこから表示したい字を切り貼りしていく、というアプローチになります。
ただこれではフォントの準備だけで大仕事になってしまうので、Java AWT と連動させて動的に文字を書き込んだ画像を作っていく方法についても触れていきたいと思います。

2014/02/25 追記

LWJGL での日本語表示をより手軽に実現できるライブラリ LWJGFont をリリースしました。
この記事で紹介している方法とは表示するためのアプローチが異なりますが、あわせてご検討ください。


ソースコード

サンプルプログラムのソースコードです。
実行には昔使った以下のファイルが必要になりますので、いつも通りプロジェクトに配置してください。
test.monolith.v3.TextureLoader
test.monolith.v3.Texture
test.alphablend.AlphaBlend

サンプルプログラムのソースコードは、以下の 2 つになります。

DivergenceMeter.java
package test.font;

import static org.lwjgl.opengl.GL11.GL_BACK;
import static org.lwjgl.opengl.GL11.GL_BLEND;
import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT;
import static org.lwjgl.opengl.GL11.GL_CULL_FACE;
import static org.lwjgl.opengl.GL11.GL_MODELVIEW;
import static org.lwjgl.opengl.GL11.GL_PROJECTION;
import static org.lwjgl.opengl.GL11.GL_QUADS;
import static org.lwjgl.opengl.GL11.GL_TEXTURE_2D;
import static org.lwjgl.opengl.GL11.glBegin;
import static org.lwjgl.opengl.GL11.glClear;
import static org.lwjgl.opengl.GL11.glClearColor;
import static org.lwjgl.opengl.GL11.glColor4f;
import static org.lwjgl.opengl.GL11.glCullFace;
import static org.lwjgl.opengl.GL11.glEnable;
import static org.lwjgl.opengl.GL11.glEnd;
import static org.lwjgl.opengl.GL11.glLoadIdentity;
import static org.lwjgl.opengl.GL11.glMatrixMode;
import static org.lwjgl.opengl.GL11.glOrtho;
import static org.lwjgl.opengl.GL11.glPopMatrix;
import static org.lwjgl.opengl.GL11.glPushMatrix;
import static org.lwjgl.opengl.GL11.glRotatef;
import static org.lwjgl.opengl.GL11.glScalef;
import static org.lwjgl.opengl.GL11.glTranslatef;
import static org.lwjgl.opengl.GL11.glVertex3f;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontFormatException;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.lwjgl.LWJGLException;
import org.lwjgl.Sys;
import org.lwjgl.input.Keyboard;
import org.lwjgl.opengl.Display;
import org.lwjgl.opengl.DisplayMode;

import test.alphablend.AlphaBlend;
import test.monolith.v3.Texture;
import test.monolith.v3.TextureLoader;

public class DivergenceMeter {
	/**
	 *	プログラムの状態を表す Enum 定数
	 */
	enum Mode {
		Moving(true),
		StopMoving(true),
		Message(false);
		
		private static final List<Mode>	modes = Collections.unmodifiableList(Arrays.asList(Mode.values()));

		private boolean hasMovingEffect;

		private Mode(boolean hasMovingEffect) {
			this.hasMovingEffect = hasMovingEffect;
		}
		
		/**
		 *	現在の状態から遷移する次の状態を返す
		 */
		public Mode next() {
			return modes.get((modes.indexOf(this) + 1) % Mode.values().length);
		}

		/**
		 *	現状態で、世界線変動率にエフェクトをかけるなら true, そうでなければ false を返す
		 */
		public boolean hasMovingEffect() {
			return hasMovingEffect;
		}
	}
	
	private static final int	METER_AROUND_MILLSECOND = 3000;
	private static final float	METER_MAX = 10f;

	private static final int	TEXTURE_FONT_WIDTH = 50;
	private static final int	TEXTURE_FONT_HEIGHT = 80;
	private static final float	TRUE_TYPE_FONT_HEIGHT = 28f;
	
	private int			width = 960;
	private int			height = 540;
	private int			depth = 100;
	private String		title = "Divergence meter";

	private long		time;
	private long		timeDelta;
	private long		fps;

	private TextureLoader	textureLoader;
	private Texture			textureDivergenceFont;
	private Texture			textureKyoma;
	private Texture			textureWindow;
	private Texture			textureMessage;
	private Font			font;

	private Mode			mode;
	private float			meter;
	private long			meterStartTime;
	private Divergence		divergence;
	private WindowAnimation	windowAnimation;
	
	public void start() {
		try {
			//	ウインドウを生成する
			Display.setDisplayMode(new DisplayMode(width, height));
			Display.setTitle(title);
			Display.create();
		} catch(LWJGLException e) {
			e.printStackTrace();
			return;
		}

		try {
			//	OpenGL の初期設定

			//	オフスクリーンを初期化する際の背景色を指定する
			glClearColor(0f, 0f, 0f, 1f);

			//	テクスチャーを有効化する
			glEnable(GL_TEXTURE_2D);

			//	アルファブレンドを有効化する
			glEnable(GL_BLEND);

			//	ポリゴンの表示面を表のみに設定する
			glEnable(GL_CULL_FACE);
			glCullFace(GL_BACK);

			//	カメラ用の設定変更を宣言する
			glMatrixMode(GL_PROJECTION);
			//	設定を初期化する
			glLoadIdentity();
			//	視体積(目に見える範囲)を定義する
			glOrtho(0, width, 0, height, 0, depth);

			//	物体モデル用の設定変更を宣言する
			glMatrixMode(GL_MODELVIEW);

			init();

			while (!Display.isCloseRequested()) {
				//	オフスクリーンを初期化する
				glClear(GL_COLOR_BUFFER_BIT);

				//	FPS を計算する
				calcFps();

				update();

				//	オフスクリーンに描画する
				render();

				//	オフスクリーンをスクリーンに反映する
				Display.update();
			}
		} catch(Exception e) {
			e.printStackTrace();
		} finally {
			terminate();
			Display.destroy();
		}
	}

	private void init() throws Exception {
		time = getMillisecond();
		fps = 0;

		//	ファイルのパス指定で画像を読み込む
		textureLoader = new TextureLoader();
		textureDivergenceFont = textureLoader.loadTexture("images/divergence_font.png");
		textureKyoma = textureLoader.loadTexture("images/kyoma.png");
		textureWindow = textureLoader.loadTexture("images/dm_message_window.png");
		textureMessage = null;
		
		//	フォントを読み込む
		font = loadFont("font/migmix-1p-regular.ttf");

		//	初期化する
		mode = Mode.Moving;
		meter = 0f;
		meterStartTime = 0;
	}
	
	private void terminate() {
		if (textureDivergenceFont != null) textureDivergenceFont.dispose();
		if (textureKyoma != null) textureKyoma.dispose();
		if (textureWindow != null) textureWindow.dispose();
		if (textureMessage != null) textureMessage.dispose();
	}

	private void update() throws IOException {
		boolean	isModeChanged = isKeyPressed();

		if (mode == Mode.Moving) {
			//	世界線を移動中
			if ((isModeChanged) && (divergence == null)) {
				//	キーが押されたら、StopMoving に状態遷移する
				mode = mode.next();
				divergence = Divergence.random();
				//	メッセージウインドウとオカリンを登場させる
				windowAnimation = new WindowAnimation(true);
			} else {
				moveMeter();
			}
		} else if (mode == Mode.StopMoving) {
			//	世界線移動の終了中
			float	preMeter = meter;

			moveMeter();
			
			//	移動先の世界線に到達したら、Message に状態遷移する
			if ((preMeter <= divergence.getValue()) && (divergence.getValue() < meter)) {
				meter = divergence.getValue();
				mode = mode.next();
				textureMessage = prepareMessageTexture();
			}
		} else if (mode == Mode.Message) {
			//	キーが押されたら、Moving に状態遷移する
			if (isModeChanged) {
				mode = mode.next();
				divergence = null;
				//	メッセージウインドウとオカリンを退場させる
				windowAnimation = new WindowAnimation(false);
				textureMessage.dispose();
				textureMessage = null;
			}
		}

		//	FPS を調整する
		Display.sync(60);
	}

	/**
	 *	経過時間に応じて世界線変動率を更新する
	 */
	private void moveMeter() {
		if (meterStartTime <= 0) {
			meterStartTime = getMillisecond();
		}
		long	now = getMillisecond();

		//	経過時間からメーターの変更値を求めると小数点以下4桁めからがほぼとまって見えるので、その分は適当に乱数を加えて動かして見せる
		meter += (((now - meterStartTime) * 1.0f / METER_AROUND_MILLSECOND * METER_MAX * 1000000) + rand(1) * 1000) / 1000000;
		meter %= METER_MAX;
		meterStartTime = now;
	}

	/**
	 *	キーボードで F1 キーが押されたら true を、そうでなければ false を返す
	 */
	private boolean isKeyPressed() {
		boolean	isPressed = false;

		while (Keyboard.next()) {
			if ((Keyboard.getEventKey() == Keyboard.KEY_F1) && (Keyboard.getEventKeyState())) {
				isPressed = true;
			}
		}

		return isPressed;
	}

	private void render() {
		//	設定を初期化する
		glLoadIdentity();

		//	世界線変動率を表示する
		drawDivergenceMeter(meter, 120, 370);
		
		//	メッセージウインドウとオカリンを表示する
		if (windowAnimation != null) {
			float	kyomaX;
			float	windowY;

			//	ウインドウのアニメーションの進捗に応じて表示位置を修正する
			if (windowAnimation.isOpen()) {
				kyomaX = width - (textureKyoma.getWidth() * windowAnimation.getPercentage());
				windowY = textureWindow.getHeight() * windowAnimation.getPercentage();
			} else {
				kyomaX = width - (textureKyoma.getWidth() * (1f - windowAnimation.getPercentage()));
				windowY = textureWindow.getHeight() * (1f - windowAnimation.getPercentage());
			}

			AlphaBlend.AlphaBlend.config(textureKyoma);
			glColor4f(1f, 1f, 1f, 1f);
			drawPicture(textureKyoma, kyomaX, textureKyoma.getHeight());
			drawPicture(textureWindow, 0, windowY);
		}
		
		//	外部フォントで描いたメッセージを表示する
		if (mode == Mode.Message) {
			drawPicture(textureMessage, 0, textureWindow.getHeight());
		}
	}

	private void drawPicture(Texture texture, float x, float y) {
		drawPicture(texture, 0, 0, texture.getWidth(), texture.getHeight(), x, y);
	}

	private void drawPicture(Texture texture, int srcX1, int srcY1, int srcX2, int srcY2, float dstX, float dstY) {
		//	テクスチャをバインドする
		texture.bind();

		glBegin(GL_QUADS);

		float	dstX2 = dstX + srcX2 - srcX1;
		float	dstY2 = dstY - (srcY2 - srcY1);

		texture.point(srcX2, srcY1);
		glVertex3f(dstX2, dstY, 0);

		texture.point(srcX1, srcY1);
		glVertex3f(dstX, dstY, 0);

		texture.point(srcX1, srcY2);
		glVertex3f(dstX, dstY2, 0);
	
		texture.point(srcX2, srcY2);
		glVertex3f(dstX2, dstY2, 0);
		
		glEnd();
	}
	
	/**
	 *	テクスチャーマッピングを用いて数値を表示する
	 */
	private void drawDivergenceMeter(float value, float x, float y) {
		String	string = String.format("%.6f", value);
		float	alpha = 1f;

		//	数値の表示位置にランダム要素を加える
		if (mode.hasMovingEffect()) {
			x += (rand(6, true)) * 500;
			y -= (rand(6, true)) * 500;
			alpha = 0.6f;
		}
		
		final int		fontHalfWidth = TEXTURE_FONT_WIDTH / 2;
		final int		fontHalfHeight = TEXTURE_FONT_HEIGHT / 2;

		//	加算合成で表示する
		AlphaBlend.Add.config(textureKyoma);

		for (int i = 0; i < string.length(); i++) {
			char	c = string.charAt(i);
			int		number = 10;

			if (Character.isDigit(c)) {
				number = (int)c - '0';
			}

			//	行列スタックに現在の行列を退避させ、新しい行列に対してモデルビュー変換を開始する
			glPushMatrix();
			
			//	数字の中心が原点となるよう座標軸を移動する
			glTranslatef(x + (i * TEXTURE_FONT_WIDTH) + fontHalfWidth, y - fontHalfHeight, 0);

			drawDivergenceChar(number, 1f, 1f, alpha);

			if (mode.hasMovingEffect()) {
				//	各数字のスケールにランダム要素を加える
				float	scale = rand(3);
				
				//	スケールが上がるほど透過率は下げる
				float	effectAlpha = (1f - scale) * 0.5f;
	
				//	スケールを 1 倍以上で拡大する
				float	scaleX = scale * 5f + 1f;
				float	scaleY = scale * 8f + 1f;
	
				drawDivergenceChar(number, scaleX, scaleY, effectAlpha);
			}

			//	行列スタックからもとの行列を取り出す
			glPopMatrix();
		}
	}
	
	private void drawDivergenceChar(int number, float scaleX, float scaleY, float alpha) {
		final int		fontHalfWidth = TEXTURE_FONT_WIDTH / 2;
		final int		fontHalfHeight = TEXTURE_FONT_HEIGHT / 2;

		//	スケールを設定する
		glScalef(scaleX, scaleY, 1f);

		//	アルファ率を設定する
		glColor4f(1f, 1f, 1f, alpha);

		//	テクスチャーから文字を切り出して表示する
		drawPicture(textureDivergenceFont,
				number * TEXTURE_FONT_WIDTH, 0,
				(number + 1) * TEXTURE_FONT_WIDTH, TEXTURE_FONT_HEIGHT,
				-fontHalfWidth, fontHalfHeight);
	}

	/**
	 *	引数 n の回数だけ乱数を生成し、それを掛け合わせた値を返す。
	 */
	private float rand(int n) {
		return rand(n, false);
	}

	/**
	 *	引数 n の回数だけ乱数を生成し、それを掛け合わせた値を返す。
	 *	containsNegative に true を指定した場合、ランダムで符号を逆転させる。
	 */
	private float rand(int n, boolean containsNegative) {
		double	r = 1;

		for (int i = 0; i < n; i++) {
			r *= Math.random();
		}

		if (containsNegative) {
			if (Math.random() < 0.5) {
				r *= -1;
			}
		}
		
		return (float)r;
	}
	
	/**
	 *	外部フォントで表示したい文字列を書いたテクスチャーを生成する
	 */
	private Texture prepareMessageTexture() throws IOException {
		if (textureMessage != null) {
			textureMessage.dispose();
		}

		BufferedImage	image = null;
		Graphics2D		g = null;
		try {
			image = textureLoader.createImageData(textureWindow.getWidth(), textureWindow.getHeight());
	
			//	透明色で塗りつぶし、BufferedImage を初期化する
			g = image.createGraphics();
			g.setColor(new Color(0f, 0f, 0f, 0f));
			g.fillRect(0, 0, textureWindow.getWidth(), textureWindow.getHeight());

			//	外部フォントを使う準備をする
			g.setFont(font);
			g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
			g.setColor(new Color(1f, 1f, 1f, 1f));

			//	表示する文字列の位置を計算する
			String	message = divergence.getMessage();
			int		count = 0;
			int		y = 0;
			while (0 < message.length()) {
				boolean	isDrawed = false;
				
				if (message.length() <= count) {
					isDrawed = true;
				} else {
					//	メッセージウインドウの右端まで文字を書き込んだら、改行する
					int	width = g.getFontMetrics().stringWidth(message.substring(0, count + 1));

					isDrawed = (textureWindow.getWidth() - 200 < width);
				}
				
				if (isDrawed) {
					g.drawString(message.substring(0, count), 100, 40 + y);
					message = message.substring(count);
					y += TRUE_TYPE_FONT_HEIGHT + 6;
					count = 0;
				} else {
					count++;
				}
			}

			return textureLoader.loadTexture(image);
		} finally {
			if (g != null) {
				g.dispose();
			}
			if (image != null) {
				image.flush();
			}
		}
	}
	
	/**
	 *	引数のクラスローダーでクラスパス上からフォントを読み込んで返す
	 */
	private Font loadFont(String fontPath, ClassLoader classLoader) throws FontFormatException, IOException {
		return loadFont(classLoader.getResourceAsStream(fontPath));
	}

	/**
	 *	引数のファイルパスのフォントを読み込んで返す
	 */
	private Font loadFont(String fontPath) throws FontFormatException, IOException {
		return loadFont(new FileInputStream(fontPath));
	}

	private Font loadFont(InputStream in) throws FontFormatException, IOException {
		try {
			//	外部フォントを読み込んで、サイズ変更したものを返す
			return Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(TRUE_TYPE_FONT_HEIGHT);
		} finally {
			if (in != null) {
				try {
					in.close();
				} catch (IOException e) {}
			}
		}
	}

	private void calcFps() {
		long	now = getMillisecond();

		//	前のフレームからの経過時間を求め、timeDelta に足す
		timeDelta += now - time;
		time = now;

		if (1000 <= timeDelta) {
			//	timeDelta が 1 秒を上回ったら、FPS をリセットする
			timeDelta -= 1000;
			Display.setTitle(title + "(" + fps + "fps)");
			fps = 1;
		} else {
			fps++;
		}
	}
	
	public static long getMillisecond() {
		//	現在の時間をミリ秒で返す
		return (Sys.getTime() * 1000) / Sys.getTimerResolution();
	}

	public static void main(String[] args) {
		new DivergenceMeter().start();
	}
	
	class WindowAnimation {
		private static final long	EXPAND_MILLSECOND = 400;
		
		private final long		startTime;
		private final boolean	isOpen;
		
		WindowAnimation(boolean isOpen) {
			this.startTime = getMillisecond();
			this.isOpen = isOpen;
		}
	
		/**
		 *	アニメーションの現在の進捗を 0.0f ~ 1.0f で返す
		 */
		public float getPercentage() {
			long	now = getMillisecond();
			float	percentage = (now - startTime) * 1f / EXPAND_MILLSECOND;
			
			return (1f <= percentage)? 1f: percentage;
		}

		/**
		 *	アニメーションの向きを返す。
		 *	登場アニメーションなら true を、退場アニメーションなら false を返す。
		 */
		public boolean isOpen() {
			return isOpen;
		}
	}
}

Divergence.java
package test.font;

public enum Divergence {
	D_0_571024("俺は狂気のマッドサイエンティスト、鳳凰院凶真だ!!!"),
	D_0_337187("Hey mister! I am mad scientist! It's so cool!"),
	D_0_456914("妖刀五月雨を持って来い!"),
	D_0_571046("これより!オペレーション・ベルダンディ 最終フェイズを開始するっ!!"),
	D_1_130205("―――世界の支配構造はリセットされ、混沌の未来が待つであろう!これこそが、シュタインズ・ゲートの選択っ…!!"),
	D_1_130212("おまえの 3 週間の世界線漂流を無駄にしてはならない。なかったことにしてはいけない。"),
	D_1_048596("助けが欲しい時はそれを握りしめ…ラ・ヨーダ・スタセッラと唱えるがいい")	;
	
	private final String	message;
	private final float		value;

	private Divergence(String message) {
		this.message = message;
		this.value = Float.parseFloat(name().replace("D_", "").replace("_", "."));
	}
	
	public String getMessage() {
		return message;
	}

	public float getValue() {
		return value;
	}
	
	public static Divergence random() {
		return Divergence.values()[(int)(Math.random() * Divergence.values().length)];
	}
}

こちらが使用する画像です。プロジェクトのディレクトリ直下に images というディレクトリを用意して、その中に指定の名前で置いてください。

divergence_font.png
divergence_font.png

kyoma.png
kyoma.png

dm_message_window.png
dm_message_window.png

また、今回は表示する日本語フォントとして、M+IPA フォントを使わせていただいてます。
こちらの MigMix 1P フォントをダウンロードし、migmix-1p-regular.ttf というフォントファイルを、プロジェクト内に作った font ディレクトリ内に配置してください。

プログラムの操作方法

キーボード
・F1 キー … ダイバージェンスメーターを止める / 動かす

テクスチャマッピングでフォントを表示する

まずは基本的な方法として、予めすべての文字を書き込んでおいた画像を用意しておく手法について紹介します。サンプルプログラムではダイバージェンスメーターの数値をこの方法で表示しています。
実装内容としては、単純にテクスチャーに変換した画像ファイルの必要な部分を表示するというだけです。今回のサンプルプログラムでは必要な字は 0 ~ 9 までの数字と小数点 '.' だけなので、これを描き込んだ画像をテクスチャマッピングで表示しています。
//	0 ~ 9 と小数点を書き込んでおいた画像をテクスチャーとして読み込む
Texture	textureDivergenceFont = textureLoader.loadTexture("images/divergence_font.png");
...

//	テクスチャー内での、表示する文字の位置を計算する
//	デフォルトでは小数点を表示する
int		number = 10;

//	表示する文字が数値なら、その数値のテクスチャー内での位置を計算する
if (Character.isDigit(c)) {
	number = (int)c - '0';
}
...

//	テクスチャーから表示する文字を切り出して表示する
drawPicture(textureDivergenceFont,
		number * TEXTURE_FONT_WIDTH, 0,
		(number + 1) * TEXTURE_FONT_WIDTH, TEXTURE_FONT_HEIGHT,
		-fontHalfWidth, fontHalfHeight);
唯一考慮しなければならないのは、表示したい字だけを画像の中から切り貼りするために、画像内での各文字の位置を覚えておかなければならないという点です。
サンプルプログラムでは、画像内での 0 ~ 9 の文字の並びを Java の文字コードである UTF8(正確には、英字だけなので互換のある ASCII 配列)の文字配列とあわせているので、文字のキーコードから画像の位置を計算できるようにしています。文字 '0' のキーコードは 48、文字 '1' のキーコードは 49 ですが、画像ファイル内では左から 0 番目、左から 1 番目に配置しています。そのため、表示したい文字のキーコードから基準となる '0' のキーコードを引くことで、画像内でその文字が左端からの何番目にあるかを算出しています。画像内での文字の横幅はすべて 50 ピクセルとしているので、以下の計算式でテクスチャー内での文字の位置を計算することができます。
テクスチャー内での文字の X 座標 = (表示文字のキーコード - '0' のキーコード) * 各文字の横幅: 50 ピクセル

外部フォントファイルを読み込んで表示する

テクスチャマッピングでフォントを表示する場合、日本語の表示が難関となります。あらかじめすべての文字を書き込んだ画像ファイルを用意しておかなければならないのですが、日本語には膨大な量の漢字が存在するため、すべての文字を網羅するにはかなりの労力が必要になります。
また、テクスチャマッピングするためには個々の文字が画像内のどこに配置されているかを覚えておかなければなりません。サンプルプログラムで使っている画像のように、すべての文字の横幅が同じな等幅フォントでは位置の算出が比較的にしやすいですが、字によって横幅の異なるプロポーショナルフォント(True Type フォント)では位置の算出がかなり面倒になります。

OpenGL ではテクスチャーを切り貼りする API は提供されていますが、任意のフォントで文字列を表示するというような機能はありません。そこで、LWJGL ではなく Java AWT を使って、任意の外部フォントで文字列を書き込んだ画像を作成し、それを LWJGL のテクスチャーに変換するという実装をしてみたいと思います。

外部フォントを用意する

ここで外部フォントとは、MS Pゴシックやさざなみゴシックといった、OS にインストールされているフォントファイルのことを言います。ネット上にはいろいろなフォントが公開されていますし、製品として売られているフォントファイルも多く存在しています。これらを画像ファイルのようにゲームプログラムで読み込んで使うので、まずはフォントファイル自体を用意しなければなりません。

なお、実装の特性上、ゲームを配布する際には外部フォントファイルもいっしょに再配布することになります。フォントファイルを再配布する場合には、無償・商用に関わらず、必ずフォント製作者にライセンスを確認してください。自由に文字をレンダリングできるゲームなどのソフトウェアでは、フォントを使ったドキュメントや印刷物などとはライセンスの扱いが違う場合があるので、利用には十分注意してください。

Java Graphics2D を使って外部フォントを利用する

LWJGL でテクスチャーを生成する時には、任意の画像ファイルを Java AWT の BufferedImage として読み込んでから LWJGL で扱えるテクスチャーへ変換しています。つまり、Java で BufferedImage として扱えるデータはすべて LWJGL のテクスチャーに変換することができます。
Java AWT では BufferedImage から Graphics / Graphics2D を取得すれば、BufferedImage に対して自由に図形や文字列をレンダリングすることができます。好きなフォントを設定した上で java.awt.Graphics2D.drawString() を使えば、設定した外部フォントで任意の文字列を描画できます。
サンプルプログラムでは、外部フォントで任意の文字列だけを描いた BufferedImage をテクスチャーに変換し、それをアルファブレンドで重ねて表示しています。

BufferedImage の生成からテクスチャーへの変換処理は、以下のようになります。
private Texture prepareMessageTexture() throws IOException {
	...
	//	テクスチャーで使える画像形式で、BufferedImage を生成する
	BufferedImage	image = textureLoader.createImageData(textureWindow.getWidth(), textureWindow.getHeight());

	//	BufferedImage から Graphics2D を取り出す	
	Graphics2D		g = image.createGraphics();

	//	透明色で塗りつぶし、BufferedImage を初期化する
	g.setColor(new Color(0f, 0f, 0f, 0f));
	g.fillRect(0, 0, textureWindow.getWidth(), textureWindow.getHeight());

	//	外部フォントを使う準備をする
	g.setFont(font);
	g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
	g.setColor(new Color(1f, 1f, 1f, 1f));

	//	表示する文字列の位置を計算する
	String	message = divergence.getMessage();
	...
	
	//	任意の文字得を描画する
	g.drawString(message.substring(0, count), 100, 40 + y);
	...

	//	BufferedImage を LWJGL のテクスチャーに変換する
	return textureLoader.loadTexture(image);
}
サンプルプログラムの test.monolith.v3.TextureLoader.createImageData() が空の BufferedImage を生成するメソッドです。引数には生成する画像データの横幅と縦幅を渡します。
このメソッドを使わなくても BufferedImage インスタンスは作れますが、LWJGL のテクスチャーに変換するには test.monolith.v3.TextureLoader.loadTexture() 内で扱っている画像データ形式にあわせて BufferedImage インスタンスを作る必要があります。自分で new BufferedImage() する際には注意してください。

空の BufferedImage を作ったら、透過色(RGBA: 0, 0, 0, 0)で塗りつぶして初期化します。この辺りは LWJGL ではなく Java AWT のグラフィクス処理ですね。
BufferedImage.createGraphics() で Graphics2D を取り出し、fillRect() メソッドを呼び出せば塗りつぶしができます。これで完全に透明な画像データが準備されます。

次に java.awt.Graphics2D.setFont() メソッドで読み込んでいた java.awt.Font インスタンスを設定し、java.awt.Graphics2D.drawString() で文字列を描画します。Java AWT では java.awt.Graphics2D.setRenderingHint() メソッドで描画時のアンチエイリアスを有効にできるので、綺麗にスムーズされた文字列を書き込むこともできます。

なお、AWT の Graphics を使った描画処理では実行速度に懸念もあるのですが、手軽に日本語フォントを使うためには仕方ないか、というのが私の見解です。

最後に TextureLoader.loadTexture() で BufferedImage を LWJGL のテクスチャーに変換します。
LWJGL では(というか、OpenGL 自体の特性なのでしょうが)テクスチャーの描画はめちゃくちゃ早いのですが、テクスチャーの生成処理はそれなりに重いです。なので、Graphics2D を使えば好きなフォントで手軽に文字列を描けるようになるとはいえ、頻繁にテクスチャーを生成することは避けた方がいいです。あらかじめ表示するメッセージをある程度まとめてテクスチャーに変換しておき、アニメーションさせたい場合などには、そのテクスチャーの一部分を切り出して貼り付けていく、というような使用方法がいいと思います。

Java Graphics2D で外部フォントを読み込む

順番が前後してしまいましたが、外部フォントを java.awt.Font インスタンスとして読み込むには java.awt.Font.createFont() メソッドを使います。これも LWJGL ではなく Java AWT の領域となります。
[java:code]
java.awt.Font.createFont(int, InputStream)InputStream からフォントを読み込むことができるので、ファイルパスを指定した FileInputStream インスタンスを渡してもいいですし、java.lang.ClassLoader.getResourceAsStream() でクラスパス上から読み込んでもいいです。サンプルプログラムでは前者の方法を使っていますが、将来的に完成したゲームをひとつの jar ファイルで配布する場合には、jar ファイル内から直接読み込める後者のほうが便利かもしれません。

Java Graphics2D を使ってフォントの表示サイズを調べる

Graphics2D を使って外部フォントを利用する場合、等幅フォントのみならず、プロポーショナルフォント(True Type フォント)も扱うことができます。ただし等幅フォントと異なり、プロポーショナルフォントでは文字によって横幅が異なるため、文字列を表示するのに実際に何ピクセル必要なのかを調べなければならない場面が多くあります。サンプルプログラムのメッセージウインドウでも、表示するメッセージが画面に収まりきらない場合には自動で改行を入れるように実装してあります。
int	width = g.getFontMetrics().stringWidth(message.substring(0, count + 1));
Java AWT では設定されているフォントで文字列を表示するのに何ピクセル必要なのかを調べる java.awt.FontMetrics.stringWidth() メソッドが提供されています。

フォントの縦幅については、サンプルプログラムでは Font インスタンス生成時に指定しているので特に計算する必要はありませんが、デフォルトのフォントをそのまま使っている等でわからない場合には、java.awt.Font.getSize() を使って取得してください。

おしまい

LWJGL での日本語表示を検索していらっしゃる方が少なからずいるので、今回は文字列表示についてまとめてみました。
Graphics2D を使ったやり方は正直なところ逃げな方法ではあるのですが、コンシューマー用ゲームと違って LWJGL では日本語用のフォントライブラリ(jar 的な意味で)なんてありませんから、個人ユーザーが使うにはこういうやり方が妥当なんじゃないかと個人的には感じています。テクスチャーの生成コストがかかるので、そこだけ注意ですね。

PSP 版だーりんは昨日やっとプレイし始めました。るか子とまゆりルート以外は攻略済みです。シュタゲ本編と時間軸(世界線)を別にして、各キャラをもう少し掘り下げたファンディスクという感じなんですねー。本編と違ってシリアス展開もないですし、ショートストーリー集として気楽に読める仕上がりでした。
  LWJGL  コメント (0)  2012/05/05 09:28:25


公開範囲:
プロフィール HN: ももかん
ゲーム作ったり雑談書いたり・・・していた時期が私にもありました。
カレンダー
<<2019, 11>>
272829303112
3456789
10111213141516
17181920212223
24252627282930