How to optimize rendering performance when drawing a large number of animated shapes on a JavaFX canvas

95 views Asked by At

I'm using Javafx to achieve the same effect in an HTML5 demo, but with Javafx the frame rate is very low

how do I optimize it, am I writing it incorrectly or is there another better way to achieve it.

this is the original html5 demo project:

<!doctype html>
<html>
<head>
    <meta charset="utf-8">
    <title>snows</title>
    <script type="text/javascript" src="js/jquery.min.js"></script>
    <style>
        html,
        body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            overflow: hidden;
        }

        .container {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="jsi-snow-container" class="container"></div>

    <script>
        var RENDERER = {
            SNOW_COUNT: { INIT: 100, DELTA: 1 },
            BACKGROUND_COLOR: 'hsl(%h, 50%, %l%)',
            INIT_HUE: 180,
            DELTA_HUE: 0.1,
            init: function () {
                this.setParameters();
                this.reconstructMethod();
                this.createSnow(this.SNOW_COUNT.INIT * this.countRate, true);
                this.render();
            },
            setParameters: function () {
                this.$window = $(window);
                this.$container = $('#jsi-snow-container');
                this.width = this.$container.width();
                this.height = this.$container.height();
                this.center = { x: this.width / 2, y: this.height / 2 };
                this.countRate = this.width * this.height / 500 / 500;
                this.canvas = $('<canvas />').attr({ width: this.width, height: this.height }).appendTo(this.$container).get(0);
                this.context = this.canvas.getContext('2d');
                this.radius = Math.sqrt(this.center.x * this.center.x + this.center.y * this.center.y);
                this.hue = this.INIT_HUE;
                this.snows = [];
            },
            reconstructMethod: function () {
                this.render = this.render.bind(this);
            },
            createSnow: function (count, toRandomize) {
                for (var i = 0; i < count; i++) {
                    this.snows.push(new SNOW(this.width, this.height, this.center, toRandomize));
                }
            },
            render: function () {
                requestAnimationFrame(this.render);
                var gradient = this.context.createRadialGradient(this.center.x, this.center.y, 0, this.center.x, this.center.y, this.radius),
                    backgroundColor = this.BACKGROUND_COLOR.replace('%h', this.hue);
                gradient.addColorStop(0, backgroundColor.replace('%l', 30));
                gradient.addColorStop(0.2, backgroundColor.replace('%l', 20));
                gradient.addColorStop(1, backgroundColor.replace('%l', 5));
                this.context.fillStyle = gradient;
                this.context.fillRect(0, 0, this.width, this.height);
                for (var i = this.snows.length - 1; i >= 0; i--) {
                    if (!this.snows[i].render(this.context)) {
                        this.snows.splice(i, 1);
                    }
                }

                this.hue += this.DELTA_HUE;
                this.hue %= 360;
                this.createSnow(this.SNOW_COUNT.DELTA, false);
            }
        };
        var SNOW = function (width, height, center, toRandomize) {
            this.width = width;
            this.height = height;
            this.center = center;
            this.init(toRandomize);
        };
        SNOW.prototype = {
            RADIUS: 20,
            OFFSET: 4,
            INIT_POSITION_MARGIN: 20,
            COLOR: 'rgba(255, 255, 255, 0.8)',
            TOP_RADIUS: { MIN: 1, MAX: 3 },
            SCALE: { INIT: 0.04, DELTA: 0.01 },
            DELTA_ROTATE: { MIN: -Math.PI / 180 / 2, MAX: Math.PI / 180 / 2 },
            THRESHOLD_TRANSPARENCY: 0.7,
            VELOCITY: { MIN: -1, MAX: 1 },
            LINE_WIDTH: 2,
            BLUR: 10,
            init: function (toRandomize) {
                this.setParameters(toRandomize);
                this.createSnow();
            },
            setParameters: function (toRandomize) {
                if (!this.canvas) {
                    this.radius = this.RADIUS + this.TOP_RADIUS.MAX * 2 + this.LINE_WIDTH;
                    this.length = this.radius * 2;
                    this.canvas = $('<canvas />').attr({
                        width: this.length, height: this.length
                    }).get(0);
                    this.context = this.canvas.getContext('2d');
                }
                this.topRadius = this.getRandomValue(this.TOP_RADIUS);
                var theta = Math.PI * 2 * Math.random();
                this.x = this.center.x + this.INIT_POSITION_MARGIN * Math.cos(theta);
                this.y = this.center.y + this.INIT_POSITION_MARGIN * Math.sin(theta);
                this.vx = this.getRandomValue(this.VELOCITY);
                this.vy = this.getRandomValue(this.VELOCITY);
                this.deltaRotate = this.getRandomValue(this.DELTA_ROTATE);
                this.scale = this.SCALE.INIT;
                this.deltaScale = 1 + this.SCALE.DELTA * 500 / Math.max(this.width, this.height);
                this.rotate = 0;
                if (toRandomize) {
                    for (var i = 0, count = Math.random() * 1000; i < count; i++) {
                        this.x += this.vx;
                        this.y += this.vy;
                        this.scale *= this.deltaScale;
                        this.rotate += this.deltaRotate;
                    }
                }
            },
            getRandomValue: function (range) {
                return range.MIN + (range.MAX - range.MIN) * Math.random();
            },
            createSnow: function () {
                this.context.clearRect(0, 0, this.length, this.length);
                this.context.save();
                this.context.beginPath();
                this.context.translate(this.radius, this.radius);
                this.context.strokeStyle = this.COLOR;
                this.context.lineWidth = this.LINE_WIDTH;
                this.context.shadowColor = this.COLOR;
                this.context.shadowBlur = this.BLUR;
                var angle60 = Math.PI / 180 * 60,
                    sin60 = Math.sin(angle60),
                    cos60 = Math.cos(angle60),
                    threshold = Math.random() * this.RADIUS / this.OFFSET | 0,
                    rate = 0.5 + Math.random() * 0.5,
                    offsetY = this.OFFSET * Math.random() * 2,
                    offsetCount = this.RADIUS / this.OFFSET;
                for (var i = 0; i < 6; i++) {
                    this.context.save();
                    this.context.rotate(angle60 * i);
                    for (var j = 0; j <= threshold; j++) {
                        var y = -this.OFFSET * j;
                        this.context.moveTo(0, y);
                        this.context.lineTo(y * sin60, y * cos60);
                    }
                    for (var j = threshold; j < offsetCount; j++) {
                        var y = -this.OFFSET * j,
                            x = j * (offsetCount - j + 1) * rate;
                        this.context.moveTo(x, y - offsetY);
                        this.context.lineTo(0, y);
                        this.context.lineTo(-x, y - offsetY);
                    }
                    this.context.moveTo(0, 0);
                    this.context.lineTo(0, -this.RADIUS);
                    this.context.arc(0, -this.RADIUS - this.topRadius, this.topRadius, Math.PI /
                        2, Math.PI * 2.5, false);
                    this.context.restore();
                }
                this.context.stroke();
                this.context.restore();
            },
            render: function (context) {
                context.save();
                if (this.scale > this.THRESHOLD_TRANSPARENCY) {
                    context.globalAlpha = Math.max(0, (1 - this.scale) / (1 - this.THRESHOLD_TRANSPARENCY));
                    if (this.scale > 1 || this.x < -this.radius || this.x > this.width + this.radius || this.y < -this.radius || this.y > this.height + this.radius) {
                        context.restore();
                        return false;
                    }
                }
                context.translate(this.x, this.y);
                context.rotate(this.rotate);
                context.scale(this.scale, this.scale);
                context.drawImage(this.canvas, -this.radius, -this.radius);
                context.restore();
                this.x += this.vx;
                this.y += this.vy;
                this.scale *= this.deltaScale;
                this.rotate += this.deltaRotate;
                return true;
            }
        };
        $(function () {
            RENDERER.init();
        });
    </script>
</body>

</html>

and then this is what I achieved with javafx:

the java main class:

package fx.demo;

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.CacheHint;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.RadialGradient;
import javafx.scene.paint.Stop;
import javafx.stage.Stage;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class CanvasTest4 extends Application {
    private static final int SNOW_COUNT_INT = 15;
    private static final int SNOW_COUNT_DELTA = 1;
    private static final String BACKGROUND_COLOR = "hsl(%h, 50%, %l%)";
    private static final double INIT_HUE = 180;
    private static final double DELTA_HUE = 0.1;

    private final double width = 1920;
    private final double height = 911;
    private final double centerX = width / 2.0;
    private final double centerY = height / 2.0;
    private final int countRate = (int) (width * height / 500 / 500);
    private final double radius = Math.sqrt(centerX * centerX + centerY * centerX);
    private double hue = INIT_HUE;
    private final List<Snow> snows = new ArrayList<>();

    private long lastUpdate;

    @Override
    public void start(Stage primaryStage) {
        Group root = new Group();
        Canvas canvas = new Canvas(width, height);
        // try to use cache
        canvas.setCache(true);
        canvas.setCacheHint(CacheHint.SPEED);
        GraphicsContext gc = canvas.getGraphicsContext2D();

        long begin = System.currentTimeMillis();
        createSnow(SNOW_COUNT_INT * countRate, width, height, centerX, centerY, true);
        System.out.printf("initial: %sms%n", (System.currentTimeMillis() - begin));
        AnimationTimer animationTimer = new AnimationTimer() {
            @Override
            public void handle(long now) {
                System.out.printf("frame duration: %sms%n", (now - lastUpdate) / 1_000_000.0);
                lastUpdate = now;

                long start = System.currentTimeMillis();
                // draw background
                drawBackground(gc);
                System.out.printf("draw bg: %sms%n", (System.currentTimeMillis() - start));

                long l = System.currentTimeMillis();
                // draw snows
                snows.removeIf(snow -> !snow.render(gc));
                System.out.printf("draw snows: %sms%n", (System.currentTimeMillis() - l));

                // limit the number
                if (snows.size() < SNOW_COUNT_INT * countRate) {
                    createSnow(SNOW_COUNT_DELTA, width, height, centerX, centerY, false);
                }

                System.out.printf("total time: %sms%n", (System.currentTimeMillis() - start));
                System.out.println("snows: " + snows.size());
                System.out.println("-------------------------");
            }
        };
        animationTimer.start();

        root.getChildren().addAll(canvas);
        primaryStage.setScene(new Scene(root));
        primaryStage.setWidth(width);
        primaryStage.setHeight(height);
        primaryStage.show();
    }

    private void drawBackground(GraphicsContext gc) {
        String background = BACKGROUND_COLOR.replace("%h", String.valueOf(hue));
        List<Stop> stops = Arrays.asList(
                new Stop(0, Color.web(background.replace("%l", "30"))),
                new Stop(0.2, Color.web(background.replace("%l", "20"))),
                new Stop(1, Color.web(background.replace("%l", "5")))
        );
        RadialGradient radialGradient = new RadialGradient(0, 0, centerX, centerY, radius, false, CycleMethod.NO_CYCLE, stops);
        gc.setFill(radialGradient);
        gc.fillRect(0, 0, width, height);

        hue += DELTA_HUE;
        hue %= 360;
    }

    private void createSnow(int count, double width, double height, double centerX, double centerY, boolean toRandomize) {
        for (int i = 0; i < count; i++) {
            Snow snow = new Snow(width, height, centerX, centerY, toRandomize);
            snows.add(snow);
        }
    }
}

the Snow class (the shaps which will render on canvas):

package fx.demo;

import javafx.scene.canvas.GraphicsContext;
import javafx.scene.effect.DropShadow;
import javafx.scene.paint.Color;

public class Snow {
    private static final double RADIUS = 20;
    private static final double OFFSET = 4;
    private static final double INIT_POSITION_MARGIN = 20;
    private static final Color COLOR = Color.web("rgba(255, 255, 255, 0.8)");
    private static final double TOP_RADIUS_MIN = 1;
    private static final double TOP_RADIUS_MAX = 3;
    private static final double SCALE_INIT = 0.04;
    private static final double SCALE_DELTA = 0.01;
    private static final double DELTA_ROTATE_MIN = -Math.PI / 180 / 2;
    private static final double DELTA_ROTATE_MAX = Math.PI / 180 / 2;
    private static final double THRESHOLD_TRANSPARENCY = 0.7;
    private static final double VELOCITY_MIN = -1;
    private static final double VELOCITY_MAX = 1;
    private static final double LINE_WIDTH = 2;
    private static final double BLUR = 10;

    private double length;
    private final double width;
    private final double height;
    private final double centerX;
    private final double centerY;
    private final boolean toRandomize;
    private double radius;
    private double topRadius;
    private double x;
    private double y;
    private double vx;
    private double vy;
    private double deltaRotate;
    private double scale;
    private double deltaScale;
    private double rotate;

    private double sin60;
    private double cos60;
    private double rate;
    private double offsetY;
    private double offsetCount;
    private int threshold;

    public Snow(double width, double height, double centerX, double centerY, boolean toRandomize) {
        this.width = width;
        this.height = height;
        this.centerX = centerX;
        this.centerY = centerY;
        this.toRandomize = toRandomize;

        init();
    }

    private void init() {
        this.radius = RADIUS + TOP_RADIUS_MAX * 2 + LINE_WIDTH;
        this.length = this.radius * 2;

        this.topRadius = getRandomValue(TOP_RADIUS_MIN, TOP_RADIUS_MAX);
        double theta = Math.PI * 2 * Math.random();
        this.x = centerX + INIT_POSITION_MARGIN * Math.cos(theta);
        this.y = centerY + INIT_POSITION_MARGIN * Math.sin(theta);
        this.vx = getRandomValue(VELOCITY_MIN, VELOCITY_MAX);
        this.vy = getRandomValue(VELOCITY_MIN, VELOCITY_MAX);
        this.deltaRotate = getRandomValue(DELTA_ROTATE_MIN, DELTA_ROTATE_MAX);
        this.scale = SCALE_INIT;
        this.deltaScale = 1 + SCALE_DELTA * 500 / Math.max(this.width, this.height);
        this.rotate = 0;
        double angle60 = Math.PI / 180 * 60;
        this.sin60 = Math.sin(angle60);
        this.cos60 = Math.cos(angle60);
        this.threshold = (int) (Math.random() * RADIUS / OFFSET);
        this.rate = 0.5 + Math.random() * 0.5;
        this.offsetY = OFFSET * Math.random() * 2;
        this.offsetCount = RADIUS / OFFSET;

        if (toRandomize) {
            for (int i = 0, count = (int) (Math.random() * 1000); i < count; i++) {
                this.x += this.vx;
                this.y += this.vy;
                this.scale *= this.deltaScale;
                this.rotate += this.deltaRotate;
            }
        }
    }

    public boolean render(GraphicsContext gc) {
        gc.save();
        if (this.scale > THRESHOLD_TRANSPARENCY) {
            gc.setGlobalAlpha(Math.max(0, (1 - this.scale) / (1 - THRESHOLD_TRANSPARENCY)));
            if (this.scale > 1 || this.x < -this.radius || this.x > this.width + this.radius ||
                    this.y < -this.radius || this.y > this.height + this.radius) {
                gc.restore();
                // invisible
                return false;
            }
        }

        gc.beginPath();
        gc.translate(x, y);
        gc.rotate(rotate);
        gc.scale(scale, scale);

        gc.setStroke(COLOR);
        gc.setLineWidth(LINE_WIDTH);

        DropShadow dropShadow = new DropShadow();
        dropShadow.setColor(COLOR);
        dropShadow.setRadius(BLUR);
        gc.setEffect(dropShadow);

        for (int i = 0; i < 6; i++) {
            gc.save();
            gc.rotate(60 * i);

            for (int j = 0; j <= threshold; j++) {
                double y = -4 * j;
                gc.moveTo(0, y);
                gc.lineTo(y * sin60, y * cos60);
            }
            for (int j = threshold; j < offsetCount; j++) {
                double y = -4 * j,
                        x = j * (offsetCount - j + 1) * rate;
                gc.moveTo(x, y - offsetY);
                gc.lineTo(0, y);
                gc.lineTo(-x, y - offsetY);
            }

            gc.moveTo(0, 0);
            gc.lineTo(0, -RADIUS);
            gc.arc(0, -RADIUS - this.topRadius, this.topRadius, this.topRadius, 0, 360);

            gc.restore();
        }
        gc.stroke();
        gc.restore();

        this.x += this.vx;
        this.y += this.vy;
        this.scale *= this.deltaScale;
        // origin
        this.rotate += this.deltaRotate;
        // too slowly,let it speed
        this.rotate += this.deltaRotate + this.deltaRotate > 0 ? 0.2 : -0.2;

        return true;
    }

    private double getRandomValue(double rangeMin, double rangeMax) {
        return rangeMin + (rangeMax - rangeMin) * Math.random();
    }
}

Thank you so much for your help.

1

There are 1 answers

0
trashgod On

Summarizing the helpful comments and offering a few additional suggestions, several activities are common in this effort:

Isolate: Proverbially, well begun is half done, and your complete example allows the problem to be reproduced and studied in isolation.

Target: Devote some attention to identifying minimum platform capabilities that you intend to support. Then verify that optimizations scale up as desired.

Profile: General profiling tools can help spot problems. In the particular case of JavaFX, as @jewelsea suggests here and here, you can enable the JavaFX Pulse Logger. Extant in Java 8, it was restored in recent versions. Enable it as a java system property at run-time:

-Djavafx.pulseLogger=true

You should see consecutively numbered records with details about any painting that took longer than a single pulse.

…
PULSE: 33 [16ms:20ms]
…
T16 (1 +2ms): Waiting for previous rendering
…

As run-time optimization evolves, you should see a transition to records showing timely painting.

[34 17ms:17ms]
[35 16ms:15ms]
[36 16ms:13ms]
[37 16ms:13ms]
[38 16ms:12ms]
[39 16ms:12ms]
[40 16ms:12ms]
[41 16ms:14ms]
[42 16ms:13ms]

Time: As @James_D observes here, DropShadow "is causing a huge decrease in the frame rate." As screen sizes vary, one strategy is to let the canvas grow to fill the enclosing parent, as discussed here. In this way the rendering burden can be adapted to the environment or by the user. The variation below uses the basic approach shown here, while this approach illustrates a custom layout. In addition, the example omits DropShadow rendering below an arbitrary scale:

if (scale > 0.20) gc.setEffect(dropShadow);

Memory: As the DropShadow instance remains constant, it can be instantiated just once and used repeatedly in render(). The example also uses RADIUS for the instance.

Code:

import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.scene.CacheHint;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
import javafx.scene.paint.CycleMethod;
import javafx.scene.paint.RadialGradient;
import javafx.scene.paint.Stop;
import javafx.stage.Stage;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javafx.scene.control.Label;
import javafx.scene.effect.DropShadow;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;

/**
 * @see https://stackoverflow.com/q/77890758/230513
 */
public class CanvasSnow extends Application {

    private static final double PREF_WIDTH = 1200;
    private static final double PREF_HEIGHT = PREF_WIDTH * 0.618;
    private static final int SNOW_COUNT_INT = 15;
    private static final int SNOW_COUNT_DELTA = 1;
    private static final String BACKGROUND_COLOR = "hsl(%h, 50%, %l%)";
    private static final double INIT_HUE = 180;
    private static final double DELTA_HUE = 0.1;
    private final List<Snow> snows = new ArrayList<>();
    private double hue = INIT_HUE;
    private long lastUpdate;

    @Override
    public void start(Stage primaryStage) {
        var root = new BorderPane();
        var status = new Label("Rate Hz.");
        var canvas = new Canvas(PREF_WIDTH, PREF_HEIGHT);
        canvas.setCache(true);
        canvas.setCacheHint(CacheHint.SPEED);
        var view = new Pane(canvas);
        view.setPrefSize(PREF_WIDTH, PREF_HEIGHT);
        var gc = canvas.getGraphicsContext2D();
        var countRate = (int) (canvas.getWidth() * canvas.getHeight() / 500 / 500);
        createSnow(canvas, SNOW_COUNT_INT * countRate, true);
        AnimationTimer animationTimer = new AnimationTimer() {
            @Override
            public void handle(long now) {
                status.setText(String.format("Snows: %d; Rate: %7.4f Hz.",
                    snows.size(), 1.0e9 / (now - lastUpdate)));
                lastUpdate = now;
                drawBackground(canvas);
                snows.removeIf(snow -> !snow.render(gc));
                if (snows.size() < SNOW_COUNT_INT * countRate) {
                    createSnow(canvas, SNOW_COUNT_DELTA, false);
                }
            }
        };
        canvas.widthProperty().bind(view.widthProperty());
        canvas.heightProperty().bind(view.heightProperty());
        root.setCenter(view);
        root.setBottom(status);
        primaryStage.setScene(new Scene(root));
        primaryStage.show();
        animationTimer.start();
    }

    private void drawBackground(Canvas canvas) {
        String background = BACKGROUND_COLOR.replace("%h", String.valueOf(hue));
        List<Stop> stops = Arrays.asList(
            new Stop(0, Color.web(background.replace("%l", "30"))),
            new Stop(0.2, Color.web(background.replace("%l", "20"))),
            new Stop(1, Color.web(background.replace("%l", "5")))
        );
        var gc = canvas.getGraphicsContext2D();
        var centerX = canvas.getWidth() / 2;
        var centerY = canvas.getHeight() / 2;
        var radius = Math.sqrt(centerX * centerX + centerY * centerX);
        RadialGradient radialGradient = new RadialGradient(0, 0, centerX, centerY, radius, false, CycleMethod.NO_CYCLE, stops);
        gc.setFill(radialGradient);
        gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
        hue += DELTA_HUE;
        hue %= 360;
    }

    private void createSnow(Canvas canvas, int count, boolean toRandomize) {
        var gc = canvas.getGraphicsContext2D();
        var width = canvas.getWidth();
        var height = canvas.getHeight();
        var centerX = canvas.getWidth() / 2;
        var centerY = canvas.getHeight() / 2;
        for (int i = 0; i < count; i++) {
            Snow snow = new Snow(width, height, centerX, centerY, toRandomize);
            snows.add(snow);
        }
    }

    private static class Snow {

        private static final double RADIUS = 20;
        private static final double OFFSET = 4;
        private static final double INIT_POSITION_MARGIN = 20;
        private static final Color COLOR = Color.web("rgba(255, 255, 255, 0.8)");
        private static final double TOP_RADIUS_MIN = 1;
        private static final double TOP_RADIUS_MAX = 3;
        private static final double SCALE_INIT = 0.04;
        private static final double SCALE_DELTA = 0.01;
        private static final double DELTA_ROTATE_MIN = -Math.PI / 180 / 2;
        private static final double DELTA_ROTATE_MAX = Math.PI / 180 / 2;
        private static final double THRESHOLD_TRANSPARENCY = 0.7;
        private static final double VELOCITY_MIN = -1;
        private static final double VELOCITY_MAX = 1;
        private static final double LINE_WIDTH = 2;
        private final DropShadow dropShadow = new DropShadow(RADIUS, COLOR);
        private final double width;
        private final double height;
        private final double centerX;
        private final double centerY;
        private final boolean toRandomize;
        private double radius;
        private double topRadius;
        private double x;
        private double y;
        private double vx;
        private double vy;
        private double deltaRotate;
        private double scale;
        private double deltaScale;
        private double rotate;
        private double sin60;
        private double cos60;
        private double rate;
        private double offsetY;
        private double offsetCount;
        private int threshold;

        public Snow(double width, double height, double centerX, double centerY, boolean toRandomize) {
            this.width = width;
            this.height = height;
            this.centerX = centerX;
            this.centerY = centerY;
            this.toRandomize = toRandomize;
            init();
        }

        private void init() {
            this.radius = RADIUS + TOP_RADIUS_MAX * 2 + LINE_WIDTH;
            this.topRadius = getRandomValue(TOP_RADIUS_MIN, TOP_RADIUS_MAX);
            double theta = Math.PI * 2 * Math.random();
            this.x = centerX + INIT_POSITION_MARGIN * Math.cos(theta);
            this.y = centerY + INIT_POSITION_MARGIN * Math.sin(theta);
            this.vx = getRandomValue(VELOCITY_MIN, VELOCITY_MAX);
            this.vy = getRandomValue(VELOCITY_MIN, VELOCITY_MAX);
            this.deltaRotate = getRandomValue(DELTA_ROTATE_MIN, DELTA_ROTATE_MAX);
            this.scale = SCALE_INIT;
            this.deltaScale = 1 + SCALE_DELTA * 500 / Math.max(this.width, this.height);
            this.rotate = 0;
            double angle60 = Math.PI / 180 * 60;
            this.sin60 = Math.sin(angle60);
            this.cos60 = Math.cos(angle60);
            this.threshold = (int) (Math.random() * RADIUS / OFFSET);
            this.rate = 0.5 + Math.random() * 0.5;
            this.offsetY = OFFSET * Math.random() * 2;
            this.offsetCount = RADIUS / OFFSET;
            if (toRandomize) {
                for (int i = 0, count = (int) (Math.random() * 1000); i < count; i++) {
                    this.x += this.vx;
                    this.y += this.vy;
                    this.scale *= this.deltaScale;
                    this.rotate += this.deltaRotate;
                }
            }
        }

        public boolean render(GraphicsContext gc) {
            gc.save();
            if (this.scale > THRESHOLD_TRANSPARENCY) {
                gc.setGlobalAlpha(Math.max(0, (1 - this.scale) / (1 - THRESHOLD_TRANSPARENCY)));
                if (this.scale > 1 || this.x < -this.radius || this.x > this.width + this.radius
                    || this.y < -this.radius || this.y > this.height + this.radius) {
                    gc.restore();
                    // invisible
                    return false;
                }
            }
            gc.beginPath();
            gc.translate(x, y);
            gc.rotate(rotate);
            gc.scale(scale, scale);
            gc.setStroke(COLOR);
            gc.setLineWidth(LINE_WIDTH);
            if (scale > 0.20) gc.setEffect(dropShadow);
            for (int i = 0; i < 6; i++) {
                gc.save();
                gc.rotate(60 * i);
                for (int j = 0; j <= threshold; j++) {
                    double y = -4 * j;
                    gc.moveTo(0, y);
                    gc.lineTo(y * sin60, y * cos60);
                }
                for (int j = threshold; j < offsetCount; j++) {
                    double y = -4 * j,
                        x = j * (offsetCount - j + 1) * rate;
                    gc.moveTo(x, y - offsetY);
                    gc.lineTo(0, y);
                    gc.lineTo(-x, y - offsetY);
                }
                gc.moveTo(0, 0);
                gc.lineTo(0, -RADIUS);
                gc.arc(0, -RADIUS - this.topRadius, this.topRadius, this.topRadius, 0, 360);
                gc.restore();
            }
            gc.stroke();
            gc.restore();
            this.x += this.vx;
            this.y += this.vy;
            this.scale *= this.deltaScale;
            // origin
            this.rotate += this.deltaRotate;
            // too slowly,let it speed
            this.rotate += this.deltaRotate + this.deltaRotate > 0 ? 0.2 : -0.2;
            return true;
        }

        private double getRandomValue(double rangeMin, double rangeMax) {
            return rangeMin + (rangeMax - rangeMin) * Math.random();
        }
    }

    public static void main(String[] args) {
        Application.launch();
    }
}