Commits

Michael Ludwig  committed 7cfe9c2

Implement the text support, which basically works. There will be minor changes later on.

  • Participants
  • Parent commits a78bc46

Comments (0)

Files changed (12)

File src/com/ferox/renderer/impl/jogl/JoglRenderSurface.java

 			throw new RenderException("Cannot render a surface that must be attached to GL context");
 		
 		this.helper.render(gd);
+		gd.getGL().glFlush();
 	}
 
 	@Override

File src/com/ferox/renderer/impl/jogl/drivers/JoglDepthTestStateDriver.java

 		PixelOpRecord pr = record.pixelOpRecord;
 		FramebufferRecord fr = record.frameRecord;
 		
-		// func
 		int test = EnumUtil.getGLPixelTest(nextState.getTest());
-		if (pr.depthFunc != test) {
-			pr.depthFunc = test;
-			gl.glDepthFunc(test);
+		if (test == GL.GL_ALWAYS) {
+			// just disable the depth test
+			if (pr.enableDepthTest) {
+				pr.enableDepthTest = false;
+				gl.glDisable(GL.GL_DEPTH_TEST);
+			}
+		} else {
+			// func
+			if (pr.depthFunc != test) {
+				pr.depthFunc = test;
+				gl.glDepthFunc(test);
+			}
+			
+			// enable
+			if (!pr.enableDepthTest) {
+				pr.enableDepthTest = true;
+				gl.glEnable(GL.GL_DEPTH_TEST);
+			}
 		}
+		
 		// writing
 		if (nextState.isWriteEnabled() != fr.depthWriteMask) {
 			fr.depthWriteMask = nextState.isWriteEnabled();
 			gl.glDepthMask(fr.depthWriteMask);
 		}
-		// enable
-		if (!pr.enableDepthTest) {
-			pr.enableDepthTest = true;
-			gl.glEnable(GL.GL_DEPTH_TEST);
-		}
 	}
 }

File src/com/ferox/renderer/impl/jogl/drivers/JoglTextDriver.java

+package com.ferox.renderer.impl.jogl.drivers;
+
+import java.nio.FloatBuffer;
+
+import javax.media.opengl.GL;
+
+import com.ferox.renderer.impl.GeometryDriver;
+import com.ferox.renderer.impl.ResourceData;
+import com.ferox.renderer.impl.ResourceData.Handle;
+import com.ferox.renderer.impl.jogl.JoglSurfaceFactory;
+import com.ferox.renderer.impl.jogl.record.VertexArrayRecord;
+import com.ferox.resource.Geometry;
+import com.ferox.resource.Resource;
+import com.ferox.resource.Resource.Status;
+import com.ferox.resource.text.Text;
+import com.sun.opengl.util.BufferUtil;
+
+/** JoglTextDriver handles the rendering of instances of Text
+ * by using glDrawArrays.
+ * 
+ * @author Michael Ludwig
+ *
+ */
+public class JoglTextDriver implements GeometryDriver {
+	private static class TextHandle implements Handle {
+		private FloatBuffer bufferedCoords;
+		private int numVerts;
+		
+		@Override
+		public int getId() {
+			return -1;
+		}
+	}
+	
+	private final JoglSurfaceFactory factory;
+	private TextHandle lastRendered;
+	
+	public JoglTextDriver(JoglSurfaceFactory factory) {
+		this.factory = factory;
+		this.lastRendered = null;
+	}
+
+	@Override
+	public int render(Geometry geom, ResourceData data) {
+		GL gl = this.factory.getGL();
+		VertexArrayRecord vr = this.factory.getRecord().vertexArrayRecord;
+		
+		TextHandle toRender = (TextHandle) data.getHandle();
+		if (this.lastRendered != toRender) {
+			// must enable vertex arrays and the 1st tex coord array unit
+			if (!vr.enableVertexArray) {
+				vr.enableVertexArray = true;
+				gl.glEnableClientState(GL.GL_VERTEX_ARRAY);
+			}
+			
+			if (vr.clientActiveTexture != 0) {
+				vr.clientActiveTexture = 0;
+				gl.glClientActiveTexture(GL.GL_TEXTURE0);
+			}
+			if (!vr.enableTexCoordArrays[0]) {
+				vr.enableTexCoordArrays[0] = true;
+				gl.glEnableClientState(GL.GL_TEXTURE_COORD_ARRAY);
+			}
+			
+			// set the interleaved arrays
+			FloatBuffer buff = toRender.bufferedCoords;
+			buff.clear().limit(toRender.numVerts * 5);
+			gl.glInterleavedArrays(GL.GL_T2F_V3F, 0, buff);
+			
+			this.lastRendered = toRender; // store this
+		}
+		
+		// render it
+		gl.glDrawArrays(GL.GL_QUADS, 0, toRender.numVerts);
+		return toRender.numVerts >> 2; // verts / 4 to get quad count
+	}
+
+	@Override
+	public void reset() {
+		if (this.lastRendered != null) {
+			GL gl = this.factory.getGL();
+			VertexArrayRecord vr = this.factory.getRecord().vertexArrayRecord;
+			
+			// we only have to disable vertex and texCoord 0, since that's all Text uses
+			if (vr.enableVertexArray) {
+				vr.enableVertexArray = false;
+				gl.glDisableClientState(GL.GL_VERTEX_ARRAY);
+			}
+			
+			if (vr.clientActiveTexture != 0) {
+				vr.clientActiveTexture = 0;
+				gl.glClientActiveTexture(GL.GL_TEXTURE0);
+			}
+			if (vr.enableTexCoordArrays[0]) {
+				vr.enableTexCoordArrays[0] = false;
+				gl.glDisableClientState(GL.GL_TEXTURE_COORD_ARRAY);
+			}
+			
+			this.lastRendered = null;
+		}
+	}
+
+	@Override
+	public void cleanUp(Resource resource, ResourceData data) {
+		// do nothing -> no cleanUp necessary
+	}
+
+	@Override
+	public void update(Resource resource, ResourceData data, boolean fullUpdate) {
+		TextHandle handle = (TextHandle) data.getHandle();
+		if (handle == null) {
+			// need a new handle
+			handle = new TextHandle();
+			data.setHandle(handle);
+		}
+		
+		Text text = (Text) resource;
+		// perform layout if needed
+		if (text.isLayoutDirty())
+			text.layoutText();
+		
+		float[] coords = text.getInterleavedCoordinates();
+		if (handle.bufferedCoords == null || handle.bufferedCoords.capacity() < coords.length) {
+			// must expand the buffer
+			handle.bufferedCoords = BufferUtil.newFloatBuffer(coords.length);
+		}
+		
+		// fill the buffer
+		handle.bufferedCoords.clear();
+		handle.bufferedCoords.put(coords);
+		
+		handle.numVerts = text.getVertexCount();
+		
+		if (handle.numVerts > 0) {
+			data.setStatus(Status.OK);
+			data.setStatusMessage("");
+		} else {
+			data.setStatus(Status.ERROR);
+			data.setStatusMessage("ERROR is used to not render empty strings");
+		}
+	}
+}

File src/com/ferox/resource/text/CharacterSet.java

+package com.ferox.resource.text;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.font.FontRenderContext;
+import java.awt.font.GlyphMetrics;
+import java.awt.font.GlyphVector;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.util.HashSet;
+import java.util.Set;
+
+import com.ferox.resource.BufferData;
+import com.ferox.resource.Texture2D;
+import com.ferox.resource.TextureFormat;
+import com.ferox.resource.BufferData.DataType;
+import com.ferox.resource.TextureImage.Filter;
+import com.ferox.resource.text.RectanglePacker.Rectangle;
+
+/** CharacterSet represents a packed character sheet
+ * for a set of characters and a Font that they are
+ * rendered with.  It provides mappings to access the
+ * locations of specific characters within its Texture2D.
+ * 
+ * Each character in a CharacterSet has a Glyph representing
+ * its tex-coord location within it, as well as its local
+ * coordinates to aid in generating vertex coordinates for
+ * a string of coordinates.
+ * 
+ * Like Glyph, this was designed with English, left-to-right
+ * characters in mind.  Support for complex characters is untested.
+ * 
+ * @author Michael Ludwig
+ *
+ */
+public class CharacterSet {
+	static {
+		StringBuilder b = new StringBuilder();
+		for (int i = 32; i < 128; i++)
+			b.append((char) i);
+		DEFAULT_CHAR_SET = b.toString();
+	}
+	
+	public static final String DEFAULT_CHAR_SET;
+	public static final int CHAR_PADDING = 4;
+	
+	private Texture2D characters;
+	private Font font;
+	private FontRenderContext context;
+	private boolean antiAlias;
+	
+	private Glyph[] metrics;
+	private int metricOffset;
+	
+	/** Create a CharacterSet using the Font "Arial-PLAIN-12"
+	 * and DEFAULT_CHAR_SET. */
+	public CharacterSet(boolean antiAlias) {
+		this(null, antiAlias);
+	}
+	
+	/** Create a CharacterSet usint the given font and 
+	 * DEFAULT_CHAR_SET.  If the font is null, then
+	 * Arial-PLAIN-12 is used instead. */
+	public CharacterSet(Font font, boolean antiAlias) {
+		this(font, null, antiAlias);
+	}
+	
+	/** Create a CharacterSet using the given font and
+	 * character set.  If the font is null, "Arial-PLAIN-12"
+	 * is used.  If the characterSet is null, DEFAULT_CHAR_SET
+	 * is used.  All duplicate characters are removed from the
+	 * character set.  */
+	public CharacterSet(Font font, String characterSet, boolean antiAlias) {
+		if (font == null)
+			font = Font.decode("Arial-PLAIN-12");
+		if (characterSet == null)
+			characterSet = DEFAULT_CHAR_SET;
+		
+		this.font = font;
+		this.antiAlias = antiAlias;
+		this.buildCharacterSet(characterSet);
+	}
+	
+	/** Return the Glyph for the given character,
+	 * associated with this CharacterSet.  Returns the 
+	 * glyph for the "unknown" character if
+	 * c isn't present (all CharacterSet's have the unknown
+	 * character). */
+	public Glyph getGlyph(char c) {
+		int index = c - this.metricOffset;
+		if (index < 0 || index >= this.metrics.length)
+			return this.getGlyph((char) this.font.getMissingGlyphCode());
+		else
+			return this.metrics[index];
+	}
+	
+	/** Return the Texture2D that contains the character sheet
+	 * for all characters of this CharacterSet.  Use the
+	 * character metrics returned by getMetric() to access the
+	 * image data.
+	 * 
+	 * The texture will have a transparent background, with
+	 * the characters rendered in white. */
+	public Texture2D getCharacterSet() {
+		return this.characters;
+	}
+	
+	/** Return the Font that the characters of this
+	 * CharacterSet are rendered with. */
+	public Font getFont() {
+		return this.font;
+	}
+	
+	/** Return true if the character sheet was rendered with
+	 * anti-aliased text.  If true, a BlendMode should be used
+	 * to get the correct display, otherwise an AlphaTest should
+	 * be acceptable. */
+	public boolean isAntiAliased() {
+		return this.antiAlias;
+	}
+	
+	/** Return the FontRenderContext used to layout this CharacterSheet. */
+	public FontRenderContext getFontRenderContext() {
+		return this.context;
+	}
+	
+	/* Compute metrics[] and metricOffset, must be called after font is assigned
+	 * and generate the Texture2D that stores the packed glyphs. */
+	private void buildCharacterSet(String characterSet) {
+		char[] characters = this.getCharArray(characterSet);
+		this.createGlyphArray(characters);
+		
+		BufferedImage charSet = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
+		Graphics2D g2d = charSet.createGraphics();
+		this.context = g2d.getFontRenderContext();
+		
+		// pack all the glyphs
+		RectanglePacker<GlyphMetrics> rp = new RectanglePacker<GlyphMetrics>(64, 64);
+		GlyphVector v = this.font.layoutGlyphVector(this.context, characters, 0, characters.length, Font.LAYOUT_LEFT_TO_RIGHT);
+		GlyphMetrics g;
+		
+		GlyphMetrics[] glyphs = new GlyphMetrics[characters.length];
+		Rectangle[] glyphRectangles = new Rectangle[characters.length];
+		for (int i = 0; i < characters.length; i++) {
+			g = v.getGlyphMetrics(i);
+			glyphs[i] = g;
+			glyphRectangles[i] = rp.insert(g, (int) g.getBounds2D().getWidth() + CHAR_PADDING * 2, (int) g.getBounds2D().getHeight() + CHAR_PADDING * 2);
+		}
+		g2d.dispose(); // dispose of dummy image
+		
+		// compute a Glyph for each character and render it into the image
+		charSet = new BufferedImage(rp.getWidth(), rp.getHeight(), BufferedImage.TYPE_INT_ARGB);
+		g2d = charSet.createGraphics();
+		// clear the image
+		g2d.setColor(new Color(0f, 0f, 0f, 0f));
+		g2d.fillRect(0, 0, rp.getWidth(), rp.getHeight());
+		
+		// prepare the text to be rendered as white,
+		g2d.setColor(Color.WHITE);
+		g2d.setFont(this.font);
+		g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, (this.antiAlias ? RenderingHints.VALUE_TEXT_ANTIALIAS_ON : 
+																					 RenderingHints.VALUE_TEXT_ANTIALIAS_OFF));
+
+		// and flipped, so glyph dimensions make sense in openGL coord system
+		g2d.scale(1, -1);
+		g2d.translate(0, -rp.getHeight());
+		
+		Rectangle r;
+		Rectangle2D glyphBounds;
+		Glyph glyph;
+		// create an actual Glyph and render the char into the buffered image
+		for (int i = 0; i < characters.length; i++) {
+			g = glyphs[i];
+			r = glyphRectangles[i];
+			glyphBounds = g.getBounds2D();
+
+			glyph = new Glyph(g.getAdvance(), // advance
+							  (float) r.getX() / rp.getWidth(), (float) (r.getX() + r.getWidth()) / rp.getWidth(), // left-right
+							  (float) (rp.getHeight() - r.getY()) / rp.getHeight(), (float) (rp.getHeight() - r.getY() - r.getHeight()) / rp.getHeight(), // top-bottom
+							  (float) glyphBounds.getX(), (float) -(glyphBounds.getHeight() + glyphBounds.getY()), // x-y
+							  (float) glyphBounds.getWidth() + CHAR_PADDING * 2, (float) glyphBounds.getHeight() + CHAR_PADDING * 2); // width-height
+			this.metrics[characters[i]] = glyph;
+			
+			g2d.drawChars(characters, i, 1, r.getX() - (int) glyphBounds.getX() + CHAR_PADDING, r.getY() - (int) glyphBounds.getY() + CHAR_PADDING);
+		}
+		g2d.dispose();
+		
+		// create the texture
+		int[] data = ((DataBufferInt) charSet.getRaster().getDataBuffer()).getData();
+		BufferData[] imageData = {new BufferData(data, true)};
+		this.characters = new Texture2D(imageData, rp.getWidth(), rp.getHeight(), TextureFormat.ARGB_8888, DataType.UNSIGNED_INT, Filter.LINEAR);
+	}
+	
+	/* Create the array to hold the Glyphs. */
+	private void createGlyphArray(char[] chars) {
+		int minIndex = Integer.MAX_VALUE;
+		int maxIndex = Integer.MIN_VALUE;
+		
+		for (int i = 0; i < chars.length; i++) {
+			if (chars[i] < minIndex)
+				minIndex = chars[i];
+			if (chars[i] > maxIndex)
+				maxIndex = chars[i];
+		}
+				
+		this.metricOffset = minIndex;
+		this.metrics = new Glyph[maxIndex - minIndex + 1];
+	}
+	
+	/* Turn the string into an array of characters, and
+	 * add the missing glyph code and whitespaces. Only includes characters
+	 * the font can render. */
+	private char[] getCharArray(String characterSet) {
+		Set<Character> set = new HashSet<Character>();
+		for (char c: characterSet.toCharArray()) {
+			if (this.font.canDisplay(c))
+				set.add(Character.valueOf(c));
+		}
+		
+		 // always add these
+		set.add(Character.valueOf((char) this.font.getMissingGlyphCode()));
+		set.add(Character.valueOf('\n'));
+		set.add(Character.valueOf('\r'));
+		set.add(Character.valueOf('\t'));
+
+		char[] characters = new char[set.size()];
+		int i = 0;
+		for (Character c: set) {
+			characters[i++] = c.charValue();
+		}
+		
+		return characters;
+	}
+}

File src/com/ferox/resource/text/Glyph.java

+package com.ferox.resource.text;
+
+/** A Glyph represents the bounding-box and positioning information
+ * for a single character within a font.  It does not distinguish between
+ * AWT's various types of GlyphMetrics for multiple character sequences.
+ * 
+ * It assumes a fairly standard English character set that proceeds from
+ * left-to-right.  Each glyph has a local origin that represents the
+ * "lower-left" corner of the character.  This is not the absolute location
+ * of the character.  Characters may be positioned above or below this baseline,
+ * or to the left or right of it.  Above and to the right represent positive
+ * coordinate values.
+ * 
+ * @author Michael Ludwig
+ *
+ */
+public class Glyph {
+	private float advance;
+	private float tcL, tcR, tcT, tcB;
+	private float x, y;
+	private float width, height;
+	
+	/** Create a new Glyph with the given values. */
+	public Glyph(float advance, float tcL, float tcR, float tcT, float tcB, float x, float y, float width, float height) {
+		this.advance = advance;
+		this.tcL = tcL;
+		this.tcR = tcR;
+		this.tcT = tcT;
+		this.tcB = tcB;
+
+		this.x = x;
+		this.y = y;
+		this.width = width;
+		this.height = height;
+	}
+	
+	/** Return the horizontal distance to advance
+	 * for the next character. The advance is measured
+	 * from this character's origin (not getX()). */
+	public float getAdvance() { 
+		return this.advance; 
+	}
+	
+	/** Return the texture coord that represents the
+	 * left edge within the associated char-set's texture. */
+	public float getTexCoordLeft() { 
+		return this.tcL; 
+	}
+	
+	/** Return the texture coord that represents the
+	 * right edge within the associated char-set's texture. */
+	public float getTexCoordRight() { 
+		return this.tcR;
+	}
+	
+	/** Return the texture coord that represents the
+	 * top edge within the associated char-set's texture. */
+	public float getTexCoordTop() { 
+		return this.tcT; 
+	}
+	
+	/** Return the texture coord that represents the
+	 * bottom edge within the associated char-set's texture. */
+	public float getTexCoordBottom() { 
+		return this.tcB; 
+	}
+	
+	/** Return the appropriate x distance relative to
+	 * this metric's local origin of the character's 
+	 * quad's left edge.
+	 * 
+	 * Negative implies to the left of the origin. */
+	public float getX() { 
+		return this.x; 
+	}
+	
+	/** Return the appropriate y distance relative to
+	 * this metric's local origin of the character's
+	 * bottom edge.
+	 * 
+	 * A negative value implies the character is position
+	 * below the baseline of the font. */
+	public float getY() { 
+		return this.y; 
+	}
+	
+	/** Return the width of the character's quad,
+	 * this will be positive. */
+	public float getWidth() { 
+		return this.width; 
+	}
+	
+	/** Return the height of the character's quad,
+	 * this will be positive. */
+	public float getHeight() {
+		return this.height;
+	}
+}

File src/com/ferox/resource/text/RectanglePacker.java

+package com.ferox.resource.text;
+
+public class RectanglePacker<T> {
+	public static class Rectangle {
+		private int x, y;
+		private int width, height;
+		
+		public Rectangle(int x, int y, int width, int height) {
+			this.x = x;
+			this.y = y;
+			this.width = width;
+			this.height = height;
+		}
+		
+		public int getX() { return this.x; }
+		public int getY() { return this.y; }
+		public int getWidth() { return this.width; }
+		public int getHeight() { return this.height; }
+	}
+	
+	private static class Node<T> {
+		private Rectangle rc;
+		private Node<T> child1;
+		private Node<T> child2;
+		
+		private T data;
+		
+		public Node<T> get(T data) {
+			// check if we match
+			if (this.data == data)
+				return this;
+			
+			Node<T> n = null;
+			if (!this.isLeaf()) {
+				// we're not a leaf, so check children
+				n = this.child1.get(data);
+				if (n == null)
+					n = this.child2.get(data);
+			}
+			
+			return n;
+		}
+		
+		public Node<T> insert(T data, int width, int height) {
+			if (!this.isLeaf()) {
+				// test first child
+				Node<T> n = this.child1.insert(data, width, height);
+				if (n == null) // first failed, so check second
+					n = this.child2.insert(data, width, height);
+				return n;
+			} else {
+				if (this.data != null) 
+					return null; // already filled up
+				
+				if (this.rc.width < width || this.rc.height < height)
+					return null; // we're too small
+				
+				// check if we fit perfectly
+				if (this.rc.width == width && this.rc.height == height)
+					return this;
+				
+				// split this node, to form two children
+				this.child1 = new Node<T>();
+				this.child2 = new Node<T>();
+				
+				int dw = this.rc.width - width;
+				int dh = this.rc.height - height;
+				
+				// create rectangles
+				if (dw > dh) {
+					this.child1.rc = new Rectangle(this.rc.x, this.rc.y,
+												   width, this.rc.height);
+					this.child2.rc = new Rectangle(this.rc.x + width, this.rc.y,
+												   this.rc.width - width, this.rc.height);
+				} else {
+					this.child1.rc = new Rectangle(this.rc.x, this.rc.y,
+												   this.rc.width, height);
+					this.child2.rc = new Rectangle(this.rc.x, this.rc.y + height,
+												   this.rc.width, this.rc.height - height);
+				}
+				
+				return this.child1.insert(data, width, height);
+			}
+		}
+		
+		public boolean isLeaf() {
+			return this.child1 == null && this.child2 == null;
+		}
+	}
+	
+	private Node<T> root;
+	
+	public RectanglePacker(int startWidth, int startHeight) {
+		if (startWidth <= 0 || startHeight <= 0)
+			throw new IllegalArgumentException("Starting dimensions must be positive: " + startWidth + " " + startHeight);
+		startWidth = ceilPot(startWidth);
+		startHeight = ceilPot(startHeight);
+		
+		Rectangle rootBounds = new Rectangle(0, 0, startWidth, startHeight);
+		this.root = new Node<T>();
+		this.root.rc = rootBounds;
+	}
+	
+	public int getWidth() {
+		return this.root.rc.width;
+	}
+	
+	public int getHeight() {
+		return this.root.rc.height;
+	}
+	
+	public Rectangle get(T data) {
+		Node<T> n = this.root.get(data);
+		return (n == null ? null : n.rc);
+	}
+	
+	public Rectangle insert(T data, int width, int height) {
+		Node<T> n = null;
+		
+		while((n = this.root.insert(data, width, height)) == null) {
+			// we must expand it, choose the option that keeps
+			// the dimension smallest
+			if (this.root.rc.width + width <= this.root.rc.height + height)
+				this.expandWidth(width);
+			else
+				this.expandHeight(height);
+		}
+			
+		// assign the data and return
+		n.data = data;
+		return n.rc;
+	}
+	
+	private void expandWidth(int dw) {
+		Rectangle oldBounds = this.root.rc;
+		
+		int newW = oldBounds.width + dw;
+		newW = ceilPot(newW);
+
+		if (this.root.isLeaf() && this.root.data == null) {
+			// just expand the rectangle
+			this.root.rc.width = newW;
+		} else {
+			// create a new root node
+			Node<T> n = new Node<T>();
+			n.rc = new Rectangle(0, 0, newW, oldBounds.height);
+
+			n.child1 = this.root; // first child is old root
+			n.child2 = new Node<T>(); // second child is leaf with left-over space
+			n.child2.rc = new Rectangle(oldBounds.width, 0, newW - oldBounds.width, oldBounds.height);
+
+			this.root = n;
+		}
+	}
+	
+	private void expandHeight(int dh) {
+		Rectangle oldBounds = this.root.rc;
+		
+		int newH = oldBounds.height + dh;
+		newH = ceilPot(newH);
+		
+		if (this.root.isLeaf() && this.root.data == null) {
+			// just expand the rectangle
+			this.root.rc.height = newH;
+		} else {
+			// create a new root node
+			Node<T> n = new Node<T>();
+			n.rc = new Rectangle(0, 0, oldBounds.width, newH);
+
+			n.child1 = this.root; // first child is old root
+			n.child2 = new Node<T>(); // second child is leaf with left-over space
+			n.child2.rc = new Rectangle(0, oldBounds.height, oldBounds.width, newH - oldBounds.height);
+
+			this.root = n;
+		}
+	}
+	
+	// Return smallest POT >= num
+	private static int ceilPot(int num) {
+		int pot = 1;
+		while(pot < num)
+			pot = pot << 1;
+		return pot;
+	}
+}

File src/com/ferox/resource/text/Text.java

+package com.ferox.resource.text;
+
+import java.awt.font.LineMetrics;
+
+import com.ferox.math.AxisAlignedBox;
+import com.ferox.math.BoundSphere;
+import com.ferox.math.BoundVolume;
+import com.ferox.math.Color;
+import com.ferox.resource.Geometry;
+import com.ferox.state.AlphaTest;
+import com.ferox.state.Appearance;
+import com.ferox.state.BlendMode;
+import com.ferox.state.Material;
+import com.ferox.state.Texture;
+import com.ferox.state.State.PixelTest;
+
+public class Text implements Geometry {
+	private CharacterSet charSet;
+	private String text;
+	
+	private float width;
+	private float height;
+	
+	private float maxTextWidth; // if <= 0, then no wrapping is done
+	
+	private boolean layoutDirty;
+	private float[] coords; // it is interleaved T2F_V3F
+	private int numCoords;
+	
+	private AxisAlignedBox cacheBox;
+	private BoundSphere cacheSphere;
+	
+	private Object renderData;
+	
+	public Text(CharacterSet charSet) throws NullPointerException {
+		this(charSet, "");
+	}
+	
+	public Text(CharacterSet charSet, String text) throws NullPointerException {
+		if (charSet == null)
+			throw new NullPointerException("Must provide a non-null CharacterSet");
+		
+		this.charSet = charSet;
+		this.setText(text);
+		this.setWrapWidth(-1f);
+		
+		this.layoutText();
+	}
+	
+	public Appearance createAppearance(Color textColor) {
+		Material m = new Material(textColor);
+		Texture chars = new Texture(this.charSet.getCharacterSet());
+		
+		AlphaTest at = new AlphaTest();
+		Appearance a = new Appearance(m, chars, at);
+		
+		if (this.charSet.isAntiAliased()) {
+			BlendMode bm = new BlendMode();
+			
+			// must set it this way, so inter-poly 
+			// depth testing doesn't get goofed up
+			at.setTest(PixelTest.GREATER);
+			at.setReferenceValue(0f);
+			
+			a.addState(bm);
+		}
+		
+		return a;
+	}
+	
+	public float getWrapWidth() {
+		return this.maxTextWidth;
+	}
+	
+	public float getTextWidth() {
+		return this.width;
+	}
+	
+	public float getTextHeight() {
+		return this.height;
+	}
+	
+	public float[] getInterleavedCoordinates() {
+		return this.coords;
+	}
+	
+	public void setWrapWidth(float maxWidth) {
+		this.maxTextWidth = maxWidth;
+		this.layoutDirty = true;
+	}
+	
+	public void setText(String text) {
+		this.text = text;
+		this.layoutDirty = true;
+	}
+	
+	public String getText() {
+		return this.text;
+	}
+	
+	public CharacterSet getCharacterSet() {
+		return this.charSet;
+	}
+	
+	public boolean isLayoutDirty() {
+		return this.layoutDirty;
+	}
+	
+	public void layoutText() {
+		if (this.layoutDirty) {
+			LineMetrics lm = this.charSet.getFont().getLineMetrics(this.text, this.charSet.getFontRenderContext());
+			TextLayout tl = new TextLayout(this.charSet, lm, this.maxTextWidth);
+			this.coords = tl.doLayout(this.text, this.coords);
+						
+			this.width = tl.getMaxWidth();
+			this.height = tl.getMaxHeight();
+			
+			this.numCoords = this.text.length() * 4;
+			this.layoutDirty = false;
+		}
+	}
+
+	@Override
+	public void getBounds(BoundVolume result) {
+		if (result != null) {
+			if (result instanceof AxisAlignedBox) {
+				if (this.cacheBox == null) {
+					this.cacheBox = new AxisAlignedBox();
+					this.cacheBox.enclose(this);
+				}
+				this.cacheBox.clone(result);
+			} else if (result instanceof BoundSphere) {
+				if (this.cacheSphere == null) {
+					this.cacheSphere = new BoundSphere();
+					this.cacheSphere.enclose(this);
+				}
+				this.cacheSphere.clone(result);
+			} else
+				result.enclose(this);
+		}
+	}
+
+	@Override
+	public float getVertex(int index, int coord) throws IllegalArgumentException {
+		if (index < 0 || index >= this.getVertexCount())
+			throw new IllegalArgumentException("Illegal vertex index: " + index + " must be in [0, " + this.getVertexCount() + "]");
+		if (coord < 0 || coord > 3)
+			throw new IllegalArgumentException("Illegal vertex coordinate: " + coord + " must be in [0, 3]");
+		
+		if (coord == 3)
+			return 1f; // we don't have a 4th coordinate
+		
+		int bIndex = 2 + index * 5;
+		return this.coords[bIndex + coord];
+	}
+
+	@Override
+	public int getVertexCount() {
+		return this.numCoords;
+	}
+
+	@Override
+	public void clearDirtyDescriptor() {
+		// do nothing
+	}
+
+	@Override
+	public Object getDirtyDescriptor() {
+		return null;
+	}
+
+	@Override
+	public Object getResourceData() {
+		return this.renderData;
+	}
+
+	@Override
+	public void setResourceData(Object data) {
+		this.renderData = data;
+	}
+	
+	/** Helper class to place the characters into a multi-line block of text. */
+	private static class TextLayout {
+		// progress of cursor within text
+		private float cursorX;
+		private float cursorY;
+		
+		private float leftEdge;
+		private float height; // amount to subtract cursorY to get the next line
+		private float ascent, descent;
+		
+		private float maxWidth;
+		
+		private float wrapWidth;
+		
+		private CharacterSet charSet;
+				
+		/** If wrapWidth <= 0, then no forced wrapping is performed.
+		 * charSet and lm must not be null. */
+		public TextLayout(CharacterSet charSet, LineMetrics lm, float wrapWidth) {
+			this.leftEdge = 0f;
+			
+			this.height = lm.getHeight();
+			this.ascent = lm.getAscent();
+			this.descent = lm.getDescent();
+			
+			this.wrapWidth = wrapWidth;
+			
+			this.charSet = charSet;
+		}
+		
+		/** Return the height of the multi-line text block, after
+		 * the last call to doLayout(). */
+		public float getMaxHeight() {
+			return -this.cursorY + this.descent;
+		}
+		
+		/** Return the max width of the multi-line text block, after
+		 * the last call to doLayout(). */
+		public float getMaxWidth() {
+			return this.maxWidth;
+		}
+		
+		/** Layout out the given text, using coords as the storage for the T2F_V3F
+		 * coordinates.  Returns the float[] actually holding the coords (this creates
+		 * a new one if coords is too small). */
+		public float[] doLayout(String text, float[] coords) {			
+			int numPrims = text.length() * 20;
+			if (coords == null || numPrims > coords.length)
+				coords = new float[numPrims];
+			
+			this.layout(text, coords);
+			return coords;
+		}
+		
+		/* Reset the position, and layout each word. */
+		private void layout(String text, float[] coords) {
+			this.maxWidth = this.leftEdge; // reset this, too
+			
+			this.cursorX = this.leftEdge;
+			this.cursorY = -this.ascent;
+			
+			String[] words = text.split("\\b");
+			int charIndex = 0;
+			for (int i = 1; i < words.length; i++) {
+				charIndex = this.placeWord(words[i], coords, charIndex);
+			}
+			// must update one more, for the last line that never had newline() called.
+			this.maxWidth = Math.max(this.maxWidth, this.cursorX);
+		}
+		
+		/* Possibly move to a newline, and then place each char within the
+		 * word.  Return the index for the next character. */
+		private int placeWord(String word, float[] coords, int index) {
+			if (this.wrapWidth > 0) {
+				float wordWidth = this.getWordWidth(word);
+				if (wordWidth < this.wrapWidth 
+					&& (wordWidth + this.cursorX) > this.wrapWidth) {
+					this.newline();
+				} 
+			}
+				
+			return this.placeChars(word.toCharArray(), coords, index);
+		}
+		
+		/* Place all chars within c, moving to a newline if they can't fit.
+		 * Returns the index for the next character after all of c have
+		 * been placed. */
+		private int placeChars(char[] c, float[] coords, int index) {
+			Glyph g;
+			for (int i = 0; i < c.length; i++) {
+				// check for newline and carriage return chars
+				if (c[i] == '\n') {
+					this.newline();
+				} else if (c[i] == '\r') {
+					// only move to a newline if the previous char wasn't '\n'
+					if (i == 0 || c[i - 1] != '\n')
+						this.newline();
+				} else {
+					// place a glyph for the char
+					g = this.charSet.getGlyph(c[i]);
+					
+					if (this.wrapWidth > 0f) {
+						// place a newline if the char can't fit on this line
+						// and it wasn't the first char for the line (we always put 1 char)
+						if (this.cursorX > this.leftEdge 
+							&& this.cursorX + g.getAdvance() > this.wrapWidth) {
+							this.newline();
+						}
+					}
+					index = this.placeGlyph(g, coords, index);
+				}
+			}
+			
+			return index;
+		}
+		
+		/* Update coords, at index, to represent the glyph.
+		 * It updates the cursorX position for the next char, 
+		 * and returns the index for the next character. */
+		private int placeGlyph(Glyph g, float[] coords, int index) {
+			// tex coords for the glyph
+			float tcL = g.getTexCoordLeft();
+			float tcR = g.getTexCoordRight();
+			float tcB = g.getTexCoordBottom();
+			float tcT = g.getTexCoordTop();
+			
+			// adjusted vertices for the glyph's quad
+			float vtL = this.cursorX + g.getX();
+			float vtR = this.cursorX + g.getX() + g.getWidth();
+			float vtB = this.cursorY + g.getY();
+			float vtT = this.cursorY + g.getY() + g.getHeight();
+			
+			// lower left
+			coords[index++] = tcL; coords[index++] = tcB;
+			coords[index++] = vtL; coords[index++] = vtB; coords[index++] = 0f;
+			
+			// lower right
+			coords[index++] = tcR; coords[index++] = tcB;
+			coords[index++] = vtR; coords[index++] = vtB; coords[index++] = 0f;
+			
+			// upper right
+			coords[index++] = tcR; coords[index++] = tcT;
+			coords[index++] = vtR; coords[index++] = vtT; coords[index++] = 0f;
+			
+			// upper left
+			coords[index++] = tcL; coords[index++] = tcT;
+			coords[index++] = vtL; coords[index++] = vtT; coords[index++] = 0f;
+			
+			// advance the x position
+			this.cursorX += g.getAdvance();
+			
+			return index;
+		}
+		
+		/* Update cursorX and cursorY so that the next placed 
+		 * characters are one the newline. */
+		private void newline() {
+			this.maxWidth = Math.max(this.maxWidth, this.cursorX);
+			
+			this.cursorX = this.leftEdge;
+			this.cursorY -= this.height;
+		}
+		
+		/* Calculate the width of an un-split word, based off 
+		 * the advances of all Glyphs present in the word. */
+		private float getWordWidth(String word) {
+			float width = 0f;
+			int l = word.length();
+			for (int i = 0; i < l; i++) {
+				width += this.charSet.getGlyph(word.charAt(i)).getAdvance();
+			}
+			
+			return width;
+		}
+	}
+}

File src/com/ferox/scene/Group.java

 	
 	/** Override update(...) to be more efficient with updating bounds and transforms.  
 	 * Updates this group's transform, then updates each child, then updates this group's bounds.
-	 * Still obeys transform and bounds locks.
-	 */
+	 * Still obeys transform and bounds locks. */
 	@Override
 	public void update(boolean initiator) {
 		if (!this.isTransformLocked())

File src/com/ferox/state/Appearance.java

 	
 	/** Create an appearance with no attached states. */
 	public Appearance() {
-		this((State)null);
+		this(new State[0]);
 	}
 	
 	/** Add the given state to this appearance.  Does nothing if state is null.

File test/com/ferox/ApplicationBase.java

 public class ApplicationBase {
 	public static final float T_VEL = 20f;
 	
-	private FrameStatistics stats;
+	protected FrameStatistics stats;
 	private Renderer renderer;
 	
 	private InputManager input;

File test/com/ferox/BasicApplication.java

 package com.ferox;
 
+import java.awt.Font;
 import java.awt.Frame;
 import java.nio.FloatBuffer;
 import java.nio.IntBuffer;
 import com.ferox.resource.VertexBufferObject;
 import com.ferox.resource.BufferedGeometry.PolygonType;
 import com.ferox.resource.VertexBufferObject.UsageHint;
+import com.ferox.resource.text.CharacterSet;
+import com.ferox.resource.text.Text;
+import com.ferox.scene.Group;
 import com.ferox.scene.SceneElement;
+import com.ferox.scene.Shape;
 import com.ferox.scene.ViewNode;
+import com.ferox.scene.Node.CullMode;
+import com.ferox.state.DepthTest;
+import com.ferox.state.State.PixelTest;
 import com.sun.opengl.util.BufferUtil;
 
 /** BasicApplication extends ApplicationBase and imposes
  *
  */
 public abstract class BasicApplication extends ApplicationBase {
+	public static final long UPDATE_TIME = 100; // ms between updates of fps text
+	
 	protected OnscreenSurface window;
 	protected BasicRenderPass pass;
+	protected BasicRenderPass fpsPass;
+	protected Text fpsText;
 	
 	private ViewNode view;
 	private SceneElement scene;
 	
+	private long lastFpsUpdate;
+	
 	public BasicApplication(boolean debug) {
 		super(debug);
 	}
 		this.pass = new BasicRenderPass(null, v, this.createQueue(), false);
 		
 		//this.window = renderer.createFullscreenSurface(new DisplayOptions(), 800, 600);
-		this.window = renderer.createWindowSurface(this.createOptions(), 10, 10, 640, 480, false, false);
+		this.window = renderer.createWindowSurface(this.createOptions(), 10, 10, 1024, 768, false, false);
 		this.window.addRenderPass(this.pass);
 		this.window.setTitle(this.getClass().getSimpleName());
-		this.window.setClearColor(new Color(.5f, .5f, .5f, 1f));
 		
-		System.out.println(this.window.getDisplayOptions());
 		System.out.println(this.window.getWidth() + " " + this.window.getHeight());
 		v.setPerspective(60f, (float) this.window.getWidth() / this.window.getHeight(), 1f, 1000f);
 		
 		this.scene = this.buildScene(renderer, this.view);
 		this.pass.setScene(this.scene);
 		
+		CharacterSet charSet = new CharacterSet(Font.decode("FranklinGothic-Book-Medium-16"), true);
+		this.fpsText = new Text(charSet, "FPS: \nMeshes: \nPolygons: \nUsed: \nFree: ");
+		
+		System.out.println(this.fpsText.getTextHeight() + " " + this.fpsText.getTextWidth());
+		renderer.requestUpdate(charSet.getCharacterSet(), true);
+		renderer.requestUpdate(this.fpsText, true);
+		
+		Shape fpsNode = new Shape(this.fpsText, this.fpsText.createAppearance(new Color(.8f, .8f, .8f)));
+		fpsNode.setCullMode(CullMode.NEVER);
+		fpsNode.getLocalTransform().getTranslation().set(0f, this.fpsText.getTextHeight(), 0f);
+		DepthTest dt = new DepthTest();
+		dt.setTest(PixelTest.ALWAYS);
+		fpsNode.getAppearance().addState(dt);
+		
+		Group g = new Group();
+		g.add(fpsNode);
+		
+		View ortho = new View();
+		ortho.setOrthogonalProjection(true);
+		ortho.setFrustum(0, this.window.getWidth(), 0, this.window.getHeight(), -1, 1);
+		this.fpsPass = new BasicRenderPass(g, ortho);
+		this.fpsPass.setSceneUpdated(true);
+		this.window.addRenderPass(this.fpsPass);
+		
 		// somewhat lame to get input working for now
 		Frame f = (Frame) this.window.getWindowImpl();
 		this.configureInputHandling(f, viewTrans);
+		
+		this.lastFpsUpdate = 0;
 	}
 	
 	@Override
 		
 		if (this.window.isVisible()) {
 			renderer.queueRender(this.window);
-			return super.render(renderer);
+			boolean res = super.render(renderer);
+			
+			if (System.currentTimeMillis() - this.lastFpsUpdate > UPDATE_TIME) {
+				this.lastFpsUpdate = System.currentTimeMillis();
+				Runtime run = Runtime.getRuntime();
+				this.fpsText.setText("FPS: " + this.stats.getFramesPerSecond() + "\nMeshes: " + this.stats.getMeshCount() + "\nPolygons: " + this.stats.getPolygonCount() 
+									 + "\nUsed: " + run.totalMemory() / 1e6f + " M\nFree: " + run.freeMemory() / 1e6f + " M");
+				renderer.requestUpdate(this.fpsText, true);
+			}
+			
+			return res;
 		} else {
 			try {
 				Thread.sleep(50);
 	
 	@Override
 	protected void destroy(Renderer renderer) {
+		System.out.println(this.window.getDisplayOptions());
 		if (!this.window.isDestroyed())
 			renderer.destroy(this.window);
 		super.destroy(renderer);

File test/com/ferox/resource/text/TextTest.java

+package com.ferox.resource.text;
+
+import java.awt.Font;
+import java.awt.GraphicsEnvironment;
+
+import com.ferox.BasicApplication;
+import com.ferox.math.BoundSphere;
+import com.ferox.math.Color;
+import com.ferox.renderer.Renderer;
+import com.ferox.resource.Geometry;
+import com.ferox.resource.Texture2D;
+import com.ferox.resource.TextureImage.Filter;
+import com.ferox.scene.Group;
+import com.ferox.scene.SceneElement;
+import com.ferox.scene.Shape;
+import com.ferox.scene.ViewNode;
+import com.ferox.state.PolygonStyle;
+import com.ferox.state.PolygonStyle.DrawStyle;
+
+public class TextTest extends BasicApplication {
+	public static final boolean DEBUG = false;
+	public static final boolean USE_VBO = true;
+	
+	public static final Color bgColor = new Color(0f, 0f, 0f);
+	
+	protected Geometry geom;
+	
+	public static void main(String[] args) {
+		new TextTest(DEBUG).run();
+	}
+	
+	public TextTest(boolean debug) {
+		super(debug);
+	}
+
+	@Override
+	protected SceneElement buildScene(Renderer renderer, ViewNode view) {
+		Font[] fonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAllFonts();
+		for (Font f: fonts) {
+			System.out.println(f.getName() + " " + f.getStyle());
+		}
+		CharacterSet charSet = new CharacterSet(Font.decode("Times-Roman-Plain-32"), true);
+		Texture2D sheet = charSet.getCharacterSet();
+		sheet.setFilter(Filter.LINEAR);
+		sheet.setAnisotropicFiltering(1f);
+		
+		System.out.println(sheet.getWidth(0) + " " + sheet.getHeight(0));
+		renderer.requestUpdate(sheet, true);
+		
+		Group root = new Group();
+		root.add(view);
+		
+		Text text = new Text(charSet, "Hello World! This is my text renderer, how awesome is that. \n\rMy name is� Michael Ludwig.");
+		text.setWrapWidth(this.window.getWidth());
+		renderer.requestUpdate(text, true);
+
+		Shape t = new Shape(text, text.createAppearance(new Color(1f, 0f, 0f)));
+		
+		PolygonStyle ps = new PolygonStyle();
+		ps.setBackStyle(DrawStyle.SOLID);
+		t.getAppearance().addState(ps);
+		
+		t.setLocalBounds(new BoundSphere());
+		t.getLocalTransform().getTranslation().set(0f, 0f, 0f);
+		root.add(t);
+		
+		view.getLocalTransform().setTranslation(0f, 0f, 50f);
+		
+		return root;
+	}
+}