diff --git a/jme3-core/src/main/java/com/jme3/font/BitmapText.java b/jme3-core/src/main/java/com/jme3/font/BitmapText.java index 6825ad77b0..2147528fd2 100644 --- a/jme3-core/src/main/java/com/jme3/font/BitmapText.java +++ b/jme3-core/src/main/java/com/jme3/font/BitmapText.java @@ -56,8 +56,10 @@ public class BitmapText extends Node { private BitmapFont font; // Stores the text content and its layout properties (size, box, alignment, etc.). private StringBlock block; - // A flag indicating whether the text needs to be re-assembled + // Layout changes rebuild page membership and vertex positions. private boolean needRefresh = true; + // Color changes can reuse the existing layout and update only vertex colors. + private boolean colorsNeedRefresh; // An array of `BitmapTextPage` instances, each corresponding to a font page. private BitmapTextPage[] textPages; // Manages the individual letter quads, their positions, colors, and styles. @@ -89,17 +91,16 @@ public BitmapText(BitmapFont font, boolean rightToLeft) { /** * Creates a new `BitmapText` instance with the specified font, text direction, - * and a flag for array-based rendering. + * and a legacy rendering flag. * * @param font The {@link BitmapFont} to use for rendering the text (not null). * @param rightToLeft true for right-to-left text rendering, false for left-to-right. - * @param arrayBased If true, the internal text pages will use array-based buffers for rendering. - * This might affect performance or compatibility depending on the renderer. + * @param arrayBased ignored; array-based buffers are always used. */ public BitmapText(BitmapFont font, boolean rightToLeft, boolean arrayBased) { textPages = new BitmapTextPage[font.getPageSize()]; for (int page = 0; page < textPages.length; page++) { - textPages[page] = new BitmapTextPage(font, arrayBased, page); + textPages[page] = new BitmapTextPage(font, page); attachChild(textPages[page]); } @@ -132,13 +133,11 @@ public void cloneFields(Cloner cloner, Object original) { //this.block = cloner.clone(block); this.block = block != null ? block.clone() : null; - // Change in behavior: The 'letters' field was not cloned or recreated - // before. I'm not sure how this worked and suspect BitmapText was just - // not cloneable if you planned to change the text later. -pspeed - this.letters = new Letters(font, block, letters.getQuad().isRightToLeft()); - - // Just noticed BitmapText is not even writable/readable really... - // so I guess cloning doesn't come up that often. + this.letters = new Letters(letters, block); + // The cloned pages must refer to the cloned letters, including their + // substring styles and colors, before a color-only update can run. + needRefresh = true; + colorsNeedRefresh = false; } /** @@ -156,6 +155,9 @@ public BitmapFont getFont() { * @param size The desired text size (e.g., in world units or pixels). */ public void setSize(float size) { + if (block.getSize() == size) { + return; + } block.setSize(size); needRefresh = true; letters.invalidate(); @@ -229,8 +231,7 @@ public ColorRGBA getColor() { */ public void setColor(ColorRGBA color) { letters.setColor(color); - letters.invalidate(); // TODO: Don't have to align. - needRefresh = true; + colorsNeedRefresh = true; } /** @@ -244,7 +245,7 @@ public void setColor(ColorRGBA color) { */ public void setAlpha(float alpha) { letters.setBaseAlpha(alpha); - needRefresh = true; + colorsNeedRefresh = true; } /** @@ -264,6 +265,13 @@ public float getAlpha() { * of the text rendering area. */ public void setBox(Rectangle rect) { + Rectangle previous = block.getTextBox(); + // Rectangle coordinates are immutable, so equal bounds need no layout. + if (previous == rect || (previous != null && rect != null + && previous.x == rect.x && previous.y == rect.y + && previous.width == rect.width && previous.height == rect.height)) { + return; + } block.setTextBox(rect); letters.invalidate(); needRefresh = true; @@ -285,7 +293,7 @@ public float getLineHeight() { * @return The total height of the text block. */ public float getHeight() { - if (needRefresh) { + if (needRefresh || colorsNeedRefresh) { assemble(); } float height = getLineHeight() * block.getLineCount(); @@ -302,7 +310,7 @@ public float getHeight() { * @return The maximum line width of the text. */ public float getLineWidth() { - if (needRefresh) { + if (needRefresh || colorsNeedRefresh) { assemble(); } Rectangle textBox = block.getTextBox(); @@ -320,7 +328,7 @@ public float getLineWidth() { * @return The total number of lines. */ public int getLineCount() { - if (needRefresh) { + if (needRefresh || colorsNeedRefresh) { assemble(); } return block.getLineCount(); @@ -346,6 +354,9 @@ public void setAlignment(BitmapFont.Align align) { if (block.getTextBox() == null && align != Align.Left) { throw new RuntimeException("Bound is not set"); } + if (block.getAlignment() == align) { + return; + } block.setAlignment(align); letters.invalidate(); needRefresh = true; @@ -362,6 +373,9 @@ public void setVerticalAlignment(BitmapFont.VAlign align) { if (block.getTextBox() == null && align != VAlign.Top) { throw new RuntimeException("Bound is not set"); } + if (block.getVerticalAlignment() == align) { + return; + } block.setVerticalAlignment(align); letters.invalidate(); needRefresh = true; @@ -395,6 +409,8 @@ public BitmapFont.VAlign getVerticalAlignment() { */ public void setStyle(int start, int end, int style) { letters.setStyle(start, end, style); + letters.invalidate(); + needRefresh = true; } /** @@ -421,8 +437,7 @@ public void setStyle(String regexp, int style) { */ public void setColor(int start, int end, ColorRGBA color) { letters.setColor(start, end, color); - letters.invalidate(); - needRefresh = true; + colorsNeedRefresh = true; } /** @@ -437,8 +452,7 @@ public void setColor(String regexp, ColorRGBA color) { while (m.find()) { letters.setColor(m.start(), m.end(), color); } - letters.invalidate(); - needRefresh = true; + colorsNeedRefresh = true; } /** @@ -460,6 +474,9 @@ public void setTabPosition(float... tabs) { * @param width The default width for tabs in font units. */ public void setTabWidth(float width) { + if (block.getTabWidth() == width) { + return; + } block.setTabWidth(width); letters.invalidate(); needRefresh = true; @@ -472,6 +489,9 @@ public void setTabWidth(float width) { * @param c The character to use as the ellipsis. */ public void setEllipsisChar(char c) { + if (block.getEllipsisChar() == c) { + return; + } block.setEllipsisChar(c); letters.invalidate(); needRefresh = true; @@ -502,25 +522,41 @@ public void setLineWrapMode(LineWrapMode wrap) { @Override public void updateLogicalState(float tpf) { super.updateLogicalState(tpf); - if (needRefresh) { + if (needRefresh || colorsNeedRefresh) { assemble(); } } /** - * Assembles the text by generating the quad list (character positions and sizes) - * and then populating the vertex buffers of each `BitmapTextPage`. - * This method is called internally when `needRefresh` is true. + * Updates layout and page buffers, or only colors when the layout is valid. */ private void assemble() { - // First, generate or update the list of letter quads - // based on current text and layout properties. - letters.update(); - // Then, for each font page, assemble its mesh data from the generated quads. - for (BitmapTextPage textPage : textPages) { - textPage.assemble(letters); + if (needRefresh) { + letters.update(); + for (BitmapTextPage textPage : textPages) { + textPage.clearQuads(); + } + // Distribute letters once, rather than rescanning the entire text + // for each texture page. Keep this membership for color updates. + letters.rewind(); + while (letters.nextCharacter()) { + if (letters.isPrintable()) { + int page = letters.getCharacterSetPage(); + if (page >= 0 && page < textPages.length) { + textPages[page].addQuad(letters.getQuad()); + } + } + } + for (BitmapTextPage textPage : textPages) { + textPage.assemble(); + } + } else if (colorsNeedRefresh) { + for (BitmapTextPage textPage : textPages) { + textPage.updateColors(); + } } needRefresh = false; + colorsNeedRefresh = false; } /** diff --git a/jme3-core/src/main/java/com/jme3/font/BitmapTextPage.java b/jme3-core/src/main/java/com/jme3/font/BitmapTextPage.java index c4ac9b26f0..1599b43fc7 100644 --- a/jme3-core/src/main/java/com/jme3/font/BitmapTextPage.java +++ b/jme3-core/src/main/java/com/jme3/font/BitmapTextPage.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2009-2021 jMonkeyEngine + * Copyright (c) 2009-2026 jMonkeyEngine * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -42,7 +42,7 @@ import java.nio.ByteBuffer; import java.nio.FloatBuffer; import java.nio.ShortBuffer; -import java.util.LinkedList; +import java.util.ArrayList; /** * One page per BitmapText Font Texture. @@ -50,15 +50,20 @@ */ class BitmapTextPage extends Geometry { - private final float[] pos; - private final float[] tc; - private final short[] idx; - private final byte[] color; - private final int page; + private float[] pos; + private float[] tc; + private byte[] color; private final Texture2D texture; - private final LinkedList pageQuads = new LinkedList<>(); + private ArrayList pageQuads = new ArrayList<>(); + private int initializedIndexQuads; - BitmapTextPage(BitmapFont font, boolean arrayBased, int page) { + /** + * Creates the page at the given index for the specified font. + * + * @param font the font to use (not null) + * @param page the index of the font page to display + */ + BitmapTextPage(BitmapFont font, int page) { super("BitmapFont", new Mesh()); setRequiresUpdates(false); setBatchHint(BatchHint.Never); @@ -66,8 +71,6 @@ class BitmapTextPage extends Geometry { throw new IllegalArgumentException("font cannot be null."); } - this.page = page; - Material mat = font.getPage(page); if (mat == null) { throw new IllegalStateException("The font's texture was not found!"); @@ -86,31 +89,40 @@ class BitmapTextPage extends Geometry { // scale colors from 0 - 255 range into 0 - 1 m.getBuffer(Type.Color).setNormalized(true); - arrayBased = true; - - /* - * TODO: Since this is forced to true, should we just lose the conditional? - * - Skye (sbook) - */ - if (arrayBased) { - pos = new float[4 * 3]; // 4 vertices * 3 floats - tc = new float[4 * 2]; // 4 vertices * 2 floats - idx = new short[2 * 3]; // 2 triangles * 3 indices - color = new byte[4 * 4]; // 4 vertices * 4 bytes - } else { - pos = null; - tc = null; - idx = null; - color = null; - } + // The buffers are always array-based: one quad is stored in these + // arrays and reused for every quad of this page. + pos = new float[4 * 3]; // 4 vertices * 3 floats + tc = new float[4 * 2]; // 4 vertices * 2 floats + color = new byte[4 * 4]; // 4 vertices * 4 bytes } + /** + * @deprecated The arrayBased parameter is ignored; array-based buffers are + * always used. Use {@link #BitmapTextPage(BitmapFont, int)} instead. + * + * @param font the font to use (not null) + * @param arrayBased ignored + * @param page the index of the font page to display + */ + @Deprecated + BitmapTextPage(BitmapFont font, boolean arrayBased, int page) { + this(font, page); + } + + /** + * @deprecated The arrayBased parameter is ignored; array-based buffers are + * always used. Use {@link #BitmapTextPage(BitmapFont, int)} instead. + * + * @param font the font to use (not null) + * @param arrayBased ignored + */ + @Deprecated BitmapTextPage(BitmapFont font, boolean arrayBased) { - this(font, arrayBased, 0); + this(font, 0); } BitmapTextPage(BitmapFont font) { - this(font, false, 0); + this(font, 0); } Texture2D getTexture() { @@ -119,111 +131,162 @@ Texture2D getTexture() { @Override public BitmapTextPage clone() { - BitmapTextPage clone = (BitmapTextPage) super.clone(); - //clone.mesh = mesh.deepClone(); - return clone; + return (BitmapTextPage) super.clone(); } /** - * Called internally by com.jme3.util.clone.Cloner. Do not call directly. + * Called internally by com.jme3.util.clone.Cloner. Do not call directly. */ @Override public void cloneFields(Cloner cloner, Object original) { - Mesh originalMesh = this.mesh; - super.cloneFields(cloner, original); - - // BitmapTextPage always requires a new mesh or different - // BitmapText instances will clobber one another. - // But if we were already deep cloning meshes then we don't - // want to do it again... so we'll check first. + + // Each text needs independent mesh data, even when the caller did not + // request deep mesh cloning. if (this.mesh == originalMesh) { this.mesh = mesh.deepClone(); - } - } - - // Here is where one might add JmeCloneable related stuff except - // the old clone() method doesn't actually bother to clone anything. - // The arrays and the pageQuads are shared across all BitmapTextPage - // clones, and it doesn't seem to bother anything. That means the - // fields could probably just as well be static... but this code is - // all very fragile. I'm not tipping that particular boat today. -pspeed + } + pos = pos.clone(); + tc = tc.clone(); + color = color.clone(); + // BitmapText rebuilds the clone's membership from its own Letters. + pageQuads = new ArrayList<>(pageQuads.size()); + // A deep-cloned buffer may contain only its logical length, rather + // than the original buffer's spare, previously initialized capacity. + initializedIndexQuads = mesh.getBuffer(Type.Index).getData().limit() / 6; + } - void assemble(Letters quads) { + /** + * Clears cached membership before BitmapText redistributes its letters. + */ + void clearQuads() { pageQuads.clear(); - quads.rewind(); + } - while (quads.nextCharacter()) { - if (quads.isPrintable()) { - if (quads.getCharacterSetPage() == page) { - pageQuads.add(quads.getQuad()); - } - } - } + /** + * Adds a printable quad in text order during the layout pass. + * + * @param quad the quad belonging to this font page + */ + void addQuad(LetterQuad quad) { + pageQuads.add(quad); + } + /** + * Rebuilds layout-dependent buffers from this page's cached quads. + */ + void assemble() { Mesh m = getMesh(); - int vertCount = pageQuads.size() * 4; - int triCount = pageQuads.size() * 2; + int quadCount = pageQuads.size(); + boolean countChanged = m.getVertexCount() != quadCount * 4; + if (quadCount == 0 && !countChanged) { + return; + } VertexBuffer pb = m.getBuffer(Type.Position); VertexBuffer tb = m.getBuffer(Type.TexCoord); - VertexBuffer ib = m.getBuffer(Type.Index); VertexBuffer cb = m.getBuffer(Type.Color); + FloatBuffer fpb = prepare((FloatBuffer) pb.getData(), quadCount * 12); + FloatBuffer ftb = prepare((FloatBuffer) tb.getData(), quadCount * 8); + ByteBuffer bcb = prepare((ByteBuffer) cb.getData(), quadCount * 16); - FloatBuffer fpb = (FloatBuffer) pb.getData(); - FloatBuffer ftb = (FloatBuffer) tb.getData(); - ShortBuffer sib = (ShortBuffer) ib.getData(); - ByteBuffer bcb = (ByteBuffer) cb.getData(); - - // increase capacity of buffers as needed + for (int i = 0; i < quadCount; i++) { + LetterQuad quad = pageQuads.get(i); + quad.storeToArrays(pos, tc, color); + fpb.put(pos); + ftb.put(tc); + bcb.put(color); + } fpb.rewind(); - fpb = BufferUtils.ensureLargeEnough(fpb, vertCount * 3); - fpb.limit(vertCount * 3); - pb.updateData(fpb); - ftb.rewind(); - ftb = BufferUtils.ensureLargeEnough(ftb, vertCount * 2); - ftb.limit(vertCount * 2); - tb.updateData(ftb); - bcb.rewind(); - bcb = BufferUtils.ensureLargeEnough(bcb, vertCount * 4); - bcb.limit(vertCount * 4); + pb.updateData(fpb); + tb.updateData(ftb); cb.updateData(bcb); - sib.rewind(); - sib = BufferUtils.ensureLargeEnough(sib, triCount * 3); - sib.limit(triCount * 3); - ib.updateData(sib); - - m.updateCounts(); - - // go for each quad and append it to the buffers - if (pos != null) { - for (int i = 0; i < pageQuads.size(); i++) { - LetterQuad fq = pageQuads.get(i); - fq.storeToArrays(pos, tc, idx, color, i); - fpb.put(pos); - ftb.put(tc); - sib.put(idx); + // Index topology depends only on how many quads this page contains. + if (countChanged) { + updateIndices(quadCount); + m.updateCounts(); + } + updateModelBound(); + } + + /** + * Refreshes vertex colors without changing layout, indices, or bounds. + */ + void updateColors() { + if (pageQuads.isEmpty()) { + return; + } + VertexBuffer cb = getMesh().getBuffer(Type.Color); + ByteBuffer bcb = (ByteBuffer) cb.getData(); + boolean changed = false; + for (int i = 0; i < pageQuads.size(); i++) { + pageQuads.get(i).storeColors(color); + int offset = i * color.length; + // All four vertices of a glyph have the same RGBA tuple. Compare + // bytes explicitly so the buffer's native byte order is irrelevant. + if (bcb.get(offset) != color[0] || bcb.get(offset + 1) != color[1] + || bcb.get(offset + 2) != color[2] || bcb.get(offset + 3) != color[3]) { + bcb.position(offset); bcb.put(color); + changed = true; } - } else { - for (int i = 0; i < pageQuads.size(); i++) { - LetterQuad fq = pageQuads.get(i); - fq.appendPositions(fpb); - fq.appendTexCoords(ftb); - fq.appendIndices(sib, i); - fq.appendColors(bcb); + } + bcb.rewind(); + if (changed) { + cb.updateData(bcb); + } + } + + private void updateIndices(int quadCount) { + VertexBuffer ib = getMesh().getBuffer(Type.Index); + ShortBuffer indices = (ShortBuffer) ib.getData(); + int required = quadCount * 6; + if (indices.capacity() < required) { + indices = BufferUtils.createShortBuffer(growCapacity(indices.capacity(), required)); + initializedIndexQuads = 0; + } + indices.clear(); + if (quadCount > initializedIndexQuads) { + indices.position(initializedIndexQuads * 6); + for (int i = initializedIndexQuads; i < quadCount; i++) { + short first = (short) (i * 4); + indices.put(first).put((short) (first + 1)).put((short) (first + 2)); + indices.put(first).put((short) (first + 2)).put((short) (first + 3)); } + initializedIndexQuads = quadCount; } + indices.rewind(); + indices.limit(required); + // Even a shrink must notify the renderer of the changed draw length. + ib.updateData(indices); + } - fpb.rewind(); - ftb.rewind(); - sib.rewind(); - bcb.rewind(); + private static FloatBuffer prepare(FloatBuffer buffer, int required) { + if (buffer.capacity() < required) { + buffer = BufferUtils.createFloatBuffer(growCapacity(buffer.capacity(), required)); + } + buffer.clear(); + buffer.limit(required); + return buffer; + } - updateModelBound(); + private static ByteBuffer prepare(ByteBuffer buffer, int required) { + if (buffer.capacity() < required) { + buffer = BufferUtils.createByteBuffer(growCapacity(buffer.capacity(), required)); + } + buffer.clear(); + buffer.limit(required); + return buffer; + } + + private static int growCapacity(int capacity, int required) { + // Leave spare capacity for incrementally growing text. All buffer + // limits still describe only the currently used vertices or indices. + long grown = (long) capacity + capacity / 2; + return (int) Math.min(Integer.MAX_VALUE, Math.max(grown, required)); } } diff --git a/jme3-core/src/main/java/com/jme3/font/ColorTags.java b/jme3-core/src/main/java/com/jme3/font/ColorTags.java index 304b9832a6..91935be099 100644 --- a/jme3-core/src/main/java/com/jme3/font/ColorTags.java +++ b/jme3-core/src/main/java/com/jme3/font/ColorTags.java @@ -32,7 +32,8 @@ package com.jme3.font; import com.jme3.math.ColorRGBA; -import java.util.LinkedList; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -47,7 +48,7 @@ class ColorTags { private static final Pattern colorPattern = Pattern.compile("\\\\#([0-9a-fA-F]{8})#|\\\\#([0-9a-fA-F]{6})#|" + "\\\\#([0-9a-fA-F]{4})#|\\\\#([0-9a-fA-F]{3})#"); - private final LinkedList colors = new LinkedList<>(); + private final ArrayList colors = new ArrayList<>(); private String text; private String original; private float baseAlpha = -1; @@ -65,16 +66,37 @@ String getPlainText() { return text; } - LinkedList getTags() { + List getTags() { return colors; } + /** + * Copies current range overrides as well as the original tagged text. + * + * @param source the color-tag state to copy + */ + void copyFrom(ColorTags source) { + text = source.text; + original = source.original; + baseAlpha = source.baseAlpha; + colors.clear(); + for (Range range : source.colors) { + colors.add(new Range(range.start, range.color)); + } + } + void setText(final String charSeq) { original = charSeq; colors.clear(); if (charSeq == null) { return; } + // Plain text is the common case for counters and labels. Avoid a + // Matcher allocation when there cannot be any color tags. + if (charSeq.indexOf("\\#") < 0) { + text = charSeq; + return; + } Matcher m = colorPattern.matcher(charSeq); if (m.find()) { StringBuilder builder = new StringBuilder(); @@ -84,12 +106,12 @@ void setText(final String charSeq) { for (int i = 1; i <= 4 && colorStr==null; i++) { colorStr = m.group(i); } - builder.append(charSeq.subSequence(startIndex, m.start())); + builder.append(charSeq, startIndex, m.start()); Range range = new Range(builder.length(), colorStr); startIndex = m.end(); colors.add(range); } while (m.find()); - builder.append(charSeq.subSequence(startIndex, charSeq.length())); + builder.append(charSeq, startIndex, charSeq.length()); text = builder.toString(); } else { text = charSeq; @@ -105,8 +127,8 @@ void setBaseAlpha(float alpha) { } // Else set the alpha for all of them - for (Range r : colors) { - r.color.a = alpha; + for (int i = 0; i < colors.size(); i++) { + colors.get(i).color.a = alpha; } } @@ -119,14 +141,20 @@ void setBaseColor(ColorRGBA color) { // and the caller may have passed a constant... so we // should clone it. color = color.clone(); - for (Range r : colors) { - r.color = color; + for (int i = 0; i < colors.size(); i++) { + colors.get(i).color = color; } } class Range { int start; ColorRGBA color; + + Range(int start, ColorRGBA color) { + this.start = start; + this.color = color.clone(); + } + Range(int start, String colorStr) { this.start = start; this.color = new ColorRGBA(); diff --git a/jme3-core/src/main/java/com/jme3/font/LetterQuad.java b/jme3-core/src/main/java/com/jme3/font/LetterQuad.java index ff647ab423..e3efb5029d 100644 --- a/jme3-core/src/main/java/com/jme3/font/LetterQuad.java +++ b/jme3-core/src/main/java/com/jme3/font/LetterQuad.java @@ -32,9 +32,6 @@ package com.jme3.font; import com.jme3.math.ColorRGBA; -import java.nio.ByteBuffer; -import java.nio.FloatBuffer; -import java.nio.ShortBuffer; /** * LetterQuad contains the position, color, and UV texture information for a character in text. @@ -45,7 +42,7 @@ class LetterQuad { private static final float LINE_DIR = -1; private final BitmapFont font; - private final char c; + private char c; private final int index; private int style; @@ -100,6 +97,38 @@ protected LetterQuad(char c, LetterQuad prev) { prev.insert(this); } + /** + * Copies the character and its explicit style/color, rebuilding its layout. + * + * @param original the character to copy + * @param previous the previous quad in the new chain + */ + LetterQuad(LetterQuad original, LetterQuad previous) { + this.font = previous.font; + this.rightToLeft = previous.rightToLeft; + this.c = original.c; + this.index = previous.index + 1; + this.style = original.style; + this.colorInt = original.colorInt; + invalidate(); + previous.insert(this); + } + + /** + * Reinitializes a cached quad as a newly created character at the same index. + * + * @param c the new character at this quad's index + */ + void reset(char c) { + this.c = c; + style = 0; + colorInt = 0xFFFFFFFF; + xAdvance = 0; + lineY = 0; + sizeScale = 1; + invalidate(); + } + LetterQuad addNextCharacter(char c) { LetterQuad n = new LetterQuad(c, this); return n; @@ -268,13 +297,11 @@ void setStyle(int style) { void setColor(ColorRGBA color) { this.colorInt = color.asIntRGBA(); - invalidate(); } void setAlpha(float alpha) { int i = (int) (alpha * 255) & 0xFF; colorInt = (colorInt & 0xffffff00) | i; - invalidate(); } void setBitmapChar(char c) { @@ -460,7 +487,7 @@ boolean isBlank() { return c == ' ' || isTab(); } - public void storeToArrays(float[] pos, float[] tc, short[] idx, byte[] colors, int quadIdx) { + void storeToArrays(float[] pos, float[] tc, byte[] colors) { float x = x0+alignX; float y = y0-alignY; float xpw = x+width; @@ -479,86 +506,19 @@ public void storeToArrays(float[] pos, float[] tc, short[] idx, byte[] colors, i tc[4] = u1; tc[5] = v1; tc[6] = u1; tc[7] = v0; + storeColors(colors); + } + + void storeColors(byte[] colors) { colors[3] = (byte) (colorInt & 0xff); colors[2] = (byte) ((colorInt >> 8) & 0xff); colors[1] = (byte) ((colorInt >> 16) & 0xff); colors[0] = (byte) ((colorInt >> 24) & 0xff); - System.arraycopy(colors, 0, colors, 4, 4); - System.arraycopy(colors, 0, colors, 8, 4); + System.arraycopy(colors, 0, colors, 4, 4); + System.arraycopy(colors, 0, colors, 8, 4); System.arraycopy(colors, 0, colors, 12, 4); - - short i0 = (short) (quadIdx * 4); - short i1 = (short) (i0 + 1); - short i2 = (short) (i0 + 2); - short i3 = (short) (i0 + 3); - - idx[0] = i0; idx[1] = i1; idx[2] = i2; - idx[3] = i0; idx[4] = i2; idx[5] = i3; - } - - public void appendPositions(FloatBuffer fb) { - float sx = x0+alignX; - float sy = y0-alignY; - float ex = sx+width; - float ey = sy-height; - // NOTE: subtracting the height here - // because OGL's Ortho origin is at lower-left - fb.put(sx).put(sy).put(0f); - fb.put(sx).put(ey).put(0f); - fb.put(ex).put(ey).put(0f); - fb.put(ex).put(sy).put(0f); - } - - public void appendPositions(ShortBuffer sb) { - final float x1 = getX1(); - final float y1 = getY1(); - short x = (short) x0; - short y = (short) y0; - short xpw = (short) (x1); - short ymh = (short) (y1); - - sb.put(x).put(y).put((short)0); - sb.put(x).put(ymh).put((short)0); - sb.put(xpw).put(ymh).put((short)0); - sb.put(xpw).put(y).put((short)0); - } - - public void appendTexCoords(FloatBuffer fb) { - // flip coords to be compatible with OGL - float v0 = 1 - this.v0; - float v1 = 1 - this.v1; - - // upper left - fb.put(u0).put(v0); - // lower left - fb.put(u0).put(v1); - // lower right - fb.put(u1).put(v1); - // upper right - fb.put(u1).put(v0); - } - - public void appendColors(ByteBuffer bb) { - bb.putInt(colorInt); - bb.putInt(colorInt); - bb.putInt(colorInt); - bb.putInt(colorInt); - } - - public void appendIndices(ShortBuffer sb, int quadIndex) { - // each quad has 4 indices - short v0 = (short) (quadIndex * 4); - short v1 = (short) (v0 + 1); - short v2 = (short) (v0 + 2); - short v3 = (short) (v0 + 3); - - sb.put(v0).put(v1).put(v2); - sb.put(v0).put(v2).put(v3); -// sb.put(new short[]{ v0, v1, v2, -// v0, v2, v3 }); } - @Override public String toString() { return String.valueOf(c); diff --git a/jme3-core/src/main/java/com/jme3/font/Letters.java b/jme3-core/src/main/java/com/jme3/font/Letters.java index b545dee035..e337486f9c 100644 --- a/jme3-core/src/main/java/com/jme3/font/Letters.java +++ b/jme3-core/src/main/java/com/jme3/font/Letters.java @@ -35,8 +35,8 @@ import com.jme3.font.BitmapFont.VAlign; import com.jme3.font.ColorTags.Range; import com.jme3.math.ColorRGBA; - -import java.util.LinkedList; +import java.util.ArrayList; +import java.util.List; /** * Manage and align LetterQuads @@ -51,6 +51,8 @@ class Letters { private float totalWidth; private float totalHeight; private final ColorTags colorTags = new ColorTags(); + // Retain one quad per parsed-character index and allocate only when text grows. + private final ArrayList letterCache = new ArrayList<>(); private ColorRGBA baseColor = null; private float baseAlpha = -1; private String plainText; @@ -64,6 +66,36 @@ class Letters { setText(text); } + /** + * Copies text styling without sharing mutable letter or color-tag state. + * The parsed character sequence is copied directly, so cloning does not run + * a potentially stateful glyph parser again. + * + * @param original the letters to copy + * @param block the independently cloned text block + */ + Letters(Letters original, StringBlock block) { + this.block = block; + this.font = original.font; + head = new LetterQuad(font, original.head.isRightToLeft()); + tail = new LetterQuad(font, original.head.isRightToLeft()); + head.setNext(tail); + tail.setPrevious(head); + current = head; + baseColor = original.baseColor == null ? null : original.baseColor.clone(); + baseAlpha = original.baseAlpha; + plainText = original.plainText; + colorTags.copyFrom(original.colorTags); + + LetterQuad previous = head; + for (LetterQuad source = original.head.getNext(); !source.isTail(); source = source.getNext()) { + LetterQuad copy = new LetterQuad(source, previous); + letterCache.add(copy); + previous = copy; + } + invalidate(); + } + void setText(final String text) { colorTags.setText(text); plainText = colorTags.getPlainText(); @@ -79,7 +111,16 @@ void setText(final String text) { } for (int i = 0; i < characters.length(); i++) { - l = l.addNextCharacter(characters.charAt(i)); + char character = characters.charAt(i); + if (i < letterCache.size()) { + LetterQuad cached = letterCache.get(i); + cached.reset(character); + l.insert(cached); + l = cached; + } else { + l = l.addNextCharacter(character); + letterCache.add(l); + } if (baseColor != null) { // Give the letter a default color if // one has been provided. @@ -88,18 +129,30 @@ void setText(final String text) { } } - LinkedList ranges = colorTags.getTags(); - if (!ranges.isEmpty()) { - for (int i = 0; i < ranges.size()-1; i++) { - Range start = ranges.get(i); - Range end = ranges.get(i+1); - setColor(start.start, end.start, start.color); + applyColorTags(); + + // Every active quad was reset above; only the sentinel and cached sizes + // still need invalidating. Avoid looking up every glyph a second time. + head.invalidate(); + totalWidth = -1; + totalHeight = -1; + } + + private void applyColorTags() { + List ranges = colorTags.getTags(); + LetterQuad cursor = head.getNext(); + for (int i = 0; i < ranges.size(); i++) { + Range range = ranges.get(i); + int end = i + 1 < ranges.size() ? ranges.get(i + 1).start : plainText.length(); + // Both cursors advance only forward. Keep the plain-text end bound: + // a glyph parser may return a sequence of a different length. + while (!cursor.isTail() && cursor.getIndex() < end) { + if (cursor.getIndex() >= range.start) { + cursor.setColor(range.color); + } + cursor = cursor.getNext(); } - Range end = ranges.getLast(); - setColor(end.start, plainText.length(), end.color); } - - invalidate(); } LetterQuad getHead() { @@ -336,7 +389,7 @@ public void invalidate(LetterQuad cursor) { totalWidth = -1; totalHeight = -1; - while (!cursor.isTail() && !cursor.isInvalid()) { + while (!cursor.isTail()) { cursor.invalidate(); cursor = cursor.getNext(); } @@ -447,19 +500,8 @@ void setBaseAlpha(float alpha) { // since non-color tagged text is treated differently // even if part of a color tagged string. if (baseAlpha == -1) { - LinkedList ranges = colorTags.getTags(); - if (!ranges.isEmpty()) { - for (int i = 0; i < ranges.size()-1; i++) { - Range start = ranges.get(i); - Range end = ranges.get(i+1); - setColor(start.start, end.start, start.color); - } - Range end = ranges.getLast(); - setColor(end.start, plainText.length(), end.color); - } + applyColorTags(); } - - invalidate(); } } diff --git a/jme3-core/src/main/java/com/jme3/light/LightList.java b/jme3-core/src/main/java/com/jme3/light/LightList.java index 2770f089f7..2d331a3b39 100644 --- a/jme3-core/src/main/java/com/jme3/light/LightList.java +++ b/jme3-core/src/main/java/com/jme3/light/LightList.java @@ -49,11 +49,11 @@ public final class LightList implements Iterable, Savable, Cloneable, JmeCloneable { private Light[] list, tlist; - private float[] distToOwner; private int listSize; private Spatial owner; private static final int DEFAULT_SIZE = 1; + private static final int INSERTION_SORT_THRESHOLD = 64; private static final Comparator c = new Comparator() { /** @@ -84,8 +84,6 @@ protected LightList() { public LightList(Spatial owner) { listSize = 0; list = new Light[DEFAULT_SIZE]; - distToOwner = new float[DEFAULT_SIZE]; - Arrays.fill(distToOwner, Float.NEGATIVE_INFINITY); this.owner = owner; } @@ -100,11 +98,8 @@ public void setOwner(Spatial owner) { private void doubleSize() { Light[] temp = new Light[list.length * 2]; - float[] temp2 = new float[list.length * 2]; System.arraycopy(list, 0, temp, 0, list.length); - System.arraycopy(distToOwner, 0, temp2, 0, list.length); list = temp; - distToOwner = temp2; } /** @@ -117,8 +112,7 @@ public void add(Light l) { if (listSize == list.length) { doubleSize(); } - list[listSize] = l; - distToOwner[listSize++] = Float.NEGATIVE_INFINITY; + list[listSize++] = l; } /** @@ -136,8 +130,14 @@ public void remove(int index) { return; } - for (int i = index; i < listSize; i++) { - list[i] = list[i+1]; + int copyLength = listSize - index; + // Keep short shifts inline. + if (copyLength < 8) { + for (int i = index; i < listSize; i++) { + list[i] = list[i + 1]; + } + } else { + System.arraycopy(list, index + 1, list, index, copyLength); } list[listSize] = null; } @@ -185,9 +185,6 @@ public void clear() { for (int i = 0; i < listSize; i++) list[i] = null; - if (tlist != null) - Arrays.fill(tlist, null); - listSize = 0; } @@ -204,25 +201,59 @@ public void clear() { */ public void sort(boolean transformChanged) { if (listSize > 1) { - // resize or populate our temporary array as necessary - if (tlist == null || tlist.length != list.length) { - tlist = list.clone(); - } else { - System.arraycopy(list, 0, tlist, 0, list.length); + if (listSize <= INSERTION_SORT_THRESHOLD) { + if (insertionSort(transformChanged)) { + return; + } + // Distances were already updated before the NaN fallback. + transformChanged = false; } - if (transformChanged) { - // check distance of each light + // Distance updates can reenter this list's sort through the owner. for (int i = 0; i < listSize; i++) { list[i].computeLastDistance(owner); } } - // now merge sort tlist into list - SortUtil.msort(tlist, list, 0, listSize - 1, c); + if (tlist == null || tlist.length != list.length) { + tlist = new Light[list.length]; + } + Light[] scratch = tlist; + int sortSize = listSize; + try { + System.arraycopy(list, 0, scratch, 0, sortSize); + SortUtil.msort(scratch, list, 0, sortSize - 1, c); + } finally { + // Scratch must not retain lights between sorts, even on failure. + Arrays.fill(scratch, 0, sortSize, null); + } } } + private boolean insertionSort(boolean transformChanged) { + if (transformChanged) { + for (int i = 0; i < listSize; i++) { + list[i].computeLastDistance(owner); + } + } + // Keep the merge-sort ordering when NaN distances make comparisons non-transitive. + for (int i = 0; i < listSize; i++) { + if (Float.isNaN(list[i].lastDistance)) { + return false; + } + } + for (int i = 1; i < listSize; i++) { + Light light = list[i]; + int j = i - 1; + while (j >= 0 && c.compare(light, list[j]) < 0) { + list[j + 1] = list[j]; + j--; + } + list[j + 1] = light; + } + return true; + } + /** * Updates a "world-space" light list, using the spatial's local-space * light list and its parent's world-space light list. @@ -234,7 +265,6 @@ public void update(LightList local, LightList parent) { update(local, parent, null); } - /** * Updates a "world-space" light list, using the spatial's local-space * light list, its parent's world-space light list and an optional filter. @@ -249,33 +279,45 @@ public void update(LightList local, LightList parent, Predicate filter) { // using the arguments clear(); - while (list.length <= local.listSize) { - doubleSize(); - } - - int localListSize = 0; - for(int i=0;i 0) { + assertTrue(actual.getLineCount() > 1); + } + } + } + } + } + + @Test + public void characterWrappingMatchesExplicitLineBreaksWithAlignment() { + BitmapFont font = createFont(1); + BitmapText wrapped = new BitmapText(font); + BitmapText explicit = new BitmapText(font); + for (BitmapText text : new BitmapText[]{wrapped, explicit}) { + text.setBox(new Rectangle(7, 101, 23, 100)); + text.setAlignment(Align.Center); + text.setVerticalAlignment(VAlign.Bottom); + text.setLineWrapMode(LineWrapMode.Character); + } + wrapped.setText("ABC"); + explicit.setText("AB\nC"); + wrapped.updateLogicalState(0); + explicit.updateLogicalState(0); + assertMeshEquals(explicit, wrapped); + assertEquals(2, wrapped.getLineCount()); + assertQuad(mesh(wrapped, 0), 0, font.getCharSet().getCharacter('A'), 8.5f, 40); + assertQuad(mesh(wrapped, 0), 1, font.getCharSet().getCharacter('B'), 21.5f, 39); + assertQuad(mesh(wrapped, 0), 2, font.getCharSet().getCharacter('C'), 14.5f, 18); + } + + static BitmapFont createFont(int pageCount) { + BitmapCharacterSet characters = new BitmapCharacterSet(); + characters.setRenderedSize(16); + characters.setLineHeight(20); + characters.setBase(16); + characters.setWidth(ATLAS_WIDTH); + characters.setHeight(ATLAS_HEIGHT); + for (char c = ' '; c < 0x100 + 100; c++) { + int index = c - ' '; + BitmapCharacter glyph = new BitmapCharacter(); + glyph.setChar(c); + glyph.setX(index * 10); + glyph.setY(index % 4 * 12); + glyph.setWidth(c == ' ' ? 0 : 5 + index % 4); + glyph.setHeight(c == ' ' ? 0 : 8 + index % 3); + glyph.setXAdvance(c == ' ' ? 4 : 10 + index % 5); + glyph.setYOffset(1 + index % 3); + glyph.setPage(index % pageCount); + characters.addCharacter(c, glyph); + } + MaterialDef definition = new MaterialDef(null, "Synthetic bitmap font"); + definition.addMaterialParamTexture(VarType.Texture2D, "ColorMap", null, null); + Material[] pages = new Material[pageCount]; + for (int page = 0; page < pageCount; page++) { + pages[page] = new Material(definition); + pages[page].setTexture("ColorMap", new Texture2D()); + } + BitmapFont font = new BitmapFont(); + font.setCharSet(characters); + font.setPages(pages); + return font; + } + + private static String distinctCharacters(int count) { + StringBuilder result = new StringBuilder(count); + for (int i = 0; i < count; i++) { + result.append((char) (0x100 + i)); + } + return result.toString(); + } + + private static BitmapText assemble(BitmapFont font, String value) { + BitmapText text = new BitmapText(font); + text.setText(value); + text.updateLogicalState(0); + return text; + } + + static Mesh mesh(BitmapText text, int page) { + return ((Geometry) text.getChild(page)).getMesh(); + } + + private static void assertBufferSizes(Mesh mesh, int quadCount) { + int[] elementsPerQuad = {12, 8, 16, 6}; + for (int i = 0; i < BUFFER_TYPES.length; i++) { + Buffer buffer = mesh.getBuffer(BUFFER_TYPES[i]).getData(); + assertEquals(quadCount * elementsPerQuad[i], buffer.limit(), BUFFER_TYPES[i] + " limit"); + } + assertEquals(quadCount * 4, mesh.getVertexCount()); + assertEquals(quadCount * 2, mesh.getTriangleCount()); + assertTrue(mesh.getBuffer(Type.Color).isNormalized()); + } + + private static void assertQuad(Mesh mesh, int quad, BitmapCharacter glyph, float x, float y) { + float right = x + glyph.getWidth(); + float bottom = y - glyph.getHeight(); + float[] expectedPositions = {x, y, 0, x, bottom, 0, right, bottom, 0, right, y, 0}; + FloatBuffer positions = (FloatBuffer) mesh.getBuffer(Type.Position).getData(); + for (int i = 0; i < expectedPositions.length; i++) { + assertEquals(expectedPositions[i], positions.get(quad * 12 + i), EPSILON, + "quad " + quad + ", position " + i); + } + float u0 = (float) glyph.getX() / ATLAS_WIDTH; + float u1 = u0 + (float) glyph.getWidth() / ATLAS_WIDTH; + float v0 = 1f - (float) glyph.getY() / ATLAS_HEIGHT; + float v1 = 1f - ((float) glyph.getY() / ATLAS_HEIGHT + (float) glyph.getHeight() / ATLAS_HEIGHT); + float[] expectedTexCoords = {u0, v0, u0, v1, u1, v1, u1, v0}; + FloatBuffer texCoords = (FloatBuffer) mesh.getBuffer(Type.TexCoord).getData(); + for (int i = 0; i < expectedTexCoords.length; i++) { + assertEquals(expectedTexCoords[i], texCoords.get(quad * 8 + i), EPSILON, + "quad " + quad + ", texture coordinate " + i); + } + int[] offsets = {0, 1, 2, 0, 2, 3}; + ShortBuffer indices = (ShortBuffer) mesh.getBuffer(Type.Index).getData(); + for (int i = 0; i < offsets.length; i++) { + assertEquals(quad * 4 + offsets[i], Short.toUnsignedInt(indices.get(quad * 6 + i)), + "quad " + quad + ", index " + i); + } + } + + private static void assertColor(Mesh mesh, int quad, int red, int green, int blue, int alpha) { + int[] expected = {red, green, blue, alpha}; + ByteBuffer colors = (ByteBuffer) mesh.getBuffer(Type.Color).getData(); + for (int vertex = 0; vertex < 4; vertex++) { + for (int component = 0; component < 4; component++) { + int actual = Byte.toUnsignedInt(colors.get(quad * 16 + vertex * 4 + component)); + assertEquals(expected[component], actual, + "quad " + quad + ", vertex " + vertex + ", color component " + component); + } + } + } + + static byte[] colors(Mesh mesh) { + ByteBuffer buffer = ((ByteBuffer) mesh.getBuffer(Type.Color).getData()).duplicate(); + buffer.rewind(); + byte[] result = new byte[buffer.remaining()]; + buffer.get(result); + return result; + } + + private static void assertLayoutEquals(BitmapText expected, BitmapText actual) { + assertEquals(expected.getQuantity(), actual.getQuantity()); + assertEquals(expected.getLineCount(), actual.getLineCount()); + assertEquals(expected.getLineWidth(), actual.getLineWidth(), EPSILON); + assertEquals(expected.getHeight(), actual.getHeight(), EPSILON); + for (int page = 0; page < expected.getQuantity(); page++) { + Mesh expectedMesh = mesh(expected, page); + Mesh actualMesh = mesh(actual, page); + assertBufferSizes(actualMesh, expectedMesh.getVertexCount() / 4); + for (Type type : new Type[]{Type.Position, Type.TexCoord, Type.Index}) { + Buffer expectedBuffer = expectedMesh.getBuffer(type).getData(); + Buffer actualBuffer = actualMesh.getBuffer(type).getData(); + assertEquals(expectedBuffer.limit(), actualBuffer.limit(), + "page " + page + ", buffer " + type); + for (int i = 0; i < expectedBuffer.limit(); i++) { + String message = "page " + page + ", buffer " + type + ", element " + i; + if (type == Type.Index) { + assertEquals(((ShortBuffer) expectedBuffer).get(i), + ((ShortBuffer) actualBuffer).get(i), message); + } else { + assertEquals(((FloatBuffer) expectedBuffer).get(i), + ((FloatBuffer) actualBuffer).get(i), EPSILON, message); + } + } + } + } + } + + static void assertMeshEquals(BitmapText expected, BitmapText actual) { + assertLayoutEquals(expected, actual); + for (int page = 0; page < expected.getQuantity(); page++) { + assertArrayEquals(colors(mesh(expected, page)), colors(mesh(actual, page)), + "page " + page + " colors"); + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/font/BitmapTextCompatibilityTest.java b/jme3-core/src/test/java/com/jme3/font/BitmapTextCompatibilityTest.java new file mode 100644 index 0000000000..7b6dfe3324 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/font/BitmapTextCompatibilityTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.font; + +import static com.jme3.font.BitmapTextAssemblyTest.createFont; +import static com.jme3.font.BitmapTextAssemblyTest.mesh; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.jme3.scene.VertexBuffer.Type; +import java.nio.FloatBuffer; +import org.junit.jupiter.api.Test; + +/** Regression coverage for behavior retained while redistributing page quads. */ +public class BitmapTextCompatibilityTest { + + @Test + public void glyphsWithUnavailablePagesAreSkippedWithoutChangingLayout() { + BitmapFont font = createFont(2); + font.getCharSet().getCharacter('A').setPage(-1); + font.getCharSet().getCharacter('B').setPage(2); + BitmapText text = new BitmapText(font); + text.setText("ABCD"); + text.updateLogicalState(0); + + // The old per-page filter skipped unavailable pages. Their characters + // still participated in text layout, so C and D keep their advances. + assertEquals(4, mesh(text, 0).getVertexCount()); + assertEquals(4, mesh(text, 1).getVertexCount()); + float firstVisibleX = font.getCharSet().getCharacter('A').getXAdvance() + + font.getCharSet().getCharacter('B').getXAdvance(); + FloatBuffer positions = (FloatBuffer) mesh(text, 1).getBuffer(Type.Position).getData(); + assertEquals(firstVisibleX, positions.get(0), 0.00001f); + + text.setAlpha(0.25f); + text.updateLogicalState(0); + assertEquals(firstVisibleX, positions.get(0), 0.00001f); + + text.setText("AB"); + text.updateLogicalState(0); + assertEquals(0, mesh(text, 0).getVertexCount()); + assertEquals(0, mesh(text, 1).getVertexCount()); + } +} diff --git a/jme3-core/src/test/java/com/jme3/font/BitmapTextPageTest.java b/jme3-core/src/test/java/com/jme3/font/BitmapTextPageTest.java new file mode 100644 index 0000000000..6de62d611a --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/font/BitmapTextPageTest.java @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.font; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.material.Material; +import com.jme3.material.MaterialDef; +import com.jme3.math.ColorRGBA; +import com.jme3.scene.Mesh; +import com.jme3.scene.VertexBuffer.Type; +import com.jme3.shader.VarType; +import com.jme3.texture.Texture2D; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ShortBuffer; +import org.junit.jupiter.api.Test; + +/** + * Checks buffer reuse, selective updates, and clone ownership without a renderer. + */ +public class BitmapTextPageTest { + private static final Type[] TYPES = {Type.Position, Type.TexCoord, Type.Index, Type.Color}; + private static final int[] ELEMENTS_PER_QUAD = {12, 8, 6, 16}; + + @Test + void incrementalGrowthReusesSpareCapacityAndKeepsExactLogicalLengths() { + BitmapFont font = createFont(); + BitmapTextPage page = new BitmapTextPage(font); + Mesh mesh = page.getMesh(); + Buffer[] previous = buffers(mesh); + boolean[] reused = new boolean[TYPES.length]; + for (int length = 1; length <= 32; length++) { + rebuild(page, font, repeat('A', length)); + Buffer[] current = buffers(mesh); + for (int i = 0; i < TYPES.length; i++) { + reused[i] |= current[i] == previous[i]; + assertEquals(length * ELEMENTS_PER_QUAD[i], current[i].limit(), TYPES[i].name()); + // BoundingBox consumes the position buffer when updating bounds. + if (TYPES[i] != Type.Position) { + assertEquals(0, current[i].position(), TYPES[i].name()); + } + assertTrue(current[i].capacity() >= current[i].limit(), TYPES[i].name()); + } + assertEquals(length * 4, mesh.getVertexCount()); + assertEquals(length * 2, mesh.getTriangleCount()); + assertIndices(mesh, length); + previous = current; + } + for (int i = 0; i < TYPES.length; i++) { + assertTrue(reused[i], TYPES[i] + " should reuse capacity while text grows"); + } + rebuild(page, font, "AA"); + for (int i = 0; i < TYPES.length; i++) { + assertSame(previous[i], mesh.getBuffer(TYPES[i]).getData(), TYPES[i].name()); + } + assertIndices(mesh, 2); + rebuild(page, font, repeat('B', 32)); + for (int i = 0; i < TYPES.length; i++) { + assertSame(previous[i], mesh.getBuffer(TYPES[i]).getData(), TYPES[i].name()); + } + assertIndices(mesh, 32); + } + + @Test + void sameQuadCountPreservesIndexBufferWithoutUploadingIt() { + BitmapFont font = createFont(); + BitmapTextPage page = new BitmapTextPage(font); + rebuild(page, font, "AAAA"); + Mesh mesh = page.getMesh(); + Buffer[] original = buffers(mesh); + clearFlags(mesh); + rebuild(page, font, "BBBB"); + for (int i = 0; i < TYPES.length; i++) { + assertSame(original[i], mesh.getBuffer(TYPES[i]).getData(), TYPES[i].name()); + } + assertFalse(mesh.getBuffer(Type.Index).isUpdateNeeded()); + assertTrue(mesh.getBuffer(Type.Position).isUpdateNeeded()); + assertTrue(mesh.getBuffer(Type.TexCoord).isUpdateNeeded()); + assertIndices(mesh, 4); + } + + @Test + void colorUpdatesLeaveOtherBuffersCleanAndSkipUnchangedPackedColors() { + BitmapFont font = createFont(); + BitmapTextPage page = new BitmapTextPage(font); + Letters letters = rebuild(page, font, "ABAB"); + Mesh mesh = page.getMesh(); + Buffer[] original = buffers(mesh); + clearFlags(mesh); + letters.setColor(ColorRGBA.Red); + page.updateColors(); + for (int i = 0; i < TYPES.length; i++) { + assertSame(original[i], mesh.getBuffer(TYPES[i]).getData(), TYPES[i].name()); + assertEquals(TYPES[i] == Type.Color, mesh.getBuffer(TYPES[i]).isUpdateNeeded(), TYPES[i].name()); + } + ByteBuffer colors = (ByteBuffer) mesh.getBuffer(Type.Color).getData(); + for (int vertex = 0; vertex < mesh.getVertexCount(); vertex++) { + assertEquals(255, Byte.toUnsignedInt(colors.get(vertex * 4))); + assertEquals(0, Byte.toUnsignedInt(colors.get(vertex * 4 + 1))); + assertEquals(0, Byte.toUnsignedInt(colors.get(vertex * 4 + 2))); + assertEquals(255, Byte.toUnsignedInt(colors.get(vertex * 4 + 3))); + } + clearFlags(mesh); + page.updateColors(); + assertClean(mesh); + } + + @Test + void emptyPagesStayCleanAfterTheirDrawCountsHaveBeenCleared() { + BitmapFont font = createFont(); + BitmapTextPage page = new BitmapTextPage(font); + Mesh mesh = page.getMesh(); + clearFlags(mesh); + page.assemble(); + page.updateColors(); + assertClean(mesh); + + rebuild(page, font, "AB"); + Buffer[] previous = buffers(mesh); + clearFlags(mesh); + page.clearQuads(); + page.assemble(); + assertEquals(0, mesh.getVertexCount()); + assertEquals(0, mesh.getTriangleCount()); + for (int i = 0; i < TYPES.length; i++) { + assertSame(previous[i], mesh.getBuffer(TYPES[i]).getData(), TYPES[i].name()); + assertEquals(0, previous[i].limit(), TYPES[i].name()); + assertTrue(mesh.getBuffer(TYPES[i]).isUpdateNeeded(), TYPES[i].name()); + } + clearFlags(mesh); + page.assemble(); + page.updateColors(); + assertClean(mesh); + } + + @Test + void clonesOwnTheirBuffersAndRebuildMembershipAndIndexCapacity() { + BitmapFont font = createFont(); + BitmapTextPage original = new BitmapTextPage(font); + rebuild(original, font, repeat('A', 12)); + Letters originalLetters = rebuild(original, font, "AA"); + BitmapTextPage clone = original.clone(); + assertNotSame(original.getMesh(), clone.getMesh()); + for (Type type : TYPES) { + assertNotSame(original.getMesh().getBuffer(type).getData(), + clone.getMesh().getBuffer(type).getData(), type.name()); + } + byte[] cloneColors = colorBytes(clone.getMesh()); + clearFlags(clone.getMesh()); + originalLetters.setColor(ColorRGBA.Red); + original.updateColors(); + clone.updateColors(); + assertArrayEquals(cloneColors, colorBytes(clone.getMesh())); + assertClean(clone.getMesh()); + + Letters cloneLetters = rebuild(clone, font, repeat('B', 8)); + assertIndices(clone.getMesh(), 8); + cloneLetters.setColor(ColorRGBA.Blue); + clone.updateColors(); + original.updateColors(); + assertIndices(original.getMesh(), 2); + assertEquals(255, Byte.toUnsignedInt(colorBytes(original.getMesh())[0])); + assertEquals(0, Byte.toUnsignedInt(colorBytes(original.getMesh())[2])); + assertEquals(0, Byte.toUnsignedInt(colorBytes(clone.getMesh())[0])); + assertEquals(255, Byte.toUnsignedInt(colorBytes(clone.getMesh())[2])); + } + + private static BitmapFont createFont() { + BitmapCharacterSet characters = new BitmapCharacterSet(); + characters.setRenderedSize(16); + characters.setLineHeight(20); + characters.setBase(16); + characters.setWidth(64); + characters.setHeight(32); + for (char c = 'A'; c <= 'B'; c++) { + BitmapCharacter glyph = new BitmapCharacter(); + glyph.setChar(c); + glyph.setX((c - 'A') * 16); + glyph.setWidth(c == 'A' ? 6 : 8); + glyph.setHeight(12); + glyph.setXAdvance(c == 'A' ? 8 : 10); + characters.addCharacter(c, glyph); + } + MaterialDef definition = new MaterialDef(null, "Synthetic bitmap font"); + definition.addMaterialParamTexture(VarType.Texture2D, "ColorMap", null, null); + Material material = new Material(definition); + material.setTexture("ColorMap", new Texture2D()); + BitmapFont font = new BitmapFont(); + font.setCharSet(characters); + font.setPages(new Material[]{material}); + return font; + } + + private static Letters rebuild(BitmapTextPage page, BitmapFont font, String text) { + StringBlock block = new StringBlock(); + block.setSize(16); + block.setText(text); + Letters letters = new Letters(font, block, false); + letters.update(); + page.clearQuads(); + letters.rewind(); + while (letters.nextCharacter()) { + if (letters.isPrintable()) { + page.addQuad(letters.getQuad()); + } + } + page.assemble(); + return letters; + } + + private static String repeat(char value, int count) { + StringBuilder text = new StringBuilder(count); + for (int i = 0; i < count; i++) { + text.append(value); + } + return text.toString(); + } + + private static Buffer[] buffers(Mesh mesh) { + Buffer[] result = new Buffer[TYPES.length]; + for (int i = 0; i < TYPES.length; i++) { + result[i] = mesh.getBuffer(TYPES[i]).getData(); + } + return result; + } + + private static void clearFlags(Mesh mesh) { + for (Type type : TYPES) { + mesh.getBuffer(type).clearUpdateNeeded(); + } + } + + private static void assertClean(Mesh mesh) { + for (Type type : TYPES) { + assertFalse(mesh.getBuffer(type).isUpdateNeeded(), type.name()); + } + } + + private static byte[] colorBytes(Mesh mesh) { + ByteBuffer data = ((ByteBuffer) mesh.getBuffer(Type.Color).getData()).duplicate(); + data.rewind(); + byte[] result = new byte[data.remaining()]; + data.get(result); + return result; + } + + private static void assertIndices(Mesh mesh, int quadCount) { + ShortBuffer indices = (ShortBuffer) mesh.getBuffer(Type.Index).getData(); + int[] offsets = {0, 1, 2, 0, 2, 3}; + assertEquals(quadCount * 6, indices.limit()); + for (int quad = 0; quad < quadCount; quad++) { + for (int i = 0; i < offsets.length; i++) { + assertEquals(quad * 4 + offsets[i], Short.toUnsignedInt(indices.get(quad * 6 + i))); + } + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/font/BitmapTextUpdateTest.java b/jme3-core/src/test/java/com/jme3/font/BitmapTextUpdateTest.java new file mode 100644 index 0000000000..368df0b29e --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/font/BitmapTextUpdateTest.java @@ -0,0 +1,394 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.font; + +import static com.jme3.font.BitmapTextAssemblyTest.assertMeshEquals; +import static com.jme3.font.BitmapTextAssemblyTest.colors; +import static com.jme3.font.BitmapTextAssemblyTest.createFont; +import static com.jme3.font.BitmapTextAssemblyTest.mesh; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.bounding.BoundingVolume; +import com.jme3.math.ColorRGBA; +import com.jme3.scene.Geometry; +import com.jme3.scene.Mesh; +import com.jme3.scene.VertexBuffer.Type; +import java.nio.Buffer; +import java.nio.FloatBuffer; +import java.nio.ShortBuffer; +import org.junit.jupiter.api.Test; + +/** + * Regression tests for selective text updates and independent cloned text. + */ +public class BitmapTextUpdateTest { + private static final Type[] TYPES = { + Type.Position, Type.TexCoord, Type.Color, Type.Index + }; + + @Test + public void colorChangesPreserveWrappedClippedAndRtlLayout() { + for (boolean rightToLeft : new boolean[]{false, true}) { + for (LineWrapMode wrap : LineWrapMode.values()) { + BitmapFont font = createFont(2); + font.setRightToLeft(rightToLeft); + BitmapText text = new BitmapText(font); + text.setBox(new Rectangle(7, 110, 45, 100)); + text.setAlignment(BitmapFont.Align.Center); + text.setVerticalAlignment(BitmapFont.VAlign.Bottom); + text.setLineWrapMode(wrap); + text.setEllipsisChar('.'); + text.setText("ABCD EF\nGH"); + text.updateLogicalState(0); + for (int operation = 0; operation < 4; operation++) { + PageSnapshot[] before = snapshot(text); + clearUpdates(text); + switch (operation) { + case 0: + text.setAlpha(0.3f); + break; + case 1: + text.setColor(new ColorRGBA(0.2f, 0.5f, 0.8f, 0.7f)); + break; + case 2: + text.setColor(1, 3, ColorRGBA.Red); + break; + default: + text.setColor("[AG]", ColorRGBA.Blue); + break; + } + text.updateLogicalState(0); + for (int page = 0; page < before.length; page++) { + before[page].assertLayoutUnchanged(text, page); + assertFalse(mesh(text, page).getBuffer(Type.Position).isUpdateNeeded()); + assertFalse(mesh(text, page).getBuffer(Type.TexCoord).isUpdateNeeded()); + assertFalse(mesh(text, page).getBuffer(Type.Index).isUpdateNeeded()); + } + } + } + } + } + + @Test + public void readingDimensionsFlushesOnlyPendingColors() { + BitmapText text = text(createFont(2), "ABCD"); + float width = text.getLineWidth(); + float height = text.getHeight(); + int lines = text.getLineCount(); + clearUpdates(text); + text.setAlpha(0.5f); + assertEquals(width, text.getLineWidth()); + assertEquals(height, text.getHeight()); + assertEquals(lines, text.getLineCount()); + for (int page = 0; page < text.getQuantity(); page++) { + assertTrue(mesh(text, page).getBuffer(Type.Color).isUpdateNeeded()); + assertFalse(mesh(text, page).getBuffer(Type.Position).isUpdateNeeded()); + assertFalse(mesh(text, page).getBuffer(Type.TexCoord).isUpdateNeeded()); + assertFalse(mesh(text, page).getBuffer(Type.Index).isUpdateNeeded()); + } + clearUpdates(text); + text.updateLogicalState(0); + assertClean(text); + } + + @Test + public void layoutChangesAreNotLostWhenColorsAlsoChange() { + BitmapFont font = createFont(2); + for (boolean colorFirst : new boolean[]{false, true}) { + BitmapText actual = text(font, "AB CD"); + clearUpdates(actual); + if (colorFirst) { + actual.setAlpha(0.5f); + actual.setSize(32); + } else { + actual.setSize(32); + actual.setAlpha(0.5f); + } + actual.updateLogicalState(0); + BitmapText expected = new BitmapText(font); + expected.setText("AB CD"); + expected.setSize(32); + expected.setAlpha(0.5f); + expected.updateLogicalState(0); + assertMeshEquals(expected, actual); + for (int page = 0; page < actual.getQuantity(); page++) { + assertTrue(mesh(actual, page).getBuffer(Type.Position).isUpdateNeeded()); + assertFalse(mesh(actual, page).getBuffer(Type.Index).isUpdateNeeded()); + } + } + } + + @Test + public void reapplyingBaseColorStillClearsSubstringOverrides() { + BitmapFont font = createFont(2); + BitmapText text = text(font, "ABCD"); + text.setColor(ColorRGBA.White); + text.setColor(1, 3, ColorRGBA.Red); + text.updateLogicalState(0); + clearUpdates(text); + text.setColor(ColorRGBA.White); + text.updateLogicalState(0); + assertMeshEquals(text(font, "ABCD"), text); + assertTrue(mesh(text, 0).getBuffer(Type.Color).isUpdateNeeded()); + assertTrue(mesh(text, 1).getBuffer(Type.Color).isUpdateNeeded()); + } + + @Test + public void repeatedAlphaResetRestoresOriginalTagsAfterSubstringOverrides() { + BitmapFont font = createFont(1); + BitmapText text = text(font, "A\\#f008#BC"); + byte[] original = colors(mesh(text, 0)); + text.setAlpha(-1); + text.setColor(1, 3, ColorRGBA.Blue); + text.updateLogicalState(0); + clearUpdates(text); + assertEquals(-1f, text.getAlpha()); + text.setAlpha(-1); + text.updateLogicalState(0); + assertArrayEquals(original, colors(mesh(text, 0))); + assertTrue(mesh(text, 0).getBuffer(Type.Color).isUpdateNeeded()); + assertFalse(mesh(text, 0).getBuffer(Type.Position).isUpdateNeeded()); + clearUpdates(text); + text.setAlpha(-1); + text.updateLogicalState(0); + assertClean(text); + } + + @Test + public void unchangedScalarSettingsDoNotUploadBuffers() { + BitmapText text = text(createFont(2), "ABCD"); + clearUpdates(text); + text.setText(new String(text.getText())); + text.setSize(text.getSize()); + text.setAlignment(text.getAlignment()); + text.setVerticalAlignment(text.getVerticalAlignment()); + text.setLineWrapMode(text.getLineWrapMode()); + text.setTabWidth(50); + text.setEllipsisChar('\u2026'); + text.updateLogicalState(0); + assertClean(text); + } + + @Test + public void equalBoxesSkipRefreshButChangedBoundsRelayout() { + BitmapFont font = createFont(2); + Rectangle box = new Rectangle(0, 100, 25, 100); + BitmapText actual = text(font, "AB CD EF"); + actual.setBox(box); + actual.updateLogicalState(0); + clearUpdates(actual); + actual.setBox(box.clone()); + actual.updateLogicalState(0); + assertClean(actual); + box = new Rectangle(0, 100, 120, 100); + actual.setBox(box); + actual.updateLogicalState(0); + BitmapText expected = new BitmapText(font); + expected.setBox(box.clone()); + expected.setText("AB CD EF"); + expected.updateLogicalState(0); + assertMeshEquals(expected, actual); + } + + @Test + public void styleChangesRelayoutFollowingLettersEvenWithPendingColors() { + BitmapFont font = styledFont(); + BitmapText text = text(font, "ABC"); + FloatBuffer positions = (FloatBuffer) mesh(text, 0).getBuffer(Type.Position).getData(); + float oldThirdX = positions.get(24); + text.setStyle(1, 2, 1); + text.setAlpha(0.5f); + text.updateLogicalState(0); + assertEquals(8, mesh(text, 0).getVertexCount()); + assertEquals(4, mesh(text, 1).getVertexCount()); + FloatBuffer newPositions = (FloatBuffer) mesh(text, 0).getBuffer(Type.Position).getData(); + assertEquals(oldThirdX + 7, newPositions.get(12), 0.00001f); + } + + @Test + public void clonedTextsPreserveStylesAndColorsAndUpdateIndependently() { + BitmapText source = new BitmapText(styledFont()); + source.setColor(new ColorRGBA(0.2f, 0.4f, 0.6f, 0.8f)); + source.setText("A\\#f008#BC DE"); + source.setStyle(0, 3, 1); + source.setColor(2, 3, ColorRGBA.Blue); + source.setAlpha(0.6f); + source.updateLogicalState(0); + BitmapText clone = source.clone(); + clone.updateLogicalState(0); + assertMeshEquals(source, clone); + assertEquals(source.getAlpha(), clone.getAlpha()); + assertEquals(source.getColor(), clone.getColor()); + assertNotSame(source.getColor(), clone.getColor()); + for (int page = 0; page < source.getQuantity(); page++) { + assertNotSame(mesh(source, page), mesh(clone, page)); + for (Type type : TYPES) { + assertNotSame(mesh(source, page).getBuffer(type).getData(), + mesh(clone, page).getBuffer(type).getData()); + } + } + PageSnapshot[] sourceBefore = snapshot(source); + clone.setAlpha(0.1f); + clone.updateLogicalState(0); + assertUnchanged(sourceBefore, source); + PageSnapshot[] cloneBefore = snapshot(clone); + source.setText("ZY"); + source.updateLogicalState(0); + assertUnchanged(cloneBefore, clone); + PageSnapshot[] changedSource = snapshot(source); + clone.setColor(0, 2, ColorRGBA.Green); + clone.updateLogicalState(0); + assertUnchanged(changedSource, source); + } + + @Test + public void cloningPendingTextAndColorChangesUsesIndependentCurrentLetters() { + BitmapText source = text(createFont(2), "ABCD"); + source.setText("ZY\\#08f4#X"); + source.setColor(0, 1, ColorRGBA.Red); + BitmapText clone = source.clone(); + clone.updateLogicalState(0); + source.updateLogicalState(0); + assertMeshEquals(source, clone); + clone.setText("ABCDEF"); + clone.updateLogicalState(0); + source.setAlpha(0.5f); + source.updateLogicalState(0); + assertMeshEquals(text(source.getFont(), "ABCDEF"), clone); + } + + private static BitmapFont styledFont() { + BitmapFont font = createFont(1); + BitmapFont styled = createFont(1); + BitmapCharacter wider = styled.getCharSet().getCharacter('B'); + wider.setXAdvance(wider.getXAdvance() + 7); + styled.setStyle(1); + font.merge(styled); + return font; + } + + private static BitmapText text(BitmapFont font, String value) { + BitmapText text = new BitmapText(font); + text.setText(value); + text.updateLogicalState(0); + return text; + } + + private static void clearUpdates(BitmapText text) { + for (int page = 0; page < text.getQuantity(); page++) { + for (Type type : TYPES) { + mesh(text, page).getBuffer(type).clearUpdateNeeded(); + } + } + } + + private static void assertClean(BitmapText text) { + for (int page = 0; page < text.getQuantity(); page++) { + for (Type type : TYPES) { + assertFalse(mesh(text, page).getBuffer(type).isUpdateNeeded(), type.toString()); + } + } + } + + private static PageSnapshot[] snapshot(BitmapText text) { + PageSnapshot[] result = new PageSnapshot[text.getQuantity()]; + for (int page = 0; page < result.length; page++) { + result[page] = new PageSnapshot(text, page); + } + return result; + } + + private static void assertUnchanged(PageSnapshot[] before, BitmapText text) { + for (int page = 0; page < before.length; page++) { + before[page].assertLayoutUnchanged(text, page); + assertArrayEquals(before[page].colorData, colors(mesh(text, page))); + } + } + + private static float[] floats(Mesh mesh, Type type) { + FloatBuffer buffer = ((FloatBuffer) mesh.getBuffer(type).getData()).duplicate(); + buffer.rewind(); + float[] result = new float[buffer.remaining()]; + buffer.get(result); + return result; + } + + private static short[] indices(Mesh mesh) { + ShortBuffer buffer = ((ShortBuffer) mesh.getBuffer(Type.Index).getData()).duplicate(); + buffer.rewind(); + short[] result = new short[buffer.remaining()]; + buffer.get(result); + return result; + } + + private static class PageSnapshot { + final Geometry geometry; + final Mesh mesh; + final Buffer[] buffers; + final float[] positions; + final float[] texCoords; + final short[] indexData; + final byte[] colorData; + final BoundingVolume bound; + + PageSnapshot(BitmapText text, int page) { + geometry = (Geometry) text.getChild(page); + mesh = geometry.getMesh(); + buffers = new Buffer[TYPES.length]; + for (int i = 0; i < TYPES.length; i++) { + buffers[i] = mesh.getBuffer(TYPES[i]).getData(); + } + positions = floats(mesh, Type.Position); + texCoords = floats(mesh, Type.TexCoord); + indexData = indices(mesh); + colorData = colors(mesh); + bound = mesh.getBound().clone(); + } + + void assertLayoutUnchanged(BitmapText text, int page) { + assertSame(geometry, text.getChild(page)); + assertSame(mesh, geometry.getMesh()); + for (int i = 0; i < TYPES.length; i++) { + assertSame(buffers[i], mesh.getBuffer(TYPES[i]).getData()); + } + assertArrayEquals(positions, floats(mesh, Type.Position)); + assertArrayEquals(texCoords, floats(mesh, Type.TexCoord)); + assertArrayEquals(indexData, indices(mesh)); + assertEquals(bound, mesh.getBound()); + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/font/LettersReuseTest.java b/jme3-core/src/test/java/com/jme3/font/LettersReuseTest.java new file mode 100644 index 0000000000..edfafeff4f --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/font/LettersReuseTest.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.font; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.jme3.export.JmeExporter; +import com.jme3.export.JmeImporter; +import com.jme3.math.ColorRGBA; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Regression tests for cached letter state and forward-only color-tag traversal. + */ +public class LettersReuseTest { + + @Test + public void retainsQuadsByParsedIndexAcrossShrinkingClearingAndGrowth() { + BitmapFont font = BitmapTextAssemblyTest.createFont(1); + StringBlock block = block("ABCDE"); + Letters letters = new Letters(font, block, false); + List original = quads(letters); + + replaceText(letters, block, "ZYX"); + assertSameQuads(original, letters, 3); + replaceText(letters, block, ""); + assertSame(letters.getTail(), letters.getHead().getNext()); + replaceText(letters, block, "QWERTYU"); + assertSameQuads(original, letters, original.size()); + List expanded = quads(letters); + assertEquals(7, expanded.size()); + for (int i = 0; i < expanded.size(); i++) { + assertEquals(block.getText().charAt(i), expanded.get(i).getChar()); + assertEquals(i, expanded.get(i).getIndex()); + } + assertNotSame(original.get(4), expanded.get(5)); + } + + @Test + public void reusedQuadsResetExplicitStylesColorsAndAllWrappingState() { + BitmapFont font = BitmapTextAssemblyTest.createFont(1); + BitmapCharacterSet styled = BitmapTextAssemblyTest.createFont(1).getCharSet(); + styled.getCharacter('A').setWidth(30); + styled.getCharacter('A').setXAdvance(31); + styled.setStyle(1); + font.getCharSet().merge(styled); + ColorRGBA baseColor = new ColorRGBA(0.2f, 0.4f, 0.6f, 0.8f); + + for (LineWrapMode mode : LineWrapMode.values()) { + StringBlock block = block("AB CD\nEF"); + block.setTextBox(new Rectangle(0, 0, 17, 100)); + block.setLineWrapMode(mode); + Letters letters = new Letters(font, block, false); + LetterQuad first = letters.getHead().getNext(); + letters.setColor(baseColor); + letters.setStyle(0, block.getText().length(), 1); + letters.setColor(0, 3, ColorRGBA.Red); + letters.setBaseAlpha(0.25f); + letters.update(); + + block.setTextBox(null); + replaceText(letters, block, "ZA"); + letters.update(); + assertSame(first, letters.getHead().getNext()); + assertSame(font.getCharSet().getCharacter('A'), first.getNext().getBitmapChar()); + + Letters fresh = new Letters(font, block.clone(), false); + fresh.setColor(baseColor); + fresh.update(); + assertLetterDataEquals(fresh, letters); + } + } + + @Test + public void reuseFollowsContextDependentGlyphParserOutput() { + BitmapFont font = BitmapTextAssemblyTest.createFont(1); + font.setGlyphParser(new TestGlyphParser() { + @Override + public CharSequence parse(CharSequence text) { + return text.length() == 2 ? "ZA" : "BCDE"; + } + }); + StringBlock block = block("AB"); + Letters letters = new Letters(font, block, false); + List original = quads(letters); + assertEquals('Z', original.get(0).getChar()); + + replaceText(letters, block, "ABC"); + assertSameQuads(original, letters, 2); + List reshaped = quads(letters); + assertEquals(4, reshaped.size()); + for (int i = 0; i < reshaped.size(); i++) { + assertEquals("BCDE".charAt(i), reshaped.get(i).getChar()); + } + } + + @Test + public void tagsKeepAdjacentRangeAndPlainTextEndSemanticsAfterShaping() { + BitmapFont font = BitmapTextAssemblyTest.createFont(1); + font.setGlyphParser(new TestGlyphParser() { + @Override + public CharSequence parse(CharSequence text) { + return "ABCDE"; + } + }); + StringBlock block = block("A\\#f00#\\#0f08#B\\#abcdef80#C\\#00f#"); + Letters letters = new Letters(font, block, false); + List quads = quads(letters); + assertColor(quads.get(0), 255, 255, 255, 255); + assertColor(quads.get(1), 0, 255, 0, 136); + assertColor(quads.get(2), 171, 205, 239, 128); + assertColor(quads.get(3), 255, 255, 255, 255); + assertColor(quads.get(4), 255, 255, 255, 255); + + letters.setBaseAlpha(0.5f); + letters.setBaseAlpha(-1); + assertColor(quads.get(0), 255, 255, 255, 255); + assertColor(quads.get(1), 0, 255, 0, 136); + assertColor(quads.get(2), 171, 205, 239, 128); + assertColor(quads.get(3), 255, 255, 255, 255); + assertColor(quads.get(4), 255, 255, 255, 255); + } + + @Test + public void invalidationIncludesValidQuadsAfterAnAlreadyInvalidOne() { + BitmapFont font = BitmapTextAssemblyTest.createFont(1); + Letters letters = new Letters(font, block("ABC"), false); + letters.update(); + List quads = quads(letters); + quads.get(0).invalidate(); + assertFalse(quads.get(1).isInvalid()); + letters.invalidate(); + for (LetterQuad quad : quads) { + assertTrue(quad.isInvalid()); + } + } + + private static StringBlock block(String text) { + StringBlock block = new StringBlock(); + block.setSize(16); + block.setText(text); + return block; + } + + private static void replaceText(Letters letters, StringBlock block, String text) { + block.setText(text); + letters.setText(text); + } + + private static List quads(Letters letters) { + List result = new ArrayList<>(); + for (LetterQuad quad = letters.getHead().getNext(); !quad.isTail(); quad = quad.getNext()) { + result.add(quad); + } + return result; + } + + private static void assertSameQuads(List expected, Letters actual, int count) { + List actualQuads = quads(actual); + for (int i = 0; i < count; i++) { + assertSame(expected.get(i), actualQuads.get(i), "quad " + i); + } + } + + private static void assertLetterDataEquals(Letters expected, Letters actual) { + List expectedQuads = quads(expected); + List actualQuads = quads(actual); + assertEquals(expectedQuads.size(), actualQuads.size()); + for (int i = 0; i < expectedQuads.size(); i++) { + float[] expectedPositions = new float[12]; + float[] actualPositions = new float[12]; + float[] expectedTexCoords = new float[8]; + float[] actualTexCoords = new float[8]; + byte[] expectedColors = new byte[16]; + byte[] actualColors = new byte[16]; + expectedQuads.get(i).storeToArrays(expectedPositions, expectedTexCoords, expectedColors); + actualQuads.get(i).storeToArrays(actualPositions, actualTexCoords, actualColors); + assertArrayEquals(expectedPositions, actualPositions, 0.00001f); + assertArrayEquals(expectedTexCoords, actualTexCoords, 0.00001f); + assertArrayEquals(expectedColors, actualColors); + } + } + + private static void assertColor(LetterQuad quad, int red, int green, int blue, int alpha) { + byte[] color = new byte[16]; + quad.storeColors(color); + int[] components = {red, green, blue, alpha}; + for (int i = 0; i < color.length; i++) { + assertEquals(components[i % 4], Byte.toUnsignedInt(color[i]), "color byte " + i); + } + } + + private abstract static class TestGlyphParser implements GlyphParser { + @Override + public void write(JmeExporter exporter) { + } + + @Override + public void read(JmeImporter importer) { + } + } +} diff --git a/jme3-core/src/test/java/com/jme3/light/LightListTest.java b/jme3-core/src/test/java/com/jme3/light/LightListTest.java new file mode 100644 index 0000000000..c0c5034b60 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/light/LightListTest.java @@ -0,0 +1,465 @@ +/* + * Copyright (c) 2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.light; + +import com.jme3.math.Vector3f; +import com.jme3.scene.Geometry; +import com.jme3.scene.Mesh; +import com.jme3.scene.Spatial; +import com.jme3.scene.Node; +import com.jme3.scene.shape.Box; +import java.lang.reflect.Field; +import com.jme3.util.SortUtil; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Random; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Test cases for LightList. + */ +public class LightListTest { + + /** + * Verify sort() remains correct after the list previously held more lights. + */ + @Test + public void testSortAfterRetainedCapacity() { + Geometry owner = new Geometry("owner", new Mesh()); + LightList list = new LightList(owner); + + for (int i = 0; i < 32; i++) { + list.add(new PointLight(new Vector3f(100f + i, 0f, 0f))); + } + list.sort(true); + list.clear(); + + PointLight far = new PointLight(new Vector3f(10f, 0f, 0f)); + PointLight near = new PointLight(new Vector3f(1f, 0f, 0f)); + PointLight middle = new PointLight(new Vector3f(5f, 0f, 0f)); + + list.add(far); + list.add(near); + list.add(middle); + list.sort(true); + + Assertions.assertSame(near, list.get(0)); + Assertions.assertSame(middle, list.get(1)); + Assertions.assertSame(far, list.get(2)); + } + + /** + * Verify indexed removal preserves order for front, middle, and tail removals. + */ + @Test + public void testRemovePreservesOrder() { + LightList list = new LightList(new Geometry("owner", new Mesh())); + AmbientLight first = new AmbientLight(); + AmbientLight second = new AmbientLight(); + AmbientLight third = new AmbientLight(); + AmbientLight fourth = new AmbientLight(); + + list.add(first); + list.add(second); + list.add(third); + list.add(fourth); + + list.remove(0); + Assertions.assertEquals(3, list.size()); + Assertions.assertSame(second, list.get(0)); + Assertions.assertSame(third, list.get(1)); + Assertions.assertSame(fourth, list.get(2)); + + list.remove(1); + Assertions.assertEquals(2, list.size()); + Assertions.assertSame(second, list.get(0)); + Assertions.assertSame(fourth, list.get(1)); + + list.remove(1); + Assertions.assertEquals(1, list.size()); + Assertions.assertSame(second, list.get(0)); + } + + /** + * Verify unfiltered world-list updates preserve local-then-parent ordering. + */ + @Test + public void testUpdateCopiesLocalAndParentLights() { + LightList local = new LightList(new Geometry("local", new Mesh())); + LightList parent = new LightList(new Geometry("parent", new Mesh())); + LightList world = new LightList(new Geometry("world", new Mesh())); + + AmbientLight localFirst = new AmbientLight(); + AmbientLight localSecond = new AmbientLight(); + AmbientLight parentFirst = new AmbientLight(); + AmbientLight parentSecond = new AmbientLight(); + + local.add(localFirst); + local.add(localSecond); + parent.add(parentFirst); + parent.add(parentSecond); + + world.update(local, parent); + + Assertions.assertEquals(4, world.size()); + Assertions.assertSame(localFirst, world.get(0)); + Assertions.assertSame(localSecond, world.get(1)); + Assertions.assertSame(parentFirst, world.get(2)); + Assertions.assertSame(parentSecond, world.get(3)); + } + + /** + * Verify filtered world-list updates keep only accepted local lights. + */ + @Test + public void testUpdateFiltersLocalLights() { + LightList local = new LightList(new Geometry("local", new Mesh())); + LightList parent = new LightList(new Geometry("parent", new Mesh())); + LightList world = new LightList(new Geometry("world", new Mesh())); + + AmbientLight keep = new AmbientLight(); + AmbientLight discard = new AmbientLight(); + AmbientLight parentLight = new AmbientLight(); + + local.add(keep); + local.add(discard); + parent.add(parentLight); + + world.update(local, parent, light -> light != discard); + + Assertions.assertEquals(2, world.size()); + Assertions.assertSame(keep, world.get(0)); + Assertions.assertSame(parentLight, world.get(1)); + } + + @Test + public void testRemovalAcrossCopySizes() { + Random random = new Random(92741L); + for (int count : new int[]{1, 4, 8, 16, 32, 64, 256}) { + LightList list = new LightList(new Geometry("owner", new Mesh())); + List expected = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Light light = new AmbientLight(); + list.add(light); + expected.add(light); + } + while (!expected.isEmpty()) { + int index = random.nextInt(expected.size()); + list.remove(index); + expected.remove(index); + Assertions.assertEquals(expected.size(), list.size()); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertSame(expected.get(i), list.get(i)); + } + } + } + } + + @Test + public void testStableSortAcrossSizesAndOrders() { + Random random = new Random(21743L); + for (int count : new int[]{2, 8, 16, 31, 32, 33, 63, 64, 65, 256}) { + for (int order = 0; order < 3; order++) { + Geometry owner = new Geometry("owner", new Mesh()); + LightList list = new LightList(owner); + List input = new ArrayList<>(); + for (int i = 0; i < count; i++) { + input.add(new PointLight(new Vector3f(i % 9 - 4, 0f, 0f))); + } + input.sort(Comparator.comparingDouble(light -> + light.getPosition().distanceSquared(owner.getWorldTranslation()))); + if (order == 1) { + Collections.reverse(input); + } else if (order == 2) { + Collections.shuffle(input, random); + } + for (PointLight light : input) { + list.add(light); + } + List expected = new ArrayList<>(input); + expected.sort(Comparator.comparingDouble(light -> + light.getPosition().distanceSquared(owner.getWorldTranslation()))); + list.sort(true); + for (int i = 0; i < count; i++) { + Assertions.assertSame(expected.get(i), list.get(i)); + } + + list.remove(count / 2); + expected.remove(count / 2); + list.sort(false); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertSame(expected.get(i), list.get(i)); + } + + owner.setLocalTranslation(-2f, 1f, 3f); + owner.updateGeometricState(); + expected.sort(Comparator.comparingDouble(light -> + light.getPosition().distanceSquared(owner.getWorldTranslation()))); + list.sort(true); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertSame(expected.get(i), list.get(i)); + } + } + } + } + + @Test + public void testNaNDistanceKeepsLegacyOrderAndSingleDistanceUpdate() { + Geometry owner = new Geometry("owner", new Mesh()); + for (int count : new int[]{2, 8, 32, 64, 65}) { + LightList list = new LightList(owner); + Light[] input = new Light[count]; + for (int i = 0; i < count; i++) { + float x = i == count / 2 ? Float.NaN : count - i; + CountingPointLight light = new CountingPointLight(new Vector3f(x, 0f, 0f)); + light.lastDistance = light.getPosition().distanceSquared(owner.getWorldTranslation()); + input[i] = light; + list.add(light); + } + Comparator comparator = (a, b) -> + a.lastDistance < b.lastDistance ? -1 : a.lastDistance > b.lastDistance ? 1 : 0; + Light[] expected = input.clone(); + SortUtil.msort(input.clone(), expected, 0, count - 1, comparator); + list.sort(true); + for (int i = 0; i < count; i++) { + Assertions.assertSame(expected[i], list.get(i)); + Assertions.assertEquals(1, ((CountingPointLight) input[i]).distanceUpdates); + } + assertScratchEmptyUnchecked(list); + list.sort(false); + assertScratchEmptyUnchecked(list); + for (Light light : input) { + Assertions.assertEquals(1, ((CountingPointLight) light).distanceUpdates); + } + } + } + + @Test + public void testScratchDoesNotRetainLightsAcrossShrinkingSorts() throws Exception { + LightList list = new LightList(new Geometry("owner", new Mesh())); + for (int i = 0; i < 128; i++) { + list.add(new AmbientLight()); + } + list.sort(false); + assertScratchEmpty(list); + Light[] scratch = backingArray(list, "tlist"); + for (int size : new int[]{80, 65, 64, 8, 1, 0}) { + while (list.size() > size) { + list.remove(list.size() - 1); + } + list.sort(false); + Assertions.assertEquals(size, list.size()); + assertScratchEmpty(list); + Assertions.assertSame(scratch, backingArray(list, "tlist")); + } + } + + @Test + public void testRemoveAllAndEmptyClearDoNotRetainSortReferences() throws Exception { + LightList list = new LightList(new Geometry("owner", new Mesh())); + for (int i = 0; i < 128; i++) { + list.add(new AmbientLight()); + } + list.sort(false); + while (list.size() > 0) { + list.remove(list.size() - 1); + } + list.clear(); + assertScratchEmpty(list); + for (Light light : backingArray(list, "list")) { + Assertions.assertNull(light); + } + } + + @Test + public void testScratchIsEmptyAfterMergeComparisonThrows() throws Exception { + LightList list = new LightList(new Geometry("owner", new Mesh())); + for (int i = 0; i < 127; i++) { + list.add(new AmbientLight()); + } + list.add(null); + Assertions.assertThrows(NullPointerException.class, () -> list.sort(false)); + assertScratchEmpty(list); + } + + @Test + public void testScratchIsEmptyAfterDistanceComputationThrows() throws Exception { + LightList list = new LightList(new Geometry("owner", new Mesh())); + for (int i = 0; i < 127; i++) { + list.add(new PointLight()); + } + list.add(new PointLight() { + @Override + public void computeLastDistance(Spatial owner) { + throw new IllegalStateException("distance failure"); + } + }); + Assertions.assertThrows(IllegalStateException.class, () -> list.sort(true)); + assertScratchEmpty(list); + } + + @Test + public void testDirtyParentedOwnerCanReenterWorldLightSort() throws Exception { + Node parent = new Node("parent"); + Geometry owner = new Geometry("owner", new Box(1, 1, 1)); + parent.attachChild(owner); + for (int i = 0; i < 128; i++) { + owner.addLight(new CountingPointLight(new Vector3f(i * 2, 0, 0))); + } + parent.updateGeometricState(); + LightList world = owner.getWorldLightList(); + List expected = new ArrayList<>(); + for (Light light : world) { + ((CountingPointLight) light).distanceUpdates = 0; + expected.add(light); + } + owner.setLocalTranslation(11, 3, -2); + world.sort(true); + int distanceUpdates = 0; + for (Light light : expected) { + distanceUpdates += ((CountingPointLight) light).distanceUpdates; + } + Assertions.assertTrue(distanceUpdates > expected.size(), "Must exercise the nested world-list sort"); + expected.sort((a, b) -> a.lastDistance < b.lastDistance ? -1 + : a.lastDistance > b.lastDistance ? 1 : 0); + for (int i = 0; i < expected.size(); i++) { + Assertions.assertSame(expected.get(i), world.get(i)); + } + assertScratchEmpty(world); + } + + @Test + public void testFilteredUpdateDoesNotReserveRejectedLights() throws Exception { + Geometry owner = new Geometry("owner", new Mesh()); + LightList local = new LightList(owner); + LightList parent = new LightList(owner); + for (int i = 0; i < 257; i++) { + local.add(new AmbientLight()); + parent.add(new AmbientLight()); + } + for (int stride : new int[]{0, 16}) { + LightList world = new LightList(owner); + int[] calls = {0}; + world.update(local, parent, light -> { + int index = calls[0]++; + return stride != 0 && index % stride == 0; + }); + int accepted = stride == 0 ? 0 : 17; + Assertions.assertEquals(257, calls[0]); + Assertions.assertEquals(accepted + parent.size(), world.size()); + Assertions.assertTrue(backingArray(world, "list").length <= 512, + "Rejected locals must not force a larger retained array"); + for (int i = 0; i < accepted; i++) { + Assertions.assertSame(local.get(i * stride), world.get(i)); + } + for (int i = 0; i < parent.size(); i++) { + Assertions.assertSame(parent.get(i), world.get(accepted + i)); + } + } + LightList empty = new LightList(owner); + int originalCapacity = backingArray(empty, "list").length; + empty.update(local, null, light -> false); + Assertions.assertEquals(originalCapacity, backingArray(empty, "list").length); + } + + @Test + public void testFilterCanChangeParentBeforeItIsCopied() { + Geometry owner = new Geometry("owner", new Mesh()); + for (boolean grow : new boolean[]{true, false}) { + LightList local = new LightList(owner); + LightList parent = new LightList(owner); + LightList world = new LightList(owner); + for (int i = 0; i < 3; i++) { + local.add(new AmbientLight()); + } + parent.add(new AmbientLight()); + int[] calls = {0}; + world.update(local, parent, light -> { + if (calls[0]++ == 0) { + if (grow) { + for (int i = 0; i < 8; i++) { + parent.add(new AmbientLight()); + } + } else { + parent.clear(); + } + } + return false; + }); + Assertions.assertEquals(local.size(), calls[0]); + Assertions.assertEquals(grow ? 9 : 0, world.size()); + for (int i = 0; i < parent.size(); i++) { + Assertions.assertSame(parent.get(i), world.get(i)); + } + } + } + + private static Light[] backingArray(LightList list, String name) throws Exception { + Field field = LightList.class.getDeclaredField(name); + field.setAccessible(true); + return (Light[]) field.get(list); + } + + private static void assertScratchEmpty(LightList list) throws Exception { + Light[] scratch = backingArray(list, "tlist"); + if (scratch != null) { + for (int i = 0; i < scratch.length; i++) { + Assertions.assertNull(scratch[i], "Scratch reference at index " + i); + } + } + } + + private static void assertScratchEmptyUnchecked(LightList list) { + try { + assertScratchEmpty(list); + } catch (Exception exception) { + throw new AssertionError(exception); + } + } + + private static class CountingPointLight extends PointLight { + private int distanceUpdates; + + CountingPointLight(Vector3f position) { + super(position); + } + + @Override + public void computeLastDistance(Spatial owner) { + distanceUpdates++; + super.computeLastDistance(owner); + } + } +}