refactor(examples): convert GraphicsBenchmark to automated test runner
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 15 Mar 2026 02:43:56 +0000 (04:43 +0200)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sun, 15 Mar 2026 02:43:56 +0000 (04:43 +0200)
Change from interactive demo with keyboard controls to an automated
benchmark that runs multiple tests sequentially for fixed durations
and outputs reproducible results.

src/main/java/eu/svjatoslav/sixth/e3d/examples/GraphicsBenchmark.java

index a861c4b..4c523c0 100644 (file)
@@ -11,7 +11,6 @@ import eu.svjatoslav.sixth.e3d.gui.Camera;
 import eu.svjatoslav.sixth.e3d.gui.FrameListener;
 import eu.svjatoslav.sixth.e3d.gui.ViewFrame;
 import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
-import eu.svjatoslav.sixth.e3d.gui.humaninput.WorldNavigationUserInputTracker;
 import eu.svjatoslav.sixth.e3d.math.Vertex;
 import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
 import eu.svjatoslav.sixth.e3d.renderer.raster.ShapeCollection;
@@ -20,70 +19,180 @@ import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCom
 import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube;
 import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
 
-import java.awt.event.KeyEvent;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Random;
 
 /**
- * Fill-rate benchmark demo that tests the engine's rendering performance.
- * Creates a 16x16x16 grid of cubes (4096 total) that orbit around the center point.
- * The camera follows a wobbling orbital path while FPS statistics are printed to console.
+ * Automated graphics benchmark that tests the engine's rendering performance.
+ * Runs multiple tests sequentially, each for a fixed duration, and outputs
+ * reproducible benchmark results to standard output.
  * 
- * <p>Key controls:
- * <ul>
- *   <li>Press 1 - Switch to solid color cubes (faster rendering)</li>
- *   <li>Press 2 - Switch to textured cubes (slower rendering, tests texture fill-rate)</li>
- * </ul>
+ * <p>The benchmark creates a 16x16x16 grid of cubes (4096 total) with the camera
+ * following a deterministic orbital path. Each test runs for 30 seconds.</p>
  * 
  * @see SolidPolygonCube
  * @see TexturedPolygon
  */
-public class GraphicsBenchmark extends WorldNavigationUserInputTracker implements FrameListener {
+public class GraphicsBenchmark implements FrameListener {
 
-    /** Number of cubes along each axis of the grid. */
+    private static final int WINDOW_WIDTH = 1920;
+    private static final int WINDOW_HEIGHT = 1080;
     private static final int GRID_SIZE = 16;
-    /** Spacing between cube centers in world units. */
     private static final double SPACING = 80;
-    /** Half-size of each cube. */
     private static final double CUBE_SIZE = 25;
-    /** Distance of camera from center during orbit. */
     private static final double ORBIT_DISTANCE = 1200;
-    /** Angular speed of camera orbit in radians per millisecond. */
     private static final double ORBIT_SPEED = 0.0003;
-    /** Amplitude of vertical wobble during orbit. */
     private static final double WOBBLE_AMPLITUDE = 800;
-    /** Number of unique textures to create for textured mode. */
     private static final int TEXTURE_COUNT = 20;
+    private static final int TEST_DURATION_MS = 30000;
+    private static final long RANDOM_SEED = 42;
 
-    private final ViewFrame viewFrame;
-    private final ViewPanel viewPanel;
-    private final ShapeCollection shapes;
-    private final Random random = new Random(42);
-    private final Camera camera;
+    private ViewFrame viewFrame;
+    private ViewPanel viewPanel;
+    private ShapeCollection shapes;
+    private final Random random = new Random(RANDOM_SEED);
+    private Camera camera;
 
-    private final List<Object> cubes = new ArrayList<>();
     private final Texture[] textures = new Texture[TEXTURE_COUNT];
-    private final int[] cubeTextureIndices;
+    private final int[] cubeTextureIndices = new int[GRID_SIZE * GRID_SIZE * GRID_SIZE];
 
-    private boolean texturedMode = false;
     private double orbitAngle = 0;
+    private long testStartTime;
+    private long frameCount;
+    private BenchmarkTest currentTest;
+    private boolean testFinished = false;
+    private final List<TestResult> results = new ArrayList<>();
+    private final List<BenchmarkTest> tests = new ArrayList<>();
 
-    private long frameCount = 0;
-    private long fpsStartTime = System.currentTimeMillis();
-    private long totalFrameCount = 0;
-    private long appStartTime = System.currentTimeMillis();
+    /**
+     * Interface for a single benchmark test.
+     * Implementations define how to set up and tear down the test scene.
+     */
+    public interface BenchmarkTest {
+        String getName();
+        void setup(ShapeCollection shapes, Texture[] textures, int[] cubeTextureIndices);
+        void teardown(ShapeCollection shapes);
+    }
 
-    /** Resets FPS statistics counters. */
-    private void resetFpsStats() {
-        frameCount = 0;
-        fpsStartTime = System.currentTimeMillis();
-        totalFrameCount = 0;
-        appStartTime = fpsStartTime;
+    /**
+     * Holds the results of a single benchmark test.
+     */
+    public static class TestResult {
+        final String testName;
+        final long frameCount;
+        final double durationSeconds;
+        final double averageFps;
+
+        TestResult(String testName, long frameCount, double durationSeconds) {
+            this.testName = testName;
+            this.frameCount = frameCount;
+            this.durationSeconds = durationSeconds;
+            this.averageFps = frameCount / durationSeconds;
+        }
+    }
+
+    /**
+     * Benchmark test for solid-color cubes.
+     */
+    public static class SolidCubesTest implements BenchmarkTest {
+        private final Random random = new Random(RANDOM_SEED);
+        private final List<Object> cubes = new ArrayList<>();
+
+        @Override
+        public String getName() {
+            return "Solid Cubes";
+        }
+
+        @Override
+        public void setup(ShapeCollection shapes, Texture[] textures, int[] cubeTextureIndices) {
+            random.setSeed(RANDOM_SEED);
+            double offset = -(GRID_SIZE - 1) * SPACING / 2;
+
+            for (int x = 0; x < GRID_SIZE; x++) {
+                for (int y = 0; y < GRID_SIZE; y++) {
+                    for (int z = 0; z < GRID_SIZE; z++) {
+                        double px = offset + x * SPACING;
+                        double py = offset + y * SPACING;
+                        double pz = offset + z * SPACING;
+
+                        Color color = new Color(
+                                50 + random.nextInt(150),
+                                100 + random.nextInt(155),
+                                50 + random.nextInt(150),
+                                40
+                        );
+                        SolidPolygonCube cube = new SolidPolygonCube(
+                                new Point3D(px, py, pz),
+                                CUBE_SIZE,
+                                color
+                        );
+                        shapes.addShape(cube);
+                        cubes.add(cube);
+                    }
+                }
+            }
+        }
+
+        @Override
+        public void teardown(ShapeCollection shapes) {
+            for (Object cube : cubes) {
+                shapes.getShapes().remove(cube);
+            }
+            cubes.clear();
+        }
     }
 
     /**
-     * Entry point for the graphics benchmark demo.
+     * Benchmark test for textured cubes.
+     */
+    public static class TexturedCubesTest implements BenchmarkTest {
+        private final List<Object> cubes = new ArrayList<>();
+
+        @Override
+        public String getName() {
+            return "Textured Cubes";
+        }
+
+        @Override
+        public void setup(ShapeCollection shapes, Texture[] textures, int[] cubeTextureIndices) {
+            double offset = -(GRID_SIZE - 1) * SPACING / 2;
+            int idx = 0;
+
+            for (int x = 0; x < GRID_SIZE; x++) {
+                for (int y = 0; y < GRID_SIZE; y++) {
+                    for (int z = 0; z < GRID_SIZE; z++) {
+                        double px = offset + x * SPACING;
+                        double py = offset + y * SPACING;
+                        double pz = offset + z * SPACING;
+
+                        Texture tex = textures[cubeTextureIndices[idx]];
+                        TexturedCube cube = new TexturedCube(
+                                new Point3D(px, py, pz),
+                                CUBE_SIZE,
+                                tex
+                        );
+                        shapes.addShape(cube);
+                        cubes.add(cube);
+                        idx++;
+                    }
+                }
+            }
+        }
+
+        @Override
+        public void teardown(ShapeCollection shapes) {
+            for (Object cube : cubes) {
+                shapes.getShapes().remove(cube);
+            }
+            cubes.clear();
+        }
+    }
+
+    /**
+     * Entry point for the graphics benchmark.
      * @param args command line arguments (ignored)
      */
     public static void main(String[] args) {
@@ -91,31 +200,26 @@ public class GraphicsBenchmark extends WorldNavigationUserInputTracker implement
     }
 
     /**
-     * Constructs the graphics benchmark demo with a 1920x1080 window.
-     * Creates the cube grid and initializes camera orbit animation.
+     * Constructs and runs the graphics benchmark.
      */
     public GraphicsBenchmark() {
-        viewFrame = new ViewFrame(1920, 1080);
+        initializeWindow();
+        initializeTextures();
+        initializeCubeTextureIndices();
+        registerTests();
+        startNextTest();
+    }
+
+    private void initializeWindow() {
+        viewFrame = new ViewFrame(WINDOW_WIDTH, WINDOW_HEIGHT);
         viewPanel = viewFrame.getViewPanel();
         viewPanel.setFrameRate(0);
         shapes = viewPanel.getRootShapeCollection();
-
         viewPanel.addFrameListener(this);
-        viewPanel.getKeyboardFocusStack().pushFocusOwner(this);
-
         camera = viewPanel.getCamera();
-
-        cubeTextureIndices = new int[GRID_SIZE * GRID_SIZE * GRID_SIZE];
-        for (int i = 0; i < cubeTextureIndices.length; i++) {
-            cubeTextureIndices[i] = random.nextInt(TEXTURE_COUNT);
-        }
-
-        createTextures();
-        createCubes(false);
     }
 
-    /** Creates the pool of randomly colored glow textures. */
-    private void createTextures() {
+    private void initializeTextures() {
         for (int i = 0; i < TEXTURE_COUNT; i++) {
             textures[i] = createGlowTexture(
                     50 + random.nextInt(200),
@@ -125,13 +229,68 @@ public class GraphicsBenchmark extends WorldNavigationUserInputTracker implement
         }
     }
 
-    /**
-     * Creates a glow texture with the specified RGB color.
-     * @param r red component (0-255)
-     * @param g green component (0-255)
-     * @param b blue component (0-255)
-     * @return a 64x64 texture with glowing border effect
-     */
+    private void initializeCubeTextureIndices() {
+        random.setSeed(RANDOM_SEED);
+        for (int i = 0; i < cubeTextureIndices.length; i++) {
+            cubeTextureIndices[i] = random.nextInt(TEXTURE_COUNT);
+        }
+    }
+
+    private void registerTests() {
+        tests.add(new SolidCubesTest());
+        tests.add(new TexturedCubesTest());
+    }
+
+    private void startNextTest() {
+        int nextIndex = results.size();
+        if (nextIndex >= tests.size()) {
+            finishBenchmark();
+            return;
+        }
+
+        currentTest = tests.get(nextIndex);
+        testFinished = false;
+        orbitAngle = 0;
+        frameCount = 0;
+        testStartTime = System.currentTimeMillis();
+
+        currentTest.setup(shapes, textures, cubeTextureIndices);
+    }
+
+    private void finishBenchmark() {
+        currentTest = null;
+        viewFrame.dispose();
+        printResults();
+        System.exit(0);
+    }
+
+    private void printResults() {
+        String separator = "================================================================================";
+        String thinSeparator = "--------------------------------------------------------------------------------";
+
+        System.out.println(separator);
+        System.out.println("                         GRAPHICS BENCHMARK RESULTS");
+        System.out.println(separator);
+        System.out.println("Date:       " + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+        System.out.println("Resolution: " + WINDOW_WIDTH + "x" + WINDOW_HEIGHT);
+        System.out.println("Cubes:      " + (GRID_SIZE * GRID_SIZE * GRID_SIZE) + " (" + GRID_SIZE + "x" + GRID_SIZE + "x" + GRID_SIZE + " grid)");
+        System.out.println("Duration:   " + (TEST_DURATION_MS / 1000) + " seconds per test");
+        System.out.println();
+        System.out.println(thinSeparator);
+        System.out.printf("%-28s %-11s %-9s %-10s%n", "Test", "Duration", "Frames", "Avg FPS");
+        System.out.println(thinSeparator);
+
+        for (TestResult result : results) {
+            System.out.printf("%-28s %-11s %-9d %-10.2f%n",
+                    result.testName,
+                    String.format("%.2fs", result.durationSeconds),
+                    result.frameCount,
+                    result.averageFps);
+        }
+
+        System.out.println(separator);
+    }
+
     private Texture createGlowTexture(int r, int g, int b) {
         int texSize = 64;
         Texture texture = new Texture(texSize, texSize, 2);
@@ -158,70 +317,12 @@ public class GraphicsBenchmark extends WorldNavigationUserInputTracker implement
         return texture;
     }
 
-    /** Removes all cubes from the scene. */
-    private void clearCubes() {
-        for (Object cube : cubes) {
-            shapes.getShapes().remove(cube);
-        }
-        cubes.clear();
-    }
-
-    /**
-     * Creates the grid of cubes in the scene.
-     * @param textured if true, creates textured cubes; if false, creates solid-colored cubes
-     */
-    private void createCubes(boolean textured) {
-        clearCubes();
-        random.setSeed(42);
-
-        double offset = -(GRID_SIZE - 1) * SPACING / 2;
-        int idx = 0;
-
-        for (int x = 0; x < GRID_SIZE; x++) {
-            for (int y = 0; y < GRID_SIZE; y++) {
-                for (int z = 0; z < GRID_SIZE; z++) {
-                    double px = offset + x * SPACING;
-                    double py = offset + y * SPACING;
-                    double pz = offset + z * SPACING;
-
-                    if (textured) {
-                        Texture tex = textures[cubeTextureIndices[idx]];
-                        TexturedCube cube = new TexturedCube(
-                                new Point3D(px, py, pz),
-                                CUBE_SIZE,
-                                tex
-                        );
-                        shapes.addShape(cube);
-                        cubes.add(cube);
-                    } else {
-                        Color color = new Color(
-                                50 + random.nextInt(150),
-                                100 + random.nextInt(155),
-                                50 + random.nextInt(150),
-                                40
-                        );
-                        SolidPolygonCube cube = new SolidPolygonCube(
-                                new Point3D(px, py, pz),
-                                CUBE_SIZE,
-                                color
-                        );
-                        shapes.addShape(cube);
-                        cubes.add(cube);
-                    }
-                    idx++;
-                }
-            }
-        }
-    }
-
-    /**
-     * Called each frame to animate the camera orbit and track FPS.
-     * @param viewPanel the view panel rendering the scene
-     * @param millisecondsSinceLastFrame time elapsed since last frame
-     * @return true to continue rendering
-     */
     @Override
     public boolean onFrame(ViewPanel viewPanel, int millisecondsSinceLastFrame) {
+        if (currentTest == null) {
+            return true;
+        }
+
         orbitAngle += ORBIT_SPEED * millisecondsSinceLastFrame;
 
         double x = Math.sin(orbitAngle) * ORBIT_DISTANCE;
@@ -232,60 +333,25 @@ public class GraphicsBenchmark extends WorldNavigationUserInputTracker implement
         camera.lookAt(new Point3D(0, 0, 0));
 
         frameCount++;
-        totalFrameCount++;
-        long now = System.currentTimeMillis();
-        long elapsed = now - fpsStartTime;
-        if (elapsed >= 1000) {
-            int fps = (int) (frameCount * 1000 / elapsed);
-            long totalElapsed = now - appStartTime;
-            double avgFps = totalFrameCount * 1000.0 / totalElapsed;
-            System.out.println("current: " + fps + " average: " + String.format("%.2f", avgFps));
-            frameCount = 0;
-            fpsStartTime = now;
-        }
 
-        return true;
-    }
+        long elapsed = System.currentTimeMillis() - testStartTime;
+        if (elapsed >= TEST_DURATION_MS && !testFinished) {
+            testFinished = true;
+            double durationSeconds = elapsed / 1000.0;
+            results.add(new TestResult(currentTest.getName(), frameCount, durationSeconds));
 
-    /**
-     * Handles keyboard input for switching between rendering modes.
-     * @param event the key event
-     * @param viewPanel the view panel
-     * @return true if the event was consumed
-     */
-    @Override
-    public boolean keyPressed(KeyEvent event, ViewPanel viewPanel) {
-        switch (event.getKeyCode()) {
-            case KeyEvent.VK_1:
-                if (texturedMode) {
-                    texturedMode = false;
-                    createCubes(false);
-                    resetFpsStats();
-                }
-                return true;
-            case KeyEvent.VK_2:
-                if (!texturedMode) {
-                    texturedMode = true;
-                    createCubes(true);
-                    resetFpsStats();
-                }
-                return true;
+            currentTest.teardown(shapes);
+            startNextTest();
         }
-        return super.keyPressed(event, viewPanel);
+
+        return true;
     }
 
     /**
      * A cube composed of textured polygons.
-     * Each face is rendered as two triangles with UV coordinates for texture mapping.
      */
     private static class TexturedCube extends AbstractCompositeShape {
 
-        /**
-         * Constructs a textured cube at the specified position.
-         * @param center the center position of the cube
-         * @param size half-size of the cube (total width is 2*size)
-         * @param texture the texture to apply to all faces
-         */
         public TexturedCube(Point3D center, double size, Texture texture) {
             double s = size;
             Point3D p1 = new Point3D(center.x - s, center.y - s, center.z - s);
@@ -313,18 +379,6 @@ public class GraphicsBenchmark extends WorldNavigationUserInputTracker implement
             setBackfaceCulling(true);
         }
 
-        /**
-         * Adds a quad face as two textured triangles.
-         * @param p1 first corner position
-         * @param p2 second corner position
-         * @param p3 third corner position
-         * @param p4 fourth corner position
-         * @param t1 texture coordinate for p1
-         * @param t2 texture coordinate for p2
-         * @param t3 texture coordinate for p3
-         * @param t4 texture coordinate for p4
-         * @param texture the texture to apply
-         */
         private void addTexturedFace(Point3D p1, Point3D p2, Point3D p3, Point3D p4,
                                      Point2D t1, Point2D t2, Point2D t3, Point2D t4,
                                      Texture texture) {