Why is javafx mangling my semi-transparent cursors?
Below are two PNG images:
Visually they are exactly identical - the only difference is that one has the semi-transparent background in some of the pixels (you can download the images to check it).
But when I use those images as an image cursor on JavaFX nodes, I get the following result:
First cursor (without partially transparent pixels) is still crisp, but the second gets distorted.
After fighting with the problem for a while, I discovered the algorithm that accounts for this difference - blending mode:
"Expected" way (that you can see in this browser, for example) is to take the sum of values per channel, weighted by alpha values:
(1 - alpha) * background_color + alpha * foreground_color
."JavaFX Cursor" gives the different formula:
(1 - alpha) * background_color + alpha^2 * foreground_color
(note the square).
I discovered the distortion, but I can't figure out what I did wrong and how I can correct this problem.
Here's the complete runnable source code for my testing program:
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
import javafx.scene.ImageCursor;
import javafx.scene.image.Image;
public class HelloWorld extends Application {
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage primaryStage) {
System.out.println(ImageCursor.getBestSize(32, 32));
primaryStage.setTitle("Hello World!");
StackPane root = new StackPane();
root.setCursor(new ImageCursor(new Image("/test-cursor.png"), 0, 0));
primaryStage.setScene(new Scene(root, 100, 100));
primaryStage.show();
}
}
How can I achieve proper rendering of such semi-transparent cursors?
UPDATE: Upon deeper inspection it seems that JavaFX is not at fault - the fault seems to be in video driver implementations. Code below does work on some combinations of hardware, drivers and OSes - but not on all of them.
Unfortunately it seems that for now the best solution is to avoid cursors that have partially-transparent white or gray pixels. Partially-transparent black pixels are fine, though.
I found a way to work around the problem (tested on JDK 8 and Linux&Windows). It's ugly and requires reflection, but seems to work. Code below (in Scala syntax, but can easily be adapted to Java):
import com.sun.prism.PixelFormat
import javafx.scene.ImageCursor
import javafx.scene.image.{Image, WritableImage}
private def undoPremultipliedAlpha(image: Image): Image = {
// Fixes JavaFX bug with semi-transparent cursors -
// somewhere deep in JavaFX code they premultiply alpha
// on already premultiplied image, which screws up transparencies.
// This method attempts to counteract it by removing premultiplied alpha
// directly from bytes of internal JavaFX image.
def getPlatformImage(image: Image) = image.impl_getPlatformImage()
val platformImage = getPlatformImage(image)
val pixelFormat = platformImage.getClass.getDeclaredMethod("getPixelFormat").invoke(platformImage).asInstanceOf[PixelFormat]
if (pixelFormat != PixelFormat.BYTE_BGRA_PRE) {
println(s"wrong platform image pixel format (${pixelFormat}), unable to apply cursor transparency bug workaround")
} else {
val pixelBufferField = platformImage.getClass.getDeclaredField("pixelBuffer")
pixelBufferField.setAccessible(true)
val pixelBuffer = pixelBufferField.get(platformImage).asInstanceOf[java.nio.Buffer]
val pixelArray = pixelBuffer.array().asInstanceOf[Array[Byte]]
for (i <- 0 until pixelArray.length / 4) {
val alpha = (pixelArray(i * 4 + 3).toInt & 0xff) / 255.0
if (alpha != 0) {
pixelArray(i * 4) = math.min(255, math.max(0, ((pixelArray(i * 4).toInt & 0xff).toDouble / alpha))).toInt.toByte
pixelArray(i * 4 + 1) = math.min(255, math.max(0, ((pixelArray(i * 4 + 1).toInt & 0xff).toDouble / alpha))).toInt.toByte
pixelArray(i * 4 + 2) = math.min(255, math.max(0, ((pixelArray(i * 4 + 2).toInt & 0xff).toDouble / alpha))).toInt.toByte
}
}
}
image
}
def createImageCursor(resource: String, hotspotX: Int, hotspotY: Int): ImageCursor = {
new ImageCursor(
undoPremultipliedAlpha(
new Image(resource)),
hotspotX,
hotspotY
)
}