From 3928357d6ab217eba0d511393a9dcce60b3bd4d5 Mon Sep 17 00:00:00 2001 From: Davis Rollman Date: Sat, 16 May 2026 02:44:58 -0700 Subject: [PATCH 1/4] Optimize LightList updates, removal, and sorting --- .../main/java/com/jme3/light/LightList.java | 91 +++--- .../java/com/jme3/light/LightListTest.java | 277 ++++++++++++++++++ 2 files changed, 334 insertions(+), 34 deletions(-) create mode 100644 jme3-core/src/test/java/com/jme3/light/LightListTest.java 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..6838087ee1 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; } @@ -204,11 +204,18 @@ public void clear() { */ public void sort(boolean transformChanged) { if (listSize > 1) { + if (listSize <= INSERTION_SORT_THRESHOLD) { + if (insertionSort(transformChanged)) { + return; + } + // Distances were already updated before the NaN fallback. + transformChanged = false; + } // 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); + System.arraycopy(list, 0, tlist, 0, listSize); } if (transformChanged) { @@ -223,6 +230,30 @@ public void sort(boolean transformChanged) { } } + 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,31 +279,29 @@ public void update(LightList local, LightList parent, Predicate filter) { // using the arguments clear(); - while (list.length <= local.listSize) { + int requiredSize = local.listSize + (parent == null ? 0 : parent.listSize); + while (list.length < requiredSize) { doubleSize(); } - int localListSize = 0; - for(int i=0;i 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); + } + list.sort(false); + for (Light light : input) { + Assertions.assertEquals(1, ((CountingPointLight) light).distanceUpdates); + } + } + } + + private static class CountingPointLight extends PointLight { + private int distanceUpdates; + + CountingPointLight(Vector3f position) { + super(position); + } + + @Override + public void computeLastDistance(Spatial owner) { + distanceUpdates++; + super.computeLastDistance(owner); + } + } +} From d2cf0e3b42119a4468c38598c33035ee5be2e36a Mon Sep 17 00:00:00 2001 From: Mr Robot Date: Sat, 31 Oct 2026 00:08:31 +0100 Subject: [PATCH 2/4] BitmapFont: optimize text updates and reuse assembly buffers (#2982) * BitmapFont: complete array-based assembly and color-tag traversal cleanup Supersede PR #2963 and address issue #2221 and maintainer review 5255645948. Reconstruct the missing changes and regression tests from current master, retaining the valid BitmapTextPage work from the original PR. Co-authored-by: Jaime Bot * BitmapFont: remove unused LetterQuad buffer append helpers Remove the five obsolete append helpers and their unused nio imports after the unreachable non-array BitmapTextPage assembly path was deleted. Repository-wide Java search confirms no remaining call sites. Addresses the review by @jaime-jmebot: https://github.com/jMonkeyEngine/jmonkeyengine/pull/2982#discussion_r4147067484 The original Jaime Bot contribution remains credited in the parent commit. * Optimize bitmap-text updates and reuse glyph and mesh buffers Separate color refreshes from layout, reuse parsed letter quads and page membership, grow NIO buffers geometrically, and retain unchanged indices. Apply color tags in one pass and avoid allocations for untagged text and steady-state alpha updates. Preserve clone styling and isolate cached state. Invalidate the complete layout suffix and refresh style changes so selective updates cannot leave stale wrapping or glyphs. Add regression coverage for reuse, clipping, shaping, clones, buffer limits, and selective uploads. --------- Co-authored-by: toaster0123 <261194501+toaster0123@users.noreply.github.com> Co-authored-by: Jaime Bot Co-authored-by: Riccardo Balbo --- .../main/java/com/jme3/font/BitmapText.java | 100 +++-- .../java/com/jme3/font/BitmapTextPage.java | 273 +++++++----- .../main/java/com/jme3/font/ColorTags.java | 46 +- .../main/java/com/jme3/font/LetterQuad.java | 120 ++--- .../src/main/java/com/jme3/font/Letters.java | 94 ++-- .../com/jme3/font/BitmapTextAssemblyTest.java | 413 ++++++++++++++++++ .../font/BitmapTextCompatibilityTest.java | 72 +++ .../com/jme3/font/BitmapTextPageTest.java | 292 +++++++++++++ .../com/jme3/font/BitmapTextUpdateTest.java | 394 +++++++++++++++++ .../java/com/jme3/font/LettersReuseTest.java | 238 ++++++++++ 10 files changed, 1790 insertions(+), 252 deletions(-) create mode 100644 jme3-core/src/test/java/com/jme3/font/BitmapTextAssemblyTest.java create mode 100644 jme3-core/src/test/java/com/jme3/font/BitmapTextCompatibilityTest.java create mode 100644 jme3-core/src/test/java/com/jme3/font/BitmapTextPageTest.java create mode 100644 jme3-core/src/test/java/com/jme3/font/BitmapTextUpdateTest.java create mode 100644 jme3-core/src/test/java/com/jme3/font/LettersReuseTest.java 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/test/java/com/jme3/font/BitmapTextAssemblyTest.java b/jme3-core/src/test/java/com/jme3/font/BitmapTextAssemblyTest.java new file mode 100644 index 0000000000..4de06a0c36 --- /dev/null +++ b/jme3-core/src/test/java/com/jme3/font/BitmapTextAssemblyTest.java @@ -0,0 +1,413 @@ +/* + * 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.assertTrue; + +import com.jme3.font.BitmapFont.Align; +import com.jme3.font.BitmapFont.VAlign; +import com.jme3.material.Material; +import com.jme3.material.MaterialDef; +import com.jme3.math.ColorRGBA; +import com.jme3.scene.Geometry; +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.FloatBuffer; +import java.nio.ShortBuffer; +import org.junit.jupiter.api.Test; + +/** + * Headless regression tests for bitmap-text mesh assembly. The synthetic font + * needs neither an asset manager nor a renderer or graphics context. + */ +public class BitmapTextAssemblyTest { + + private static final int ATLAS_WIDTH = 4096; + private static final int ATLAS_HEIGHT = 64; + private static final float EPSILON = 0.00001f; + private static final Type[] BUFFER_TYPES = { + Type.Position, Type.TexCoord, Type.Color, Type.Index + }; + + @Test + public void assemblesOneHundredDistinctQuadsInCharacterOrder() { + BitmapFont font = createFont(1); + String characters = distinctCharacters(100); + BitmapText text = assemble(font, characters); + Mesh mesh = mesh(text, 0); + assertBufferSizes(mesh, 100); + + float x = 0; + for (int i = 0; i < characters.length(); i++) { + BitmapCharacter glyph = font.getCharSet().getCharacter(characters.charAt(i)); + assertQuad(mesh, i, glyph, x, -glyph.getYOffset()); + assertColor(mesh, i, 255, 255, 255, 255); + x += glyph.getXAdvance(); + } + } + + @Test + public void colorTagsPreservePrefixAdjacentRangesAndTrailingTags() { + BitmapText text = new BitmapText(createFont(1)); + text.setColor(new ColorRGBA(0.2f, 0.4f, 0.6f, 0.8f)); + text.setText("AB\\#f00#C\\#0f08#D\\#123456#E" + + "\\#010203#\\#aBcDeF80#F\\#0ff#G\\#01020304#"); + text.updateLogicalState(0); + Mesh mesh = mesh(text, 0); + assertBufferSizes(mesh, 7); + assertColor(mesh, 0, 51, 102, 153, 204); + assertColor(mesh, 1, 51, 102, 153, 204); + assertColor(mesh, 2, 255, 0, 0, 255); + assertColor(mesh, 3, 0, 255, 0, 136); + assertColor(mesh, 4, 18, 52, 86, 255); + assertColor(mesh, 5, 171, 205, 239, 128); + assertColor(mesh, 6, 0, 255, 255, 255); + assertLayoutEquals(assemble(text.getFont(), "ABCDEFG"), text); + } + + @Test + public void baseAlphaOverrideAndResetRestoreTaggedAndUntaggedColors() { + BitmapText text = new BitmapText(createFont(1)); + text.setColor(new ColorRGBA(0.2f, 0.4f, 0.6f, 0.8f)); + text.setText("A\\#f00#B\\#0f08#C\\#123456#D" + + "\\#010203#\\#abcdef80#E\\#1234#"); + text.updateLogicalState(0); + Mesh mesh = mesh(text, 0); + byte[] originalColors = colors(mesh); + assertColor(mesh, 0, 51, 102, 153, 204); + assertColor(mesh, 1, 255, 0, 0, 255); + assertColor(mesh, 2, 0, 255, 0, 136); + assertColor(mesh, 3, 18, 52, 86, 255); + assertColor(mesh, 4, 171, 205, 239, 128); + + text.setAlpha(0.25f); + text.updateLogicalState(0); + byte[] overriddenColors = colors(mesh); + for (int i = 0; i < overriddenColors.length; i++) { + int expected = i % 4 == 3 ? 63 : Byte.toUnsignedInt(originalColors[i]); + assertEquals(expected, Byte.toUnsignedInt(overriddenColors[i]), "color byte " + i); + } + + text.setAlpha(-1); + text.updateLogicalState(0); + assertEquals(-1f, text.getAlpha()); + assertArrayEquals(originalColors, colors(mesh)); + } + + @Test + public void resettingAlphaWithoutABaseColorRestoresOpaquePrefix() { + BitmapText text = assemble(createFont(1), "A\\#abcdef80#B"); + text.setAlpha(0.5f); + text.updateLogicalState(0); + assertColor(mesh(text, 0), 0, 255, 255, 255, 127); + assertColor(mesh(text, 0), 1, 171, 205, 239, 127); + text.setAlpha(-1); + text.updateLogicalState(0); + assertColor(mesh(text, 0), 0, 255, 255, 255, 255); + assertColor(mesh(text, 0), 1, 171, 205, 239, 128); + } + + @Test + public void emptyAndTagOnlyTextHaveEmptyBuffersOnEveryPage() { + BitmapText text = new BitmapText(createFont(2)); + for (String value : new String[]{"", "\\#123#\\#1234#\\#123456#\\#12345678#", "\n\n", null}) { + text.setText(value); + text.updateLogicalState(0); + for (int page = 0; page < 2; page++) { + assertBufferSizes(mesh(text, page), 0); + } + } + } + + @Test + public void reusedBuffersHandleNonemptyEmptyAndNonemptyText() { + BitmapFont font = createFont(2); + BitmapText text = assemble(font, distinctCharacters(100)); + for (String value : new String[]{"", "Z\\#ff000080#AB", distinctCharacters(100)}) { + text.setText(value); + text.updateLogicalState(0); + BitmapText fresh = assemble(font, value); + assertMeshEquals(fresh, text); + } + } + + @Test + public void alternatingFontPagesPreserveOrderAndUsePageLocalIndices() { + BitmapFont font = createFont(2); + String characters = distinctCharacters(100); + BitmapText text = assemble(font, characters); + int[] pageQuadCounts = new int[2]; + float x = 0; + for (int i = 0; i < characters.length(); i++) { + BitmapCharacter glyph = font.getCharSet().getCharacter(characters.charAt(i)); + int page = glyph.getPage(); + assertEquals(i % 2, page); + assertQuad(mesh(text, page), pageQuadCounts[page]++, glyph, x, -glyph.getYOffset()); + x += glyph.getXAdvance(); + } + assertBufferSizes(mesh(text, 0), 50); + assertBufferSizes(mesh(text, 1), 50); + + // Clearing one page must reset its counts even while another remains populated. + text.setText("A"); + text.updateLogicalState(0); + assertBufferSizes(mesh(text, 0), 0); + assertBufferSizes(mesh(text, 1), 1); + assertMeshEquals(assemble(font, "A"), text); + } + + @Test + public void legacyArrayFlagProducesIdenticalMeshesForBothDirections() { + for (boolean rightToLeft : new boolean[]{false, true}) { + BitmapFont font = createFont(2); + font.setRightToLeft(rightToLeft); + for (String value : new String[]{"", "ABC", "A\\#ff000080#B\nCD", "AB CD EF GH"}) { + BitmapText arrayBased = new BitmapText(font, rightToLeft, true); + BitmapText legacy = new BitmapText(font, rightToLeft, false); + BitmapText defaultConstructor = new BitmapText(font); + for (BitmapText text : new BitmapText[]{arrayBased, legacy, defaultConstructor}) { + text.setBox(new Rectangle(7, 101, 40, 120)); + text.setAlignment(Align.Center); + text.setVerticalAlignment(VAlign.Bottom); + text.setText(value); + text.updateLogicalState(0); + } + assertMeshEquals(arrayBased, legacy); + assertMeshEquals(arrayBased, defaultConstructor); + } + } + } + + @Test + public void tagsDoNotChangePlainMultilineOrWrappedAlignment() { + BitmapFont font = createFont(2); + String[] plain = {"ABCD", "AB\nCD", "AB CD EF GH"}; + String[] tagged = {"A\\#f00#B\\#0f08#CD\\#123#", + "A\\#f00#B\n\\#0f08#CD\\#123#", + "A\\#f00#B CD \\#0f08#EF GH\\#123#"}; + for (Align alignment : Align.values()) { + for (VAlign verticalAlignment : VAlign.values()) { + for (int i = 0; i < plain.length; i++) { + BitmapText expected = new BitmapText(font); + BitmapText actual = new BitmapText(font); + for (BitmapText text : new BitmapText[]{expected, actual}) { + text.setBox(new Rectangle(7, 101, i == 2 ? 40 : 100, 120)); + text.setAlignment(alignment); + text.setVerticalAlignment(verticalAlignment); + } + expected.setText(plain[i]); + actual.setText(tagged[i]); + expected.updateLogicalState(0); + actual.updateLogicalState(0); + assertLayoutEquals(expected, actual); + if (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) { + } + } +} From 6961525efa2aff8a79b5be9471b42e8001c0c658 Mon Sep 17 00:00:00 2001 From: Davis Rollman Date: Fri, 30 Oct 2026 16:10:28 -0700 Subject: [PATCH 3/4] Add JMH benchmark harness (#2778) * Add JMH benchmark harness * Add light sorting and filter rejection benchmarks --- gradle/libs.versions.toml | 3 + jme3-benchmark/build.gradle | 20 +++ .../bounding/BoundingVolumeBenchmark.java | 95 +++++++++++++ .../LightListFilteredUpdateBenchmark.java | 89 ++++++++++++ .../light/LightListMutationBenchmark.java | 131 ++++++++++++++++++ .../jme3/light/LightListSortBenchmark.java | 119 ++++++++++++++++ .../light/LightListSortOrderBenchmark.java | 105 ++++++++++++++ .../renderer/queue/GeometryListBenchmark.java | 86 ++++++++++++ settings.gradle | 18 ++- 9 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 jme3-benchmark/build.gradle create mode 100644 jme3-benchmark/src/main/java/com/jme3/bounding/BoundingVolumeBenchmark.java create mode 100644 jme3-benchmark/src/main/java/com/jme3/light/LightListFilteredUpdateBenchmark.java create mode 100644 jme3-benchmark/src/main/java/com/jme3/light/LightListMutationBenchmark.java create mode 100644 jme3-benchmark/src/main/java/com/jme3/light/LightListSortBenchmark.java create mode 100644 jme3-benchmark/src/main/java/com/jme3/light/LightListSortOrderBenchmark.java create mode 100644 jme3-benchmark/src/main/java/com/jme3/renderer/queue/GeometryListBenchmark.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a66235053..9d19304cb9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ checkstyle = "13.3.0" jacoco = "0.8.12" +jmh = "1.37" lwjgl3 = "3.4.3" angle = "2026-05-09" libjglios = "0.10" @@ -42,6 +43,8 @@ jbullet = "com.github.stephengold:jbullet:1.0.3" jinput = "net.java.jinput:jinput:2.0.9" jna = "net.java.dev.jna:jna:5.19.1" jnaerator-runtime = "com.nativelibs4java:jnaerator-runtime:0.12" +jmh-core = { module = "org.openjdk.jmh:jmh-core", version.ref = "jmh" } +jmh-generator-annprocess = { module = "org.openjdk.jmh:jmh-generator-annprocess", version.ref = "jmh" } junit-bom = "org.junit:junit-bom:6.1.3" junit4 = "junit:junit:4.13.2" junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } diff --git a/jme3-benchmark/build.gradle b/jme3-benchmark/build.gradle new file mode 100644 index 0000000000..ba969ca453 --- /dev/null +++ b/jme3-benchmark/build.gradle @@ -0,0 +1,20 @@ +dependencies { + implementation project(':jme3-core') + implementation libs.jmh.core + annotationProcessor libs.jmh.generator.annprocess +} + +tasks.register('jmh', JavaExec) { + dependsOn tasks.named('classes') + description = 'Run jME JMH benchmarks. Pass JMH options with -PjmhArgs="...".' + group = 'verification' + classpath = sourceSets.main.runtimeClasspath + mainClass = 'org.openjdk.jmh.Main' + + def rawArgs = providers.gradleProperty('jmhArgs').orElse('').map { it.trim() } + doFirst { + if (rawArgs.get()) { + args rawArgs.get().split(/\s+/) + } + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/bounding/BoundingVolumeBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/bounding/BoundingVolumeBenchmark.java new file mode 100644 index 0000000000..752a26b28e --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/bounding/BoundingVolumeBenchmark.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2009-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.bounding; + +import com.jme3.math.Vector3f; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +@State(Scope.Thread) +public class BoundingVolumeBenchmark { + + @Param({"inside", "outside"}) + public String pointLocation; + + private BoundingBox box; + private BoundingSphere sphere; + private Vector3f point; + + @Setup(Level.Trial) + public void setupTrial() { + box = new BoundingBox(new Vector3f(3f, -2f, 7f), 5f, 9f, 13f); + sphere = new BoundingSphere(11f, new Vector3f(3f, -2f, 7f)); + if ("inside".equals(pointLocation)) { + point = new Vector3f(4f, 0f, 9f); + } else { + point = new Vector3f(39f, -31f, 45f); + } + } + + @Benchmark + public void boxDistanceToEdge(Blackhole blackhole) { + blackhole.consume(box.distanceToEdge(point)); + } + + @Benchmark + public void boxIntersectsPoint(Blackhole blackhole) { + blackhole.consume(box.intersects(point)); + } + + @Benchmark + public void sphereDistanceToEdge(Blackhole blackhole) { + blackhole.consume(sphere.distanceToEdge(point)); + } + + @Benchmark + public void sphereIntersectsPoint(Blackhole blackhole) { + blackhole.consume(sphere.intersects(point)); + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/light/LightListFilteredUpdateBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/light/LightListFilteredUpdateBenchmark.java new file mode 100644 index 0000000000..0f699512b4 --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/light/LightListFilteredUpdateBenchmark.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2009-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 java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +public class LightListFilteredUpdateBenchmark { + private static final Predicate NON_GLOBAL = l -> !l.isGlobal(); + @Param({"8", "64", "256"}) + public int lightCount; + @Param({"none", "quarter", "all"}) + public String globalLights; + private LightList local; + private LightList parent; + private LightList world; + + @Setup(Level.Trial) + public void setup() { + Geometry owner = new Geometry("owner", new Mesh()); + local = new LightList(owner); + parent = new LightList(owner); + world = new LightList(owner); + for (int i = 0; i < lightCount; i++) { + boolean global = globalLights.equals("all") + || globalLights.equals("quarter") && i % 4 == 0; + local.add(global ? new DirectionalLight(Vector3f.UNIT_Y, true) + : new PointLight(Vector3f.ZERO)); + parent.add(new PointLight(Vector3f.ZERO)); + } + } + + @Benchmark + public void update(Blackhole blackhole) { + world.update(local, parent, NON_GLOBAL); + blackhole.consume(world.size()); + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/light/LightListMutationBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/light/LightListMutationBenchmark.java new file mode 100644 index 0000000000..ebee51108e --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/light/LightListMutationBenchmark.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2009-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 java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +@State(Scope.Thread) +public class LightListMutationBenchmark { + + private static final Predicate NON_GLOBAL_LIGHT = light -> !light.isGlobal(); + + @Param({"8", "64", "256"}) + public int lightCount; + + private Geometry owner; + private Light[] lights; + private LightList list; + private LightList local; + private LightList parent; + + @Setup(Level.Trial) + public void setupTrial() { + owner = new Geometry("owner", new Mesh()); + lights = new Light[lightCount * 2]; + for (int i = 0; i < lights.length; i++) { + lights[i] = i % 4 == 0 + ? new DirectionalLight(new Vector3f(i, i * 0.5f, -i)) + : new PointLight(new Vector3f(i, i * 0.5f, -i)); + } + list = new LightList(owner); + local = new LightList(owner); + parent = new LightList(owner); + for (int i = 0; i < lightCount; i++) { + local.add(lights[i]); + parent.add(lights[lightCount + i]); + } + } + + @Setup(Level.Invocation) + public void setupInvocation() { + list.clear(); + for (int i = 0; i < lightCount; i++) { + list.add(lights[i]); + } + } + + @Benchmark + public void removeFromFront(Blackhole blackhole) { + list.remove(0); + blackhole.consume(list.size()); + } + + @Benchmark + public void removeFromMiddle(Blackhole blackhole) { + list.remove(lightCount >>> 1); + blackhole.consume(list.size()); + } + + @Benchmark + public void removeFromEnd(Blackhole blackhole) { + list.remove(lightCount - 1); + blackhole.consume(list.size()); + } + + @Benchmark + public void updateFromLocalAndParent(Blackhole blackhole) { + list.update(local, parent); + blackhole.consume(list.size()); + } + + /** + * Matches {@code Spatial.updateWorldLightList()}, which filters global + * lights from the local list before appending the parent world list. + */ + @Benchmark + public void updateWithGlobalLightFilter(Blackhole blackhole) { + list.update(local, parent, NON_GLOBAL_LIGHT); + blackhole.consume(list.size()); + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/light/LightListSortBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/light/LightListSortBenchmark.java new file mode 100644 index 0000000000..e1b7ac342b --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/light/LightListSortBenchmark.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2009-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 java.util.Random; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +@State(Scope.Thread) +public class LightListSortBenchmark { + + @Param({"8", "64", "256"}) + public int lightCount; + + @Param({"1", "8", "1024"}) + public int retainedCapacityMultiplier; + + private Geometry owner; + private Light[] lights; + private LightList list; + private int invocation; + + @Setup(Level.Trial) + public void setupTrial() { + owner = new Geometry("owner", new Mesh()); + owner.setLocalTranslation(3f, -7f, 11f); + owner.updateGeometricState(); + + lights = new Light[lightCount]; + Random random = new Random(0x51A7E5L + lightCount); + for (int i = 0; i < lightCount; i++) { + switch (i & 3) { + case 0: + lights[i] = new AmbientLight(); + break; + case 1: + lights[i] = new DirectionalLight(new Vector3f(1f, -1f, 0.25f).normalizeLocal()); + break; + default: + lights[i] = new PointLight(new Vector3f( + random.nextFloat() * 200f - 100f, + random.nextFloat() * 200f - 100f, + random.nextFloat() * 200f - 100f)); + break; + } + } + + list = new LightList(owner); + int retainedCapacity = Math.max(lightCount, lightCount * retainedCapacityMultiplier); + for (int i = 0; i < retainedCapacity; i++) { + list.add(lights[i % lightCount]); + } + list.clear(); + } + + @Setup(Level.Invocation) + public void setupInvocation() { + list.clear(); + int offset = (invocation++ & Integer.MAX_VALUE) % lightCount; + for (int i = 0; i < lightCount; i++) { + list.add(lights[(i + offset) % lightCount]); + } + } + + @Benchmark + public void sortTransformChanged(Blackhole blackhole) { + list.sort(true); + blackhole.consume(list.get(lightCount - 1)); + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/light/LightListSortOrderBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/light/LightListSortOrderBenchmark.java new file mode 100644 index 0000000000..5a3d25d3d1 --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/light/LightListSortOrderBenchmark.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2009-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 java.util.Random; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +public class LightListSortOrderBenchmark { + @Param({"8", "32", "64", "256"}) + public int lightCount; + @Param({"sorted", "reverse", "random"}) + public String order; + private Light[] lights; + private LightList list; + + @Setup(Level.Trial) + public void setup() { + Geometry owner = new Geometry("owner", new Mesh()); + list = new LightList(owner); + lights = new Light[lightCount]; + for (int i = 0; i < lightCount; i++) { + lights[i] = new PointLight(new Vector3f(i + 1, 0, 0)); + } + if (order.equals("reverse")) { + for (int i = 0; i < lightCount / 2; i++) { + Light temp = lights[i]; + lights[i] = lights[lightCount - 1 - i]; + lights[lightCount - 1 - i] = temp; + } + } else if (order.equals("random")) { + Random random = new Random(721L + lightCount); + for (int i = lightCount - 1; i > 0; i--) { + int j = random.nextInt(i + 1); + Light temp = lights[i]; + lights[i] = lights[j]; + lights[j] = temp; + } + } + } + + @Setup(Level.Invocation) + public void refill() { + list.clear(); + for (Light light : lights) { + list.add(light); + } + } + + @Benchmark + public void sort(Blackhole blackhole) { + list.sort(true); + blackhole.consume(list.get(lightCount - 1)); + } +} diff --git a/jme3-benchmark/src/main/java/com/jme3/renderer/queue/GeometryListBenchmark.java b/jme3-benchmark/src/main/java/com/jme3/renderer/queue/GeometryListBenchmark.java new file mode 100644 index 0000000000..518b4cea2c --- /dev/null +++ b/jme3-benchmark/src/main/java/com/jme3/renderer/queue/GeometryListBenchmark.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2009-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.renderer.queue; + +import com.jme3.scene.Geometry; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Measurement(iterations = 5, time = 200, timeUnit = TimeUnit.MILLISECONDS) +@Fork(2) +@State(Scope.Thread) +public class GeometryListBenchmark { + + @Param({"8", "64", "256", "1024"}) + public int geometryCount; + + private Geometry[] geometries; + private GeometryList list; + + @Setup(Level.Trial) + public void setupTrial() { + geometries = new Geometry[geometryCount]; + for (int i = 0; i < geometryCount; i++) { + geometries[i] = new Geometry("geom-" + i); + } + list = new GeometryList(new NullComparator()); + } + + @Setup(Level.Invocation) + public void setupInvocation() { + list.clear(); + for (Geometry geometry : geometries) { + list.add(geometry); + } + } + + @Benchmark + public void clear(Blackhole blackhole) { + list.clear(); + blackhole.consume(list.size()); + } +} diff --git a/settings.gradle b/settings.gradle index 3f402b30c0..c3c8f5b764 100644 --- a/settings.gradle +++ b/settings.gradle @@ -26,7 +26,7 @@ def isAndroidExamplesTaskRequested = { || taskName.startsWith('jme3-android-examples:') } } -def isIosExamplesTaskRequested = { +def isIosExamplesTaskRequested = { gradle.startParameter.taskNames.any { taskName -> taskName == 'runIosExamples' || taskName == ':runIosExamples' @@ -34,7 +34,16 @@ def isIosExamplesTaskRequested = { || taskName.startsWith(':jme3-ios-examples:') || taskName.startsWith('jme3-ios-examples:') } -} +} +def isJmhTaskRequested = { + gradle.startParameter.taskNames.any { taskName -> + taskName == 'jmh' + || taskName == ':jmh' + || taskName == 'jme3-benchmark' + || taskName.startsWith(':jme3-benchmark:') + || taskName.startsWith('jme3-benchmark:') + } +} def findAndroidSdk = { def sdkDirs = [] @@ -82,7 +91,10 @@ def findAndroidSdk = { } // Core classes, should work on all java platforms -include 'jme3-core' +include 'jme3-core' +if (isJmhTaskRequested()) { + include 'jme3-benchmark' +} include 'jme3-effects' include 'jme3-networking' include 'jme3-plugins' From 175dc9b62178ad3c6eb31608b518c03b37020d03 Mon Sep 17 00:00:00 2001 From: Davis Rollman Date: Sat, 31 Oct 2026 14:13:47 -0400 Subject: [PATCH 4/4] Release LightList sorting scratch and bound filtered capacity --- .../main/java/com/jme3/light/LightList.java | 62 +++--- .../java/com/jme3/light/LightListTest.java | 188 ++++++++++++++++++ 2 files changed, 226 insertions(+), 24 deletions(-) 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 6838087ee1..2d331a3b39 100644 --- a/jme3-core/src/main/java/com/jme3/light/LightList.java +++ b/jme3-core/src/main/java/com/jme3/light/LightList.java @@ -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; } @@ -211,22 +208,25 @@ public void sort(boolean transformChanged) { // Distances were already updated before the NaN fallback. transformChanged = false; } - // 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, listSize); - } - 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); + } } } @@ -279,31 +279,45 @@ public void update(LightList local, LightList parent, Predicate filter) { // using the arguments clear(); - int requiredSize = local.listSize + (parent == null ? 0 : parent.listSize); - while (list.length < requiredSize) { - doubleSize(); - } - + int parentSize = parent == null ? 0 : parent.listSize; int localListSize; if (filter == null) { localListSize = local.listSize; + int requiredSize = localListSize + parentSize; + while (list.length < requiredSize) { + doubleSize(); + } System.arraycopy(local.list, 0, list, 0, localListSize); } else { localListSize = 0; - for(int i=0;i 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;