Skip to content

Instantly share code, notes, and snippets.

@Birdasaur
Created June 18, 2025 00:36
Show Gist options
  • Select an option

  • Save Birdasaur/6661771bce60076464b631e91f1cbb8b to your computer and use it in GitHub Desktop.

Select an option

Save Birdasaur/6661771bce60076464b631e91f1cbb8b to your computer and use it in GitHub Desktop.
Animated 3D Radial Grid using JavaFX 3D
package edu.jhuapl.trinity.javafx.javafx3d.animated;
import javafx.animation.AnimationTimer;
import javafx.geometry.Point3D;
import javafx.scene.Group;
import javafx.scene.Node;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Cylinder;
import javafx.scene.transform.Rotate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Sean Phillips
*/
public class RadialGrid extends Group {
private static final Logger LOG = LoggerFactory.getLogger(RadialGrid.class);
private final Rotate worldRotateY = new Rotate(0, Rotate.Y_AXIS);
private static final int NUM_CIRCLES = 5;
private static final int NUM_RADIAL_LINES = 20;
private static final double MAX_RADIUS = 1000;
private static final double LINE_RADIUS = 0.5;
private static final double CIRCLE_SEGMENTS = 60;
AnimationTimer pulseAnimator;
private double pulseScalar = 0.25;
private double baseRadius = LINE_RADIUS;
private double rotationSpeed = 7; // degrees per second
private double pulseSpeedHz = 0.12; // pulses per second
private boolean enableRotation = false;
private boolean enablePulsation = false;
public PhongMaterial gridMaterial = new PhongMaterial(Color.DEEPSKYBLUE); // deep sky blue
public RadialGrid() {
this(NUM_CIRCLES, NUM_RADIAL_LINES, MAX_RADIUS, LINE_RADIUS, CIRCLE_SEGMENTS);
}
public RadialGrid(int circles, int radialLines, double maxRadius, double lineRadius, double circleSegments) {
gridMaterial.setSpecularColor(Color.LIGHTCYAN);
getTransforms().add(worldRotateY); // apply the rotating transform
// Build grid and axis markers
createRadialGrid(circles, radialLines, maxRadius, lineRadius, circleSegments);
pulseAnimator = new AnimationTimer() {
private long startTime = -1;
private long lastTime = 0;
@Override
public void handle(long now) {
if (startTime < 0) startTime = now;
if (lastTime > 0) {
if (isEnableRotation()) {
double deltaSeconds = (now - lastTime) / 1_000_000_000.0;
double deltaAngle = deltaSeconds * getRotationSpeed();
worldRotateY.setAngle(worldRotateY.getAngle() + deltaAngle);
}
}
lastTime = now;
if (isEnablePulsation()) {
double elapsedSec = (now - startTime) / 1_000_000_000.0;
double pulse = (Math.sin(2 * Math.PI * getPulseSpeedHz() * elapsedSec) + 1) / 2; // 0 to 1
// Optional: vary radius slightly for "breathing"
for (Node node : getChildren()) {
if (node instanceof Cylinder cylinder) {
cylinder.setRadius(getBaseRadius() * (getPulseScalar() + pulse));
}
}
}
}
};
}
public void regenerate(int circles, int radialLines, double maxRadius, double lineRadius, double circleSegments) {
getChildren().clear();
createRadialGrid(circles, radialLines, maxRadius, lineRadius, circleSegments);
}
public void setEnableAnimation(boolean enable) {
if (enable)
pulseAnimator.start();
else
pulseAnimator.stop();
}
private void createRadialGrid(int numCircles, int radialLines, double maxRadius, double lineRadius, double circleSegments) {
// Draw concentric circles using short cylinders
for (int i = 1; i <= numCircles; i++) {
double radius = (maxRadius / numCircles) * i;
for (int j = 0; j < circleSegments; j++) {
double angle1 = 2 * Math.PI * j / circleSegments;
double angle2 = 2 * Math.PI * (j + 1) / circleSegments;
double x1 = radius * Math.cos(angle1);
double z1 = radius * Math.sin(angle1);
double x2 = radius * Math.cos(angle2);
double z2 = radius * Math.sin(angle2);
addLine3D(this, x1, 0, z1, x2, 0, z2, lineRadius, gridMaterial);
}
}
// Draw radial lines
for (int i = 0; i < radialLines; i++) {
double angle = 2 * Math.PI * i / radialLines;
double x = maxRadius * Math.cos(angle);
double z = maxRadius * Math.sin(angle);
addLine3D(this, 0, 0, 0, x, 0, z, lineRadius, gridMaterial);
}
}
public static void addLine3D(Group group, double x1, double y1, double z1,
double x2, double y2, double z2, double radius, PhongMaterial material) {
// Vector from point A to B
double dx = x2 - x1;
double dy = y2 - y1;
double dz = z2 - z1;
double length = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (length < 1e-6) return;
// Create cylinder (default aligned along Y)
Cylinder line = new Cylinder(radius, length);
line.setMaterial(material);
// Move to midpoint
double midX = (x1 + x2) / 2.0;
double midY = (y1 + y2) / 2.0;
double midZ = (z1 + z2) / 2.0;
line.setTranslateX(midX);
line.setTranslateY(midY);
line.setTranslateZ(midZ);
// Default direction of cylinder is (0,1,0)
Point3D originDirection = new Point3D(0, 1, 0);
Point3D targetDirection = new Point3D(dx, dy, dz).normalize();
// Compute rotation axis and angle
Point3D rotationAxis = originDirection.crossProduct(targetDirection);
double angle = Math.toDegrees(Math.acos(originDirection.dotProduct(targetDirection)));
if (!rotationAxis.equals(Point3D.ZERO)) {
line.getTransforms().add(new Rotate(angle, rotationAxis));
}
group.getChildren().add(line);
}
/**
* @return the rotationSpeed
*/
public double getRotationSpeed() {
return rotationSpeed;
}
/**
* @param rotationSpeed the rotationSpeed to set
*/
public void setRotationSpeed(double rotationSpeed) {
this.rotationSpeed = rotationSpeed;
}
/**
* @return the pulseSpeedHz
*/
public double getPulseSpeedHz() {
return pulseSpeedHz;
}
/**
* @param pulseSpeedHz the pulseSpeedHz to set
*/
public void setPulseSpeedHz(double pulseSpeedHz) {
this.pulseSpeedHz = pulseSpeedHz;
}
/**
* @return the enableRotation
*/
public boolean isEnableRotation() {
return enableRotation;
}
/**
* @param enableRotation the enableRotation to set
*/
public void setEnableRotation(boolean enableRotation) {
this.enableRotation = enableRotation;
}
/**
* @return the enablePulsation
*/
public boolean isEnablePulsation() {
return enablePulsation;
}
/**
* @param enablePulsation the enablePulsation to set
*/
public void setEnablePulsation(boolean enablePulsation) {
this.enablePulsation = enablePulsation;
}
/**
* @return the baseRadius
*/
public double getBaseRadius() {
return baseRadius;
}
/**
* @param baseRadius the baseRadius to set
*/
public void setBaseRadius(double baseRadius) {
this.baseRadius = baseRadius;
}
/**
* @return the pulseScalar
*/
public double getPulseScalar() {
return pulseScalar;
}
/**
* @param pulseScalar the pulseScalar to set
*/
public void setPulseScalar(double pulseScalar) {
this.pulseScalar = pulseScalar;
}
}
package edu.jhuapl.trinity.javafx.javafx3d.animated;
import javafx.scene.control.Button;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.ToggleButton;
import javafx.scene.layout.VBox;
import javafx.scene.paint.Color;
/**
* @author Sean Phillips
*/
public class RadialGridControlBox extends VBox {
private static final double CONTROL_PREF_WIDTH = 100;
private static final int NUM_CIRCLES = 5;
private static final int NUM_RADIAL_LINES = 20;
private static final double MAX_RADIUS = 1000;
private static final double LINE_RADIUS = 0.5;
private static final double CIRCLE_SEGMENTS = 60;
RadialGrid radialGrid;
Spinner<Integer> numCirclesSpinner;
Spinner<Integer> numLinesSpinner;
Spinner<Double> maxRadiusSpinner;
Spinner<Double> lineRadiusSpinner;
Spinner<Double> circleSegmentsSpinner;
public RadialGridControlBox(RadialGrid radialGrid) {
this.radialGrid = radialGrid;
ToggleButton animateToggle = new ToggleButton("Animation");
animateToggle.setPrefWidth(CONTROL_PREF_WIDTH * 2);
animateToggle.setOnAction(e ->
radialGrid.setEnableAnimation(animateToggle.isSelected()));
ToggleButton pulseToggle = new ToggleButton("Pulse");
pulseToggle.setPrefWidth(CONTROL_PREF_WIDTH * 2);
pulseToggle.setOnAction(e ->
radialGrid.setEnablePulsation(pulseToggle.isSelected()));
ToggleButton rotateToggle = new ToggleButton("Rotate");
rotateToggle.setPrefWidth(CONTROL_PREF_WIDTH * 2);
rotateToggle.setOnAction(e ->
radialGrid.setEnableRotation(rotateToggle.isSelected()));
numCirclesSpinner = new Spinner(2, 200, NUM_CIRCLES, 1);
numCirclesSpinner.valueProperty().addListener(e -> regenerate());
numCirclesSpinner.setPrefWidth(CONTROL_PREF_WIDTH);
numLinesSpinner = new Spinner(2, 200, NUM_RADIAL_LINES, 1);
numLinesSpinner.valueProperty().addListener(e -> regenerate());
numLinesSpinner.setPrefWidth(CONTROL_PREF_WIDTH);
maxRadiusSpinner = new Spinner(1, 10000, MAX_RADIUS, 100);
maxRadiusSpinner.valueProperty().addListener(e -> regenerate());
maxRadiusSpinner.setPrefWidth(CONTROL_PREF_WIDTH);
lineRadiusSpinner = new Spinner(0.1, 100, LINE_RADIUS, 0.1);
lineRadiusSpinner.valueProperty().addListener(e -> {
this.radialGrid.setBaseRadius(lineRadiusSpinner.getValue());
regenerate();
});
lineRadiusSpinner.setPrefWidth(CONTROL_PREF_WIDTH);
circleSegmentsSpinner = new Spinner(1, 360, CIRCLE_SEGMENTS, 1);
circleSegmentsSpinner.valueProperty().addListener(e -> regenerate());
circleSegmentsSpinner.setPrefWidth(CONTROL_PREF_WIDTH);
Button generateButton = new Button("Regenerate");
generateButton.setPrefWidth(CONTROL_PREF_WIDTH * 2);
generateButton.setOnAction(e -> regenerate());
ColorPicker diffuseColorPicker = new ColorPicker(Color.DEEPSKYBLUE);
diffuseColorPicker.valueProperty().bindBidirectional(radialGrid.gridMaterial.diffuseColorProperty());
ColorPicker specularColorPicker = new ColorPicker(Color.LIGHTCYAN);
specularColorPicker.valueProperty().bindBidirectional(radialGrid.gridMaterial.specularColorProperty());
getChildren().addAll(
animateToggle,
pulseToggle,
rotateToggle,
new Label("Number of Circles"),
numCirclesSpinner,
new Label("Number of Lines"),
numLinesSpinner,
new Label("Max Radius"),
maxRadiusSpinner,
new Label("Line Radius"),
lineRadiusSpinner,
new Label("Circle Segment Arc"),
circleSegmentsSpinner,
generateButton,
new Label("Diffuse Color"),
diffuseColorPicker,
new Label("Specular Color"),
specularColorPicker
);
setSpacing(10);
}
private void regenerate() {
radialGrid.regenerate(numCirclesSpinner.getValue(),
numLinesSpinner.getValue(), maxRadiusSpinner.getValue(),
lineRadiusSpinner.getValue(), circleSegmentsSpinner.getValue());
}
}
package edu.jhuapl.trinity.javafx.javafx3d.animated;
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.SceneAntialiasing;
import javafx.scene.SubScene;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.Pane;
import javafx.scene.paint.Color;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Translate;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Sean Phillips
*/
public class RadialGridTest extends Application {
private static final Logger LOG = LoggerFactory.getLogger(RadialGridTest.class);
private final double sceneWidth = 800;
private final double sceneHeight = 800;
Scene scene;
private double mouseOldX, mouseOldY;
private final Rotate rotateX = new Rotate(-30, Rotate.X_AXIS);
private final Rotate rotateY = new Rotate(-30, Rotate.Y_AXIS);
private final Translate cameraTranslate = new Translate(0, -50, -500);
private static final int NUM_CIRCLES = 10;
private static final int NUM_RADIAL_LINES = 24;
private static final double MAX_RADIUS = 200;
private static final double LINE_RADIUS = 0.2;
private static final double CIRCLE_SEGMENTS = 90;
RadialGrid radialGrid;
RadialGridControlBox controls;
Group sceneRoot;
@Override
public void start(Stage primaryStage) {
sceneRoot = new Group();
radialGrid = new RadialGrid(NUM_CIRCLES, NUM_RADIAL_LINES,
MAX_RADIUS, LINE_RADIUS, CIRCLE_SEGMENTS);
sceneRoot.getChildren().add(radialGrid);
// Camera
PerspectiveCamera camera = new PerspectiveCamera(true);
camera.setNearClip(0.1);
camera.setFarClip(10000.0);
camera.getTransforms().addAll(rotateX, rotateY, cameraTranslate);
SubScene subScene = new SubScene(sceneRoot, 800, 600, true, SceneAntialiasing.BALANCED);
subScene.setWidth(sceneWidth);
subScene.setHeight(sceneHeight);
subScene.setFill(Color.BLACK);
subScene.setCamera(camera);
Pane subScenePane = new Pane(subScene);
subScene.widthProperty().bind(subScenePane.widthProperty());
subScene.heightProperty().bind(subScenePane.heightProperty());
// Mouse controls
handleMouse(subScene);
controls = new RadialGridControlBox(radialGrid);
BorderPane root = new BorderPane(subScenePane);
root.setLeft(controls);
root.setMinSize(800, 800);
Scene scene = new Scene(root);
primaryStage.setTitle("3D Radial Grid with Camera Controls");
primaryStage.setScene(scene);
primaryStage.show();
}
private void handleMouse(SubScene scene) {
scene.setOnMousePressed((MouseEvent event) -> {
mouseOldX = event.getSceneX();
mouseOldY = event.getSceneY();
});
scene.setOnMouseDragged((MouseEvent event) -> {
double dx = event.getSceneX() - mouseOldX;
double dy = event.getSceneY() - mouseOldY;
rotateY.setAngle(rotateY.getAngle() + dx * 0.3);
rotateX.setAngle(rotateX.getAngle() - dy * 0.3);
mouseOldX = event.getSceneX();
mouseOldY = event.getSceneY();
});
scene.addEventHandler(ScrollEvent.SCROLL, event -> {
double delta = event.getDeltaY();
cameraTranslate.setZ(cameraTranslate.getZ() + delta * 0.2);
});
}
}
@Birdasaur
Copy link
Author

Demonstrates configurable construction of a 3D radial grid in JavaFX. This is a compound approach that uses multiple 3D cylinders positioned and rotated to achieve the effect. This is more expensive than a pure triangle mesh approach but looks nicer with lighting and it can be properly animated via rotations and scaling.

@Birdasaur
Copy link
Author

thumbnail

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment