Commits

Michael Ludwig committed f5d2e5e

Document the source code, and allow RectanglePacker and CharacterSheet to use NPOT dimensions (can quarter the texture size if supported!).

Comments (0)

Files changed (6)

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

 		}
 		
 		// render it
+		gl.glNormal3f(0f, 0f, 1f);
 		gl.glDrawArrays(GL.GL_QUADS, 0, toRender.numVerts);
 		return toRender.numVerts >> 2; // verts / 4 to get quad count
 	}

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

  * rendered with.  It provides mappings to access the
  * locations of specific characters within its Texture2D.
  * 
+ * The generated Texture2D can be configured to be a power-of-two
+ * texture or not.  This should be chosen based on the hardware
+ * constraints.  If supported, a npot texture may provide a more
+ * efficient use of space.
+ * 
  * 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
  *
  */
 public class CharacterSet {
+	public static final String DEFAULT_CHAR_SET;
+	public static final int CHAR_PADDING = 4;
+	
 	static {
 		StringBuilder b = new StringBuilder();
 		for (int i = 32; i < 128; 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 boolean useNpotTexture;
 	
 	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);
+	public CharacterSet(boolean antiAlias, boolean useNpotTexture) {
+		this(null, antiAlias, useNpotTexture);
 	}
 	
 	/** 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);
+	public CharacterSet(Font font, boolean antiAlias, boolean useNpotTexture) {
+		this(font, null, antiAlias, useNpotTexture);
 	}
 	
 	/** Create a CharacterSet using the given font and
 	 * 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) {
+	public CharacterSet(Font font, String characterSet, boolean antiAlias, boolean useNpotTexture) {
 		if (font == null)
 			font = Font.decode("Arial-PLAIN-12");
 		if (characterSet == null)
 		
 		this.font = font;
 		this.antiAlias = antiAlias;
+		this.useNpotTexture = useNpotTexture;
 		this.buildCharacterSet(characterSet);
 	}
 	
 		}
 		g2d.dispose(); // dispose of dummy image
 		
+		int width = (!this.useNpotTexture ? ceilPot(rp.getWidth()) : rp.getWidth());
+		int height = (!this.useNpotTexture ? ceilPot(rp.getHeight()) : rp.getHeight());
+		
 		// compute a Glyph for each character and render it into the image
-		charSet = new BufferedImage(rp.getWidth(), rp.getHeight(), BufferedImage.TYPE_INT_ARGB);
+		charSet = new BufferedImage(width, height, 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());
+		g2d.fillRect(0, 0, width, height);
 		
 		// prepare the text to be rendered as white,
 		g2d.setColor(Color.WHITE);
 
 		// and flipped, so glyph dimensions make sense in openGL coord system
 		g2d.scale(1, -1);
-		g2d.translate(0, -rp.getHeight());
+		g2d.translate(0, -height);
 		
 		Rectangle r;
 		Rectangle2D glyphBounds;
 			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) r.getX() / width, (float) (r.getX() + r.getWidth()) / width, // left-right
+							  (float) (height - r.getY()) / height, (float) (height - r.getY() - r.getHeight()) / height, // 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;
 		// 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);
+		this.characters = new Texture2D(imageData, width, height, TextureFormat.ARGB_8888, DataType.UNSIGNED_INT, Filter.LINEAR);
 	}
 	
 	/* Create the array to hold the Glyphs. */
 		
 		return characters;
 	}
+	
+	// Return smallest POT >= num
+	private static int ceilPot(int num) {
+		int pot = 1;
+		while(pot < num)
+			pot = pot << 1;
+		return pot;
+	}
 }

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

 package com.ferox.resource.text;
 
+/** RectanglePacker provides a simple algorithm for packing
+ * smaller rectangles into a larger rectangle.  The algorithm
+ * will expand the containing rectangle's dimensions as necessary
+ * to contain new entries.
+ * 
+ * The algorithm is a java port of the C++ implementation described here:
+ * <http://www.blackpawn.com/texts/lightmaps/default.html>
+ * 
+ * with modifications to allow the top-level rectangle to expand as
+ * more items are added in.  Although the entire rectangle will
+ * grow, items placed at rectangles will not be moved or resized.
+ * 
+ * Its primary purpose is to provide Glyph packing for CharacterSets
+ * but it could prove useful in other contexts.
+ * 
+ * @author Michael Ludwig
+ *
+ */
 public class RectanglePacker<T> {
+	/** A simple Rectangle class that has no dependencies.
+	 * It performs no logic checking, so it is possible for
+	 * Rectangles to have negative dimensions, if they
+	 * were created as such.
+	 * 
+	 * Although not publicly modifiable, RectanglePacker
+	 * may modify Rectangles that represent the top-level
+	 * container. */
 	public static class Rectangle {
 		private int x, y;
 		private int width, height;
 			this.height = height;
 		}
 		
+		/** Methods to get the location and dimensions
+		 * of the contained rectangle. 
+		 * 
+		 * The actual rectangle is represented by the 
+		 * four vertices created by getX(), getY(),
+		 * (getX() + getWidth()), and (getY() + getHeight()). */
 		public int getX() { return this.x; }
 		public int getY() { return this.y; }
 		public int getWidth() { return this.width; }
 		public int getHeight() { return this.height; }
 	}
 	
+	// as per the algorithm described above
 	private static class Node<T> {
 		private Rectangle rc;
 		private Node<T> child1;
 	
 	private Node<T> root;
 	
-	public RectanglePacker(int startWidth, int startHeight) {
+	/** Create a RectanglePacker with the initial sizes for the
+	 * top-level container rectangle.
+	 * 
+	 * Throws an IllegalArgumentException if they aren't positive. */
+	public RectanglePacker(int startWidth, int startHeight) throws IllegalArgumentException {
 		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;
 	}
 	
+	/** Return the current width of the top-level container. */
 	public int getWidth() {
 		return this.root.rc.width;
 	}
 	
+	/** Return the current height of the top-level container. */
 	public int getHeight() {
 		return this.root.rc.height;
 	}
 	
+	/** Return the Rectangle that was previously returned
+	 * by a call to insert(data) for this instance.
+	 * If data has been inserted more than once, it is
+	 * undefined which Rectangle will be returned, since
+	 * it is still technically stored in multiple places.
+	 * 
+	 * Returns null if data is null, or if there is no
+	 * Rectangle associated with it. */
 	public Rectangle get(T data) {
+		if (data == null)
+			return null;
+		
 		Node<T> n = this.root.get(data);
 		return (n == null ? null : n.rc);
 	}
 	
-	public Rectangle insert(T data, int width, int height) {
+	/** Insert the given object, that requires a rectangle
+	 * with the given dimensions.  The containing rectangle
+	 * will be enlarged if necessary to contain it.
+	 * 
+	 * This does nothing if data is null.  If data has already
+	 * been inserted, then this packer will contain multiple
+	 * rectangles referencing the given object.  This results
+	 * in undefined behavior with get(data) and should be avoided
+	 * if get() is necessary. 
+	 * 
+	 * If the dimensions are not > 0, then an IllegalArgumentException
+	 * is thrown. */
+	public Rectangle insert(T data, int width, int height) throws IllegalArgumentException {
+		if (width <= 0 || height <= 0)
+			throw new IllegalArgumentException("Dimensions must be > 0, " + width + "x" + height);
+		if (data == null)
+			return null;
+		
 		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
 		return n.rc;
 	}
 	
+	// internal method to expand the width of the top-level
+	// container by dw.
 	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 = n;
 		}
 	}
-	
+
+	// internal method to expand the height of the top-level
+	// container by dh.
 	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 = n;
 		}
 	}
-	
-	// Return smallest POT >= num
-	private static int ceilPot(int num) {
-		int pot = 1;
-		while(pot < num)
-			pot = pot << 1;
-		return pot;
-	}
 }

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

 import com.ferox.state.Texture;
 import com.ferox.state.State.PixelTest;
 
+/** Text represents a Geometry that can generate laid-out
+ * text based on a CharacterSet.  It assumes that the text
+ * is laid-out left to right and Unicode-16 is untested.
+ * 
+ * Text requires that a specific type of Appearance be used:
+ * 1. Has a Texture or MultiTexture with the CharacterSet's Texture2D
+ * on the 0th texture unit.
+ * 2. It must use a BlendMode or AlphaTest to properly discard
+ * the transparent pixels.
+ * 3. DepthTest may be useful, but is not required.
+ * 4. Text does not generate any normal information, but renderers
+ * should set a normal to <0, 0, 1> so that lighting can still work.
+ * 
+ * There are two phases of updates for Text.  A Text's layout must be
+ * updated if its text string changes, or its wrapping width changes.
+ * These events mark its layout as dirty.  A call to layoutText() will
+ * generate the coordinates for the Text that look up the correctly
+ * sized positions in its CharacterSet.
+ * 
+ * In addition to laying out the text, it must also be updated with
+ * a Renderer, since Text is a geometry.  Renderers must layout the
+ * text if isLayoutDirty() returns true when updating Text.
+ * 
+ * After making changes that cause isLayoutDirty() to return true,
+ * the Text should be updated with the Renderer.
+ * 
+ * It is HIGHLY recommended that CharacterSets are shared by multiple
+ * instances of Text that need the same font.
+ * 
+ * @author Michael Ludwig
+ *
+ */
 public class Text implements Geometry {
 	private CharacterSet charSet;
 	private String text;
 	
 	private Object renderData;
 	
+	/** Create a Text that uses the given CharacterSet
+	 * and has its text set to the empty string.
+	 * 
+	 * Throws a NullPointerException if charSet is null. */
 	public Text(CharacterSet charSet) throws NullPointerException {
 		this(charSet, "");
 	}
 	
+	/** Create a Text with the given CharacterSet and
+	 * initial text value.
+	 * 
+	 * Throws a NullPointerException if charSet is null. */
 	public Text(CharacterSet charSet, String text) throws NullPointerException {
 		if (charSet == null)
 			throw new NullPointerException("Must provide a non-null CharacterSet");
 		this.setText(text);
 		this.setWrapWidth(-1f);
 		
-		this.layoutText();
+		this.layoutText(); // give us some valid values for the other properties
 	}
 	
+	/** Utility method to generate a Appearance suitable for displaying
+	 * the Text with the given color for the text.  If the CharacterSet
+	 * was anti-aliased, it uses a BlendMode, otherwise it uses an AlphaTest
+	 * to present only the correct pixels.
+	 * 
+	 * When the BlendMode is used, it also sets an AlphaTest to discard all
+	 * completely transparent pixels.  This helps to prevent awkward z-fighting
+	 * between letters in the Text for certain fonts where the character
+	 * boundaries overlap.  
+	 * 
+	 * If possible, a better solution would be to add a DepthTest to the
+	 * returned Appearance that disables depth testing.  This depend on
+	 * the use of the Text, though. */
 	public Appearance createAppearance(Color textColor) {
 		Material m = new Material(textColor);
 		Texture chars = new Texture(this.charSet.getCharacterSet());
 		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);
 			
+			BlendMode bm = new BlendMode();
 			a.addState(bm);
 		}
 		
 		return a;
 	}
 	
+	/** Return the wrap width used by this Text.
+	 * See setWrapWidth() for more information. */
 	public float getWrapWidth() {
 		return this.maxTextWidth;
 	}
 	
+	/** Get the current width of this Text.  This
+	 * is only valid if isLayoutDirty() returns false. 
+	 * The returned value is suitable for drawing a tightly
+	 * packed box around the text.
+	 * 
+	 * It is measured from the left edge of the lines of text,
+	 * to the right edge of the longest line of text. */
 	public float getTextWidth() {
 		return this.width;
 	}
 	
+	/** Get the current height of this Text.  This 
+	 * is only valid if isLayoutDirty() returns false.
+	 * The returned value can be used to draw a tightly 
+	 * packed box around the text.
+	 * 
+	 * It is measured from the top edge of the text, to
+	 * the bottom edge of the last line of text. This includes
+	 * the ascent and descent of the font. */
 	public float getTextHeight() {
 		return this.height;
 	}
 	
+	/** Return the computed coordinates of the Text that
+	 * will correctly draw the laid out characters.  These
+	 * might be on multiple lines.
+	 * 
+	 * The coordinates represent interleaved data, where
+	 * it goes a 2-valued texture coordinate, then a 3-valued
+	 * vertex.  Each batch of four complete 5 values represent
+	 * a quad in counter-clockwise order. */
 	public float[] getInterleavedCoordinates() {
 		return this.coords;
 	}
-	
+
+	/** Set the wrap width that determines how text is laid out.
+	 * A value <= 0 implies that no wrapping is formed.  In this
+	 * case text will only be on multiple lines if \n, \r or \n\r.
+	 * 
+	 * If it's positive, then this value represents the maximum
+	 * allowed width of a line of text.  Words that would extend
+	 * beyond this will be placed on a newline.  If a word can't fit
+	 * on a line, its characters will be wrapped.  Punctuation proceeding
+	 * words are treated as part of the word.
+	 * 
+	 * As far as layout works, the upper left corner of the first
+	 * character of text represents the origin.  Subsequent lines
+	 * start at progressively negative y-values.  A rectangle with
+	 * corners (0,0) and (getTextWidth(), getTextHeight()) would tightly
+	 * enclose the body of text.
+	 * 
+	 * Characters that are not present in the CharacterSet are rendered
+	 * with the missing glyph for that set's font.
+	 * 
+	 * This causes the Text's layout to be flagged as dirty. */
 	public void setWrapWidth(float maxWidth) {
 		this.maxTextWidth = maxWidth;
 		this.layoutDirty = true;
 	}
 	
+	/** Set the text that will be rendered.  If null is given, the
+	 * empty string is used instead.
+	 * 
+	 * See setWrapWidth() for how the text is laid out. 
+	 * This causes the Text's layout to be flagged as dirty. */
 	public void setText(String text) {
+		if (text == null)
+			text = "";
+		
 		this.text = text;
 		this.layoutDirty = true;
 	}
 	
+	/** Return the text that will be rendered if isLayoutDirty() 
+	 * returns false. */
 	public String getText() {
 		return this.text;
 	}
 	
+	/** Return the CharacterSet used to display the text. 
+	 * This will not be null. */
 	public CharacterSet getCharacterSet() {
 		return this.charSet;
 	}
 	
+	/** Set the CharacterSet used to render individual characters
+	 * in the set String for this Text.
+	 * 
+	 * Throws a NullPointerException if set is null.
+	 * This marks the Text's layout as dirty. */
+	public void setCharacterSet(CharacterSet set) throws NullPointerException {
+		if (set == null)
+			throw new NullPointerException("Cannot use a null CharacterSet");
+		
+		this.charSet = set;
+		this.layoutDirty = true;
+	}
+	
+	/** Return whether or not this Text needs to be re-laid out.
+	 * It can be more efficient to make many changes that require re-laying
+	 * out, and then perform the layout at the end.
+	 * 
+	 * This will be true if setCharacterSet(), setWrapWidth() or setText()
+	 * have been called after the last call to layoutText(). */
 	public boolean isLayoutDirty() {
 		return this.layoutDirty;
 	}
 	
+	/** Perform the layout computations for this Text.  This
+	 * performs the same operations even if isLayoutDirty() returns
+	 * false, so use only when necessary. */
 	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;
-		}
+		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;
+		
+		// reset the bound caches
+		this.cacheBox = null;
+		this.cacheSphere = null;
 	}
 
 	@Override

test/com/ferox/BasicApplication.java

 		this.scene = this.buildScene(renderer, this.view);
 		this.pass.setScene(this.scene);
 		
-		CharacterSet charSet = new CharacterSet(Font.decode("FranklinGothic-Book-Medium-16"), true);
+		CharacterSet charSet = new CharacterSet(Font.decode("FranklinGothic-Book-Medium-16"), true, false);
 		this.fpsText = new Text(charSet, "FPS: \nMeshes: \nPolygons: \nUsed: \nFree: ");
 		
 		System.out.println(this.fpsText.getTextHeight() + " " + this.fpsText.getTextWidth());

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

 		for (Font f: fonts) {
 			System.out.println(f.getName() + " " + f.getStyle());
 		}
-		CharacterSet charSet = new CharacterSet(Font.decode("Times-Roman-Plain-32"), true);
+		CharacterSet charSet = new CharacterSet(Font.decode("Times-Roman-Plain-32"), true, true);
 		Texture2D sheet = charSet.getCharacterSet();
 		sheet.setFilter(Filter.LINEAR);
 		sheet.setAnisotropicFiltering(1f);