Skip to content

Commit df04135

Browse files
committed
added an experimental glyph atlas to render labels without the fx api
1 parent d94d114 commit df04135

4 files changed

Lines changed: 271 additions & 11 deletions

File tree

chartfx-chart/src/main/java/io/fair_acc/chartfx/axes/spi/AbstractAxis.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,7 +1061,7 @@ protected static void drawAxisLabel(final GraphicsContext gc, final double x, fi
10611061
}
10621062

10631063
gc.save();
1064-
gc.translate(x, y);
1064+
gc.translate(Math.round(x), Math.round(y)); // pixel alignment in case of texture rendering
10651065
label.copyStyleTo(gc);
10661066

10671067
// The text alignment is used for the location on the axis, but
@@ -1102,6 +1102,11 @@ protected static void drawTickMarkLabel(final GraphicsContext gc, Side side, fin
11021102
break;
11031103
}
11041104

1105+
// we want to align with the pixel boundary in case
1106+
// we render via textures
1107+
centerX = Math.round(centerX);
1108+
centerY = Math.round(centerY);
1109+
11051110
// translate before applying any rotation
11061111
gc.translate(centerX, centerY);
11071112

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package io.fair_acc.chartfx.axes.spi;
2+
3+
import io.fair_acc.chartfx.ui.css.StyleUtil;
4+
import io.fair_acc.chartfx.ui.css.TextStyle;
5+
import io.fair_acc.chartfx.ui.css.TextStyle.TextBounds;
6+
import io.fair_acc.chartfx.utils.PropUtil;
7+
import javafx.beans.binding.Bindings;
8+
import javafx.beans.binding.BooleanBinding;
9+
import javafx.beans.property.BooleanProperty;
10+
import javafx.beans.property.LongProperty;
11+
import javafx.beans.property.SimpleBooleanProperty;
12+
import javafx.beans.property.SimpleLongProperty;
13+
import javafx.css.StyleOrigin;
14+
import javafx.geometry.VPos;
15+
import javafx.scene.Scene;
16+
import javafx.scene.SnapshotParameters;
17+
import javafx.scene.canvas.Canvas;
18+
import javafx.scene.canvas.GraphicsContext;
19+
import javafx.scene.image.Image;
20+
import javafx.scene.image.ImageView;
21+
import javafx.scene.layout.VBox;
22+
import javafx.scene.paint.Color;
23+
import javafx.scene.text.TextAlignment;
24+
import javafx.scene.transform.Transform;
25+
import javafx.stage.Screen;
26+
import javafx.stage.Stage;
27+
28+
import java.util.Objects;
29+
30+
/**
31+
* Pre-determined sizes and images for rendering text
32+
*
33+
* @author ennerf
34+
*/
35+
public class GlyphAtlas {
36+
37+
public GlyphAtlas(TextStyle style) {
38+
this.style = style;
39+
PropUtil.initAndRunOnChange(() -> needsScaling.set(style.getRotate() % 90 != 0), style.rotateProperty());
40+
var listener = StyleUtil.incrementOnChange(invalidCounter);
41+
listener.accept(needsScaling);
42+
listener.accept(style.fontProperty());
43+
listener.accept(style.opacityProperty());
44+
listener.accept(style.fillProperty());
45+
listener.accept(style.strokeProperty());
46+
listener.accept(style.strokeWidthProperty());
47+
listener.accept(style.fontSmoothingTypeProperty());
48+
PropUtil.initAndRunOnChange(() -> valid = false, invalidCounter);
49+
}
50+
51+
protected static class GlyphRegion {
52+
double atlasX, atlasY, atlasW, atlasH;
53+
double visualWidth, visualHeight;
54+
}
55+
56+
/**
57+
* @return false if the input can't be mapped
58+
*/
59+
public boolean computeLayoutBounds(CharSequence chars, TextBounds result) {
60+
// Check for various features that are currently not supported
61+
ensureValid();
62+
double height = 0;
63+
double width = 0;
64+
final int n = chars.length();
65+
for (int i = 0; i < n; i++) {
66+
final char c = chars.charAt(i);
67+
if (c < MIN_CHAR || c > MAX_CHAR) {
68+
return false;
69+
}
70+
final var glyph = glyphMap[c];
71+
height = Math.max(height, glyph.visualHeight);
72+
width += glyph.visualWidth;
73+
}
74+
result.set(width, height);
75+
return true;
76+
}
77+
78+
public boolean tryFillText(GraphicsContext gc, CharSequence text, double x, double y) {
79+
if (!computeLayoutBounds(text, bounds)) {
80+
return false;
81+
}
82+
83+
final boolean smoothing = gc.isImageSmoothing();
84+
gc.setImageSmoothing(style.getRotate() % 90 != 0);
85+
86+
// coordinates are at the center, but we write from the left
87+
double curX = x - bounds.getWidth() / 2;
88+
double curY = y - bounds.getHeight() / 2;
89+
final int n = text.length();
90+
for (int i = 0; i < n; i++) {
91+
final char c = text.charAt(i);
92+
final var glyph = glyphMap[c];
93+
94+
// Note: the starting pixels need to be at a pixel boundary,
95+
// and it is important that source and destination have the
96+
// same exact size. Not applying rounding to the width/height
97+
// should work for both scaled and unscaled cases as the ratio
98+
// remains the same.
99+
gc.drawImage(atlas,
100+
glyph.atlasX, glyph.atlasY,
101+
glyph.atlasW, glyph.atlasH,
102+
Math.ceil(curX), Math.round(curY),
103+
glyph.visualWidth, glyph.visualHeight
104+
);
105+
curX += glyph.visualWidth;
106+
}
107+
108+
gc.setImageSmoothing(smoothing);
109+
return true;
110+
}
111+
112+
protected void ensureValid() {
113+
if (valid) return;
114+
String prevText = style.getText();
115+
try {
116+
bake();
117+
} finally {
118+
style.setText(prevText);
119+
}
120+
valid = true;
121+
}
122+
123+
protected void bake() {
124+
double currentX = minPadding;
125+
double maxHeight = 0;
126+
127+
// A 1 to 1 pixel mapping is best, but we need more
128+
// resolution for subpixel operations
129+
final double scale = (screenScale == 1 && style.getRotate() == 0) ? 1 : 2 * screenScale;
130+
131+
// Determine char sizes
132+
for (char c = MIN_CHAR; c <= MAX_CHAR; c++) {
133+
currentX = padToAlignment(currentX);
134+
135+
style.setText(String.valueOf(c));
136+
var bounds = style.getLayoutBounds();
137+
var pxWidth = Math.ceil(bounds.getWidth());
138+
var pxHeight = Math.ceil(bounds.getHeight());
139+
maxHeight = Math.max(maxHeight, pxHeight);
140+
141+
// Preliminary coordinates
142+
var coords = new GlyphRegion();
143+
coords.atlasX = currentX;
144+
coords.atlasY = minPadding;
145+
coords.atlasW = bounds.getWidth();
146+
coords.atlasH = bounds.getHeight();
147+
coords.visualWidth = bounds.getWidth();
148+
coords.visualHeight = bounds.getHeight();
149+
glyphMap[c] = coords;
150+
151+
// leave one letter distance
152+
currentX += Math.max(minPadding, pxWidth);
153+
currentX = padToAlignment(currentX);
154+
}
155+
double height = padToAlignment(2 * minPadding + maxHeight);
156+
157+
// Create atlas
158+
var canvas = new Canvas(scale * currentX, scale * height);
159+
var gc = canvas.getGraphicsContext2D();
160+
style.copyStyleTo(gc);
161+
gc.setTextAlign(TextAlignment.RIGHT);
162+
gc.scale(scale, scale); // better quality than scaling the snapshot
163+
gc.setTextAlign(TextAlignment.LEFT);
164+
gc.setTextBaseline(VPos.TOP);
165+
166+
for (char c = MIN_CHAR; c <= MAX_CHAR; c++) {
167+
var coords = glyphMap[c];
168+
gc.fillText(String.valueOf(c), coords.atlasX, coords.atlasY);
169+
if (!Objects.equals(style.getStroke(), Color.TRANSPARENT)) {
170+
// Note: might look odd, but enable at will
171+
gc.strokeText(String.valueOf(c), coords.atlasX, coords.atlasY);
172+
}
173+
coords.atlasX = Math.floor(coords.atlasX * scale);
174+
coords.atlasY = Math.floor(coords.atlasY * scale);
175+
coords.atlasW = coords.atlasW * scale;
176+
coords.atlasH = coords.atlasH * scale;
177+
}
178+
179+
SnapshotParameters params = new SnapshotParameters();
180+
params.setFill(Color.TRANSPARENT);
181+
atlas = canvas.snapshot(params, null);
182+
183+
if (showDebug) {
184+
185+
var showCanvas = new Stage();
186+
showCanvas.setScene(new Scene(new VBox(canvas)));
187+
showCanvas.setTitle(canvas.getWidth() + "x" + canvas.getHeight());
188+
showCanvas.show();
189+
190+
var showImage = new Stage();
191+
showImage.setScene(new Scene(new VBox(new ImageView(atlas))));
192+
showImage.setTitle(atlas.getWidth() + "x" + atlas.getHeight());
193+
showImage.show();
194+
195+
showDebug = false;
196+
}
197+
198+
}
199+
200+
private static boolean showDebug = false;
201+
202+
protected double padToAlignment(double currentX) {
203+
return Math.ceil(currentX / alignment) * alignment;
204+
}
205+
206+
protected final TextStyle style;
207+
protected boolean valid = false;
208+
209+
private Image atlas;
210+
private final GlyphRegion[] glyphMap = new GlyphRegion[128];
211+
private final TextBounds bounds = new TextBounds();
212+
private static final int MIN_CHAR = 32;
213+
private static final int MAX_CHAR = 126;
214+
private final double screenScale = Screen.getPrimary().getOutputScaleX();
215+
private final double minPadding = 8;
216+
private static final double alignment = 8;
217+
private final BooleanProperty needsScaling = new SimpleBooleanProperty(this, "needsScaling", false);
218+
private final LongProperty invalidCounter = new SimpleLongProperty(this, "invalidCounter", 0);
219+
220+
}

chartfx-chart/src/main/java/io/fair_acc/chartfx/ui/css/StyleUtil.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ private static boolean removeEndIf(StringBuilder builder, String end) {
181181
return true;
182182
}
183183

184-
static Consumer<ObservableValue<?>> incrementOnChange(LongProperty counter) {
184+
public static Consumer<ObservableValue<?>> incrementOnChange(LongProperty counter) {
185185
ChangeListener<Object> listener = (obs, old, value) -> counter.set(counter.get() + 1);
186186
return prop -> prop.addListener(listener);
187187
}

chartfx-chart/src/main/java/io/fair_acc/chartfx/ui/css/TextStyle.java

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
package io.fair_acc.chartfx.ui.css;
22

3+
import io.fair_acc.chartfx.axes.spi.GlyphAtlas;
34
import io.fair_acc.chartfx.fxinternals.FxFontMetrics;
45
import io.fair_acc.chartfx.utils.PropUtil;
5-
import javafx.beans.property.LongProperty;
6-
import javafx.beans.property.ReadOnlyLongProperty;
7-
import javafx.beans.property.SimpleLongProperty;
6+
import javafx.beans.property.*;
87
import javafx.scene.canvas.GraphicsContext;
98
import javafx.scene.paint.Color;
109
import javafx.scene.text.Text;
@@ -18,7 +17,9 @@ public class TextStyle extends Text implements StyleUtil.StyleNode {
1817

1918
public TextStyle(String... styles) {
2019
StyleUtil.styleNode(this, styles);
21-
StyleUtil.forEachStyleProp(this, StyleUtil.incrementOnChange(changeCounter));
20+
var incrementCounter = StyleUtil.incrementOnChange(changeCounter);
21+
StyleUtil.forEachStyleProp(this, incrementCounter);
22+
incrementCounter.accept(glyphAtlasEnabled);
2223
changeCounter.addListener(observable -> boundsValid = false);
2324
textProperty().addListener(observable -> boundsValid = false);
2425
}
@@ -70,7 +71,10 @@ public TextBounds computeTextBounds(CharSequence chars, TextBounds result) {
7071
// tests the diff was generally within 1px, so this should not matter in practice.
7172
var text = result.toString();
7273
final double w, h;
73-
if (FxFontMetrics.isAvailable()) {
74+
if (isGlyphAtlasEnabled() && getGlyphAtlas().computeLayoutBounds(chars, result)) {
75+
w = result.getWidth();
76+
h = result.getHeight();
77+
} else if (FxFontMetrics.isAvailable()) {
7478
h = FxFontMetrics.getLineHeight(getFont()) * countLines(chars);
7579
w = FxFontMetrics.getWidth(getFont(), chars);
7680
} else {
@@ -125,15 +129,25 @@ public void renderText(GraphicsContext gc, CharSequence text) {
125129
gc.rotate(getRotate());
126130
}
127131

132+
renderTextRotated(gc, text);
133+
134+
if (getRotate() != 0) {
135+
gc.rotate(-getRotate());
136+
}
137+
}
138+
139+
private void renderTextRotated(GraphicsContext gc, CharSequence text) {
140+
// Optional fast path using copied images
141+
if (isGlyphAtlasEnabled() && getGlyphAtlas().tryFillText(gc, text, 0, 0)) {
142+
return;
143+
}
144+
145+
// Fallback using standard text rendering
128146
String string = text.toString();
129147
gc.fillText(string, 0, 0);
130148
if (!Objects.equals(gc.getStroke(), Color.TRANSPARENT)) {
131149
gc.strokeText(string, 0, 0);
132150
}
133-
134-
if (getRotate() != 0) {
135-
gc.rotate(-getRotate());
136-
}
137151
}
138152

139153
/**
@@ -169,5 +183,26 @@ public ReadOnlyLongProperty changeCounterProperty() {
169183
return changeCounter;
170184
}
171185

186+
protected GlyphAtlas getGlyphAtlas() {
187+
if(glyphAtlas == null) {
188+
glyphAtlas = new GlyphAtlas(this);
189+
}
190+
return glyphAtlas;
191+
}
192+
193+
public boolean isGlyphAtlasEnabled() {
194+
return glyphAtlasEnabled.get();
195+
}
196+
197+
public void setGlyphAtlasEnabled(boolean glyphAtlasEnabled) {
198+
this.glyphAtlasEnabled.set(glyphAtlasEnabled);
199+
}
200+
201+
public BooleanProperty glyphAtlasEnabledProperty() {
202+
return glyphAtlasEnabled;
203+
}
204+
172205
private final LongProperty changeCounter = new SimpleLongProperty(0);
206+
protected final BooleanProperty glyphAtlasEnabled = new SimpleBooleanProperty(false);
207+
protected GlyphAtlas glyphAtlas;
173208
}

0 commit comments

Comments
 (0)