diff --git a/README.md b/README.md index d599495..0a60e38 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,69 @@ # fluidsim -Third semester project +![themo](image.png) + +Third semester project. ## Leírás -A projekt egy 2D folyadék-szimulációs alkalmazás, amely a Navier–Stokes egyenleteken alapul. A felhasználó képes módosítani a szimuláció fizikai paramétereit, interakcióba lépni a folyadékkal, valamint elmenteni és betölteni a konfigurációkat. +A projekt egy 2D folyadék-szimulációs alkalmazás, amely [Jos Stam Real-Time Fluid Dynamics for Games](https://graphics.cs.cmu.edu/nsp/course/15-464/Fall09/papers/StamFluidforGames.pdf) c. papírján alapszik. A felhasználó képes módosítani a szimuláció fizikai paramétereit, interakcióba lépni a folyadékkal, valamint elmenteni és betölteni a konfigurációkat, illetve objektumokat. ## Használat & kezelés -Az ablaknak két fő része van: - -### Sidebar - -Egy oldalsó vagy alsó panel, amelyen a szimuláció paraméterei állíthatóak valós időben. - -### Vizualizáció: +Két ablak van: -Egy canvas, amelyen szimuláció vizualizálva van. +#### Sidebar -A `JPanel` `paintComponent(Graphics g)` metódusa felel a renderelésért, amely a `visualization.drawMode` paramétertől függően a következőket rajzolja ki: +Egy vertikáis swing panel, amelyen a beállítások vannak. -- **Sűrűség:** Egy adott cella színe a sűrűség alapján van színkódolva. -- **Folyam Iránya:** Vektorokat rajzol a rácsra, amelyek szemléltetik a folyam irányát és sebességét. -- **Mindkettő:** A fenti két opciót egyszerre rajzolja ki. +#### Vizualizáció: -### Interakció +Egy OpenGL ablak, amelyen szimuláció vizualizálva van. Két vizualizációs beállítás van: sűrűség, és áramlássebesség. Ezen felül bekapcsolhatóak kis erővektorok négyzetrácsa, amely szemlélteti a folyadékra ható erők irányát. -A felhasználónak lehetősége van folyamokat csinálni az egér segítségével a vásznon (`MouseListener` és `MouseMotionListener` használatával). +## Interakció -**Erő hozzáadása** +A felhasználónak lehetősége van folyamokat csinálni az egér segítségével a vásznon. Az interakció a kurzor körül egy felhasználó által megadott sugárban elhelyezkedő cellákra vonatkozik. -Ha a felhasználó lenyomja a jobb egérgombot és húzza az egeret, a program erőt (sebességet) ad a folyadékhoz. Az erő iránya megegyezik az egér elmozdulásának irányával (az előző pozíciótól a jelenlegiig), nagysága pedig arányos az `interaction.forceAmount` értékkel. +**Erő hozzáadása** a bal oldal egérgomb lenyomásával és az egér húzásával elérhető. Az erő egybeesik a kurzor mozgásának irányával, nagysága pedig arányos annak sebességével. -**Folyadék hozzáadása** +**Folyadék hozzáadása** a mindeközbeni `shift` gomb lenyomásával lehetséges. -Ha a felhasználó lenyomja a bal egérgombot és húzza az egeret, a program "több folyadékot ad" a kurzor alatti cellákhoz (`interaction.densityAmount`-tal). +**Folyadék elvonása** a mindeközbeni `ctrl` gomb lenyomásával lehetséges. ## Mentés és betöltés -A szoftvernek biztosítania kell a 3. pontban részletezett összes paraméter mentését és betöltését **XML formátumban**. +Lehetőség van a felhasználó által módosítható paraméterek mentésére és betöltésére. Ezen felül léteznek felhasználó által nem módosítható paraméterek is, amelyek indításkor kerülnek beolvasásra - ez egy XML konfigurációs fájl, amelyet az első command line argument-nek lehet megadni. **Mentés** -A felhasználó a `File > Save config...` menüből kiválaszthat egy célfájlt. -Az alkalmazás az aktuális paramétereket egy XML fájlba írja. -Például: +A felhasználó a `File > Save config` menüből kiválaszthat egy célfájlt. Egy konfigurációs fájl így néz ki: ```xml - - - - - - - + + + 26 + 0.0043 + 0.0256 + 24.8 + 0.9944 + 200.0 + true + true + + + 1500 + 1000 + + + + + + + ``` +A `simulation` tagek között elhelyezkedő elemek a felhasználó által frissíthető paraméterek, minden más nem. + **Betöltés** A felhasználó egy menüből (pl. `File > Load config...`) kiválaszthat egy létező `.xml` konfigurációs fájlt. @@ -65,24 +73,45 @@ Az alkalmazás beolvassa az XML-t, és frissíti a szimuláció paramétereit ## Szimuláció paraméterei -A felhasználónak képesnek kell lennie a szimuláció viselkedésének finomhangolására egy dedikált UI panelen (pl. `JSlider` vagy `JTextField` komponensekkel). Az összes paraméter-kulcs angol nyelvű. - -| Kategória | Paraméter neve | Típus | Leírás | -| :----------- | :-------------------------- | :----- | :------------------------------------------------------------------------------------------------------------- | -| Szimuláció | `simulation.gridResolution` | int | A szimulációs rács felbontása, cellákban | -| | `simulation.timeStep` | double | Az egyes szimulációs lépések közötti idő ($dt$). Nagyobb érték gyorsabb, de instabilabb szimulációt eredményez | -| Fizika | `physics.viscosity` | double | A folyadék viszkozitása (belső súrlódása). Magasabb érték "sűrűbb", mézszerűbb folyadékot eredményez | -| | `physics.diffusionRate` | double | A sűrűség (festék) diffúziós rátája. Mennyire gyorsan terjed szét a festék a folyadékban | -| | `physics.solverIterations` | int | A nyomás-korrekciós (Poisson) egyenlet megoldójának iterációszáma. Magasabb érték pontosabb, de lassabb | -| Interakció | `interaction.forceAmount` | double | Az egér által a folyadékhoz adott erő (sebesség) nagysága | -| | `interaction.densityAmount` | double | Az egér által a folyadékhoz adott sűrűség (festék) mennyisége | -| Vizualizáció | `visualization.drawMode` | Enum | A megjelenítés módja (pl. `DENSITY_ONLY`, `VELOCITY_ONLY`, `BOTH`) | -| | `visualization.vectorScale` | double | A sebességvektorok megjelenítési hosszának skálázási faktora | -| | `visualization.vectorSkip` | int | Hány cellánként rajzoljon ki egy vektort (teljesítményoptimalizálás céljából) | +- `iterations: float`: diffegyenlet solver iterációinak száma (több = pontosabb eredmény, rosszabb teljesítmény) +- `viscosity: float`: folyadék belső ellenállása +- `diffusion: float`: diffúziós paraméter +- `vorticity: float`: turbulencia együttható +- `dissipation: float`: milyen gyorsan tűnik el a sűrűség +- `interactionRadius: float`: hány pixeles környezetben érintettek a cellák a kurzor körül +- `velocityView: bool`: sebességvektorok nagyságának kijelzése (sűrűség helyett) +- `showArrows: bool`: erővektorok megjelenítése ---- +`grid`: + +- `width: int`: ablak (és szimuláció) szélessége +- `height: int`: ablak (és szimuláció) magassága -### Algoritmus Leírása (Navier-Stokes) +`arrows`: + +- `density: float`: a szélesség/magasság hányadrészén van nyíl +- `maxLength: float`: nyíl maximális hossza pixelekben +- `headLength: float`: nyíl fejének hossza +- `headAngle: float`: nyíl feje és teste között bezárt szög +- `colorR: int`, `colorG: int`, `colorB: int`, `colorA: int`: nyilak RGBA színe + +`nozzles`: befúvók listája + +`nozzle`: + +- `side: LEFT|RIGHT|TOP|BOTTOM`: melyik falon helyezkedik el +- `position: float`: a fal hanyadrészén van (pl. 0.5 -> fele) +- `radius: float`: interakció sugara +- `length: float`: gyorsítás hossza +- `directionDegrees: float`: irány (fokokban) +- `forceMagnitude: float`: erő mértéke +- `density: float`: kibocsátott sűrűség mértéke + +## Objektumok betöltése + +A felhasználó be tud tölteni egy "falnak" szolgáló entitást az `Objects > Load object...` menüben. A formátum egy egyszerű PNG képfájl, amelynek méretei megegyeznek az ablak méreteivel, a fehér pixelek falnak számítanak, a feketék pedig nem. + +## Algoritmus Leírása (Navier-Stokes) A szimuláció a Navier–Stokes egyenleteket oldja meg. @@ -108,3 +137,5 @@ Az algoritmus minden időlépésben a következő lépéseket hajtja végre: 3. Diffúzió (diffusion): a viszkozitás (sebességnél) vagy a diffúziós ráta (sűrűségnél) hatásának szimulálása. Ez a lépés "simítja" a mezőket, ahogy a folyadék belső súrlódása hat. 4. Nyomás korrekció (projection): a folyadék "összenyomhatatlanságának" biztosítása. Kiszámítja a nyomást, és korrigálja a sebességeket. + +5. Vorticity confinement: a "swirl" effektus előállításáért felelős lépés. diff --git a/example-configs/main.xml b/example-configs/main.xml new file mode 100644 index 0000000..b4eced7 --- /dev/null +++ b/example-configs/main.xml @@ -0,0 +1,17 @@ + + + 40 + 0.0043 + 0.002 + 23.1 + 0.997 + 200.0 + false + true + + + 1000 + 1000 + + + diff --git a/example-configs/oppositebeams.xml b/example-configs/oppositebeams.xml new file mode 100644 index 0000000..00b17c6 --- /dev/null +++ b/example-configs/oppositebeams.xml @@ -0,0 +1,21 @@ + + + 26 + 0.0043 + 0.0256 + 24.8 + 0.9944 + 200.0 + true + true + + + 1500 + 1000 + + + + + + + diff --git a/example-configs/windtunnel.xml b/example-configs/windtunnel.xml new file mode 100644 index 0000000..ef06cff --- /dev/null +++ b/example-configs/windtunnel.xml @@ -0,0 +1,24 @@ + + + 25 + 0.005 + 0.0256 + 25 + 0.9940 + 200.0 + true + true + + + 1000 + 1000 + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 75d175b..683919a 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,6 @@ - 4.0.0 @@ -15,10 +16,21 @@ UTF-8 21 dev.fluidsim.App + 1.10.5 + 3.3.2 + natives-windows + + + org.lwjgl + lwjgl-bom + ${lwjgl.version} + import + pom + org.junit junit-bom @@ -30,12 +42,59 @@ + + + org.lwjgl + lwjgl + + + org.lwjgl + lwjgl-glfw + + + org.lwjgl + lwjgl-opengl + + + org.lwjgl + lwjgl-stb + + + + + org.lwjgl + lwjgl + ${lwjgl.natives} + + + org.lwjgl + lwjgl-glfw + ${lwjgl.natives} + + + org.lwjgl + lwjgl-opengl + ${lwjgl.natives} + + + org.lwjgl + lwjgl-stb + ${lwjgl.natives} + + + + + org.joml + joml + ${joml.version} + + + org.junit.jupiter junit-jupiter-api test - org.junit.jupiter junit-jupiter-params @@ -44,14 +103,17 @@ - + - + maven-clean-plugin 3.4.0 - + maven-resources-plugin 3.3.1 @@ -76,7 +138,8 @@ maven-deploy-plugin 3.1.2 - + maven-site-plugin 3.12.1 @@ -87,5 +150,20 @@ + + + maven-assembly-plugin + + + + dev.fluidsim.App + + + + jar-with-dependencies + + + + - + \ No newline at end of file diff --git a/src/main/java/dev/fluidsim/App.java b/src/main/java/dev/fluidsim/App.java index 296da10..8a8e5ba 100644 --- a/src/main/java/dev/fluidsim/App.java +++ b/src/main/java/dev/fluidsim/App.java @@ -1,24 +1,45 @@ package dev.fluidsim; +import dev.fluidsim.engine.GLContext; +import dev.fluidsim.io.InputHandler; +import dev.fluidsim.io.LoadableParams; +import dev.fluidsim.render.Renderer; +import dev.fluidsim.simulation.Sim; +import dev.fluidsim.ui.SimFrame; + import javax.swing.SwingUtilities; import javax.swing.UIManager; -import dev.fluidsim.ui.BetterCanvas; - -// import javax.swing.JFrame; -// import dev.fluidsim.simulation.Sim; -// import dev.fluidsim.ui.Canvas; -// import dev.fluidsim.ui.Density; -// import dev.fluidsim.ui.MouseEventListener; -// import dev.fluidsim.ui.Vectors; -// import java.awt.BorderLayout; -// import java.awt.Color; -// import java.awt.event.MouseMotionListener; +import static org.lwjgl.opengl.GL11.GL_BLEND; +import static org.lwjgl.opengl.GL11.GL_DEPTH_TEST; +import static org.lwjgl.opengl.GL11.glClearColor; +import static org.lwjgl.opengl.GL11.glDisable; +/** + * Main application class for the fluid simulation. + * Manages OpenGL context, simulation loop, rendering, and Swing GUI + * integration. + */ public class App { + private GLContext glContext; + private Renderer renderer; + + private Sim simulation; + private LoadableParams params; + + private InputHandler inputHandler; + private SimFrame frame; + private long lastTime; + private int frameCount; + + /** + * Application entry point. + * Configures system properties and starts the application. + * + * @param args command line arguments (unused) + */ public static void main(String[] args) { - // force enable hardware acceleration System.setProperty("sun.java2d.opengl", "true"); try { @@ -27,61 +48,129 @@ public static void main(String[] args) { System.err.println("Failed to set look and feel"); } - // SwingUtilities.invokeLater(new Runnable() { - // public void run() { - // createAndShowGUI(); - // } - // }); + if (args.length > 0) { + new App(args[0]).run(); + } else { + new App().run(); + } - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - startWindow(); - } - }); } - public static void startWindow() { - int FRAME_WIDTH = 1000; - int FRAME_HEIGHT = 1000; - int SIMULATION_WIDTH = 250; - int SIMULATION_HEIGHT = 250; + /** + * Creates the application and loads default simulation parameters + */ + public App() { + params = new LoadableParams(); + } - var thread = new Thread(() -> BetterCanvas.run(FRAME_WIDTH, FRAME_HEIGHT, SIMULATION_WIDTH, SIMULATION_HEIGHT)); + /** + * Creates the application and loads simulation parameters from XML + * configuration file. + * Falls back to default parameters if loading fails. + */ + public App(String fileToLoad) { + try { + params = LoadableParams.load(fileToLoad); + } catch (Exception e) { + System.err.println("Failed to load parameters from " + fileToLoad + ", using defaults."); + params = new LoadableParams(); + } + } - thread.start(); + /** + * Starts the application by initializing OpenGL context and GUI on separate + * threads, then enters the main simulation loop. + */ + public void run() { + System.out.println("Initializing Fluid Simulation with OpenGL and Swing GUI..."); + + SwingUtilities.invokeLater(() -> { + new Thread(() -> { + try { + Thread.sleep(100); + init(); + + SwingUtilities.invokeLater(() -> { + frame = new SimFrame(params, simulation, params.width, params.height); + frame.setVisible(true); + + // add simulation objects + simulation.deleteAllObjects(); + params.nozzles.forEach(x -> simulation.addObject(x)); + }); + + loop(); + cleanup(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + }).start(); + }); } - // private static void createAndShowGUI() { - // JFrame frame = new JFrame("Fluid simulation"); - // frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + private void init() { + // Initialize OpenGL context (undecorated window) + glContext = new GLContext(params.width, params.height, "Fluid Simulation - OpenGL"); + glContext.init(); + + // Set OpenGL state + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glDisable(GL_DEPTH_TEST); + glDisable(GL_BLEND); + + simulation = new Sim(params.width, params.height, params); + renderer = new Renderer(); + + inputHandler = new InputHandler(simulation); + glContext.setKeyCallback(inputHandler.createKeyCallback()); + glContext.setMouseButtonCallback(inputHandler.createMouseButtonCallback()); + glContext.setCursorPosCallback(inputHandler.createCursorPosCallback()); + + lastTime = System.nanoTime(); + frameCount = 0; + } - // int FRAME_WIDTH = 1000; - // int FRAME_HEIGHT = 1000; - // int SIMULATION_WIDTH = 200; - // int SIMULATION_HEIGHT = 200; + private void loop() { + long lastFPSUpdate = System.currentTimeMillis(); - // frame.setSize(FRAME_WIDTH, FRAME_HEIGHT); + while (!glContext.shouldClose()) { + long currentTime = System.nanoTime(); + float deltaTime = (currentTime - lastTime) / 1_000_000_000.0f; + lastTime = currentTime; - // Sim simulation = new Sim(SIMULATION_WIDTH, SIMULATION_HEIGHT); + // Update simulation + simulation.update(deltaTime); - // var canvas = new Canvas(FRAME_WIDTH, FRAME_HEIGHT); - // canvas.onUpdate(simulation::update); + // Render + renderer.renderToScreen(simulation, params.width, params.height, params.isVelocityView(), + params.isShowArrows()); - // var density = new Density(simulation); - // var vectors = new Vectors(simulation, FRAME_WIDTH, FRAME_HEIGHT, 5); + // Swap buffers and poll events + glContext.swapBuffers(); + glContext.pollEvents(); - // canvas.add(density); - // // canvas.add(vectors); + frameCount++; - // canvas.addMouseMotionListener(new MouseEventListener(simulation, FRAME_WIDTH, - // FRAME_HEIGHT)); - // canvas.setBackground(Color.BLACK); + if (System.currentTimeMillis() - lastFPSUpdate > 1000) { + frame.updateFrameRate(frameCount); + frameCount = 0; + lastFPSUpdate = System.currentTimeMillis(); + } + } + } - // frame.getContentPane().add(canvas, BorderLayout.CENTER); + private void cleanup() { + System.out.println("Quit"); - // frame.setLocationRelativeTo(null); - // frame.setVisible(true); + simulation.cleanup(); + renderer.cleanup(); + glContext.cleanup(); - // } + // Close Swing GUI + SwingUtilities.invokeLater(() -> { + if (frame != null) { + frame.dispose(); + } + }); + } } diff --git a/src/main/java/dev/fluidsim/common/Vec.java b/src/main/java/dev/fluidsim/common/Vec.java deleted file mode 100644 index c929510..0000000 --- a/src/main/java/dev/fluidsim/common/Vec.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.fluidsim.common; - -public class Vec { - public int x = 0; - public int y = 0; - - public Vec(int x, int y) { - this.x = x; - this.y = y; - } - - public static Vec NULL() { - return new Vec(0, 0); - } - - @Override - public boolean equals(Object obj) { - Vec other = (Vec) obj; - return other.x == x && other.y == y; - } - - @Override - public String toString() { - return "Vec2 [x=" + x + ", y=" + y + "]"; - } -} diff --git a/src/main/java/dev/fluidsim/engine/Framebuffer.java b/src/main/java/dev/fluidsim/engine/Framebuffer.java new file mode 100644 index 0000000..68e7d3f --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/Framebuffer.java @@ -0,0 +1,79 @@ +package dev.fluidsim.engine; + +import static org.lwjgl.opengl.GL33.*; + +/** + * OpenGL framebuffer object with attached floating-point texture. + * Used for off-screen rendering in fluid simulation pipeline. + */ +public class Framebuffer { + private int FBO; + private Texture texture; + + /** + * Creates a framebuffer with attached floating-point texture. + * + * @param width texture width in pixels, must be positive + * @param height texture height in pixels, must be positive + * @throws RuntimeException if framebuffer is not complete + */ + public Framebuffer(int width, int height) { + // Create texture + texture = Texture.createFloat(width, height); + + // Create framebuffer + FBO = glGenFramebuffers(); + glBindFramebuffer(GL_FRAMEBUFFER, FBO); + + // Attach texture to framebuffer + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture.getId(), 0); + + // Check framebuffer status + int status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + throw new RuntimeException("Framebuffer is not complete: " + status); + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + /** + * Binds this framebuffer as the active render target. + */ + public void bind() { + glBindFramebuffer(GL_FRAMEBUFFER, FBO); + } + + /** + * Unbinds this framebuffer, returning to default framebuffer. + */ + public void unbind() { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + } + + /** + * Returns the attached color texture. + * + * @return the framebuffer's texture + */ + public Texture getTexture() { + return texture; + } + + /** + * Returns the OpenGL framebuffer object ID. + * + * @return the FBO handle + */ + public int getFBO() { + return FBO; + } + + /** + * Deletes the framebuffer and its attached texture from GPU memory. + */ + public void delete() { + glDeleteFramebuffers(FBO); + texture.delete(); + } +} diff --git a/src/main/java/dev/fluidsim/engine/GLContext.java b/src/main/java/dev/fluidsim/engine/GLContext.java new file mode 100644 index 0000000..85df96f --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/GLContext.java @@ -0,0 +1,187 @@ +package dev.fluidsim.engine; + +import org.lwjgl.glfw.*; +import org.lwjgl.opengl.GL; + +import static org.lwjgl.glfw.GLFW.*; +import static org.lwjgl.opengl.GL33.*; +import static org.lwjgl.system.MemoryUtil.*; + +/** + * Manages GLFW window creation and OpenGL context lifecycle. + * Handles window events, callbacks, and context configuration. + */ +public class GLContext { + private long window; + private int width; + private int height; + private String title; + + private GLFWKeyCallback keyCallback; + private GLFWCursorPosCallback cursorPosCallback; + private GLFWMouseButtonCallback mouseButtonCallback; + + /** + * Creates an OpenGL context configuration. + * + * @param width window width in pixels, must be positive + * @param height window height in pixels, must be positive + * @param title window title string + */ + public GLContext(int width, int height, String title) { + this.width = width; + this.height = height; + this.title = title; + } + + /** + * Initializes GLFW, creates the window, and sets up OpenGL context. + * + * @throws IllegalStateException if GLFW initialization fails + * @throws RuntimeException if window creation fails + */ + public void init() { + // Setup error callback + GLFWErrorCallback.createPrint(System.err).set(); + + // Initialize GLFW + if (!glfwInit()) { + throw new IllegalStateException("Unable to initialize GLFW"); + } + + glfwDefaultWindowHints(); + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); + glfwWindowHint(GLFW_DECORATED, GLFW_TRUE); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + + // Create window + window = glfwCreateWindow(width, height, title, NULL, NULL); + if (window == NULL) { + throw new RuntimeException("Failed to create GLFW window"); + } + + glfwMakeContextCurrent(window); + glfwSwapInterval(1); + glfwShowWindow(window); + GL.createCapabilities(); + + // Print OpenGL version + System.out.println("OpenGL version: " + glGetString(GL_VERSION)); + System.out.println("GLSL version: " + glGetString(GL_SHADING_LANGUAGE_VERSION)); + } + + /** + * Sets the window position on screen. + * + * @param x x-coordinate in screen pixels + * @param y y-coordinate in screen pixels + */ + public void setWindowPosition(int x, int y) { + glfwSetWindowPos(window, x, y); + } + + /** + * Checks if the window should close. + * + * @return true if window close was requested + */ + public boolean shouldClose() { + return glfwWindowShouldClose(window); + } + + /** + * Processes pending window events from the event queue. + */ + public void pollEvents() { + glfwPollEvents(); + } + + /** + * Swaps front and back buffers to display rendered frame. + */ + public void swapBuffers() { + glfwSwapBuffers(window); + } + + /** + * Sets the keyboard input callback. + * + * @param callback the callback to invoke on key events + */ + public void setKeyCallback(GLFWKeyCallback callback) { + this.keyCallback = callback; + glfwSetKeyCallback(window, callback); + } + + /** + * Sets the cursor position callback. + * + * @param callback the callback to invoke on mouse movement + */ + public void setCursorPosCallback(GLFWCursorPosCallback callback) { + this.cursorPosCallback = callback; + glfwSetCursorPosCallback(window, callback); + } + + /** + * Sets the mouse button callback. + * + * @param callback the callback to invoke on mouse button events + */ + public void setMouseButtonCallback(GLFWMouseButtonCallback callback) { + this.mouseButtonCallback = callback; + glfwSetMouseButtonCallback(window, callback); + } + + /** + * Returns the GLFW window handle. + * + * @return the window pointer + */ + public long getWindow() { + return window; + } + + /** + * Returns the window width. + * + * @return width in pixels + */ + public int getWidth() { + return width; + } + + /** + * Returns the window height. + * + * @return height in pixels + */ + public int getHeight() { + return height; + } + + /** + * Releases all GLFW resources including callbacks, window, and terminates GLFW. + */ + public void cleanup() { + if (keyCallback != null) + keyCallback.free(); + + if (cursorPosCallback != null) + cursorPosCallback.free(); + + if (mouseButtonCallback != null) + mouseButtonCallback.free(); + + glfwDestroyWindow(window); + glfwTerminate(); + + GLFWErrorCallback callback = glfwSetErrorCallback(null); + if (callback != null) + callback.free(); + } +} diff --git a/src/main/java/dev/fluidsim/engine/ObstacleLoader.java b/src/main/java/dev/fluidsim/engine/ObstacleLoader.java new file mode 100644 index 0000000..4908c9e --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/ObstacleLoader.java @@ -0,0 +1,152 @@ +package dev.fluidsim.engine; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.FloatBuffer; + +import static org.lwjgl.opengl.GL33.*; +import static org.lwjgl.system.MemoryUtil.*; + +/** + * Utility for loading obstacle masks from PNG files to create boundary + * conditions in the simulation. + * Provides static methods only; no instantiation needed. + */ +public class ObstacleLoader { + private ObstacleLoader() { + // Utility class, no instantiation + } + + /** + * Validate that a PNG file exists and has correct dimensions (safe to call from + * any thread) + * + * @param path file path to PNG image + * @param expectedWidth expected image width in pixels + * @param expectedHeight expected image height in pixels + * @throws IOException if file cannot be read or does not exist + * @throws IllegalArgumentException if dimensions do not match + */ + public static void validateObjectPNG(String path, int expectedWidth, int expectedHeight) + throws IOException, IllegalArgumentException { + + File file = new File(path); + if (!file.exists()) { + throw new IOException("File not found: " + path); + } + + BufferedImage image = ImageIO.read(file); + if (image == null) { + throw new IOException("Failed to load image from: " + path); + } + + int width = image.getWidth(); + int height = image.getHeight(); + + if (width != expectedWidth || height != expectedHeight) { + throw new IllegalArgumentException( + String.format("Image dimensions (%dx%d) don't match expected (%dx%d)", + width, height, expectedWidth, expectedHeight)); + } + } + + /** + * Load an obstacle bitmap from a PNG file + * + * @param path Path to PNG file + * @param targetWidth Expected width (should match simulation grid) + * @param targetHeight Expected height (should match simulation grid) + * @return Texture containing obstacle mask (1.0 = wall, 0.0 = free space) + * @throws IOException if file cannot be read + * @throws IllegalArgumentException if dimensions don't match + */ + public static Texture loadObjectFromPNG(String path, int targetWidth, int targetHeight) + throws IOException, IllegalArgumentException { + BufferedImage image = ImageIO.read(new File(path)); + if (image == null) { + throw new IOException("Failed to load image from: " + path); + } + + int w = image.getWidth(); + int h = image.getHeight(); + + // Validate dimensions + if (w != targetWidth || h != targetHeight) { + throw new IllegalArgumentException( + String.format("Image dimensions (%dx%d) don't match expected (%dx%d)", + w, h, targetWidth, targetHeight)); + } + + // Convert to float array (0.0 = free, 1.0 = wall) + float[] obstacleData = new float[w * h]; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + // Get pixel value + int rgb = image.getRGB(x, y); + + // Extract grayscale value (use red channel or average) + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + // Average to grayscale + float gray = (r + g + b) / (3.0f * 255.0f); + + // Invert Y coordinate (OpenGL has origin at bottom-left, image at top-left) + int flippedY = h - 1 - y; + int index = flippedY * w + x; + + // White (1.0) = wall, Black (0.0) = free space + obstacleData[index] = gray; + } + } + + // Create texture + return createObjectTexture(w, h, obstacleData); + } + + /** + * Create an empty obstacle texture (no walls) + * + * @param width texture width in pixels + * @param height texture height in pixels + * @return texture with all zeros (free space) + */ + public static Texture empty(int width, int height) { + float[] emptyData = new float[width * height]; + // All zeros = no walls + return createObjectTexture(width, height, emptyData); + } + + /** + * Create obstacle texture from float array + */ + private static Texture createObjectTexture(int width, int height, float[] data) { + // Create texture + int textureId = glGenTextures(); + glBindTexture(GL_TEXTURE_2D, textureId); + + // Upload data + FloatBuffer buffer = memAllocFloat(data.length); + buffer.put(data); + buffer.flip(); + + glTexImage2D(GL_TEXTURE_2D, 0, GL_R32F, width, height, 0, GL_RED, GL_FLOAT, buffer); + + memFree(buffer); + + // Set texture parameters + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + glBindTexture(GL_TEXTURE_2D, 0); + + // Wrap in Texture object using protected constructor + return new Texture(textureId, width, height); + } +} diff --git a/src/main/java/dev/fluidsim/engine/QuadMesh.java b/src/main/java/dev/fluidsim/engine/QuadMesh.java new file mode 100644 index 0000000..c094a71 --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/QuadMesh.java @@ -0,0 +1,74 @@ +package dev.fluidsim.engine; + +import org.lwjgl.system.MemoryUtil; + +import java.nio.FloatBuffer; + +import static org.lwjgl.opengl.GL33.*; + +/** + * Fullscreen quad mesh for rendering to framebuffers. + * Used throughout fluid simulation for compute shader passes. + */ +public class QuadMesh { + private int VAO; + private int VBO; + + /** + * Creates a fullscreen quad mesh with position and texture coordinate + * attributes. + */ + public QuadMesh() { + // Fullscreen quad vertices (position + texcoord) + float[] vertices = { + // positions // texCoords + -1.0f, 1.0f, 0.0f, 1.0f, + -1.0f, -1.0f, 0.0f, 0.0f, + 1.0f, -1.0f, 1.0f, 0.0f, + + -1.0f, 1.0f, 0.0f, 1.0f, + 1.0f, -1.0f, 1.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 1.0f + }; + + FloatBuffer buffer = MemoryUtil.memAllocFloat(vertices.length); + buffer.put(vertices).flip(); + + VAO = glGenVertexArrays(); + VBO = glGenBuffers(); + + glBindVertexArray(VAO); + glBindBuffer(GL_ARRAY_BUFFER, VBO); + glBufferData(GL_ARRAY_BUFFER, buffer, GL_STATIC_DRAW); + + // Position attribute + glVertexAttribPointer(0, 2, GL_FLOAT, false, 4 * Float.BYTES, 0); + glEnableVertexAttribArray(0); + + // TexCoord attribute + glVertexAttribPointer(1, 2, GL_FLOAT, false, 4 * Float.BYTES, 2 * Float.BYTES); + glEnableVertexAttribArray(1); + + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindVertexArray(0); + + MemoryUtil.memFree(buffer); + } + + /** + * Renders the quad mesh as two triangles covering the screen. + */ + public void draw() { + glBindVertexArray(VAO); + glDrawArrays(GL_TRIANGLES, 0, 6); + glBindVertexArray(0); + } + + /** + * Deletes the VAO and VBO from GPU memory. + */ + public void delete() { + glDeleteBuffers(VBO); + glDeleteVertexArrays(VAO); + } +} diff --git a/src/main/java/dev/fluidsim/engine/Shader.java b/src/main/java/dev/fluidsim/engine/Shader.java new file mode 100644 index 0000000..2003db0 --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/Shader.java @@ -0,0 +1,95 @@ +package dev.fluidsim.engine; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.stream.Collectors; + +import static org.lwjgl.opengl.GL33.*; + +/** + * OpenGL shader object wrapper for vertex and fragment shaders. + * Handles compilation and error reporting. + */ +public class Shader { + private final int id; + private final int type; + + /** + * Creates and compiles a shader from source code. + * + * @param source GLSL shader source code + * @param type shader type (GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, etc.) + * @throws RuntimeException if shader creation or compilation fails + */ + public Shader(String source, int type) { + this.type = type; + this.id = glCreateShader(type); + + if (id == 0) { + throw new RuntimeException("Failed to create shader"); + } + + glShaderSource(id, source); + glCompileShader(id); + + // Check compilation status + if (glGetShaderi(id, GL_COMPILE_STATUS) == GL_FALSE) { + String log = glGetShaderInfoLog(id); + glDeleteShader(id); + throw new RuntimeException("Shader compilation failed:\n" + log); + } + } + + /** + * Loads and compiles a shader from a resource file. + * + * @param path resource path to shader file + * @param type shader type (GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, etc.) + * @return compiled shader object + * @throws RuntimeException if resource not found or compilation fails + */ + public static Shader load(String path, int type) { + String source = loadResource(path); + return new Shader(source, type); + } + + private static String loadResource(String path) { + try (InputStream in = Shader.class.getResourceAsStream(path)) { + if (in == null) { + throw new RuntimeException("Resource not found: " + path); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) { + return reader.lines().collect(Collectors.joining("\n")); + } + } catch (IOException e) { + throw new RuntimeException("Failed to load resource: " + path, e); + } + } + + /** + * Returns the OpenGL shader object ID. + * + * @return shader ID + */ + public int getId() { + return id; + } + + /** + * Returns the shader type. + * + * @return GL_VERTEX_SHADER, GL_FRAGMENT_SHADER, etc. + */ + public int getType() { + return type; + } + + /** + * Deletes the shader object from GPU memory. + */ + public void delete() { + glDeleteShader(id); + } +} diff --git a/src/main/java/dev/fluidsim/engine/ShaderProgram.java b/src/main/java/dev/fluidsim/engine/ShaderProgram.java new file mode 100644 index 0000000..19a829e --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/ShaderProgram.java @@ -0,0 +1,228 @@ +package dev.fluidsim.engine; + +import org.joml.Vector2f; +import org.joml.Vector4f; + +import static org.lwjgl.opengl.GL33.*; + +/** + * OpenGL shader program wrapper linking vertex and fragment shaders. + * Provides uniform setting methods and lifecycle management. + */ +public class ShaderProgram { + private final int id; + private final String name; + + /** + * Creates and links a shader program from compiled vertex and fragment shaders. + * + * @param vertexShader compiled vertex shader + * @param fragmentShader compiled fragment shader + * @param name program name for debugging + * @throws RuntimeException if program creation or linking fails + */ + public ShaderProgram(Shader vertexShader, Shader fragmentShader, String name) { + id = glCreateProgram(); + this.name = name; + + if (id == 0) { + throw new RuntimeException("Failed to create shader program"); + } + + glAttachShader(id, vertexShader.getId()); + glAttachShader(id, fragmentShader.getId()); + glLinkProgram(id); + + // Check linking status + if (glGetProgrami(id, GL_LINK_STATUS) == GL_FALSE) { + String log = glGetProgramInfoLog(id); + glDeleteProgram(id); + throw new RuntimeException("Shader program linking failed:\n" + log); + } + + // Validate program + glValidateProgram(id); + if (glGetProgrami(id, GL_VALIDATE_STATUS) == GL_FALSE) { + System.err.println("Warning validating shader program: " + glGetProgramInfoLog(id)); + } + + // Detach shaders after linking + glDetachShader(id, vertexShader.getId()); + glDetachShader(id, fragmentShader.getId()); + } + + /** + * Loads, compiles, and links a shader program from resource files. + * + * @param vertPath resource path to vertex shader + * @param fragPath resource path to fragment shader + * @return linked shader program + * @throws RuntimeException if loading or compilation fails + */ + public static ShaderProgram load(String vertPath, String fragPath) { + Shader vertShader = Shader.load(vertPath, GL_VERTEX_SHADER); + Shader fragShader = Shader.load(fragPath, GL_FRAGMENT_SHADER); + + ShaderProgram program = new ShaderProgram(vertShader, fragShader, vertPath + " + " + fragPath); + + // Clean up shaders + vertShader.delete(); + fragShader.delete(); + + return program; + } + + /** + * Activates this shader program for rendering. + */ + public void use() { + glUseProgram(id); + } + + /** + * Deactivates all shader programs. + */ + public void unuse() { + glUseProgram(0); + } + + /** + * Gets the location of a uniform variable in the shader program. + * + * @param name uniform variable name + * @return uniform location, or -1 if not found + */ + public int getUniformLocation(String name) { + int location = glGetUniformLocation(id, name); + if (location == -1) { + System.err.println("Warning: uniform '" + name + "' not found in shader program " + this.name); + } + return location; + } + + /** + * Sets an integer uniform value. + * + * @param name uniform variable name + * @param value integer value to set + */ + public void setUniform(String name, int value) { + glUniform1i(getUniformLocation(name), value); + } + + /** + * Sets a float uniform value. + * + * @param name uniform variable name + * @param value float value to set + */ + public void setUniform(String name, float value) { + glUniform1f(getUniformLocation(name), value); + } + + /** + * Sets a vec2 uniform from Vector2f. + * + * @param name uniform variable name + * @param value vector containing x and y components + */ + public void setUniform(String name, Vector2f value) { + glUniform2f(getUniformLocation(name), value.x, value.y); + } + + /** + * Sets a vec2 uniform from separate components. + * + * @param name uniform variable name + * @param x x component + * @param y y component + */ + public void setUniform(String name, float x, float y) { + glUniform2f(getUniformLocation(name), x, y); + } + + /** + * Sets a vec4 uniform from Vector4f. + * + * @param name uniform variable name + * @param value vector containing x, y, z, and w components + */ + public void setUniform(String name, Vector4f value) { + glUniform4f(getUniformLocation(name), value.x, value.y, value.z, value.w); + } + + /** + * Sets a vec4 uniform from separate components. + * + * @param name uniform variable name + * @param x x component + * @param y y component + * @param z z component + * @param w w component + */ + public void setUniform(String name, float x, float y, float z, float w) { + glUniform4f(getUniformLocation(name), x, y, z, w); + } + + /** + * Sets a vec3 uniform from separate components. + * + * @param name uniform variable name + * @param x x component + * @param y y component + * @param z z component + */ + public void setUniform(String name, float x, float y, float z) { + glUniform3f(getUniformLocation(name), x, y, z); + } + + /** + * Sets a mat4 uniform from separate matrix components. + * + * @param name uniform variable name + * @param m00 matrix element at row 0, column 0 + * @param m01 matrix element at row 0, column 1 + * @param m02 matrix element at row 0, column 2 + * @param m03 matrix element at row 0, column 3 + * @param m10 matrix element at row 1, column 0 + * @param m11 matrix element at row 1, column 1 + * @param m12 matrix element at row 1, column 2 + * @param m13 matrix element at row 1, column 3 + * @param m20 matrix element at row 2, column 0 + * @param m21 matrix element at row 2, column 1 + * @param m22 matrix element at row 2, column 2 + * @param m23 matrix element at row 2, column 3 + * @param m30 matrix element at row 3, column 0 + * @param m31 matrix element at row 3, column 1 + * @param m32 matrix element at row 3, column 2 + * @param m33 matrix element at row 3, column 3 + */ + public void setUniform(String name, float m00, float m01, float m02, float m03, + float m10, float m11, float m12, float m13, + float m20, float m21, float m22, float m23, + float m30, float m31, float m32, float m33) { + float[] matrix = new float[] { + m00, m01, m02, m03, + m10, m11, m12, m13, + m20, m21, m22, m23, + m30, m31, m32, m33 + }; + glUniformMatrix4fv(getUniformLocation(name), false, matrix); + } + + /** + * Returns the OpenGL program object ID. + * + * @return program ID + */ + public int getId() { + return id; + } + + /** + * Deletes the shader program from GPU memory. + */ + public void delete() { + glDeleteProgram(id); + } +} diff --git a/src/main/java/dev/fluidsim/engine/Texture.java b/src/main/java/dev/fluidsim/engine/Texture.java new file mode 100644 index 0000000..ac8196e --- /dev/null +++ b/src/main/java/dev/fluidsim/engine/Texture.java @@ -0,0 +1,116 @@ +package dev.fluidsim.engine; + +import static org.lwjgl.opengl.GL33.*; + +/** + * OpenGL texture wrapper for managing 2D textures. + * Supports floating-point and standard texture formats. + */ +public class Texture { + private int id; + private int width; + private int height; + + /** + * Creates a new texture with specified format. + * + * @param width texture width in pixels, must be positive + * @param height texture height in pixels, must be positive + * @param internalFormat OpenGL internal format (e.g., GL_R32F, GL_RGBA) + * @param format OpenGL pixel format (e.g., GL_RED, GL_RGBA) + * @param type OpenGL data type (e.g., GL_FLOAT, GL_UNSIGNED_BYTE) + */ + public Texture(int width, int height, int internalFormat, int format, int type) { + this.width = width; + this.height = height; + + id = glGenTextures(); + glBindTexture(GL_TEXTURE_2D, id); + + // Create empty texture + glTexImage2D(GL_TEXTURE_2D, 0, internalFormat, width, height, 0, format, type, 0); + + // Set texture parameters + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + glBindTexture(GL_TEXTURE_2D, 0); + } + + /** + * Wraps an existing OpenGL texture ID. + * + * @param existingId OpenGL texture ID to wrap + * @param width texture width in pixels + * @param height texture height in pixels + */ + protected Texture(int existingId, int width, int height) { + this.id = existingId; + this.width = width; + this.height = height; + } + + /** + * Creates a single-channel floating-point texture. + * + * @param width texture width in pixels + * @param height texture height in pixels + * @return floating-point texture + */ + public static Texture createFloat(int width, int height) { + return new Texture(width, height, GL_R32F, GL_RED, GL_FLOAT); + } + + /** + * Binds this texture to a texture unit for rendering. + * + * @param unit texture unit index (0-31) + */ + public void bind(int unit) { + glActiveTexture(GL_TEXTURE0 + unit); + glBindTexture(GL_TEXTURE_2D, id); + } + + /** + * Unbinds any texture from the current texture unit. + */ + public void unbind() { + glBindTexture(GL_TEXTURE_2D, 0); + } + + /** + * Returns the OpenGL texture ID. + * + * @return texture ID + */ + public int getId() { + return id; + } + + /** + * Returns the texture width. + * + * @return width in pixels + */ + public int getWidth() { + return width; + } + + /** + * Returns the texture height. + * + * @return height in pixels + */ + public int getHeight() { + return height; + } + + /** + * Deletes the texture from GPU memory. + */ + public void delete() { + glDeleteTextures(id); + } +} diff --git a/src/main/java/dev/fluidsim/io/InputHandler.java b/src/main/java/dev/fluidsim/io/InputHandler.java new file mode 100644 index 0000000..b58884e --- /dev/null +++ b/src/main/java/dev/fluidsim/io/InputHandler.java @@ -0,0 +1,117 @@ +package dev.fluidsim.io; + +import dev.fluidsim.simulation.Sim; +import org.lwjgl.glfw.GLFWCursorPosCallback; +import org.lwjgl.glfw.GLFWKeyCallback; +import org.lwjgl.glfw.GLFWMouseButtonCallback; + +import static org.lwjgl.glfw.GLFW.*; + +/** + * Handles keyboard and mouse input for fluid simulation interaction. + * Translates user input into forces and density applied to the simulation. + */ +public class InputHandler { + private final Sim simulation; + + private double prevMouseX = 0; + private double prevMouseY = 0; + private boolean mousePressed = false; + private boolean shiftDown = false; + private boolean ctrlDown = false; + + /** + * Creates an input handler for the specified simulation. + * + * @param simulation the simulation to receive input events + */ + public InputHandler(Sim simulation) { + this.simulation = simulation; + } + + /** + * Creates a keyboard callback that handles ESC for window close and tracks + * modifier keys. + * + * @return the GLFW key callback + */ + public GLFWKeyCallback createKeyCallback() { + return new GLFWKeyCallback() { + @Override + public void invoke(long window, int key, int scancode, int action, int mods) { + if (key == GLFW_KEY_ESCAPE && action == GLFW_RELEASE) { + glfwSetWindowShouldClose(window, true); + } + + // Track modifier keys + shiftDown = (mods & GLFW_MOD_SHIFT) != 0; + ctrlDown = (mods & GLFW_MOD_CONTROL) != 0; + } + }; + } + + /** + * Creates a mouse button callback that tracks left button state and modifier + * keys. + * + * @return the GLFW mouse button callback + */ + public GLFWMouseButtonCallback createMouseButtonCallback() { + return new GLFWMouseButtonCallback() { + @Override + public void invoke(long window, int button, int action, int mods) { + if (button == GLFW_MOUSE_BUTTON_LEFT) { + mousePressed = (action == GLFW_PRESS); + } + + // Track modifier keys + shiftDown = (mods & GLFW_MOD_SHIFT) != 0; + ctrlDown = (mods & GLFW_MOD_CONTROL) != 0; + } + }; + } + + /** + * Creates a cursor position callback that applies forces/density based on mouse + * drag. + * Shift adds density, Ctrl removes density, plain drag applies force only. + * + * @return the GLFW cursor position callback + */ + public GLFWCursorPosCallback createCursorPosCallback() { + return new GLFWCursorPosCallback() { + @Override + public void invoke(long window, double xpos, double ypos) { + if (mousePressed) { + float gridX = (float) xpos; + // Y: invert because GLFW has Y=0 at top, OpenGL has Y=0 at bottom + float gridY = (float) (simulation.getHeight() - ypos); + + // Calculate mouse delta + float deltaX = (float) (xpos - prevMouseX); + float deltaY = (float) (ypos - prevMouseY); + + // Scale forces and invert Y (increased multiplier for visible effect) + float forceX = deltaX * 10_000.0f; + float forceY = -deltaY * 10_000.0f; + + if (shiftDown) { + // Add density and force + float magnitude = (float) Math.sqrt(deltaX * deltaX + deltaY * deltaY); + float density = 50.0f * (1.0f + magnitude / 10.0f); + simulation.addForceAndDensity(gridX, gridY, forceX, forceY, density); + + } else if (ctrlDown) { + simulation.addForceAndDensity(gridX, gridY, 0, 0, -1000); + + } else { + simulation.addForceAndDensity(gridX, gridY, forceX, forceY, 0); + } + } + + prevMouseX = xpos; + prevMouseY = ypos; + } + }; + } +} diff --git a/src/main/java/dev/fluidsim/io/LoadableParams.java b/src/main/java/dev/fluidsim/io/LoadableParams.java new file mode 100644 index 0000000..793a0c0 --- /dev/null +++ b/src/main/java/dev/fluidsim/io/LoadableParams.java @@ -0,0 +1,201 @@ +package dev.fluidsim.io; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.sax.SAXTransformerFactory; +import javax.xml.transform.sax.TransformerHandler; +import javax.xml.transform.stream.StreamResult; + +import org.xml.sax.SAXException; +import org.xml.sax.helpers.AttributesImpl; + +import dev.fluidsim.simulation.Nozzle; +import dev.fluidsim.simulation.SimParams; + +/** + * Extends SimParams with XML serialization capabilities. + * Supports loading and saving simulation configuration to/from XML files. + */ +public class LoadableParams extends SimParams { + /** + * Creates simulation parameters with default values. + */ + public LoadableParams() { + super(); + } + + /** + * Creates simulation parameters with specified runtime values. + * + * @param iterations number of solver iterations per step, must be + * positive + * @param viscosity fluid viscosity coefficient, typically 0.0-1.0 + * @param diffusion density diffusion rate, typically 0.0-1.0 + * @param vorticity vorticity confinement strength, typically 0.0-50.0 + * @param dissipation velocity dissipation factor, typically 0.99-1.0 + * @param interactionRadius mouse interaction radius in pixels, must be positive + */ + public LoadableParams(int iterations, float viscosity, float diffusion, + float vorticity, float dissipation, float interactionRadius) { + super(iterations, viscosity, diffusion, vorticity, dissipation, interactionRadius); + } + + /** + * Helper method to write a simple element with text content + */ + private void writeElement(TransformerHandler handler, String name, T value) throws org.xml.sax.SAXException { + AttributesImpl atts = new AttributesImpl(); + handler.startElement("", "", name, atts); + handler.characters(value.toString().toCharArray(), 0, value.toString().length()); + handler.endElement("", "", name); + } + + /** + * Save SimParams to XML file + * + * @param path Path to save the XML file + * @throws IOException If file writing fails + * @throws TransformerException If XML transformation fails + * @throws SAXException If XML generation fails + */ + public void save(String path) throws IOException, TransformerException, SAXException { + SAXTransformerFactory factory = (SAXTransformerFactory) TransformerFactory.newInstance(); + TransformerHandler handler = factory.newTransformerHandler(); + Transformer transformer = handler.getTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + + FileWriter writer = new FileWriter(path); + handler.setResult(new StreamResult(writer)); + + AttributesImpl attributes = new AttributesImpl(); + + try { + handler.startDocument(); + handler.startElement("", "", "SimParams", attributes); + + // Simulation section + handler.startElement("", "", "simulation", attributes); + writeElement(handler, "iterations", iterations); + writeElement(handler, "viscosity", viscosity); + writeElement(handler, "diffusion", diffusion); + writeElement(handler, "vorticity", vorticity); + writeElement(handler, "dissipation", dissipation); + writeElement(handler, "interactionRadius", interactionRadius); + writeElement(handler, "velocityView", velocityView); + writeElement(handler, "showArrows", showArrows); + handler.endElement("", "", "simulation"); + + // Grid section + handler.startElement("", "", "grid", attributes); + writeElement(handler, "width", width); + writeElement(handler, "height", height); + handler.endElement("", "", "grid"); + + // Arrow section + attributes.clear(); + attributes.addAttribute("", "", "density", "CDATA", String.valueOf(arrowDensity)); + attributes.addAttribute("", "", "maxLength", "CDATA", String.valueOf(maxArrowLength)); + attributes.addAttribute("", "", "headLength", "CDATA", String.valueOf(arrowHeadLength)); + attributes.addAttribute("", "", "headAngle", "CDATA", String.valueOf(arrowHeadAngle)); + attributes.addAttribute("", "", "velocityScale", "CDATA", String.valueOf(velocityScale)); + attributes.addAttribute("", "", "colorR", "CDATA", String.valueOf(arrowColor.getRed())); + attributes.addAttribute("", "", "colorG", "CDATA", String.valueOf(arrowColor.getGreen())); + attributes.addAttribute("", "", "colorB", "CDATA", String.valueOf(arrowColor.getBlue())); + attributes.addAttribute("", "", "colorA", "CDATA", String.valueOf(arrowColor.getAlpha())); + handler.startElement("", "", "arrow", attributes); + handler.endElement("", "", "arrow"); + + // Nozzles section + if (nozzles != null && !nozzles.isEmpty()) { + attributes.clear(); + handler.startElement("", "", "nozzles", attributes); + + for (Nozzle nozzle : this.nozzles) { + attributes.clear(); + attributes.addAttribute("", "", "side", "CDATA", nozzle.getSide().toString()); + attributes.addAttribute("", "", "position", "CDATA", String.valueOf(nozzle.getPosition())); + attributes.addAttribute("", "", "radius", "CDATA", String.valueOf(nozzle.getRadius())); + attributes.addAttribute("", "", "length", "CDATA", String.valueOf(nozzle.getLength())); + attributes.addAttribute("", "", "directionDegrees", "CDATA", + String.valueOf(Math.toDegrees(Math.atan2(nozzle.getForceY(), nozzle.getForceX())))); + attributes.addAttribute("", "", "forceMagnitude", "CDATA", + String.valueOf(nozzle.getForceMagnitude())); + attributes.addAttribute("", "", "density", "CDATA", String.valueOf(nozzle.getDensity())); + handler.startElement("", "", "nozzle", attributes); + handler.endElement("", "", "nozzle"); + } + + handler.endElement("", "", "nozzles"); + } + + handler.endElement("", "", "SimParams"); + handler.endDocument(); + } finally { + writer.close(); + } + } + + /** + * Loads parameters from XML file and updates this instance's fields. + * + * @param path file path to XML configuration + * @throws ParserConfigurationException if parser cannot be configured + * @throws SAXException if XML parsing fails + * @throws IOException if file cannot be read + */ + public void loadAndSet(String path) throws ParserConfigurationException, SAXException, IOException { + var p = LoadableParams.load(path); + // width = p.width; + // height = p.height; + + iterations = p.iterations; + viscosity = p.viscosity; + diffusion = p.diffusion; + vorticity = p.vorticity; + dissipation = p.dissipation; + interactionRadius = p.interactionRadius; + velocityView = p.velocityView; + showArrows = p.showArrows; + + arrowDensity = p.arrowDensity; + maxArrowLength = p.maxArrowLength; + arrowHeadLength = p.arrowHeadLength; + arrowHeadAngle = p.arrowHeadAngle; + velocityScale = p.velocityScale; + arrowColor = p.arrowColor; + + nozzles = p.nozzles; + } + + /** + * Load SimParams from XML file + * + * @param path Path to the XML file + * @return SimParams object loaded from XML + * @throws ParserConfigurationException If parser configuration fails + * @throws SAXException If XML parsing fails + * @throws IOException If file reading fails + */ + public static LoadableParams load(String path) + throws ParserConfigurationException, SAXException, IOException { + + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + ParamsHandler handler = new ParamsHandler(); + + parser.parse(new File(path), handler); + + return handler.getParams(); + } +} diff --git a/src/main/java/dev/fluidsim/io/ParamsHandler.java b/src/main/java/dev/fluidsim/io/ParamsHandler.java new file mode 100644 index 0000000..87f1c57 --- /dev/null +++ b/src/main/java/dev/fluidsim/io/ParamsHandler.java @@ -0,0 +1,154 @@ +package dev.fluidsim.io; + +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +import dev.fluidsim.simulation.Nozzle; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * SAX handler for parsing SimParams XML files + */ +public class ParamsHandler extends DefaultHandler { + private LoadableParams params; + private StringBuilder currentValue; + + private List nozzles; + + // Track current element context + private String currentSection; + + /** + * Creates a params handler with a new default LoadableParams instance. + */ + public ParamsHandler() { + this(new LoadableParams()); + } + + /** + * Creates a params handler that populates the specified LoadableParams + * instance. + * + * @param params the parameter object to populate during parsing + */ + public ParamsHandler(LoadableParams params) { + this.params = params; + this.currentValue = new StringBuilder(); + this.nozzles = new ArrayList<>(); + } + + /** + * Returns the populated parameters after parsing completes. + * + * @return the loaded simulation parameters + */ + public LoadableParams getParams() { + return params; + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException { + currentValue.setLength(0); + + switch (qName) { + case "simulation": + currentSection = "user"; + break; + + case "grid": + currentSection = "grid"; + break; + + case "arrow": + currentSection = "arrow"; + + // Parse arrow attributes + params.arrowDensity = Float.parseFloat(attributes.getValue("density")); + params.maxArrowLength = Float.parseFloat(attributes.getValue("maxLength")); + params.arrowHeadLength = Float.parseFloat(attributes.getValue("headLength")); + params.arrowHeadAngle = Float.parseFloat(attributes.getValue("headAngle")); + params.velocityScale = Float.parseFloat(attributes.getValue("velocityScale")); + params.arrowColor = new Color( + Integer.parseInt(attributes.getValue("colorR")), + Integer.parseInt(attributes.getValue("colorG")), + Integer.parseInt(attributes.getValue("colorB")), + Integer.parseInt(attributes.getValue("colorA"))); + + break; + + case "nozzles": + currentSection = "nozzles"; + break; + + case "nozzle": + nozzles.add(new Nozzle( + Nozzle.Side.valueOf(attributes.getValue("side")), + Float.parseFloat(attributes.getValue("position")), + Float.parseFloat(attributes.getValue("radius")), + Float.parseFloat(attributes.getValue("length")), + Float.parseFloat(attributes.getValue("directionDegrees")), + Float.parseFloat(attributes.getValue("forceMagnitude")), + Float.parseFloat(attributes.getValue("density")))); + break; + } + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + String value = currentValue.toString().trim(); + + // Parse based on current section + if ("user".equals(currentSection)) { + parseUserInteractable(qName, value); + } else if ("grid".equals(currentSection)) { + parseGrid(qName, value); + } else if ("nozzles".equals(currentSection)) { + if ("nozzles".equals(qName)) { + params.nozzles = nozzles; + } + } + } + + private void parseUserInteractable(String qName, String value) { + switch (qName) { + case "iterations": + params.iterations = Integer.parseInt(value); + break; + case "viscosity": + params.viscosity = Float.parseFloat(value); + break; + case "diffusion": + params.diffusion = Float.parseFloat(value); + break; + case "vorticity": + params.vorticity = Float.parseFloat(value); + break; + case "dissipation": + params.dissipation = Float.parseFloat(value); + break; + case "interactionRadius": + params.interactionRadius = Float.parseFloat(value); + break; + } + } + + private void parseGrid(String qName, String value) { + switch (qName) { + case "width": + params.width = Integer.parseInt(value); + break; + case "height": + params.height = Integer.parseInt(value); + break; + } + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + currentValue.append(ch, start, length); + } +} diff --git a/src/main/java/dev/fluidsim/render/ArrowRenderer.java b/src/main/java/dev/fluidsim/render/ArrowRenderer.java new file mode 100644 index 0000000..9d4b865 --- /dev/null +++ b/src/main/java/dev/fluidsim/render/ArrowRenderer.java @@ -0,0 +1,171 @@ +package dev.fluidsim.render; + +import dev.fluidsim.engine.ShaderProgram; +import dev.fluidsim.engine.Texture; +import org.joml.Matrix4f; + +import java.nio.FloatBuffer; + +import static org.lwjgl.opengl.GL33.*; +import static org.lwjgl.system.MemoryUtil.*; + +/** + * Renders velocity field arrows using GPU instancing. + * Samples velocity textures at grid positions and draws directional arrows. + */ +public class ArrowRenderer { + private static final float ARROW_DENSITY = 0.05f; + private static final float MAX_ARROW_LENGTH = 15.0f; + private static final float ARROWHEAD_LENGTH = 4.0f; + private static final float ARROWHEAD_ANGLE = 25.0f; // degrees + private static final float VELOCITY_SCALE = 0.5f; + + private ShaderProgram arrowShader; + private int VAO; + private int templateVBO; // Arrow template vertices + private int instanceVBO; // Instance positions + + private int instanceCount; + + /** + * Initializes the arrow renderer with shader program, VAO, and VBOs for + * template and instance data. + */ + public ArrowRenderer() { + arrowShader = ShaderProgram.load("/shaders/arrow.vert", "/shaders/arrow.frag"); + + VAO = glGenVertexArrays(); + glBindVertexArray(VAO); + + // Create arrow template (3 lines: main line + 2 arrowhead lines) + // Positions relative to origin, will be scaled and rotated per instance + + var rad = Math.toRadians(ARROWHEAD_ANGLE); + + float[] arrowTemplate = { + // Main line (from origin to 1.0) + 0.0f, 0.0f, + 1.0f, 0.0f, + + // Left arrowhead line + 1.0f, 0.0f, + 1.0f - ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.cos(rad), + ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.sin(rad), + + // Right arrowhead line + 1.0f, 0.0f, + 1.0f - ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.cos(rad), + -ARROWHEAD_LENGTH / MAX_ARROW_LENGTH * (float) Math.sin(rad) + }; + + // Upload arrow template + templateVBO = glGenBuffers(); + glBindBuffer(GL_ARRAY_BUFFER, templateVBO); + glBufferData(GL_ARRAY_BUFFER, arrowTemplate, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, false, 2 * Float.BYTES, 0); + glEnableVertexAttribArray(0); + + // Create instance VBO (will be filled per frame) + instanceVBO = glGenBuffers(); + glBindBuffer(GL_ARRAY_BUFFER, instanceVBO); + glVertexAttribPointer(1, 2, GL_FLOAT, false, 2 * Float.BYTES, 0); + glEnableVertexAttribArray(1); + glVertexAttribDivisor(1, 1); // This attribute advances once per instance + + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindVertexArray(0); + } + + /** + * Renders velocity field arrows by sampling velocity textures at grid + * positions. + * + * @param velocityXTexture the x-component velocity field texture + * @param velocityYTexture the y-component velocity field texture + * @param width screen width in pixels + * @param height screen height in pixels + */ + public void renderArrows(Texture velocityXTexture, Texture velocityYTexture, int width, int height) { + int gridWidth = velocityXTexture.getWidth(); + int gridHeight = velocityXTexture.getHeight(); + + int arrowGridWidth = (int) (gridWidth * ARROW_DENSITY); + int arrowGridHeight = (int) (gridHeight * ARROW_DENSITY); + + // Generate instance positions + instanceCount = arrowGridWidth * arrowGridHeight; + FloatBuffer instanceData = memAllocFloat(instanceCount * 2); + + int stepX = gridWidth / arrowGridWidth; + int stepY = gridHeight / arrowGridHeight; + + for (int j = 0; j < arrowGridHeight; j++) { + for (int i = 0; i < arrowGridWidth; i++) { + float gridX = i * stepX + stepX / 2.0f; + float gridY = j * stepY + stepY / 2.0f; + + // Pass grid coordinates (will convert to screen space in shader) + instanceData.put(gridX); + instanceData.put(gridY); + } + } + instanceData.flip(); + + // Upload instance data + glBindBuffer(GL_ARRAY_BUFFER, instanceVBO); + glBufferData(GL_ARRAY_BUFFER, instanceData, GL_DYNAMIC_DRAW); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + memFree(instanceData); + + // Set up rendering state + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + glLineWidth(2.0f); + + // Create orthographic projection matrix + Matrix4f projection = new Matrix4f().ortho(0, width, height, 0, -1, 1); + float[] projectionData = new float[16]; + projection.get(projectionData); + + // Render arrows with instancing + arrowShader.use(); + + velocityXTexture.bind(0); + velocityYTexture.bind(1); + + arrowShader.setUniform("uProjection", + projectionData[0], projectionData[1], projectionData[2], projectionData[3], + projectionData[4], projectionData[5], projectionData[6], projectionData[7], + projectionData[8], projectionData[9], projectionData[10], projectionData[11], + projectionData[12], projectionData[13], projectionData[14], projectionData[15]); + + arrowShader.setUniform("uVelocityX", 0); + arrowShader.setUniform("uVelocityY", 1); + arrowShader.setUniform("uTexelSize", 1.0f / gridWidth, 1.0f / gridHeight); + arrowShader.setUniform("uGridSize", (float) gridWidth, (float) gridHeight); + arrowShader.setUniform("uScreenSize", (float) width, (float) height); + arrowShader.setUniform("uMaxArrowLength", MAX_ARROW_LENGTH); + arrowShader.setUniform("uVelocityScale", VELOCITY_SCALE); + arrowShader.setUniform("uColor", 1.0f, 1.0f, 1.0f); + + glBindVertexArray(VAO); + glDrawArraysInstanced(GL_LINES, 0, 6, instanceCount); // 6 vertices per arrow, multiple instances + glBindVertexArray(0); + + arrowShader.unuse(); + + glDisable(GL_BLEND); + glLineWidth(1.0f); + } + + /** + * Releases OpenGL resources including VAO, VBOs, and shader program. + */ + public void cleanup() { + glDeleteVertexArrays(VAO); + glDeleteBuffers(templateVBO); + glDeleteBuffers(instanceVBO); + arrowShader.delete(); + } +} diff --git a/src/main/java/dev/fluidsim/render/Renderer.java b/src/main/java/dev/fluidsim/render/Renderer.java new file mode 100644 index 0000000..1c4227b --- /dev/null +++ b/src/main/java/dev/fluidsim/render/Renderer.java @@ -0,0 +1,78 @@ +package dev.fluidsim.render; + +import dev.fluidsim.engine.QuadMesh; +import dev.fluidsim.engine.ShaderProgram; +import dev.fluidsim.simulation.Constants; +import dev.fluidsim.simulation.Sim; + +import static org.lwjgl.opengl.GL33.*; + +/** + * Main renderer for fluid simulation visualization. + * Composites density/velocity fields and optional arrow overlay. + */ +public class Renderer { + private ShaderProgram displayShader; + private QuadMesh quad; + private ArrowRenderer arrowRenderer; + + /** + * Creates a renderer with display shader, fullscreen quad, and arrow renderer. + */ + public Renderer() { + displayShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/display.frag"); + quad = new QuadMesh(); + arrowRenderer = new ArrowRenderer(); + } + + /** + * Renders the simulation state to the screen framebuffer. + * + * @param simulation the simulation to render + * @param width viewport width in pixels + * @param height viewport height in pixels + * @param showVelocity if true, visualizes velocity field instead of density + * @param showArrows if true, renders velocity arrows on top + */ + public void renderToScreen(Sim simulation, int width, int height, boolean showVelocity, boolean showArrows) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, width, height); + glClear(GL_COLOR_BUFFER_BIT); + + displayShader.use(); + + var velocityXTexture = simulation.getVelocityXTexture(); + var velocityYTexture = simulation.getVelocityYTexture(); + + simulation.getDensityTexture().bind(0); + velocityXTexture.bind(1); + velocityYTexture.bind(2); + simulation.getObjectTexture().bind(3); + + displayShader.setUniform("uDensity", 0); + displayShader.setUniform("uVelocityX", 1); + displayShader.setUniform("uVelocityY", 2); + displayShader.setUniform("uObstacle", 3); + displayShader.setUniform("uMinDensity", Constants.MIN_DENSITY); + displayShader.setUniform("uMaxDensity", Constants.MAX_DENSITY); + displayShader.setUniform("uShowVelocity", showVelocity ? 1 : 0); + + quad.draw(); + + displayShader.unuse(); + + if (showArrows) { + arrowRenderer.renderArrows(velocityXTexture, velocityYTexture, width, height); + } + } + + /** + * Releases all rendering resources including shaders, quad mesh, and arrow + * renderer. + */ + public void cleanup() { + displayShader.delete(); + quad.delete(); + arrowRenderer.cleanup(); + } +} diff --git a/src/main/java/dev/fluidsim/simulation/Cell.java b/src/main/java/dev/fluidsim/simulation/Cell.java deleted file mode 100644 index 2e96daf..0000000 --- a/src/main/java/dev/fluidsim/simulation/Cell.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.fluidsim.simulation; - -public class Cell { - private double flowUp = 0; - private double flowDown = 0; - private double flowLeft = 0; - private double flowRight = 0; -} diff --git a/src/main/java/dev/fluidsim/simulation/Constants.java b/src/main/java/dev/fluidsim/simulation/Constants.java new file mode 100644 index 0000000..92504cf --- /dev/null +++ b/src/main/java/dev/fluidsim/simulation/Constants.java @@ -0,0 +1,51 @@ +package dev.fluidsim.simulation; + +import java.awt.Color; + +/** + * Default constant values for fluid simulation parameters. + * Defines grid dimensions, simulation physics constants, and arrow rendering + * properties. + */ +public interface Constants { + /** minimum density value for clamping */ + static final float MIN_DENSITY = 0.0f; + /** maximum density value for clamping */ + static final float MAX_DENSITY = 1.0f; + + /** default grid width in pixels */ + public static final int WIDTH = 1000; + /** default grid height in pixels */ + public static final int HEIGHT = 1000; + + /** default number of solver iterations per step */ + public static final int ITERATIONS = 20; + /** default fluid viscosity coefficient */ + public static final float VISCOSITY = 0.004f; + /** default density diffusion rate */ + public static final float DIFFUSION = 0.002f; + /** default vorticity confinement strength */ + public static final float VORTICITY = 25.0f; + /** default velocity dissipation factor (0.9998 = very slow decay) */ + public static final float DISSIPATION = 0.9998f; + /** default mouse interaction radius in pixels */ + public static final float INTERACTION_RADIUS = 200.0f; + /** default velocity field visualization toggle */ + public static final boolean VELOCITY_VIEW = false; + /** default arrow visualization toggle */ + public static final boolean SHOW_ARROWS = false; + + /** default spacing between arrows (as fraction of grid size) */ + public static final float ARROW_DENSITY = 0.1f; + /** default maximum arrow length in pixels */ + public static final float MAX_ARROW_LENGTH = 30.0f; + /** default arrowhead length in pixels */ + public static final float ARROWHEAD_LENGTH = 5.0f; + /** default arrowhead angle in radians */ + public static final float ARROWHEAD_ANGLE = 0.5f; + /** default velocity to arrow length scale factor */ + public static final float VELOCITY_SCALE = 1.0f; + + /** default arrow color (white, fully opaque) */ + public static final Color ARROW_COLOR = new Color(255, 255, 255, 255); +} diff --git a/src/main/java/dev/fluidsim/simulation/GridUtil.java b/src/main/java/dev/fluidsim/simulation/GridUtil.java deleted file mode 100644 index ab17c32..0000000 --- a/src/main/java/dev/fluidsim/simulation/GridUtil.java +++ /dev/null @@ -1,100 +0,0 @@ -package dev.fluidsim.simulation; - -import java.util.Locale; -import java.util.Random; - -public class GridUtil { - /** - * Formats a 1D float array into a 2D grid string with borders. - * - * @param data The 1D array containing the 2D grid data (in row-major order). - * @param width The number of columns in the grid. - * @return A string representing the formatted grid. - */ - public static String print(float[] data, int cols, int rows) { - StringBuilder sb = new StringBuilder(); - - // --- 2. First Pass: Find max width of formatted numbers --- - int maxFormattedWidth = 0; - for (float val : data) { - String formattedVal = String.format("%.2f", val); - maxFormattedWidth = Math.max(maxFormattedWidth, formattedVal.length()); - } - - // --- 3. Define Cell Format and Separator Line --- - - var cellFormat = " %" + maxFormattedWidth + ".2f "; - var cellWidth = cellFormat.formatted(0.0f).length(); - - String separatorLine = createSeparator(cols, cellWidth); - - for (int r = 0; r < rows; r++) { - sb.append(separatorLine); - - for (int c = 0; c < cols; c++) { - sb.append("|"); - sb.append(String.format(cellFormat, data[c + (cols + 2) * r])); - } - - sb.append("|\n"); - } - - sb.append(separatorLine); - return sb.toString(); - } - - /** - * Formats a 1D float array into a 2D grid string with borders. - * - * @param data The 1D array containing the 2D grid data (in row-major order). - * @param width The number of columns in the grid. - * @return A string representing the formatted grid. - */ - public static String toCSV(float[] data, int cols, int rows) { - StringBuilder sb = new StringBuilder(); - Locale.setDefault(Locale.GERMANY); - - for (int r = 0; r < rows; r++) { - for (int c = 0; c < cols; c++) { - sb.append(String.format("%.5f", data[c + (cols + 2) * r])); - if (c != cols - 1) - sb.append(";"); - } - - sb.append("\n"); - } - - return sb.toString(); - } - - /** - * Helper function to create a row separator string like "+----+----+" - */ - private static String createSeparator(int cols, int cellWidth) { - StringBuilder separator = new StringBuilder(); - for (int c = 0; c < cols; c++) { - separator.append("+"); - for (int i = 0; i < cellWidth; i++) - separator.append("-"); - } - - separator.append("+\n"); - return separator.toString(); - } - - private static Random rand = new Random(); - - public static void addRandomValues(float array[], float min, float max) { - for (int i = 1; i < array.length - 1; i++) { - array[i] = rand.nextFloat(min, max); - } - } - - public static void addValueTo(float array[], int rows, int cols, int i, int j, int width, int height, float value) { - for (int y = j; y < j + height; y++) { - for (int x = i; x < i + width; x++) { - array[x + (cols + 2) * y] = value; - } - } - } -} \ No newline at end of file diff --git a/src/main/java/dev/fluidsim/simulation/Nozzle.java b/src/main/java/dev/fluidsim/simulation/Nozzle.java new file mode 100644 index 0000000..942cbbb --- /dev/null +++ b/src/main/java/dev/fluidsim/simulation/Nozzle.java @@ -0,0 +1,237 @@ +package dev.fluidsim.simulation; + +/** + * A nozzle that continuously emits fluid at a specific location with a given + * direction and force. + * Can be placed on the sides of the simulation area. + * + * Example usage: + * + *
+ * // Create a nozzle on the left side, at center position, pointing right
+ * Nozzle leftNozzle = new Nozzle(
+ *         Nozzle.Side.LEFT, // Place on left side
+ *         0.5f, // At center (50% of the way up)
+ *         30.0f, // 30 pixel radius
+ *         200.0f, // 200 pixel beam length
+ *         0.0f, // 0 degrees (pointing right)
+ *         500.0f, // Force magnitude
+ *         0.5f // Density amount
+ * );
+ * simulation.addNozzle(leftNozzle);
+ * 
+ * // Create a nozzle on the bottom, pointing diagonally up-right
+ * Nozzle bottomNozzle = new Nozzle(
+ *         Nozzle.Side.BOTTOM, // Place on bottom side
+ *         0.3f, // 30% from left edge
+ *         40.0f, // 40 pixel radius
+ *         150.0f, // 150 pixel beam length
+ *         45.0f, // 45 degrees (diagonal up-right)
+ *         300.0f, // Force magnitude
+ *         0.8f // Density amount
+ * );
+ * simulation.addNozzle(bottomNozzle);
+ * 
+ */ +public class Nozzle implements SimObject { + /** + * The side of the simulation area where a nozzle can be placed. + */ + public enum Side { + /** left edge of simulation */ + LEFT, + /** right edge of simulation */ + RIGHT, + /** top edge of simulation */ + TOP, + /** bottom edge of simulation */ + BOTTOM + } + + private final Side side; + private final float position; // Position along the side (0.0 to 1.0) + private final float radius; // Base area radius (similar to interaction radius) + private final float length; // Length of the beam in the direction of emission + private final float forceX; // X component of force direction + private final float forceY; // Y component of force direction + private final float forceMagnitude;// Magnitude of the force + private final float density; // Density amount to emit + + private float worldX; // Actual X position in grid coordinates + private float worldY; // Actual Y position in grid coordinates + private float directionRadians; // Direction in radians for beam calculation + + /** + * Create a nozzle at a specific side of the simulation area + * + * @param side Which side to place the nozzle (LEFT, RIGHT, TOP, + * BOTTOM) + * @param position Position along the side (0.0 to 1.0, where 0.5 is + * center) + * @param radius Base area radius for emission + * @param length Length of the beam in pixels + * @param directionDegrees Direction angle in degrees (0 = right, 90 = up, 180 = + * left, 270 = down) + * @param forceMagnitude Magnitude of the force + * @param density Density amount to emit + */ + public Nozzle(Side side, float position, float radius, float length, float directionDegrees, + float forceMagnitude, float density) { + this.side = side; + this.position = Math.max(0.0f, Math.min(1.0f, position)); + this.radius = radius; + this.length = length; + this.forceMagnitude = forceMagnitude; + this.density = density; + + // Convert direction angle to force components + this.directionRadians = (float) Math.toRadians(directionDegrees); + this.forceX = (float) Math.cos(directionRadians) * forceMagnitude; + this.forceY = (float) Math.sin(directionRadians) * forceMagnitude; + } + + /** + * Initialize nozzle world position based on grid dimensions + */ + @Override + public void initialize(int gridWidth, int gridHeight) { + switch (side) { + case LEFT: + worldX = 0; + worldY = position * gridHeight; + break; + case RIGHT: + worldX = gridWidth; + worldY = position * gridHeight; + break; + case TOP: + worldX = position * gridWidth; + worldY = gridHeight; + break; + case BOTTOM: + worldX = position * gridWidth; + worldY = 0; + break; + } + } + + /** + * Apply this nozzle's force and density to the simulation along a beam + */ + @Override + public void apply(Sim simulation) { + // Calculate normalized direction + float dirX = (float) Math.cos(directionRadians); + float dirY = (float) Math.sin(directionRadians); + + // Calculate number of points along the beam (space them by radius distance) + int numPoints = Math.max(1, (int) (length / radius) + 1); + + // Apply force at multiple points along the beam + for (int i = 0; i < numPoints; i++) { + float t = i / (float) Math.max(1, numPoints - 1); // 0 to 1 + float distance = t * length; + + float x = worldX + dirX * distance; + float y = worldY + dirY * distance; + + simulation.addForceAndDensity(x, y, forceX, forceY, density, radius); + } + } + + /** + * Returns the side where this nozzle is placed. + * + * @return the placement side + */ + public Side getSide() { + return side; + } + + /** + * Returns the position along the side. + * + * @return position from 0.0 (start) to 1.0 (end) + */ + public float getPosition() { + return position; + } + + /** + * Returns the base emission radius. + * + * @return radius in pixels + */ + public float getRadius() { + return radius; + } + + /** + * Returns the emission beam length. + * + * @return length in pixels + */ + public float getLength() { + return length; + } + + /** + * Returns the x-component of the force vector. + * + * @return force in x direction + */ + public float getForceX() { + return forceX; + } + + /** + * Returns the y-component of the force vector. + * + * @return force in y direction + */ + public float getForceY() { + return forceY; + } + + /** + * Returns the total force magnitude. + * + * @return force magnitude + */ + public float getForceMagnitude() { + return forceMagnitude; + } + + /** + * Returns the density emission amount. + * + * @return density value + */ + public float getDensity() { + return density; + } + + /** + * Returns the world x-coordinate after initialization. + * + * @return x position in grid coordinates + */ + public float getWorldX() { + return worldX; + } + + /** + * Returns the world y-coordinate after initialization. + * + * @return y position in grid coordinates + */ + public float getWorldY() { + return worldY; + } + + @Override + public String toString() { + return String.format("Nozzle[side=%s, pos=%.2f, force=(%.2f,%.2f), density=%.2f]", + side, position, forceX, forceY, density); + } +} diff --git a/src/main/java/dev/fluidsim/simulation/Sim.java b/src/main/java/dev/fluidsim/simulation/Sim.java index 71c01e9..39de225 100644 --- a/src/main/java/dev/fluidsim/simulation/Sim.java +++ b/src/main/java/dev/fluidsim/simulation/Sim.java @@ -1,454 +1,779 @@ package dev.fluidsim.simulation; -import java.util.stream.IntStream; +import dev.fluidsim.engine.*; -public class Sim { - public enum Boundary { - None, - Vertical, - Horizontal, - } - - private static final float RAND_MIN = 1f; - private static final float RAND_MAX = 10f; - - float[] mVelocityX, mVelocityY, - mSourceVelocityX, mSourceVelocityY, - mDensity, mSourceDensity; - - private final int mRows; - private final int mCols; - private final int mSize; - - private final int mIterations; - private final float mViscosity; - private final float mDiffusion; - private final float mVorticity; - - private float mMinDensity = Float.MAX_VALUE; - private float mMaxDensity = 0; - - public Sim(int rows, int cols) { - mSize = (rows + 2) * (cols + 2); - - mVelocityX = new float[mSize]; - mVelocityY = new float[mSize]; - mSourceVelocityX = new float[mSize]; - mSourceVelocityY = new float[mSize]; - mDensity = new float[mSize]; - mSourceDensity = new float[mSize]; - - mRows = rows; - mCols = cols; - - mIterations = 15; - mViscosity = 1.5f; - mDiffusion = 0.2f; - mVorticity = 15f; - - // GridUtil.addRandomValues(mSourceDensity, RAND_MIN, RAND_MAX); - // GridUtil.addRandomValues(mSourceVelocityX, -RAND_MAX * .5f, RAND_MAX * .5f); - // GridUtil.addRandomValues(mSourceVelocityY, -RAND_MAX * .5f, RAND_MAX * .5f); - - // GridUtil.addValueTo(mSourceDensity, rows, cols, cols / 4, rows / 4, cols / 2, - // rows / 2, 10f); - - // for (int i = 1; i <= mRows; i += 25) { - // for (int k = 0; k < 3; k++) { - // for (int j = 1; j < 5; j++) { - // mSourceDensity[at(j, i + k)] = 10f; - // // mSourceVelocityX[at(j, i + k)] = 5f; - // } - // } - // } - } - - // Add a force and density to the simulation - public void addForceAndDensity(int x, int y, float powerX, float powerY, float density) { - // % of the cell affected - float brushSize = 0.2f; - - // Add the forces and density to every cell next to the one given - int size = (int) (20 * brushSize * 0.5f); - - for (int i = -size; i < size; ++i) { - for (int j = -size; j < size; ++j) { - int posX = x + i; - int posY = y + j; - - if (posX < 0 || posX > mCols || posY < 0 || posY > mRows) - continue; - - int deltaX = (i < 0) ? -i : i; - int deltaY = (j < 0) ? -j : j; - - if (deltaX + deltaY < size) { - mSourceDensity[at(posX, posY)] += density / (1 + deltaX + deltaY); - mSourceVelocityX[at(posX, posY)] += powerX / (1 + deltaX + deltaY); - mSourceVelocityY[at(posX, posY)] += powerY / (1 + deltaX + deltaY); - } - } - } +import java.util.ArrayList; +import java.util.List; + +import static org.lwjgl.opengl.GL11.glViewport; +import static org.lwjgl.opengl.GL30.GL_FRAMEBUFFER; +import static org.lwjgl.opengl.GL30.glBindFramebuffer; +/** + * GPU-accelerated Navier-Stokes fluid simulation using OpenGL shaders. + * Implements velocity advection, diffusion, pressure projection, and vorticity + * confinement. + */ +public class Sim { + private final int width; + private final int height; + private final SimParams params; + + // Object data + private final List simObjects = new ArrayList<>(); + private Texture objectTexture; + private volatile String requestObjectLoadPath = null; + private volatile boolean requestObjectClear = false; + + // Framebuffers + private Framebuffer velocityX_read, velocityX_write; + private Framebuffer velocityY_read, velocityY_write; + private Framebuffer density_read, density_write; + private Framebuffer pressure_read, pressure_write; + private Framebuffer divergence; + private Framebuffer curl; + + // Source buffers + private Framebuffer sourceVelocityX; + private Framebuffer sourceVelocityY; + private Framebuffer sourceDensity; + + // Shader programs + private ShaderProgram advectShader; + private ShaderProgram diffuseShader; + private ShaderProgram divergenceShader; + private ShaderProgram pressureShader; + private ShaderProgram gradientShader; + private ShaderProgram vorticityShader; + private ShaderProgram vorticityForceShader; + private ShaderProgram boundaryShader; + private ShaderProgram addSourceShader; + private ShaderProgram splatShader; + private ShaderProgram clearShader; + + private QuadMesh quad; + + /** + * Creates a new fluid simulation with specified grid dimensions and parameters. + * + * @param width grid width in cells + * @param height grid height in cells + * @param params simulation parameters controlling physics behavior + */ + public Sim(int width, int height, SimParams params) { + this.width = width; + this.height = height; + this.params = params; + + initFramebuffers(); + initShaders(); + + // Initialize empty obstacle (no walls) + objectTexture = ObstacleLoader.empty(width, height); + + quad = new QuadMesh(); + + // Clear all buffers + clearAllBuffers(); } - // Add a force and density to the simulation - public void clearSourceInRadius(int x, int y) { - // Add the forces and density to every cell next to the one given - int size = (int) (Math.sqrt(mRows * y + mCols * x) * 0.2); + private void initFramebuffers() { + velocityX_read = new Framebuffer(width, height); + velocityX_write = new Framebuffer(width, height); + velocityY_read = new Framebuffer(width, height); + velocityY_write = new Framebuffer(width, height); - for (int i = clamp(0, mCols, x - size); i < clamp(0, mCols, x + size); ++i) { - for (int j = clamp(0, mRows, y - size); j < clamp(0, mRows, y + size); ++j) { - mSourceDensity[at(i, j)] = 0; - mSourceVelocityX[at(i, j)] = 0; - mSourceVelocityY[at(i, j)] = 0; - } - } + density_read = new Framebuffer(width, height); + density_write = new Framebuffer(width, height); + + pressure_read = new Framebuffer(width, height); + pressure_write = new Framebuffer(width, height); + + divergence = new Framebuffer(width, height); + curl = new Framebuffer(width, height); + + sourceVelocityX = new Framebuffer(width, height); + sourceVelocityY = new Framebuffer(width, height); + sourceDensity = new Framebuffer(width, height); } - public void clearDensity(int x, int y) { - // Add the forces and density to every cell next to the one given - int size = (int) (Math.sqrt(mRows * y + mCols * x) * 0.1); + private void initShaders() { + advectShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/advect.frag"); + diffuseShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/diffuse.frag"); + divergenceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/divergence.frag"); + pressureShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/pressure.frag"); + gradientShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/gradient.frag"); + vorticityShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/vorticity.frag"); + vorticityForceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/vorticity_force.frag"); + boundaryShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/boundary.frag"); + addSourceShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/add_source.frag"); + splatShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/splat.frag"); + clearShader = ShaderProgram.load("/shaders/quad.vert", "/shaders/clear.frag"); + } - for (int i = clamp(0, mCols, x - size); i < clamp(0, mCols, x + size); ++i) { - for (int j = clamp(0, mRows, y - size); j < clamp(0, mRows, y + size); ++j) { - mDensity[at(i, j)] = 0; - } + private void clearAllBuffers() { + clearShader.use(); + clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f); + + Framebuffer[] buffers = { + velocityX_read, velocityX_write, velocityY_read, velocityY_write, + density_read, density_write, pressure_read, pressure_write, + divergence, curl, sourceVelocityX, sourceVelocityY, sourceDensity + }; + + for (Framebuffer fb : buffers) { + fb.bind(); + glViewport(0, 0, width, height); + quad.draw(); } + + clearShader.unuse(); + glBindFramebuffer(GL_FRAMEBUFFER, 0); } + /** + * Advances the simulation by one time step, applying all physics stages. + * + * @param deltaTime time step in seconds (unused, simulation uses fixed time + * step) + */ public void update(float deltaTime) { - updateVelocity(mViscosity, deltaTime); - updateDensity(mDiffusion, deltaTime); - vorticityConfinement(mVorticity, deltaTime); - } + glViewport(0, 0, width, height); - public int at(int i, int j) { - return i + (mCols + 2) * j; - } + // Handle object loading/clearing requests + processObjectRequest(); + updateObjects(); - public float[] getDensity() { - return mDensity; - } + // Add sources + addSource(velocityX_read, sourceVelocityX, velocityX_write, deltaTime); + Framebuffer temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + + addSource(velocityY_read, sourceVelocityY, velocityY_write, deltaTime); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; + + addSource(density_read, sourceDensity, density_write, deltaTime); + temp = density_read; + density_read = density_write; + density_write = temp; + + // Diffuse velocity + diffuseVelocity(deltaTime); + + // Project (make incompressible) + project(); + + // Advect velocity + advectVelocity(deltaTime); + + // Project again + project(); + + // Apply vorticity confinement + vorticityConfinement(deltaTime); - public float getDensity(int i, int j) { - return mDensity[at(i, j)]; + // Diffuse density + diffuseDensity(deltaTime); + + // Advect density + advectDensity(deltaTime); + + // Clear source buffers for next frame + clearSourceBuffers(); } - public int getCellCountY() { - return mRows; + private void addSource(Framebuffer target, Framebuffer source, Framebuffer output, float dt) { + output.bind(); + addSourceShader.use(); + + target.getTexture().bind(0); + source.getTexture().bind(1); + + addSourceShader.setUniform("uTarget", 0); + addSourceShader.setUniform("uSource", 1); + addSourceShader.setUniform("uDeltaTime", dt); + + quad.draw(); + + addSourceShader.unuse(); + output.unbind(); } - public int getCellCountX() { - return mCols; + private void diffuseVelocity(float dt) { + float alpha = dt * params.viscosity * width * height / 10000.0f; + float beta = 1.0f + 4.0f * alpha; + + // Diffuse X component + for (int i = 0; i < params.iterations; i++) { + jacobi(velocityX_write, velocityX_read, velocityX_read, alpha, beta); + Framebuffer temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + } + + setBounds(velocityX_read, velocityX_write, 1); // horizontal boundary + Framebuffer temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + + // Diffuse Y component + for (int i = 0; i < params.iterations; i++) { + jacobi(velocityY_write, velocityY_read, velocityY_read, alpha, beta); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; + } + + setBounds(velocityY_read, velocityY_write, 2); // vertical boundary + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; } - public float getMinDensity() { - return mMinDensity; + private void diffuseDensity(float dt) { + float alpha = dt * params.diffusion * width * height / 10000.0f; + float beta = 1.0f + 4.0f * alpha; + + for (int i = 0; i < params.iterations; i++) { + jacobi(density_write, density_read, density_read, alpha, beta, 1); + Framebuffer temp = density_read; + density_read = density_write; + density_write = temp; + } + setBounds(density_read, density_write, 0); // no boundary flip + Framebuffer temp = density_read; + density_read = density_write; + density_write = temp; } - public float getMaxDensity() { - return mMaxDensity; + private void jacobi(Framebuffer output, Framebuffer source, Framebuffer original, float alpha, float beta) { + jacobi(output, source, original, alpha, beta, 0); } - public float[] getVelocityX() { - return mVelocityX; + private void jacobi(Framebuffer output, Framebuffer source, Framebuffer original, float alpha, float beta, + int isDensity) { + output.bind(); + diffuseShader.use(); + + source.getTexture().bind(0); + original.getTexture().bind(1); + + diffuseShader.setUniform("uSource", 0); + diffuseShader.setUniform("uOriginal", 1); + diffuseShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + diffuseShader.setUniform("uAlpha", alpha); + diffuseShader.setUniform("uBeta", beta); + diffuseShader.setUniform("uIsDensity", isDensity); + + quad.draw(); + + diffuseShader.unuse(); + output.unbind(); } - public float[] getVelocityY() { - return mVelocityY; + private void project() { + Framebuffer temp; + + // Compute divergence + divergence.bind(); + divergenceShader.use(); + + velocityX_read.getTexture().bind(0); + velocityY_read.getTexture().bind(1); + objectTexture.bind(2); + + divergenceShader.setUniform("uVelocityX", 0); + divergenceShader.setUniform("uVelocityY", 1); + divergenceShader.setUniform("uObstacle", 2); + divergenceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + + quad.draw(); + + divergenceShader.unuse(); + divergence.unbind(); + + // Clear pressure + clearBuffer(pressure_read); + + // Solve for pressure using Jacobi iteration + for (int i = 0; i < params.iterations; i++) { + pressure_write.bind(); + pressureShader.use(); + + pressure_read.getTexture().bind(0); + divergence.getTexture().bind(1); + objectTexture.bind(2); + + pressureShader.setUniform("uPressure", 0); + pressureShader.setUniform("uDivergence", 1); + pressureShader.setUniform("uObstacle", 2); + pressureShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + + quad.draw(); + + pressureShader.unuse(); + pressure_write.unbind(); + + temp = pressure_read; + pressure_read = pressure_write; + pressure_write = temp; + } + + setBounds(pressure_read, pressure_write, 0); + temp = pressure_read; + pressure_read = pressure_write; + pressure_write = temp; + + // Subtract pressure gradient from velocity + subtractGradient(velocityX_read, velocityX_write, 1); + temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + setBounds(velocityX_read, velocityX_write, 1); + temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + + subtractGradient(velocityY_read, velocityY_write, 0); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; + setBounds(velocityY_read, velocityY_write, 2); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; } - public float getVelocityX(int i, int j) { - return mVelocityX[at(i, j)]; + private void subtractGradient(Framebuffer velocity, Framebuffer output, int isXComponent) { + output.bind(); + gradientShader.use(); + + velocity.getTexture().bind(0); + pressure_read.getTexture().bind(1); + objectTexture.bind(2); + + gradientShader.setUniform("uVelocity", 0); + gradientShader.setUniform("uPressure", 1); + gradientShader.setUniform("uObstacle", 2); + gradientShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + gradientShader.setUniform("uIsXComponent", isXComponent); + + quad.draw(); + + gradientShader.unuse(); + output.unbind(); } - public float getVelocityY(int i, int j) { - return mVelocityY[at(i, j)]; + private void advectVelocity(float dt) { + advect(velocityX_write, velocityX_read, velocityX_read, velocityY_read, dt); + Framebuffer temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + setBounds(velocityX_read, velocityX_write, 1); + temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; + + advect(velocityY_write, velocityY_read, velocityX_read, velocityY_read, dt); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; + setBounds(velocityY_read, velocityY_write, 2); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; } - public int getSize() { - return mSize; + private void advectDensity(float dt) { + advect(density_write, density_read, velocityX_read, velocityY_read, dt, 1); + + Framebuffer temp = density_read; + density_read = density_write; + density_write = temp; + + setBounds(density_read, density_write, 0); + temp = density_read; + density_read = density_write; + density_write = temp; } - private int clamp(int min, int max, int value) { - return Math.min(Math.max(value, min), max); + private void advect(Framebuffer output, Framebuffer source, Framebuffer velX, Framebuffer velY, float dt) { + advect(output, source, velX, velY, dt, 0); } - private void addSource(float target[], float source[], float deltaTime) { - IntStream.range(0, mSize).parallel().forEach(i -> { - target[i] += source[i] * deltaTime; - }); + private void advect(Framebuffer output, Framebuffer source, Framebuffer velX, Framebuffer velY, float dt, + int isDensity) { + output.bind(); + advectShader.use(); + + velX.getTexture().bind(0); + velY.getTexture().bind(1); + source.getTexture().bind(2); + objectTexture.bind(3); + + advectShader.setUniform("uVelocityX", 0); + advectShader.setUniform("uVelocityY", 1); + advectShader.setUniform("uSource", 2); + advectShader.setUniform("uObstacle", 3); + advectShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + advectShader.setUniform("uDeltaTime", dt); + advectShader.setUniform("uDissipation", params.dissipation); + advectShader.setUniform("uIsDensity", isDensity); + + quad.draw(); + + advectShader.unuse(); + output.unbind(); } - private void diffuse(Boundary bound, float target[], float source[], float diff, float deltaTime) { - int i, j, k; - float neighbors; + private void vorticityConfinement(float dt) { + // Calculate curl + curl.bind(); + vorticityShader.use(); - float a = deltaTime / 10000.0f * diff * mRows * mCols; + velocityX_read.getTexture().bind(0); + velocityY_read.getTexture().bind(1); - for (k = 0; k < mIterations; k++) { - for (i = 1; i <= mRows; i++) { - for (j = 1; j <= mCols; j++) { - neighbors = target[at(i - 1, j)] - + target[at(i + 1, j)] - + target[at(i, j - 1)] - + target[at(i, j + 1)]; + vorticityShader.setUniform("uVelocityX", 0); + vorticityShader.setUniform("uVelocityY", 1); + vorticityShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); - target[at(i, j)] = (source[at(i, j)] + a * neighbors) / (1 + 4 * a); - } - } + quad.draw(); - // setBounds(bound, target); // FIXME - setBounds(bound, source); - } - } + vorticityShader.unuse(); + curl.unbind(); - private void advect(Boundary bound, float d[], float d0[], float u[], float v[], float dt) { - // int i0, j0, i1, j1; - // float x, y, s0, t0, s1, t1; + // Apply vorticity force to X component + velocityX_write.bind(); + vorticityForceShader.use(); - float dt0X = dt * mCols; - float dt0Y = dt * mRows; + velocityX_read.getTexture().bind(0); + curl.getTexture().bind(1); - IntStream.range(1, mRows).parallel().forEach((i) -> { - int i0, j0, i1, j1; - float x, y, s0, t0, s1, t1; + vorticityForceShader.setUniform("uVelocity", 0); + vorticityForceShader.setUniform("uCurl", 1); + vorticityForceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + vorticityForceShader.setUniform("uVorticityScale", params.vorticity); + vorticityForceShader.setUniform("uDeltaTime", dt); + vorticityForceShader.setUniform("uIsXComponent", 1); - for (int j = 1; j <= mCols; j++) { - // previous location of particle - x = i - dt0X * u[at(i, j)]; - y = j - dt0Y * v[at(i, j)]; + quad.draw(); - // check bounds - if (x < 0.5f) - x = 0.5f; - if (x > mCols + 0.5f) - x = mCols + 0.5f; + vorticityForceShader.unuse(); + velocityX_write.unbind(); + Framebuffer temp = velocityX_read; + velocityX_read = velocityX_write; + velocityX_write = temp; - if (y < 0.5f) - y = 0.5f; - if (y > mRows + 0.5f) - y = mRows + 0.5f; + // Apply vorticity force to Y component + velocityY_write.bind(); + vorticityForceShader.use(); - i0 = (int) x; - i1 = i0 + 1; - j0 = (int) y; - j1 = j0 + 1; + velocityY_read.getTexture().bind(0); + curl.getTexture().bind(1); - s1 = x - i0; - s0 = 1 - s1; - t1 = y - j0; - t0 = 1 - t1; + vorticityForceShader.setUniform("uVelocity", 0); + vorticityForceShader.setUniform("uCurl", 1); + vorticityForceShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + vorticityForceShader.setUniform("uVorticityScale", params.vorticity); + vorticityForceShader.setUniform("uDeltaTime", dt); + vorticityForceShader.setUniform("uIsXComponent", 0); - d[at(i, j)] = s0 * (t0 * d0[at(i0, j0)] + t1 * d0[at(i0, j1)]) - + s1 * (t0 * d0[at(i1, j0)] + t1 * d0[at(i1, j1)]); - } - }); + quad.draw(); - // for (int i = 1; i <= mRows; i++) { - // for (int j = 1; j <= mCols; j++) { - // // previous location of particle - // x = i - dt0X * u[at(i, j)]; - // y = j - dt0Y * v[at(i, j)]; + vorticityForceShader.unuse(); + velocityY_write.unbind(); + temp = velocityY_read; + velocityY_read = velocityY_write; + velocityY_write = temp; + } - // // check bounds - // if (x < 0.5f) - // x = 0.5f; - // if (x > mCols + 0.5f) - // x = mCols + 0.5f; + private void setBounds(Framebuffer source, Framebuffer output, int mode) { + output.bind(); + boundaryShader.use(); - // if (y < 0.5f) - // y = 0.5f; - // if (y > mRows + 0.5f) - // y = mRows + 0.5f; + source.getTexture().bind(0); - // i0 = (int) x; - // i1 = i0 + 1; - // j0 = (int) y; - // j1 = j0 + 1; + boundaryShader.setUniform("uSource", 0); + boundaryShader.setUniform("uTexelSize", 1.0f / width, 1.0f / height); + boundaryShader.setUniform("uBoundaryMode", mode); - // s1 = x - i0; - // s0 = 1 - s1; - // t1 = y - j0; - // t0 = 1 - t1; + quad.draw(); - // d[at(i, j)] = s0 * - // (t0 * d0[at(i0, j0)] + t1 * d0[at(i0, j1)]) - // + s1 * (t0 * d0[at(i1, j0)] + t1 * d0[at(i1, j1)]); - // } - // } + boundaryShader.unuse(); + output.unbind(); + } - setBounds(bound, d); + /** + * Adds force and density at a specific grid position with default interaction + * radius. + * + * @param x grid x-coordinate + * @param y grid y-coordinate + * @param forceX force in x direction + * @param forceY force in y direction + * @param densityAmount density to add (can be negative to remove) + */ + public void addForceAndDensity(float x, float y, float forceX, float forceY, float densityAmount) { + addForceAndDensity(x, y, forceX, forceY, densityAmount, params.interactionRadius); } - private void updateDensity(float diff, float deltaTime) { - float[] tempDensity = new float[mSize]; + /** + * Adds force and density at a specific grid position with custom radius. + * + * @param x grid x-coordinate + * @param y grid y-coordinate + * @param forceX force in x direction + * @param forceY force in y direction + * @param densityAmount density to add (can be negative to remove) + * @param radius interaction radius in pixels + */ + public void addForceAndDensity(float x, float y, float forceX, float forceY, float densityAmount, float radius) { + // Add force X + sourceVelocityX.bind(); + splatShader.use(); + + sourceVelocityX.getTexture().bind(0); + + splatShader.setUniform("uTarget", 0); + splatShader.setUniform("uPoint", x, y); + splatShader.setUniform("uRadius", radius); + splatShader.setUniform("uValue", forceX); + splatShader.setUniform("uGridSize", (float) width, (float) height); + + quad.draw(); + + splatShader.unuse(); + sourceVelocityX.unbind(); + + // Add force Y + sourceVelocityY.bind(); + splatShader.use(); + + sourceVelocityY.getTexture().bind(0); + + splatShader.setUniform("uTarget", 0); + splatShader.setUniform("uPoint", x, y); + splatShader.setUniform("uRadius", radius); + splatShader.setUniform("uValue", forceY); + splatShader.setUniform("uGridSize", (float) width, (float) height); - addSource(mDensity, mSourceDensity, deltaTime); + quad.draw(); - diffuse(Boundary.None, tempDensity, mDensity, diff, deltaTime); + splatShader.unuse(); + sourceVelocityY.unbind(); - var tmp = mDensity; - mDensity = tempDensity; - tempDensity = tmp; + sourceDensity.bind(); + splatShader.use(); - advect(Boundary.None, tempDensity, mDensity, mVelocityX, mVelocityY, deltaTime); + sourceDensity.getTexture().bind(0); - // tmp = mDensity; - mDensity = tempDensity; - // tempDensity = tmp; + splatShader.setUniform("uTarget", 0); + splatShader.setUniform("uPoint", x, y); + splatShader.setUniform("uRadius", radius); + splatShader.setUniform("uValue", densityAmount); + splatShader.setUniform("uGridSize", (float) width, (float) height); + + quad.draw(); + + splatShader.unuse(); + sourceDensity.unbind(); } - private void updateVelocity(float viscosity, float deltaTime) { - float[] tempVelocityX = new float[mSize]; - float[] tempVelocityY = new float[mSize]; - float[] tmp; + private void clearSourceBuffers() { + clearShader.use(); + clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f); - addSource(mVelocityX, mSourceVelocityX, deltaTime); - addSource(mVelocityY, mSourceVelocityY, deltaTime); + sourceVelocityX.bind(); + quad.draw(); + sourceVelocityX.unbind(); - diffuse(Boundary.Horizontal, tempVelocityX, mVelocityX, viscosity, deltaTime); - diffuse(Boundary.Vertical, tempVelocityY, mVelocityY, viscosity, deltaTime); + sourceVelocityY.bind(); + quad.draw(); + sourceVelocityY.unbind(); - tmp = mVelocityX; - mVelocityX = tempVelocityX; - tempVelocityX = tmp; + sourceDensity.bind(); + quad.draw(); + sourceDensity.unbind(); - tmp = mVelocityY; - mVelocityY = tempVelocityY; - tempVelocityY = tmp; + clearShader.unuse(); + } - project(mVelocityX, mVelocityY, tempVelocityX, tempVelocityY); + private void clearBuffer(Framebuffer fb) { + fb.bind(); + clearShader.use(); + clearShader.setUniform("uClearColor", 0.0f, 0.0f, 0.0f, 0.0f); + quad.draw(); + clearShader.unuse(); + fb.unbind(); + } - advect(Boundary.Horizontal, tempVelocityX, mVelocityX, mVelocityX, mVelocityY, deltaTime); - advect(Boundary.Vertical, tempVelocityY, mVelocityY, mVelocityX, mVelocityY, deltaTime); + public Texture getDensityTexture() { + return density_read.getTexture(); + } - tmp = mVelocityX; - mVelocityX = tempVelocityX; - tempVelocityX = tmp; + /** + * Returns the x-component velocity field texture. + * + * @return velocity X texture + */ + public Texture getVelocityXTexture() { + return velocityX_read.getTexture(); + } - tmp = mVelocityY; - mVelocityY = tempVelocityY; - tempVelocityY = tmp; + /** + * Returns the y-component velocity field texture. + * + * @return velocity Y texture + */ + public Texture getVelocityYTexture() { + return velocityY_read.getTexture(); + } - project(mVelocityX, mVelocityY, tempVelocityX, tempVelocityY); + /** + * Returns the simulation grid width. + * + * @return width in cells + */ + public int getWidth() { + return width; } - private void project(float u[], float v[], float poisson[], float divergence[]) { - int i, j, k; - float h = 1f / mCols; + /** + * Returns the simulation grid height. + * + * @return height in cells + */ + public int getHeight() { + return height; + } - for (i = 1; i <= mRows; i++) { - for (j = 1; j <= mCols; j++) { - divergence[at(i, j)] = -0.5f * h - * (u[at(i + 1, j)] - - u[at(i - 1, j)] - + v[at(i, j + 1)] - - v[at(i, j - 1)]); + /** + * Requests loading an obstacle bitmap from a PNG file. + * + * @param path file path to PNG image (must match grid dimensions) + * @throws Exception if file validation fails + */ + public void loadObjects(String path) throws Exception { + ObstacleLoader.validateObjectPNG(path, width, height); + requestObjectLoadPath = path; + } - poisson[at(i, j)] = 0; - } - } + /** + * Requests clearing all obstacles from the simulation. + */ + public void clearObjects() { + requestObjectClear = true; + } - setBounds(Boundary.None, divergence); - setBounds(Boundary.None, poisson); - - for (k = 0; k < mIterations; k++) { - for (i = 1; i <= mRows; i++) { - for (j = 1; j <= mCols; j++) { - poisson[at(i, j)] = (divergence[at(i, j)] // - + poisson[at(i - 1, j)] - + poisson[at(i + 1, j)] - + poisson[at(i, j - 1)] - + poisson[at(i, j + 1)]) / 4; - } - } - setBounds(Boundary.None, poisson); - } + /** + * Returns the current obstacle mask texture. + * + * @return object texture (1.0 = wall, 0.0 = free space) + */ + public Texture getObjectTexture() { + return objectTexture; + } - mMinDensity = Float.MAX_VALUE; - mMaxDensity = 0; + /** + * Process pending obstacle operations on the OpenGL thread + */ + private void processObjectRequest() { + // Handle load + if (requestObjectLoadPath != null) { + String path = requestObjectLoadPath; + requestObjectLoadPath = null; - for (i = 1; i <= mRows; i++) { - for (j = 1; j <= mCols; j++) { - u[at(i, j)] -= 0.5 * (poisson[at(i + 1, j)] - poisson[at(i - 1, j)]) / h; - v[at(i, j)] -= 0.5 * (poisson[at(i, j + 1)] - poisson[at(i, j - 1)]) / h; + try { + if (objectTexture != null) + objectTexture.delete(); - mMaxDensity = Math.max(mMaxDensity, mDensity[at(i, j)]); - mMinDensity = Math.min(mMinDensity, mDensity[at(i, j)]); + objectTexture = ObstacleLoader.loadObjectFromPNG(path, width, height); + + } catch (Exception e) { + System.err.println("Failed to load obstacle: " + e.getMessage()); + e.printStackTrace(); } } - setBounds(Boundary.Vertical, u); - setBounds(Boundary.Horizontal, v); - } - - // function Curl(x,y:integer):double; - // begin - // Curl:=xvelocity[x,y+1]-xvelocity[x,y-1] + yvelocity[x-1,y]-yvelocity[x+1, y]; - // end; - - // procedure VorticityConfinement(vorticity:double); - // var dx,dy,len:double; - // x,y:integer; - // begin - // for y:=2 to _h-3 do - // begin - // for x:=2 to _w-3 do - // begin - // dx:=abs(curl(x + 0, y - 1)) - abs(curl(x + 0, y + 1)); - // dy:=abs(curl(x + 1, y + 0)) - abs(curl(x - 1, y + 0)); - // len:=sqrt(sqr(dx)+sqr(dy))+1e-5; - // dx:=vorticity/len*dx; - // dy:=vorticity/len*dy; - // xvelocity[x,y]:=xvelocity[x,y]+timestep*curl(x,y)*dx); - // yvelocity[x,y]:=yvelocity[x,y]+timestep*curl(x,y)*dy); - // end; - // end; - // end; - - private float curl(int x, int y) { - return mVelocityX[at(x, y + 1)] - - mVelocityX[at(x, y - 1)] - + mVelocityY[at(x - 1, y)] - - mVelocityY[at(x + 1, y)]; - } - - private void vorticityConfinement(float vorticity, float deltaTime) { - float dx, dy, len; - int i, j; - - for (i = 2; i < mRows; i++) { - for (j = 2; j < mCols; j++) { - dx = Math.abs(curl(i, j - 1)) - Math.abs(curl(i, j + 1)); - dy = Math.abs(curl(i + 1, j)) - Math.abs(curl(i - 1, j)); - - len = (float) Math.sqrt(dx * dx + dy * dy) + 0.0001f; - - dx = vorticity / len * dx; - dy = vorticity / len * dy; - - mVelocityX[at(i, j)] = mVelocityX[at(i, j)] + deltaTime * curl(i, j) * dx; - mVelocityY[at(i, j)] = mVelocityY[at(i, j)] + deltaTime * curl(i, j) * dy; - } + // Handle clear + if (requestObjectClear) { + requestObjectClear = false; + + if (objectTexture != null) + objectTexture.delete(); + + objectTexture = ObstacleLoader.empty(width, height); } } - private void setBounds(Boundary b, float x[]) { - for (int i = 1; i < mCols + 1; i++) { - x[at(0, i)] = b == Boundary.Horizontal ? -x[at(1, i)] : x[at(1, i)]; - x[at(mCols + 1, i)] = b == Boundary.Horizontal ? -x[at(mCols, i)] : x[at(mCols, i)]; + /** + * Apply all active nozzles to the simulation + */ + private void updateObjects() { + for (SimObject simObject : simObjects) { + simObject.apply(this); } + } + + /** + * Apply all active nozzles to the simulation + */ + public void deleteAllObjects() { + simObjects.clear(); + } + + /** + * Add a nozzle to the simulation + * + * @param obj the simulation object to add (e.g., Nozzle) + */ + public void addObject(SimObject obj) { + obj.initialize(width, height); + simObjects.add(obj); + } - for (int i = 1; i < mRows + 1; i++) { - x[at(i, 0)] = b == Boundary.Vertical ? -x[at(i, 1)] : x[at(i, 1)]; - x[at(i, mRows + 1)] = b == Boundary.Vertical ? -x[at(i, mRows)] : x[at(i, mRows)]; + /** + * Releases all GPU resources including framebuffers, textures, and shaders. + */ + public void cleanup() { + // Delete obstacle texture + if (objectTexture != null) { + objectTexture.delete(); } - x[at(0, 0)] = 0.5f * (x[at(1, 0)] + x[at(0, 1)]); - x[at(0, mRows + 1)] = 0.5f * (x[at(1, mRows + 1)] + x[at(0, mRows)]); - x[at(mCols + 1, 0)] = 0.5f * (x[at(mCols, 0)] + x[at(mCols + 1, 1)]); - x[at(mCols + 1, mRows + 1)] = 0.5f * (x[at(mCols, mRows + 1)] + x[at(mCols + 1, mRows)]); + // Delete framebuffers + velocityX_read.delete(); + velocityX_write.delete(); + velocityY_read.delete(); + velocityY_write.delete(); + density_read.delete(); + density_write.delete(); + pressure_read.delete(); + pressure_write.delete(); + divergence.delete(); + curl.delete(); + sourceVelocityX.delete(); + sourceVelocityY.delete(); + sourceDensity.delete(); + + // Delete shaders + advectShader.delete(); + diffuseShader.delete(); + divergenceShader.delete(); + pressureShader.delete(); + gradientShader.delete(); + vorticityShader.delete(); + vorticityForceShader.delete(); + boundaryShader.delete(); + addSourceShader.delete(); + splatShader.delete(); + clearShader.delete(); + + // Delete quad + quad.delete(); } } diff --git a/src/main/java/dev/fluidsim/simulation/SimObject.java b/src/main/java/dev/fluidsim/simulation/SimObject.java new file mode 100644 index 0000000..41adfb0 --- /dev/null +++ b/src/main/java/dev/fluidsim/simulation/SimObject.java @@ -0,0 +1,22 @@ +package dev.fluidsim.simulation; + +/** + * An object that can interact with the fluid simulation. + */ +public interface SimObject { + + /** + * Initializes the object's world position based on grid dimensions. + * + * @param gridWidth simulation grid width in pixels + * @param gridHeight simulation grid height in pixels + */ + public void initialize(int gridWidth, int gridHeight); + + /** + * Applies this object's effects to the simulation state. + * + * @param simulation the simulation to modify + */ + public void apply(Sim simulation); +} diff --git a/src/main/java/dev/fluidsim/simulation/SimParams.java b/src/main/java/dev/fluidsim/simulation/SimParams.java new file mode 100644 index 0000000..cf2011e --- /dev/null +++ b/src/main/java/dev/fluidsim/simulation/SimParams.java @@ -0,0 +1,222 @@ +package dev.fluidsim.simulation; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * Container for all fluid simulation parameters. + * Includes runtime physics settings, grid dimensions, visualization options, + * and nozzle configurations. + */ +public class SimParams { + /** grid width in pixels */ + public int width = Constants.WIDTH; + /** grid height in pixels */ + public int height = Constants.HEIGHT; + + /** number of solver iterations per time step */ + public int iterations = Constants.ITERATIONS; + /** fluid viscosity coefficient */ + public float viscosity = Constants.VISCOSITY; + /** density diffusion rate */ + public float diffusion = Constants.DIFFUSION; + /** vorticity confinement strength */ + public float vorticity = Constants.VORTICITY; + /** velocity dissipation factor */ + public float dissipation = Constants.DISSIPATION; + /** mouse interaction radius in pixels */ + public float interactionRadius = Constants.INTERACTION_RADIUS; + /** if true, visualizes velocity field instead of density */ + public boolean velocityView = Constants.VELOCITY_VIEW; + /** if true, renders velocity arrows */ + public boolean showArrows = Constants.SHOW_ARROWS; + + /** spacing between arrows as fraction of grid size */ + public float arrowDensity = Constants.ARROW_DENSITY; + /** maximum arrow length in pixels */ + public float maxArrowLength = Constants.MAX_ARROW_LENGTH; + /** arrowhead length in pixels */ + public float arrowHeadLength = Constants.ARROWHEAD_LENGTH; + /** arrowhead angle in radians */ + public float arrowHeadAngle = Constants.ARROWHEAD_ANGLE; + /** velocity to arrow length scale factor */ + public float velocityScale = Constants.VELOCITY_SCALE; + + /** arrow rendering color */ + public Color arrowColor = new Color(255, 255, 255, 255); + + /** list of active nozzles emitting fluid */ + public List nozzles = new ArrayList<>(); + + /** + * Creates simulation parameters with default constant values. + */ + public SimParams() { + } + + /** + * Creates simulation parameters with specified runtime values. + * + * @param iterations number of solver iterations, must be positive + * @param viscosity fluid viscosity coefficient, typically 0.0-1.0 + * @param diffusion density diffusion rate, typically 0.0-1.0 + * @param vorticity vorticity confinement strength, typically 0.0-50.0 + * @param dissipation velocity dissipation factor, typically 0.99-1.0 + * @param interactionRadius mouse interaction radius in pixels, must be positive + */ + public SimParams(int iterations, float viscosity, float diffusion, + float vorticity, float dissipation, float interactionRadius) { + this.iterations = iterations; + this.viscosity = viscosity; + this.diffusion = diffusion; + this.vorticity = vorticity; + this.dissipation = dissipation; + this.interactionRadius = interactionRadius; + } + + /** + * Returns the number of solver iterations per time step. + * + * @return iterations count + */ + public int getIterations() { + return iterations; + } + + /** + * Sets the number of solver iterations per time step. + * + * @param iterations iterations count, must be positive + */ + public void setIterations(int iterations) { + this.iterations = iterations; + } + + /** + * Returns the fluid viscosity coefficient. + * + * @return viscosity value + */ + public float getViscosity() { + return viscosity; + } + + /** + * Sets the fluid viscosity coefficient. + * + * @param viscosity viscosity value, typically 0.0-1.0 + */ + public void setViscosity(float viscosity) { + this.viscosity = viscosity; + } + + /** + * Returns the density diffusion rate. + * + * @return diffusion value + */ + public float getDiffusion() { + return diffusion; + } + + /** + * Sets the density diffusion rate. + * + * @param diffusion diffusion value, typically 0.0-1.0 + */ + public void setDiffusion(float diffusion) { + this.diffusion = diffusion; + } + + /** + * Returns the vorticity confinement strength. + * + * @return vorticity value + */ + public float getVorticity() { + return vorticity; + } + + /** + * Sets the vorticity confinement strength. + * + * @param vorticity vorticity value, typically 0.0-50.0 + */ + public void setVorticity(float vorticity) { + this.vorticity = vorticity; + } + + /** + * Returns the velocity dissipation factor. + * + * @return dissipation value + */ + public float getDissipation() { + return dissipation; + } + + /** + * Sets the velocity dissipation factor. + * + * @param dissipation dissipation value, typically 0.99-1.0 + */ + public void setDissipation(float dissipation) { + this.dissipation = dissipation; + } + + /** + * Returns the mouse interaction radius. + * + * @return radius in pixels + */ + public float getInteractionRadius() { + return interactionRadius; + } + + /** + * Sets the mouse interaction radius. + * + * @param interactionRadius radius in pixels, must be positive + */ + public void setInteractionRadius(float interactionRadius) { + this.interactionRadius = interactionRadius; + } + + /** + * Returns whether velocity field visualization is enabled. + * + * @return true if showing velocity instead of density + */ + public boolean isVelocityView() { + return velocityView; + } + + /** + * Sets whether to visualize velocity field instead of density. + * + * @param velocityView true to show velocity field + */ + public void setVelocityView(boolean velocityView) { + this.velocityView = velocityView; + } + + /** + * Returns whether velocity arrows are enabled. + * + * @return true if rendering arrows + */ + public boolean isShowArrows() { + return showArrows; + } + + /** + * Sets whether to render velocity arrows. + * + * @param showArrows true to show arrows + */ + public void setShowArrows(boolean showArrows) { + this.showArrows = showArrows; + } + +} diff --git a/src/main/java/dev/fluidsim/ui/BetterCanvas.java b/src/main/java/dev/fluidsim/ui/BetterCanvas.java deleted file mode 100644 index 8403a72..0000000 --- a/src/main/java/dev/fluidsim/ui/BetterCanvas.java +++ /dev/null @@ -1,116 +0,0 @@ -package dev.fluidsim.ui; - -import javax.swing.JFrame; - -import dev.fluidsim.simulation.Sim; - -import java.awt.*; -import java.awt.image.BufferStrategy; - -public class BetterCanvas { - - static JFrame frame; - static java.awt.Canvas canvas; - - private static int FRAME_WIDTH, FRAME_HEIGHT, SIMULATION_WIDTH, SIMULATION_HEIGHT; - - public static float now, lastTick, deltaTime; - - public static void run(int FRAME_WIDTH, int FRAME_HEIGHT, int SIMULATION_WIDTH, int SIMULATION_HEIGHT) { - frame = new JFrame("Fluids"); - - BetterCanvas.FRAME_WIDTH = FRAME_WIDTH; - BetterCanvas.FRAME_HEIGHT = FRAME_HEIGHT; - BetterCanvas.SIMULATION_WIDTH = SIMULATION_WIDTH; - BetterCanvas.SIMULATION_HEIGHT = SIMULATION_HEIGHT; - - // frame.setUndecorated(true); - frame.setResizable(false); - frame.setSize(FRAME_WIDTH, FRAME_HEIGHT); - frame.setLocationRelativeTo(null); - - frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); - - canvas = new java.awt.Canvas(); - frame.add(canvas); - canvas.setSize(FRAME_WIDTH, FRAME_HEIGHT); - - frame.setVisible(true); - canvas.requestFocus(); - - init(); - - boolean running = true; - long gameStart = System.nanoTime(); - - double FPS = 120; - double wait = 1000 / FPS; - - int i = 0; // DEBUG - - while (running) { - long start = System.nanoTime(); - - i++; - - lastTick = now; - now = (start - gameStart) * 0.000000001f; - deltaTime = now - lastTick; - - if (i % 100 == 0) - System.out.println(1 / deltaTime); - - update(); - - BufferStrategy bs = canvas.getBufferStrategy(); - if (bs == null) { - canvas.createBufferStrategy(2); - continue; - } - - Graphics2D g = (Graphics2D) bs.getDrawGraphics(); - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g.setBackground(Color.BLACK); - - render(g); - - bs.show(); - g.dispose(); - - // long realWait = Math.round(wait - (System.nanoTime() - start) / 1_000_000); - - // if (realWait > 0) { - // try { - // Thread.sleep(realWait); - // } catch (InterruptedException e) { - // e.printStackTrace(); - // } - // } - } - } - - private static Sim simulation; - private static Density density; - private static Vectors vectors; - - private static void init() { - float scaleX = FRAME_WIDTH / (float) SIMULATION_WIDTH; - float scaleY = FRAME_HEIGHT / (float) SIMULATION_HEIGHT; - - simulation = new Sim(SIMULATION_WIDTH, SIMULATION_HEIGHT); - density = new Density(simulation, scaleX, scaleY); - vectors = new Vectors(simulation, FRAME_WIDTH, FRAME_HEIGHT, 5); - - var listener = new MouseEventListener(simulation, scaleX, scaleY); - canvas.addMouseMotionListener(listener); - } - - private static void render(Graphics2D g) { - density.draw(g, FRAME_WIDTH, FRAME_HEIGHT); - // vectors.draw(g, FRAME_WIDTH, FRAME_HEIGHT); - } - - private static void update() { - simulation.update(deltaTime); - } -} \ No newline at end of file diff --git a/src/main/java/dev/fluidsim/ui/Canvas.java b/src/main/java/dev/fluidsim/ui/Canvas.java deleted file mode 100644 index fe22d16..0000000 --- a/src/main/java/dev/fluidsim/ui/Canvas.java +++ /dev/null @@ -1,220 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Color; -import java.awt.Graphics; -import java.awt.Graphics2D; -import java.awt.GraphicsConfiguration; -import java.awt.GraphicsEnvironment; -import java.awt.RenderingHints; -import java.awt.Stroke; -import java.awt.Transparency; -import java.awt.image.VolatileImage; -import java.util.ArrayList; -import java.util.List; -import javax.swing.JPanel; - -import java.util.Collections; - -/** - * A custom canvas component that runs a render loop for animations - * Implements Runnable to create its own animation thread - */ -public class Canvas extends JPanel implements Runnable { - public interface Executable { - void exec(float deltaTime); - } - - private final int WIDTH; - private final int HEIGHT; - - private Executable updateCallback = null; - - // Thread - private Thread mThread; - // image creation - VolatileImage mVolatileImage; - - private volatile boolean isRunning = false; - // List of drawable objects. We use a thread-safe list - private List drawables = Collections.synchronizedList(new ArrayList<>()); - - protected long deltaTime; // in sec - - public Canvas() { - this(1000, 1000); - } - - public Canvas(int width, int height) { - WIDTH = width; - HEIGHT = height; - mVolatileImage = _createVolatileImage(width, height); - } - - public void onUpdate(Executable callback) { - updateCallback = callback; - } - - /** - * Adds a drawable object to the canvas - * - * @param drawable The object to be drawn - */ - public boolean add(Drawable drawable) { - return this.drawables.add(drawable); - } - - /** - * Removes a drawable object from the canvas - * - * @param drawable The object to be removed - */ - public boolean remove(Drawable drawable) { - return this.drawables.remove(drawable); - } - - /** - * Starts the animation loop when the component is added to the screen - */ - @Override - public void addNotify() { - super.addNotify(); - start(); - } - - /** - * Stops the animation loop when the component is removed - */ - @Override - public void removeNotify() { - stop(); - super.removeNotify(); - } - - /** - * Starts the animation thread - */ - public void start() { - if (mThread == null) { - isRunning = true; - mThread = new Thread(this); - mThread.start(); - } - } - - /** - * Stops the animation thread gracefully - */ - public void stop() { - isRunning = false; - if (mThread != null) { - try { - mThread.join(); // Wait for the thread to finish - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - mThread = null; - } - - /** - * The main animation loop - */ - @Override - public void run() { - long startTime; - - int frame = 0; - - while (isRunning) { - startTime = System.nanoTime(); - - updateCanvas(); - repaint(); - - // ms - deltaTime = (System.nanoTime() - startTime) / 1_000_000; - - if (frame % 100 == 0) { - System.out.println(1000 / deltaTime + " " + deltaTime + "ms"); - } - - frame++; - } - } - - // This method is where all the custom drawing happens - @Override - protected void paintComponent(Graphics g) { - super.paintComponent(g); - - GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); - GraphicsConfiguration gc = ge.getDefaultScreenDevice().getDefaultConfiguration(); - - Graphics2D g2d = null; - - do { - int valid = mVolatileImage.validate(gc); - - if (valid == VolatileImage.IMAGE_INCOMPATIBLE) { - mVolatileImage = this._createVolatileImage(WIDTH, HEIGHT); - } - - try { - g2d = mVolatileImage.createGraphics(); - g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - g2d.setBackground(Color.BLACK); - - // render - synchronized (drawables) { - Stroke defaultStroke = g2d.getStroke(); - Color defaultColor = g2d.getColor(); - - for (Drawable drawable : drawables) { - // System.out.println("drawing " + d.getClass().getCanonicalName()); - drawable.draw(g2d, WIDTH, HEIGHT); - g2d.setColor(defaultColor); - g2d.setStroke(defaultStroke); - } - } - - g.drawImage(mVolatileImage, 0, 0, null); - - } finally { // It's always best to dispose of your Graphics objects. - g2d.dispose(); - } - - } while (mVolatileImage.contentsLost()); - - } - - private VolatileImage _createVolatileImage(int width, int height) { - GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); - GraphicsConfiguration gc = ge.getDefaultScreenDevice().getDefaultConfiguration(); - VolatileImage image = null; - - image = gc.createCompatibleVolatileImage(width, height, Transparency.BITMASK); - - int valid = image.validate(gc); - - if (valid == VolatileImage.IMAGE_INCOMPATIBLE) { - image = this._createVolatileImage(width, height); - return image; - } - - return image; - } - - /** - * Calls the update method for all drawable objects - */ - private void updateCanvas() { - synchronized (drawables) { - if (updateCallback != null) - updateCallback.exec(deltaTime / 1000f); - - for (Drawable d : drawables) { - d.update(deltaTime / 1000f); - } - } - } -} diff --git a/src/main/java/dev/fluidsim/ui/Density.java b/src/main/java/dev/fluidsim/ui/Density.java deleted file mode 100644 index ae61316..0000000 --- a/src/main/java/dev/fluidsim/ui/Density.java +++ /dev/null @@ -1,58 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Color; -import java.awt.Graphics2D; -import java.awt.image.BufferedImage; - -import dev.fluidsim.simulation.Sim; - -public class Density implements Drawable { - private Sim sim; - private BufferedImage image; - - private final float scaleX; - private final float scaleY; - - public Density(Sim simulation, float scaleX, float scaleY) { - this.sim = simulation; - this.scaleX = scaleX; - this.scaleY = scaleY; - - image = new BufferedImage(sim.getCellCountX(), sim.getCellCountY(), BufferedImage.TYPE_INT_ARGB); - } - - @Override - public void update(float deltaTime) { - } - - /** - * Draws the grid lines on the canvas. - */ - @Override - public void draw(Graphics2D g, int screenWidth, int screenHeight) { - g.scale(scaleX, scaleY); - - for (int i = 0; i < sim.getCellCountX(); i++) { - for (int j = 0; j < sim.getCellCountY(); j++) { - float d = sim.getDensity(i, j) / sim.getMaxDensity(); - int colorValue = (int) (Math.min(d * 255, 255)); - - // Color c = Utils.lerp(Color.BLACK, Color.WHITE, d); - // Color c = Utils.lerp(Color.BLACK, Color.WHITE, - // (sim.getVelocityX(i, j) + sim.getVelocityY(i, j)) / 2f * d); - - Color c = new Color(colorValue, colorValue, colorValue); - - // g.setColor(c); - // g.fillRect((int) (i * scaleX), (int) (j * scaleY), (int) scaleX, (int) - // scaleY); - - image.setRGB(i, j, c.getRGB()); - } - } - - g.drawImage(image, null, null); - g.scale(1 / scaleX, 1 / scaleY); // reset - } - -} diff --git a/src/main/java/dev/fluidsim/ui/Drawable.java b/src/main/java/dev/fluidsim/ui/Drawable.java deleted file mode 100644 index 362af4e..0000000 --- a/src/main/java/dev/fluidsim/ui/Drawable.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Graphics2D; - -/** - * An interface for any object that can be drawn on the canvas and updated for - * animation. - */ -public interface Drawable { - /** - * Updates the object's state (e.g., position, angle). - */ - void update(float deltaTime); - - /** - * Renders the object on the canvas. - * - * @param g The Graphics2D context to draw on. - * @param screenWidth The current width of the canvas. - * @param screenHeight The current height of the canvas. - */ - void draw(Graphics2D g, int screenWidth, int screenHeight); -} diff --git a/src/main/java/dev/fluidsim/ui/Grid.java b/src/main/java/dev/fluidsim/ui/Grid.java deleted file mode 100644 index 7bd6ac9..0000000 --- a/src/main/java/dev/fluidsim/ui/Grid.java +++ /dev/null @@ -1,66 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Color; -import java.awt.Graphics2D; -import java.awt.Stroke; -import java.awt.BasicStroke; - -/** - * A Drawable grid that divides the canvas into N (rows) x K (cols) cells. - */ -public class Grid implements Drawable { - public record Look(Color color, float width) { - Stroke getStroke() { - return new BasicStroke(width); - } - } - - protected static final Look DEFAULT_LOOK = new Grid.Look(Color.GRAY, 1.f); - - protected int cellSize; - protected Look look; - - public Grid(int cellSize, Look look) throws IllegalArgumentException { - if (cellSize <= 0) { - throw new IllegalArgumentException( - String.format("Grid rows and cols must be >= 0! (got: %d)", cellSize)); - } - - this.cellSize = cellSize; - this.look = look; - } - - public Grid(int cellSize) { - this(cellSize, DEFAULT_LOOK); - } - - /** - * The grid is static, so update does nothing. - */ - @Override - public void update(float deltaTime) { - // Nothing to update - } - - /** - * Draws the grid lines on the canvas. - */ - @Override - public void draw(Graphics2D g, int screenWidth, int screenHeight) { - g.setColor(look.color); - g.setStroke(look.getStroke()); - - int cols = Math.floorDiv(screenWidth, cellSize); - int rows = Math.floorDiv(screenHeight, cellSize); - - for (int i = 0; i < cols; i++) { - int x = (int) (i * cellSize); - g.drawLine(x, 0, x, screenHeight); - } - - for (int j = 0; j < rows; j++) { - int y = (int) (j * cellSize); - g.drawLine(0, y, screenWidth, y); - } - } -} \ No newline at end of file diff --git a/src/main/java/dev/fluidsim/ui/MouseEventListener.java b/src/main/java/dev/fluidsim/ui/MouseEventListener.java deleted file mode 100644 index 3ef6f32..0000000 --- a/src/main/java/dev/fluidsim/ui/MouseEventListener.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.event.MouseMotionListener; - -import dev.fluidsim.simulation.Sim; - -public class MouseEventListener implements MouseMotionListener { - private final Sim mSim; - - private final float mScaleX; - private final float mScaleY; - - private int prevMouseX = 0; - private int prevMouseY = 0; - - private static final int MOVEMENT_TRESHOLD = 3; - - public MouseEventListener(Sim sim, float scaleX, float scaleY) { - super(); - - this.mSim = sim; - - mScaleX = scaleX; - mScaleY = scaleY; - } - - @Override - public void mouseDragged(java.awt.event.MouseEvent e) { - int x = (int) (e.getX() / mScaleX); - int y = (int) (e.getY() / mScaleY); - - if (x < 0 || x >= mSim.getCellCountX() || y < 0 || y >= mSim.getCellCountY()) - return; - - int dirX = (e.getX() - prevMouseX); - int dirY = (e.getY() - prevMouseY); - - prevMouseX = e.getX(); - prevMouseY = e.getY(); - - // if (Math.abs(dirX) < MOVEMENT_TRESHOLD && Math.abs(dirY) < MOVEMENT_TRESHOLD) - // return; - - float powerX = (float) (10 * dirX / 60f); - float powerY = (float) (10 * dirY / 60f); - - if (e.isShiftDown()) { - float density = (3.0f / 60f) * (1 + (float) Math.sqrt(dirX * dirX + dirY * dirY) / 3) * 1000f; - mSim.addForceAndDensity(x, y, powerX, powerY, density); - - } else if (e.isControlDown()) { - mSim.clearDensity(x, y); - - } else if (e.isAltDown()) { - mSim.clearSourceInRadius(x, y); - - } else { - mSim.addForceAndDensity(x, y, powerX, powerY, 0f); - } - - } - - @Override - public void mouseMoved(java.awt.event.MouseEvent e) { - prevMouseX = e.getX(); - prevMouseY = e.getY(); - - } -} diff --git a/src/main/java/dev/fluidsim/ui/SimControlPanel.java b/src/main/java/dev/fluidsim/ui/SimControlPanel.java new file mode 100644 index 0000000..cc12285 --- /dev/null +++ b/src/main/java/dev/fluidsim/ui/SimControlPanel.java @@ -0,0 +1,397 @@ +package dev.fluidsim.ui; + +import dev.fluidsim.simulation.Constants; +import dev.fluidsim.simulation.Sim; +import dev.fluidsim.simulation.SimParams; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; +import java.io.File; + +/** + * Control panel for adjusting simulation parameters in real-time + */ +public class SimControlPanel extends JPanel { + private final SimParams params; + private final Sim simulation; + + private JSlider iterationsSlider; + private JSlider viscositySlider; + /** slider for adjusting density diffusion rate */ + private JSlider diffusionSlider; + private JSlider vorticitySlider; + private JSlider dissipationSlider; + private JSlider interactionRadiusSlider; + + private JCheckBox showVelocityCheckBox; + private JCheckBox showArrowsCheckBox; + + private JLabel iterationsValueLabel; + private JLabel viscosityValueLabel; + private JLabel diffusionValueLabel; + private JLabel vorticityValueLabel; + private JLabel dissipationValueLabel; + private JLabel interactionRadiusValueLabel; + + /** + * Creates a control panel for real-time parameter adjustment. + * + * @param params simulation parameters to control + * @param simulation simulation instance to interact with + */ + public SimControlPanel(SimParams params, Sim simulation) { + this.params = params; + this.simulation = simulation; + + // setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setLayout(new GridBagLayout()); + GridBagConstraints constraints = new GridBagConstraints(); + constraints.fill = GridBagConstraints.HORIZONTAL; + constraints.weightx = 1; + constraints.gridx = 0; + + setBorder(new EmptyBorder(10, 10, 10, 10)); + setPreferredSize(new Dimension(280, 800)); + + // Title + JLabel titleLabel = new JLabel("Simulation Controls"); + titleLabel.setFont(new Font("Arial", Font.BOLD, 16)); + titleLabel.setAlignmentX(Component.RIGHT_ALIGNMENT); + add(titleLabel); + add(Box.createVerticalStrut(20)); + + // Create control panels + add(createVisualizationPanel(), constraints); + add(Box.createVerticalStrut(15)); + add(createIterationsPanel(), constraints); + add(Box.createVerticalStrut(10)); + add(createViscosityPanel(), constraints); + add(Box.createVerticalStrut(10)); + add(createDiffusionPanel(), constraints); + add(Box.createVerticalStrut(10)); + add(createVorticityPanel(), constraints); + add(Box.createVerticalStrut(10)); + add(createDissipationPanel(), constraints); + add(Box.createVerticalStrut(10)); + add(createInteractionRadiusPanel(), constraints); + add(Box.createVerticalStrut(20)); + + // Reset button + JButton resetButton = new JButton("Reset to Defaults"); + resetButton.setAlignmentX(Component.CENTER_ALIGNMENT); + resetButton.addActionListener(e -> resetToDefaults()); + add(resetButton, constraints); + add(Box.createVerticalStrut(20)); + + add(createInfoPanel(), constraints); + } + + /** + * Callback interface for file selection operations. + */ + public interface Executable { + /** + * Executes an operation with the selected file path. + * + * @param path absolute path to selected file + * @throws Exception if operation fails + */ + void execute(String path) throws Exception; + } + + /** + * Opens a file picker dialog and executes a callback with selected file. + * + * @param parent parent component for dialog + * @param runnable callback to execute with selected file path + */ + public static void invokeFilePicker(Component parent, Executable runnable) { + JFileChooser fileChooser = new JFileChooser(); + fileChooser.setDialogTitle("Select Obstacle PNG"); + + int result = fileChooser.showOpenDialog(parent); + if (result == JFileChooser.APPROVE_OPTION) { + File file = fileChooser.getSelectedFile(); + + try { + runnable.execute(file.getAbsolutePath()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(parent, + "Error loading file: " + ex.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + } + } + } + + private JPanel createVisualizationPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Visualization")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 110)); + + showVelocityCheckBox = new JCheckBox("Show Velocity (Color-coded)", params.isVelocityView()); + showVelocityCheckBox.setAlignmentX(Component.CENTER_ALIGNMENT); + showVelocityCheckBox.addActionListener(e -> { + params.setVelocityView(showVelocityCheckBox.isSelected()); + }); + + JLabel hintLabel = new JLabel("(Blue=Low, Red=High)"); + hintLabel.setFont(new Font("Arial", Font.ITALIC, 10)); + hintLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + hintLabel.setForeground(Color.GRAY); + + showArrowsCheckBox = new JCheckBox("Show Direction Arrows", params.isShowArrows()); + showArrowsCheckBox.setAlignmentX(Component.CENTER_ALIGNMENT); + showArrowsCheckBox.addActionListener(e -> { + params.setShowArrows(showArrowsCheckBox.isSelected()); + }); + + panel.add(Box.createVerticalStrut(5)); + panel.add(showVelocityCheckBox); + panel.add(Box.createVerticalStrut(3)); + panel.add(hintLabel); + panel.add(Box.createVerticalStrut(8)); + panel.add(showArrowsCheckBox); + panel.add(Box.createVerticalStrut(5)); + + return panel; + } + + private JPanel createIterationsPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Iterations")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + iterationsValueLabel = new JLabel(String.valueOf(params.getIterations())); + iterationsValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + iterationsValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + iterationsSlider = new JSlider(1, 100, params.getIterations()); + iterationsSlider.setMajorTickSpacing(20); + iterationsSlider.setMinorTickSpacing(5); + iterationsSlider.setPaintTicks(true); + iterationsSlider.addChangeListener(e -> { + int value = iterationsSlider.getValue(); + params.setIterations(value); + iterationsValueLabel.setText(String.valueOf(value)); + }); + + panel.add(iterationsValueLabel); + panel.add(iterationsSlider); + + return panel; + } + + private JPanel createViscosityPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Viscosity")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + viscosityValueLabel = new JLabel(String.format("%.3f", params.getViscosity())); + viscosityValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + viscosityValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + // Scale: 0.0 to 2.0, with 0.001 precision (0-10000) + viscositySlider = new JSlider(0, 10000, (int) (params.getViscosity() * 10000)); + viscositySlider.setMajorTickSpacing(5000); + viscositySlider.setMinorTickSpacing(1000); + viscositySlider.setPaintTicks(true); + viscositySlider.addChangeListener(e -> { + float value = viscositySlider.getValue() / 10000.0f; + params.setViscosity(value); + viscosityValueLabel.setText(String.format("%.3f", value)); + }); + + panel.add(viscosityValueLabel); + panel.add(viscositySlider); + + return panel; + } + + private JPanel createDiffusionPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Diffusion")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + diffusionValueLabel = new JLabel(String.format("%.5f", params.getDiffusion())); + diffusionValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + diffusionValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + // Scale: 0.0 to 0.1, with 0.0001 precision (0-10000) + diffusionSlider = new JSlider(0, 10000, (int) (params.getDiffusion() * 10000)); + diffusionSlider.setMajorTickSpacing(2500); + diffusionSlider.setMinorTickSpacing(500); + diffusionSlider.setPaintTicks(true); + diffusionSlider.addChangeListener(e -> { + float value = diffusionSlider.getValue() / 10000.0f; + params.setDiffusion(value); + diffusionValueLabel.setText(String.format("%.5f", value)); + }); + + panel.add(diffusionValueLabel); + panel.add(diffusionSlider); + + return panel; + } + + private JPanel createVorticityPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Vorticity")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + vorticityValueLabel = new JLabel(String.format("%.1f", params.getVorticity())); + vorticityValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + vorticityValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + // Scale: 0.0 to 200.0, with 0.1 precision (0-2000) + vorticitySlider = new JSlider(0, 2000, (int) (params.getVorticity() * 10)); + vorticitySlider.setMajorTickSpacing(500); + vorticitySlider.setMinorTickSpacing(100); + vorticitySlider.setPaintTicks(true); + vorticitySlider.addChangeListener(e -> { + float value = vorticitySlider.getValue() / 10.0f; + params.setVorticity(value); + vorticityValueLabel.setText(String.format("%.1f", value)); + }); + + panel.add(vorticityValueLabel); + panel.add(vorticitySlider); + + return panel; + } + + private JPanel createDissipationPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Dissipation")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + dissipationValueLabel = new JLabel(String.format("%.4f", params.getDissipation())); + dissipationValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + dissipationValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + // Scale: 0.900 to 1.000, with 0.0001 precision (9000-10000) + dissipationSlider = new JSlider(9000, 10000, (int) (params.getDissipation() * 10000)); + dissipationSlider.setMajorTickSpacing(250); + dissipationSlider.setMinorTickSpacing(50); + dissipationSlider.setPaintTicks(true); + dissipationSlider.addChangeListener(e -> { + float value = dissipationSlider.getValue() / 10000.0f; + params.setDissipation(value); + dissipationValueLabel.setText(String.format("%.4f", value)); + }); + + panel.add(dissipationValueLabel); + panel.add(dissipationSlider); + + return panel; + } + + private JPanel createInteractionRadiusPanel() { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(new TitledBorder("Interaction Radius")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 100)); + + interactionRadiusValueLabel = new JLabel(String.format("%.0f", params.getInteractionRadius())); + interactionRadiusValueLabel.setAlignmentX(Component.CENTER_ALIGNMENT); + interactionRadiusValueLabel.setFont(new Font("Monospaced", Font.BOLD, 14)); + + // Scale: 10 to 500 + interactionRadiusSlider = new JSlider(10, 500, (int) params.getInteractionRadius()); + interactionRadiusSlider.setMajorTickSpacing(100); + interactionRadiusSlider.setMinorTickSpacing(50); + interactionRadiusSlider.setPaintTicks(true); + interactionRadiusSlider.addChangeListener(e -> { + float value = interactionRadiusSlider.getValue(); + params.setInteractionRadius(value); + interactionRadiusValueLabel.setText(String.format("%.0f", value)); + }); + + panel.add(interactionRadiusValueLabel); + panel.add(interactionRadiusSlider); + + return panel; + } + + private JPanel createInfoPanel() { + JPanel panel = new JPanel(); + + panel.setLayout(new GridBagLayout()); + GridBagConstraints cons = new GridBagConstraints(); + cons.fill = GridBagConstraints.HORIZONTAL; + cons.weightx = 1; + cons.gridx = 0; + + panel.setBorder(new TitledBorder("Controls")); + panel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 120)); + + JLabel info1 = new JLabel("LMB drag: add source velocity"); + JLabel info2 = new JLabel("Shift + LMB drag: add density and velocity"); + JLabel info3 = new JLabel("Ctrl + LMB drag: add density and velocity"); + JLabel info4 = new JLabel("ESC: Exit"); + + info1.setFont(new Font("Arial", Font.PLAIN, 12)); + info2.setFont(new Font("Arial", Font.PLAIN, 12)); + info3.setFont(new Font("Arial", Font.PLAIN, 12)); + info4.setFont(new Font("Arial", Font.PLAIN, 12)); + + panel.add(Box.createVerticalStrut(5)); + panel.add(info1, cons); + panel.add(info2, cons); + panel.add(info3, cons); + panel.add(info4, cons); + panel.add(Box.createVerticalStrut(5)); + + return panel; + } + + /** + * Updates all UI controls to reflect current parameter values. + * + * @param params simulation parameters to display + */ + public void updateUI(SimParams params) { + // Update sliders + iterationsSlider.setValue(params.getIterations()); + viscositySlider.setValue((int) (params.getViscosity() * 1000)); + diffusionSlider.setValue((int) (params.getDiffusion() * 10000)); + vorticitySlider.setValue((int) (params.getVorticity() * 10)); + dissipationSlider.setValue((int) (params.getDissipation() * 10000)); + interactionRadiusSlider.setValue((int) params.getInteractionRadius()); + + // Update labels + iterationsValueLabel.setText(String.valueOf(params.getIterations())); + viscosityValueLabel.setText(String.format("%.3f", params.getViscosity())); + diffusionValueLabel.setText(String.format("%.5f", params.getDiffusion())); + vorticityValueLabel.setText(String.format("%.1f", params.getVorticity())); + dissipationValueLabel.setText(String.format("%.4f", params.getDissipation())); + interactionRadiusValueLabel.setText(String.format("%.0f", params.getInteractionRadius())); + + } + + private void resetToDefaults() { + // Default values + params.setIterations(Constants.ITERATIONS); + params.setViscosity(Constants.VISCOSITY); + params.setDiffusion(Constants.DIFFUSION); + params.setVorticity(Constants.VORTICITY); + params.setDissipation(Constants.DISSIPATION); + params.setInteractionRadius(Constants.INTERACTION_RADIUS); + params.setVelocityView(false); + params.setShowArrows(false); + + // Update checkbox + showVelocityCheckBox.setSelected(false); + showArrowsCheckBox.setSelected(false); + + updateUI(params); + } + +} diff --git a/src/main/java/dev/fluidsim/ui/SimFrame.java b/src/main/java/dev/fluidsim/ui/SimFrame.java new file mode 100644 index 0000000..9a9e9d0 --- /dev/null +++ b/src/main/java/dev/fluidsim/ui/SimFrame.java @@ -0,0 +1,145 @@ +package dev.fluidsim.ui; + +import dev.fluidsim.io.LoadableParams; +import dev.fluidsim.simulation.Sim; + +import javax.swing.*; + +import java.awt.*; + +/** + * Main Swing GUI frame that contains the OpenGL canvas and control panel + */ +public class SimFrame extends JFrame { + private final SimControlPanel controlPanel; + private JLabel frameRateLabel; + + private final Sim simulation; + private final LoadableParams params; + + /** + * Creates the main GUI frame with control panel and menu bar. + * + * @param params simulation parameters for control panel + * @param simulation simulation instance for interactions + * @param canvasWidth OpenGL canvas width (unused in current layout) + * @param canvasHeight OpenGL canvas height (unused in current layout) + */ + public SimFrame(LoadableParams params, Sim simulation, int canvasWidth, int canvasHeight) { + this.params = params; + this.simulation = simulation; + + setTitle("Fluid Simulation - OpenGL GPU"); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setResizable(false); + setType(Type.POPUP); + + // Create menu bar + createMenuBar(); + + // Create control panel + controlPanel = new SimControlPanel(params, simulation); + + // Layout + Container contentPane = getContentPane(); + contentPane.setLayout(new BorderLayout()); + + // Add control panel to the right + contentPane.add(controlPanel, BorderLayout.CENTER); + + pack(); + setLocationRelativeTo(null); // Center on screen + } + + /** + * Callback interface for file operations. + */ + public interface Executable { + /** + * Executes an operation with the selected file path. + * + * @param path absolute path to file + * @throws Exception if operation fails + */ + void execute(String path) throws Exception; + } + + private void createMenuBar() { + JMenuBar menuBar = new JMenuBar(); + + // File menu + JMenu fileMenu = new JMenu("File"); + + JMenuItem saveItem = new JMenuItem("Save config"); + saveItem.addActionListener(e -> { + SimControlPanel.invokeFilePicker(this, (String path) -> { + try { + params.save(path); + } catch (Exception ex) { + ex.printStackTrace(); + } + }); + }); + + JMenuItem loadItem = new JMenuItem("Load config"); + loadItem.addActionListener(e -> { + SimControlPanel.invokeFilePicker(this, (String path) -> { + try { + params.loadAndSet(path); + controlPanel.updateUI(params); + + simulation.deleteAllObjects(); + params.nozzles.forEach(x -> simulation.addObject(x)); + + } catch (Exception ex) { + ex.printStackTrace(); + } + }); + }); + + JMenuItem exitItem = new JMenuItem("Exit"); + exitItem.addActionListener(e -> System.exit(0)); + + fileMenu.add(saveItem); + fileMenu.add(loadItem); + fileMenu.addSeparator(); + fileMenu.add(exitItem); + + menuBar.add(fileMenu); + + // Objects menu + JMenu objectMenu = new JMenu("Objects"); + + JMenuItem loadObjectsItem = new JMenuItem("Load bitmap"); + loadObjectsItem.addActionListener(e -> { + SimControlPanel.invokeFilePicker(this, (String path) -> { + simulation.loadObjects(path); + }); + }); + + JMenuItem clearObjectsItem = new JMenuItem("Clear all"); + clearObjectsItem.addActionListener(e -> simulation.clearObjects()); + + objectMenu.add(loadObjectsItem); + objectMenu.add(clearObjectsItem); + menuBar.add(objectMenu); + + // Frame rate label + frameRateLabel = new JLabel("FPS: 000"); + frameRateLabel.setFont(new Font("Monospaced", Font.PLAIN, 14)); + + menuBar.add(Box.createHorizontalGlue()); + menuBar.add(frameRateLabel, BorderLayout.EAST); + + setJMenuBar(menuBar); + } + + /** + * Updates the displayed frame rate in the menu bar. + * + * @param fps frames per second to display + */ + public void updateFrameRate(int fps) { + frameRateLabel.setText(String.format("FPS: %03d", fps)); + } +} diff --git a/src/main/java/dev/fluidsim/ui/Utils.java b/src/main/java/dev/fluidsim/ui/Utils.java deleted file mode 100644 index 84f8f86..0000000 --- a/src/main/java/dev/fluidsim/ui/Utils.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Color; - -public class Utils { - /** - * Linearly interpolates between two colors. - * - * @param c1 The starting color (at fraction = 0). - * @param c2 The ending color (at fraction = 1). - * @param fraction A float value between 0.0 and 1.0 representing the - * interpolation point. - * @return A new Color object representing the interpolated color. - */ - public static Color lerp(Color c1, Color c2, float fraction, int alpha) { - fraction = Math.min(Math.max(fraction, 0.0f), 1.0f); - - float r1 = c1.getRed(); - float g1 = c1.getGreen(); - float b1 = c1.getBlue(); - - float r2 = c2.getRed(); - float g2 = c2.getGreen(); - float b2 = c2.getBlue(); - - int r = (int) (r1 + (r2 - r1) * fraction); - int g = (int) (g1 + (g2 - g1) * fraction); - int b = (int) (b1 + (b2 - b1) * fraction); - - return new Color(r, g, b, alpha); - } - - public static Color lerp(Color c1, Color c2, float fraction) { - return lerp(c1, c2, fraction, 255); - } -} diff --git a/src/main/java/dev/fluidsim/ui/Vector.java b/src/main/java/dev/fluidsim/ui/Vector.java deleted file mode 100644 index 940c41a..0000000 --- a/src/main/java/dev/fluidsim/ui/Vector.java +++ /dev/null @@ -1,199 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.BasicStroke; -import java.awt.Color; -import java.awt.Graphics2D; -import java.awt.geom.Path2D; - -import dev.fluidsim.common.Vec; - -public class Vector implements Drawable { - public record Look(float thickness, Color color, int radius, int barbLength, double barbAngle) { - } - - protected static Look DEFAULT_LOOK = new Look(4, Color.GREEN, 5, 6, 45); - - private Vec start; - private Vec end; - - private double angle; - private double length; - - private Look look; - private Color color; - - public Vector() { - start = Vec.NULL(); - end = Vec.NULL(); - } - - /** - * Constructor that takes an angle and length - * - * @param x1 The origin X coordinate - * @param y1 The origin Y coordinate - * @param x2 The target X coordinate - * @param y2 The target Y coordinate - * @param look The display params of the vector - */ - public Vector(int x1, int y1, int x2, int y2, Look look) { - start = new Vec(x1, y1); - end = new Vec(x2, y2); - - update(end); - this.look = look; - this.color = look.color; - } - - public Vector(int x1, int y1, int x2, int y2) { - this(x1, y1, x2, y2, DEFAULT_LOOK); - } - - /** - * Constructor that takes an angle and length - * - * @param x1 The origin X coordinate - * @param y1 The origin Y coordinate - * @param angle The angle in radians - * @param length The length of the vector - * @param look The display params of the vector - */ - public Vector(int x1, int y1, double angle, double length, Look look) { - start = new Vec(x1, y1); - this.angle = angle; - this.length = length; - - update(angle, length); - this.look = look; - this.color = look.color; - } - - /** - * Constructor that takes an angle and length - * - * @param x1 The origin X coordinate - * @param y1 The origin Y coordinate - * @param angle The angle in radians - * @param length The length of the vector - */ - public Vector(int x1, int y1, double angle, double length) { - this(x1, y1, angle, length, DEFAULT_LOOK); - } - - public double getAngle() { - return angle; - } - - public double getLength() { - return length; - } - - public Vec getStart() { - return start; - } - - public Vec getEnd() { - return end; - } - - public void setStart(Vec start) { - this.start = start; - update(end); - } - - public void setEnd(Vec end) { - this.end = end; - update(end); - } - - public void setEnd(int x, int y) { - this.end = new Vec(x, y); - update(end); - } - - public void setEndRelative(int x, int y) { - this.end = new Vec(this.start.x + x, this.start.y + y); - update(end); - } - - public void setAngle(double angle) { - this.angle = angle; - update(angle, length); - } - - public void setLength(double length) { - this.length = length; - update(angle, length); - } - - public void setColor(Color c) { - this.color = c; - } - - // --- - - @Override - public void update(float deltaTime) { - } - - @Override - public void draw(Graphics2D g, int screenWidth, int screenHeight) { - g.setStroke(new BasicStroke(look.thickness)); - g.setColor(color); - - // g.fillOval(start.x - look.radius, start.y - look.radius, look.radius * 2, - // look.radius * 2); - - // only draw the dot when the vector is null - if (start.equals(end)) - return; - - g.drawLine(start.x, start.y, end.x, end.y); - - // double barbAngle = Math.toRadians(look.barbAngle); - - // int x1 = (int) (end.x - look.barbLength * Math.cos(angle - barbAngle)); - // int y1 = (int) (end.y - look.barbLength * Math.sin(angle - barbAngle)); - - // int x2 = (int) (end.x - look.barbLength * Math.cos(angle + barbAngle)); - // int y2 = (int) (end.y - look.barbLength * Math.sin(angle + barbAngle)); - - // g.drawLine(end.x, end.y, x1, y1); - // g.drawLine(end.x, end.y, x2, y2); - - // --- - - // --- 3. Define the arrowhead shape (canonical) --- - // We define the arrowhead as if it's pointing left (angle = PI) - // at the origin (0,0). The shaft is along the positive X-axis. - // The barbs will be at angles (PI - arrowAngleRad) and (PI + arrowAngleRad). - - // We'll use simple trigonometry to find the endpoints of the barbs. - // x = length * cos(angle) - // y = length * sin(angle) - - // Point 1 (top barb) - int x_base = (int) (-look.barbLength * Math.cos(angle)); - int y_base = (int) (look.barbLength * Math.sin(angle)); // Note: positive y - - g.drawLine(end.x, end.y, end.x + x_base, end.y + y_base); - g.drawLine(end.x, end.y, end.x + x_base, end.y - y_base); - } - - /** - * Update the vector based on the endpoint - */ - private void update(Vec end) { - angle = Math.atan2(end.y - start.y, end.x - start.x); - length = (int) Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)); - } - - /** - * Update the vector based on angle and length - */ - private void update(double angle, double length) { - int x2 = (int) (start.x + length * Math.cos(angle)); - int y2 = (int) (start.y + length * Math.sin(angle)); - end = new Vec(x2, y2); - } -} diff --git a/src/main/java/dev/fluidsim/ui/Vectors.java b/src/main/java/dev/fluidsim/ui/Vectors.java deleted file mode 100644 index 19c1b6f..0000000 --- a/src/main/java/dev/fluidsim/ui/Vectors.java +++ /dev/null @@ -1,65 +0,0 @@ -package dev.fluidsim.ui; - -import java.awt.Color; -import java.awt.Graphics2D; - -import dev.fluidsim.simulation.Sim; - -public class Vectors implements Drawable { - private final int cellSize; - /** - * Every n-th cell will have a vector rendered - */ - private final int density; - - private Vector[] vecs; - private Sim sim; - - private Vector.Look thinLook = new Vector.Look(2, Color.RED, 2, 2, 55); - - public Vectors(Sim sim, int screenWidth, int screenHeight, int density) { - this.sim = sim; - this.density = density; - - if (screenWidth < screenHeight) { - this.cellSize = (int) (screenWidth / sim.getCellCountX()); - } else { - this.cellSize = (int) (screenHeight / sim.getCellCountY()); - } - - vecs = new Vector[sim.getSize()]; - - for (int i = 0; i < sim.getCellCountX(); i += density) { - for (int j = 0; j < sim.getCellCountY(); j += density) { - int x = i * cellSize + cellSize / 2; - int y = j * cellSize + cellSize / 2; - vecs[sim.at(i, j)] = new Vector(x, y, x, y, thinLook); - } - } - } - - @Override - public void update(float deltaTime) { - } - - /** - * Draws the grid lines on the canvas. - */ - @Override - public void draw(Graphics2D g, int screenWidth, int screenHeight) { - int i, j, x, y, idx; - - for (i = 0; i < sim.getCellCountX(); i += density) { - for (j = 0; j < sim.getCellCountY(); j += density) { - idx = sim.at(i, j); - - x = Math.min((int) (sim.getVelocityX(i, j) * 100f), this.cellSize); - y = Math.min((int) (sim.getVelocityY(i, j) * 100f), this.cellSize); - - vecs[idx].setEndRelative(x, y); - // vecs[idx].setColor(Utils.lerp(Color.GREEN, Color.RED, (x + y) / 10f)); - vecs[idx].draw(g, screenWidth, screenHeight); - } - } - } -} diff --git a/src/main/resources/schema/simparams.xsd b/src/main/resources/schema/simparams.xsd new file mode 100644 index 0000000..ff51197 --- /dev/null +++ b/src/main/resources/schema/simparams.xsd @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/shaders/add_source.frag b/src/main/resources/shaders/add_source.frag new file mode 100644 index 0000000..7140418 --- /dev/null +++ b/src/main/resources/shaders/add_source.frag @@ -0,0 +1,20 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uTarget; +uniform sampler2D uSource; +uniform float uDeltaTime; + +void main() { + vec2 coord = vTexCoord; + + float target = texture(uTarget, coord).r; + float source = texture(uSource, coord).r; + + // Add source scaled by time step + float result = target + source * uDeltaTime; + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/advect.frag b/src/main/resources/shaders/advect.frag new file mode 100644 index 0000000..1911a8a --- /dev/null +++ b/src/main/resources/shaders/advect.frag @@ -0,0 +1,118 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uVelocityX; +uniform sampler2D uVelocityY; +uniform sampler2D uSource; +uniform sampler2D uObstacle; +uniform vec2 uTexelSize; +uniform float uDeltaTime; +uniform float uDissipation; +uniform int uIsDensity; // 1 = density, 0 = velocity + +void main() { + vec2 coord = vTexCoord; + + // Check if current cell is inside obstacle + float obstacle = texture(uObstacle, coord).r; + if (obstacle > 0.5) { + // Inside wall - set velocity to zero, density to zero + FragColor = vec4(0.0); + return; + } + + // Get velocity at current position + float vx = texture(uVelocityX, coord).r; + float vy = texture(uVelocityY, coord).r; + + // Trace particle backwards in time + vec2 pos = coord - uDeltaTime * vec2(vx, vy) * uTexelSize; + + // Clamp to valid texture coordinates + pos = clamp(pos, uTexelSize, vec2(1.0) - uTexelSize); + + // Check if backtraced position hits an obstacle + float obstacleAtSource = texture(uObstacle, pos).r; + + if (obstacleAtSource > 0.5) { + // The backtraced position is inside a wall + if (uIsDensity == 1) { + // For density, find alternative sample location + // Try reflecting the position across the obstacle boundary + vec2 dir = normalize(pos - coord); + if (length(dir) < 0.001) { + dir = vec2(1.0, 0.0); // fallback direction + } + + // Search for nearest free cell + vec2 reflectedPos = coord; + float bestDist = 10000.0; + + // Check in multiple directions around current cell + for (int i = 0; i < 32; i++) { + float angle = float(i) * 3.14159 * 1.0 / 32.0; // 22.5 degree increments + vec2 offset = vec2(cos(angle), sin(angle)) * uTexelSize * 2.0; + vec2 testPos = coord + offset; + testPos = clamp(testPos, uTexelSize, vec2(1.0) - uTexelSize); + + float testObstacle = texture(uObstacle, testPos).r; + if (testObstacle < 0.5) { + float dist = length(testPos - pos); + if (dist < bestDist) { + bestDist = dist; + reflectedPos = testPos; + } + } + } + + // Sample from the best alternative position + vec4 result = texture(uSource, reflectedPos); + result = result * uDissipation * 0.95; // Small loss on reflection + FragColor = max(result, vec4(0.0)); + } else { + // For velocity, implement slip boundary condition + // Find the nearest non-obstacle cell by checking neighbors + vec2 offsets[4] = vec2[]( + vec2(uTexelSize.x, 0.0), + vec2(-uTexelSize.x, 0.0), + vec2(0.0, uTexelSize.y), + vec2(0.0, -uTexelSize.y) + ); + + vec2 slipPos = pos; + float minObstacle = 1.0; + + // Find the direction away from the obstacle + for (int i = 0; i < 4; i++) { + vec2 testPos = pos + offsets[i]; + testPos = clamp(testPos, uTexelSize, vec2(1.0) - uTexelSize); + float testObstacle = texture(uObstacle, testPos).r; + + if (testObstacle < minObstacle) { + minObstacle = testObstacle; + slipPos = testPos; + } + } + + // Sample velocity from the slip position + vec4 result = texture(uSource, slipPos); + result = result * 2.5; // Slight boost - super slippery walls + FragColor = result; + + } + return; + } + + // Bilinear sampling with dissipation + vec4 result = texture(uSource, pos); + result = result * uDissipation; + + // Clamp to prevent negative density + if (uIsDensity == 1) { + result = max(result, vec4(0.0)); + } + + FragColor = result; +} diff --git a/src/main/resources/shaders/arrow.frag b/src/main/resources/shaders/arrow.frag new file mode 100644 index 0000000..6bf2d15 --- /dev/null +++ b/src/main/resources/shaders/arrow.frag @@ -0,0 +1,9 @@ +#version 330 core + +out vec4 FragColor; + +uniform vec3 uColor; + +void main() { + FragColor = vec4(uColor, 1.0); +} diff --git a/src/main/resources/shaders/arrow.vert b/src/main/resources/shaders/arrow.vert new file mode 100644 index 0000000..676e1d0 --- /dev/null +++ b/src/main/resources/shaders/arrow.vert @@ -0,0 +1,73 @@ +#version 330 core + +layout(location = 0) in vec2 aPosition; // Arrow template vertex position +layout(location = 1) in vec2 aInstancePos; // Instance grid position (per instance) + +uniform mat4 uProjection; +uniform sampler2D uVelocityX; +uniform sampler2D uVelocityY; +uniform vec2 uTexelSize; +uniform vec2 uGridSize; +uniform vec2 uScreenSize; + +uniform float uMaxArrowLength; +uniform float uVelocityScale; + +void main() { + // aInstancePos is in grid space, convert to texture coordinates + vec2 texCoord = aInstancePos * uTexelSize; + + // Sample 5x5 neighborhood and average for smoothness + float avgVx = 0.0; + float avgVy = 0.0; + int samples = 0; + int radius = 3; + + for (int dy = -radius; dy <= radius; dy++) { + for (int dx = -radius; dx <= radius; dx++) { + vec2 offset = vec2(float(dx), float(dy)) * uTexelSize; + vec2 sampleCoord = texCoord + offset; + + // Clamp to valid texture coordinates + if (sampleCoord.x >= 0.0 && sampleCoord.x <= 1.0 && + sampleCoord.y >= 0.0 && sampleCoord.y <= 1.0) { + avgVx += texture(uVelocityX, sampleCoord).r; + avgVy += texture(uVelocityY, sampleCoord).r; + samples++; + } + } + } + + // Average the velocities + float vx = avgVx / float(samples); + float vy = avgVy / float(samples); + + // Calculate velocity magnitude and direction + float magnitude = sqrt(vx * vx + vy * vy); + + // Skip rendering if velocity is too small (will be clipped) + if (magnitude < 0.1) { + gl_Position = vec4(-10.0, -10.0, 0.0, 1.0); // Move off-screen + return; + } + + // Normalize direction and scale length + float length = min(magnitude * uVelocityScale, uMaxArrowLength); + vec2 dir = vec2(vx, vy) / magnitude; + + // Rotation matrix + mat2 rotation = mat2( + dir.x, -dir.y, + dir.y, dir.x + ); + + // Transform arrow template vertex + vec2 rotatedPos = rotation * aPosition * length; + + // Convert grid position to screen space + vec2 screenPos = (aInstancePos / uGridSize) * uScreenSize; + screenPos.y = uScreenSize.y - screenPos.y; + vec2 finalPos = screenPos + rotatedPos; + + gl_Position = uProjection * vec4(finalPos, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/boundary.frag b/src/main/resources/shaders/boundary.frag new file mode 100644 index 0000000..56fd535 --- /dev/null +++ b/src/main/resources/shaders/boundary.frag @@ -0,0 +1,35 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uSource; +uniform vec2 uTexelSize; +uniform int uBoundaryMode; // 0=none, 1=horizontal, 2=vertical + +void main() { + vec2 coord = vTexCoord; + float value = texture(uSource, coord).r; + + // Check if we're at boundary + bool isLeftEdge = coord.x < uTexelSize.x * 1.5; + bool isRightEdge = coord.x > 1.0 - uTexelSize.x * 1.5; + bool isBottomEdge = coord.y < uTexelSize.y * 1.5; + bool isTopEdge = coord.y > 1.0 - uTexelSize.y * 1.5; + + if (isLeftEdge) { + float innerValue = texture(uSource, coord + vec2(uTexelSize.x, 0.0)).r; + value = (uBoundaryMode == 1) ? -innerValue : innerValue; + } else if (isRightEdge) { + float innerValue = texture(uSource, coord - vec2(uTexelSize.x, 0.0)).r; + value = (uBoundaryMode == 1) ? -innerValue : innerValue; + } else if (isBottomEdge) { + float innerValue = texture(uSource, coord + vec2(0.0, uTexelSize.y)).r; + value = (uBoundaryMode == 2) ? -innerValue : innerValue; + } else if (isTopEdge) { + float innerValue = texture(uSource, coord - vec2(0.0, uTexelSize.y)).r; + value = (uBoundaryMode == 2) ? -innerValue : innerValue; + } + + FragColor = vec4(value, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/clear.frag b/src/main/resources/shaders/clear.frag new file mode 100644 index 0000000..304b868 --- /dev/null +++ b/src/main/resources/shaders/clear.frag @@ -0,0 +1,10 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform vec4 uClearColor; + +void main() { + FragColor = uClearColor; +} diff --git a/src/main/resources/shaders/common.glsl b/src/main/resources/shaders/common.glsl new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/shaders/diffuse.frag b/src/main/resources/shaders/diffuse.frag new file mode 100644 index 0000000..d46546f --- /dev/null +++ b/src/main/resources/shaders/diffuse.frag @@ -0,0 +1,32 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uSource; +uniform sampler2D uOriginal; +uniform vec2 uTexelSize; +uniform float uAlpha; +uniform float uBeta; +uniform int uIsDensity; + +void main() { + vec2 coord = vTexCoord; + + // Sample neighbors (Jacobi iteration) + float left = texture(uSource, coord - vec2(uTexelSize.x, 0.0)).r; + float right = texture(uSource, coord + vec2(uTexelSize.x, 0.0)).r; + float bottom = texture(uSource, coord - vec2(0.0, uTexelSize.y)).r; + float top = texture(uSource, coord + vec2(0.0, uTexelSize.y)).r; + + float original = texture(uOriginal, coord).r; + + // Jacobi iteration: x_new = (x_original + alpha * neighbors) / beta + float result = (original + uAlpha * (left + right + bottom + top)) / uBeta; + + if (uIsDensity == 1) { + result = max(result, 0.0); + } + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/display.frag b/src/main/resources/shaders/display.frag new file mode 100644 index 0000000..4581451 --- /dev/null +++ b/src/main/resources/shaders/display.frag @@ -0,0 +1,65 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uDensity; +uniform sampler2D uVelocityX; +uniform sampler2D uVelocityY; +uniform sampler2D uObstacle; +uniform float uMinDensity; +uniform float uMaxDensity; +uniform int uShowVelocity; // 0 = density, 1 = velocity + +// Color gradient for velocity visualization +vec3 velocityColor(float t) { + t = clamp(t, 0.0, 1.0); + + if (t < 0.25) { + return vec3(0.0, t / 0.25, 1.0); + } else if (t < 0.5) { + return vec3(0.0, 1.0, 1.0 - (t - 0.25) / 0.25); + } else if (t < 0.75) { + return vec3((t - 0.5) / 0.25, 1.0, 0.0); + } else { + return vec3(1.0, 1.0 - (t - 0.75) / 0.25, 0.0); + } +} + +void main() { + vec3 color; + + // Check if this pixel is an obstacle + float obstacle = texture(uObstacle, vTexCoord).r; + + if (obstacle > 0.5) { + // Draw obstacle as dark gray + color = vec3(0.2, 0.2, 0.2); + } else if (uShowVelocity == 1) { + // Velocity visualization + float vx = texture(uVelocityX, vTexCoord).r; + float vy = texture(uVelocityY, vTexCoord).r; + + // Calculate velocity magnitude + float magnitude = sqrt(vx * vx + vy * vy); + + // Normalize to [0, 1] range (adjust max velocity as needed) + float maxVelocity = 300.0; + float normalized = magnitude / maxVelocity; + normalized = clamp(normalized, 0.0, 1.0); + + // Apply color gradient + color = velocityColor(normalized); + } else { + // Density visualization (original grayscale) + float density = texture(uDensity, vTexCoord).r; + + // Normalize density to [0, 1] range + float normalized = (density - uMinDensity) / (uMaxDensity - uMinDensity + 0.0001); + normalized = clamp(normalized, 0.0, 1.0); + + color = vec3(normalized); + } + + FragColor = vec4(color, 1.0); +} diff --git a/src/main/resources/shaders/divergence.frag b/src/main/resources/shaders/divergence.frag new file mode 100644 index 0000000..9ab2d8c --- /dev/null +++ b/src/main/resources/shaders/divergence.frag @@ -0,0 +1,42 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uVelocityX; +uniform sampler2D uVelocityY; +uniform sampler2D uObstacle; +uniform vec2 uTexelSize; + +void main() { + vec2 coord = vTexCoord; + + // Check obstacle at neighboring cells + float obstacleC = texture(uObstacle, coord).r; + float obstacleL = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r; + float obstacleR = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r; + float obstacleB = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r; + float obstacleT = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r; + + // Sample velocity field + float vxRight = texture(uVelocityX, coord + vec2(uTexelSize.x, 0.0)).r; + float vxLeft = texture(uVelocityX, coord - vec2(uTexelSize.x, 0.0)).r; + float vyTop = texture(uVelocityY, coord + vec2(0.0, uTexelSize.y)).r; + float vyBottom = texture(uVelocityY, coord - vec2(0.0, uTexelSize.y)).r; + + // Treat obstacle boundaries as solid walls (set velocity to zero) + if (obstacleR > 0.5) vxRight = 0.0; + if (obstacleL > 0.5) vxLeft = 0.0; + if (obstacleT > 0.5) vyTop = 0.0; + if (obstacleB > 0.5) vyBottom = 0.0; + + // Calculate divergence: ∇·v = ∂vx/∂x + ∂vy/∂y + float divergence = -0.5 * ((vxRight - vxLeft) + (vyTop - vyBottom)); + + // Zero divergence inside obstacles + if (obstacleC > 0.5) { + divergence = 0.0; + } + + FragColor = vec4(divergence, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/gradient.frag b/src/main/resources/shaders/gradient.frag new file mode 100644 index 0000000..b05e4ce --- /dev/null +++ b/src/main/resources/shaders/gradient.frag @@ -0,0 +1,60 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uVelocity; +uniform sampler2D uPressure; +uniform sampler2D uObstacle; +uniform vec2 uTexelSize; +uniform int uIsXComponent; + +void main() { + vec2 coord = vTexCoord; + + float obstacleC = texture(uObstacle, coord).r; + + // Inside obstacle, velocity is zero + if (obstacleC > 0.5) { + FragColor = vec4(0.0); + return; + } + + // Check for obstacles in neighboring cells + float obstacleRight = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r; + float obstacleLeft = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r; + float obstacleTop = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r; + float obstacleBottom = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r; + + // Sample pressure gradient + float pressureRight = texture(uPressure, coord + vec2(uTexelSize.x, 0.0)).r; + float pressureLeft = texture(uPressure, coord - vec2(uTexelSize.x, 0.0)).r; + float pressureTop = texture(uPressure, coord + vec2(0.0, uTexelSize.y)).r; + float pressureBottom = texture(uPressure, coord - vec2(0.0, uTexelSize.y)).r; + + // If neighbor is obstacle, use center pressure (free-slip condition) + if (obstacleRight > 0.5) pressureRight = texture(uPressure, coord).r; + if (obstacleLeft > 0.5) pressureLeft = texture(uPressure, coord).r; + if (obstacleTop > 0.5) pressureTop = texture(uPressure, coord).r; + if (obstacleBottom > 0.5) pressureBottom = texture(uPressure, coord).r; + + float velocity = texture(uVelocity, coord).r; + + // Subtract pressure gradient from velocity + float gradient; + if (uIsXComponent == 1) { + gradient = 0.5 * (pressureRight - pressureLeft); + if (obstacleRight > 0.5 || obstacleLeft > 0.5) { + gradient = 0.0; + } + } else { + gradient = 0.5 * (pressureTop - pressureBottom); + if (obstacleTop > 0.5 || obstacleBottom > 0.5) { + gradient = 0.0; + } + } + + float result = velocity - gradient; + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/pressure.frag b/src/main/resources/shaders/pressure.frag new file mode 100644 index 0000000..de44e7d --- /dev/null +++ b/src/main/resources/shaders/pressure.frag @@ -0,0 +1,47 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uPressure; +uniform sampler2D uDivergence; +uniform sampler2D uObstacle; +uniform vec2 uTexelSize; + +void main() { + vec2 coord = vTexCoord; + + float obstacleC = texture(uObstacle, coord).r; + + // Inside obstacle, pressure is zero + if (obstacleC > 0.5) { + FragColor = vec4(0.0); + return; + } + + // Check neighbors for obstacles + float obstacleL = texture(uObstacle, coord - vec2(uTexelSize.x, 0.0)).r; + float obstacleR = texture(uObstacle, coord + vec2(uTexelSize.x, 0.0)).r; + float obstacleB = texture(uObstacle, coord - vec2(0.0, uTexelSize.y)).r; + float obstacleT = texture(uObstacle, coord + vec2(0.0, uTexelSize.y)).r; + + // Sample neighboring pressure values + float left = texture(uPressure, coord - vec2(uTexelSize.x, 0.0)).r; + float right = texture(uPressure, coord + vec2(uTexelSize.x, 0.0)).r; + float bottom = texture(uPressure, coord - vec2(0.0, uTexelSize.y)).r; + float top = texture(uPressure, coord + vec2(0.0, uTexelSize.y)).r; + float center = texture(uPressure, coord).r; + + // Use center pressure for obstacle neighbors (Neumann boundary condition) + if (obstacleL > 0.5) left = center; + if (obstacleR > 0.5) right = center; + if (obstacleB > 0.5) bottom = center; + if (obstacleT > 0.5) top = center; + + float divergence = texture(uDivergence, coord).r; + + // Jacobi iteration for Poisson equation + float result = (divergence + left + right + bottom + top) * 0.25; + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/quad.vert b/src/main/resources/shaders/quad.vert new file mode 100644 index 0000000..b8c9f38 --- /dev/null +++ b/src/main/resources/shaders/quad.vert @@ -0,0 +1,11 @@ +#version 330 core + +layout (location = 0) in vec2 aPosition; +layout (location = 1) in vec2 aTexCoord; + +out vec2 vTexCoord; + +void main() { + vTexCoord = aTexCoord; + gl_Position = vec4(aPosition, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/splat.frag b/src/main/resources/shaders/splat.frag new file mode 100644 index 0000000..84e03c0 --- /dev/null +++ b/src/main/resources/shaders/splat.frag @@ -0,0 +1,26 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uTarget; +uniform vec2 uPoint; +uniform float uRadius; +uniform float uValue; +uniform vec2 uGridSize; + +void main() { + vec2 coord = vTexCoord; + float current = texture(uTarget, coord).r; + + // Calculate distance from splat point + vec2 pixelCoord = coord * uGridSize; + vec2 pointCoord = uPoint; + float dist = length(pixelCoord - pointCoord); + + // Gaussian falloff + float splat = exp(-dist * dist / uRadius); + float result = current + uValue * splat; + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/vorticity.frag b/src/main/resources/shaders/vorticity.frag new file mode 100644 index 0000000..df5d384 --- /dev/null +++ b/src/main/resources/shaders/vorticity.frag @@ -0,0 +1,23 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uVelocityX; +uniform sampler2D uVelocityY; +uniform vec2 uTexelSize; + +void main() { + vec2 coord = vTexCoord; + + // Sample velocity field for curl calculation + float vxTop = texture(uVelocityX, coord + vec2(0.0, uTexelSize.y)).r; + float vxBottom = texture(uVelocityX, coord - vec2(0.0, uTexelSize.y)).r; + float vyRight = texture(uVelocityY, coord + vec2(uTexelSize.x, 0.0)).r; + float vyLeft = texture(uVelocityY, coord - vec2(uTexelSize.x, 0.0)).r; + + // Calculate curl (vorticity): ω = ∂vy/∂x - ∂vx/∂y + float curl = (vyRight - vyLeft) - (vxTop - vxBottom); + + FragColor = vec4(curl, 0.0, 0.0, 1.0); +} diff --git a/src/main/resources/shaders/vorticity_force.frag b/src/main/resources/shaders/vorticity_force.frag new file mode 100644 index 0000000..9064bf7 --- /dev/null +++ b/src/main/resources/shaders/vorticity_force.frag @@ -0,0 +1,41 @@ +#version 330 core + +in vec2 vTexCoord; +out vec4 FragColor; + +uniform sampler2D uVelocity; +uniform sampler2D uCurl; +uniform vec2 uTexelSize; +uniform float uVorticityScale; +uniform float uDeltaTime; +uniform int uIsXComponent; + +void main() { + vec2 coord = vTexCoord; + + // Sample curl values + float curlLeft = abs(texture(uCurl, coord - vec2(uTexelSize.x, 0.0)).r); + float curlRight = abs(texture(uCurl, coord + vec2(uTexelSize.x, 0.0)).r); + float curlBottom = abs(texture(uCurl, coord - vec2(0.0, uTexelSize.y)).r); + float curlTop = abs(texture(uCurl, coord + vec2(0.0, uTexelSize.y)).r); + float curlCenter = texture(uCurl, coord).r; + + // Calculate vorticity gradient + vec2 vorticityGradient = vec2(curlRight - curlLeft, curlTop - curlBottom); + float len = length(vorticityGradient) + 0.00001; + vorticityGradient = vorticityGradient / len; + + float velocity = texture(uVelocity, coord).r; + + // Apply vorticity confinement force + float force; + if (uIsXComponent == 1) { + force = uVorticityScale * uDeltaTime * curlCenter * vorticityGradient.y; + } else { + force = uVorticityScale * uDeltaTime * curlCenter * -vorticityGradient.x; + } + + float result = velocity + force; + + FragColor = vec4(result, 0.0, 0.0, 1.0); +} diff --git a/src/test/java/dev/fluidsim/LoadableParamsTest.java b/src/test/java/dev/fluidsim/LoadableParamsTest.java new file mode 100644 index 0000000..8246087 --- /dev/null +++ b/src/test/java/dev/fluidsim/LoadableParamsTest.java @@ -0,0 +1,266 @@ +package dev.fluidsim; + +import dev.fluidsim.io.LoadableParams; +import dev.fluidsim.simulation.Nozzle; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.xml.sax.SAXException; + +import javax.xml.parsers.ParserConfigurationException; +import java.awt.Color; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test cases for LoadableParams XML serialization and deserialization + */ +public class LoadableParamsTest { + + @TempDir + Path tempDir; + + /** + * Test 1: Successfully load a valid XML file with all parameters + */ + @Test + public void testLoadValidXMLFile() throws Exception { + // Create a valid XML file + Path xmlFile = tempDir.resolve("valid_params.xml"); + String validXml = """ + + + + 25 + 0.005 + 0.03 + 15.0 + 0.95 + 150.0 + true + true + + + 800 + 600 + + + + + + + + """; + Files.writeString(xmlFile, validXml); + + // Load and verify + LoadableParams params = LoadableParams.load(xmlFile.toString()); + + // Verify simulation parameters + assertEquals(25, params.iterations); + assertEquals(0.005f, params.viscosity, 0.0001f); + assertEquals(0.03f, params.diffusion, 0.0001f); + assertEquals(15.0f, params.vorticity, 0.0001f); + assertEquals(0.95f, params.dissipation, 0.0001f); + assertEquals(150.0f, params.interactionRadius, 0.0001f); + + // Verify grid parameters + assertEquals(800, params.width); + assertEquals(600, params.height); + + // Verify arrow parameters + assertEquals(25.0f, params.arrowDensity, 0.0001f); + assertEquals(35.0f, params.maxArrowLength, 0.0001f); + assertEquals(6.0f, params.arrowHeadLength, 0.0001f); + assertEquals(0.6f, params.arrowHeadAngle, 0.0001f); + assertEquals(1.5f, params.velocityScale, 0.0001f); + assertEquals(new Color(200, 220, 255, 230), params.arrowColor); + + // Verify nozzles + assertEquals(2, params.nozzles.size()); + Nozzle nozzle1 = params.nozzles.get(0); + assertEquals(Nozzle.Side.LEFT, nozzle1.getSide()); + assertEquals(0.5f, nozzle1.getPosition(), 0.0001f); + assertEquals(20.0f, nozzle1.getRadius(), 0.0001f); + } + + /** + * Test 2: IOException when file doesn't exist + */ + @Test + public void testLoadNonExistentFile() { + String nonExistentPath = tempDir.resolve("nonexistent.xml").toString(); + + assertThrows(IOException.class, () -> { + LoadableParams.load(nonExistentPath); + }); + } + + /** + * Test 3: SAXException when XML is malformed + */ + @Test + public void testLoadMalformedXML() throws IOException { + Path xmlFile = tempDir.resolve("malformed.xml"); + String malformedXml = """ + + + + 25 + 0.005 + + + """; + Files.writeString(xmlFile, malformedXml); + + assertThrows(SAXException.class, () -> { + LoadableParams.load(xmlFile.toString()); + }); + } + + /** + * Test 4: NumberFormatException when numeric values are invalid + */ + @Test + public void testLoadInvalidNumericValues() throws IOException { + Path xmlFile = tempDir.resolve("invalid_numbers.xml"); + String invalidXml = """ + + + + not_a_number + 0.005 + 0.03 + 15.0 + 0.95 + 150.0 + true + true + + + 800 + 600 + + + + """; + Files.writeString(xmlFile, invalidXml); + + assertThrows(NumberFormatException.class, () -> { + LoadableParams.load(xmlFile.toString()); + }); + } + + /** + * Test 5: Successfully save and reload parameters + */ + @Test + public void testSaveAndReloadParameters() throws Exception { + // Create params with test data + LoadableParams original = new LoadableParams(); + original.iterations = 30; + original.viscosity = 0.008f; + original.diffusion = 0.04f; + original.vorticity = 18.0f; + original.dissipation = 0.92f; + original.interactionRadius = 175.0f; + original.width = 1024; + original.height = 768; + original.arrowDensity = 22.0f; + original.maxArrowLength = 32.0f; + original.arrowHeadLength = 5.5f; + original.arrowHeadAngle = 0.55f; + original.velocityScale = 1.2f; + original.arrowColor = new Color(180, 200, 255, 200); + + // Add nozzles + original.nozzles.add(new Nozzle( + Nozzle.Side.RIGHT, + 0.6f, + 25.0f, + 120.0f, + 180.0f, + 180000.0f, + 220.0f)); + + // Save to file + Path xmlFile = tempDir.resolve("saved_params.xml"); + original.save(xmlFile.toString()); + + // Verify file was created + assertTrue(Files.exists(xmlFile)); + + // Load back + LoadableParams loaded = LoadableParams.load(xmlFile.toString()); + + // Verify all values match + assertEquals(original.iterations, loaded.iterations); + assertEquals(original.viscosity, loaded.viscosity, 0.0001f); + assertEquals(original.diffusion, loaded.diffusion, 0.0001f); + assertEquals(original.vorticity, loaded.vorticity, 0.0001f); + assertEquals(original.dissipation, loaded.dissipation, 0.0001f); + assertEquals(original.interactionRadius, loaded.interactionRadius, 0.0001f); + assertEquals(original.width, loaded.width); + assertEquals(original.height, loaded.height); + assertEquals(original.arrowDensity, loaded.arrowDensity, 0.0001f); + assertEquals(original.arrowColor, loaded.arrowColor); + assertEquals(original.nozzles.size(), loaded.nozzles.size()); + } + + /** + * Test 6: loadAndSet method updates existing instance + */ + @Test + public void testLoadAndSetUpdatesInstance() throws Exception { + // Create XML file with specific values + Path xmlFile = tempDir.resolve("update_params.xml"); + String xml = """ + + + + 42 + 0.007 + 0.035 + 17.5 + 0.93 + 160.0 + false + false + + + 1280 + 720 + + + + + + + """; + Files.writeString(xmlFile, xml); + + // Create instance with different initial values + LoadableParams params = new LoadableParams(); + params.iterations = 10; + params.viscosity = 0.001f; + params.arrowDensity = 5.0f; + + // Load and set from file + params.loadAndSet(xmlFile.toString()); + + // Verify values were updated + assertEquals(42, params.iterations); + assertEquals(0.007f, params.viscosity, 0.0001f); + assertEquals(0.035f, params.diffusion, 0.0001f); + assertEquals(17.5f, params.vorticity, 0.0001f); + assertEquals(28.0f, params.arrowDensity, 0.0001f); + assertEquals(38.0f, params.maxArrowLength, 0.0001f); + assertEquals(new Color(255, 200, 180, 240), params.arrowColor); + assertEquals(1, params.nozzles.size()); + assertEquals(Nozzle.Side.TOP, params.nozzles.get(0).getSide()); + } +} diff --git a/src/test/java/dev/fluidsim/NozzleTest.java b/src/test/java/dev/fluidsim/NozzleTest.java new file mode 100644 index 0000000..323160b --- /dev/null +++ b/src/test/java/dev/fluidsim/NozzleTest.java @@ -0,0 +1,124 @@ +package dev.fluidsim; + +import dev.fluidsim.simulation.Nozzle; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test cases for Nozzle class + */ +public class NozzleTest { + + /** + * Test 1: Constructor properly calculates force components from direction angle + */ + @Test + public void testConstructorCalculatesForceComponents() { + // Test 0 degrees (pointing right) + Nozzle nozzle0 = new Nozzle(Nozzle.Side.LEFT, 0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f); + assertEquals(1000.0f, nozzle0.getForceX(), 0.1f); + assertEquals(0.0f, nozzle0.getForceY(), 0.1f); + + // Test 90 degrees (pointing up) + Nozzle nozzle90 = new Nozzle(Nozzle.Side.BOTTOM, 0.5f, 20.0f, 100.0f, 90.0f, 1000.0f, 250.0f); + assertEquals(0.0f, nozzle90.getForceX(), 0.1f); + assertEquals(1000.0f, nozzle90.getForceY(), 0.1f); + + // Test 180 degrees (pointing left) + Nozzle nozzle180 = new Nozzle(Nozzle.Side.RIGHT, 0.5f, 20.0f, 100.0f, 180.0f, 1000.0f, 250.0f); + assertEquals(-1000.0f, nozzle180.getForceX(), 0.1f); + assertEquals(0.0f, nozzle180.getForceY(), 0.1f); + + // Test 270 degrees (pointing down) + Nozzle nozzle270 = new Nozzle(Nozzle.Side.TOP, 0.5f, 20.0f, 100.0f, 270.0f, 1000.0f, 250.0f); + assertEquals(0.0f, nozzle270.getForceX(), 0.1f); + assertEquals(-1000.0f, nozzle270.getForceY(), 0.1f); + } + + /** + * Test 2: Constructor clamps position to valid range [0.0, 1.0] + */ + @Test + public void testConstructorClampsPosition() { + // Test position above 1.0 is clamped + Nozzle nozzleHigh = new Nozzle(Nozzle.Side.LEFT, 1.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f); + assertEquals(1.0f, nozzleHigh.getPosition(), 0.0001f); + + // Test position below 0.0 is clamped + Nozzle nozzleLow = new Nozzle(Nozzle.Side.LEFT, -0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f); + assertEquals(0.0f, nozzleLow.getPosition(), 0.0001f); + + // Test valid position is preserved + Nozzle nozzleValid = new Nozzle(Nozzle.Side.LEFT, 0.75f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f); + assertEquals(0.75f, nozzleValid.getPosition(), 0.0001f); + } + + /** + * Test 3: Initialize method calculates correct world position for LEFT side + */ + @Test + public void testInitializeLeftSide() { + Nozzle nozzle = new Nozzle(Nozzle.Side.LEFT, 0.5f, 20.0f, 100.0f, 0.0f, 1000.0f, 250.0f); + nozzle.initialize(1000, 800); + + assertEquals(0.0f, nozzle.getWorldX(), 0.0001f); + assertEquals(400.0f, nozzle.getWorldY(), 0.0001f); // 0.5 * 800 + } + + /** + * Test 4: Initialize method calculates correct world position for RIGHT side + */ + @Test + public void testInitializeRightSide() { + Nozzle nozzle = new Nozzle(Nozzle.Side.RIGHT, 0.25f, 20.0f, 100.0f, 180.0f, 1000.0f, 250.0f); + nozzle.initialize(1000, 800); + + assertEquals(1000.0f, nozzle.getWorldX(), 0.0001f); + assertEquals(200.0f, nozzle.getWorldY(), 0.0001f); // 0.25 * 800 + } + + /** + * Test 5: Initialize method calculates correct world position for TOP side + */ + @Test + public void testInitializeTopSide() { + Nozzle nozzle = new Nozzle(Nozzle.Side.TOP, 0.75f, 20.0f, 100.0f, 270.0f, 1000.0f, 250.0f); + nozzle.initialize(1000, 800); + + assertEquals(750.0f, nozzle.getWorldX(), 0.0001f); // 0.75 * 1000 + assertEquals(800.0f, nozzle.getWorldY(), 0.0001f); + } + + /** + * Test 6: Initialize method calculates correct world position for BOTTOM side + */ + @Test + public void testInitializeBottomSide() { + Nozzle nozzle = new Nozzle(Nozzle.Side.BOTTOM, 0.3f, 20.0f, 100.0f, 90.0f, 1000.0f, 250.0f); + nozzle.initialize(1000, 800); + + assertEquals(300.0f, nozzle.getWorldX(), 0.0001f); // 0.3 * 1000 + assertEquals(0.0f, nozzle.getWorldY(), 0.0001f); + } + + /** + * Test 7: All getters return correct values + */ + @Test + public void testGetters() { + Nozzle nozzle = new Nozzle(Nozzle.Side.LEFT, 0.6f, 25.0f, 150.0f, 45.0f, 2000.0f, 300.0f); + + assertEquals(Nozzle.Side.LEFT, nozzle.getSide()); + assertEquals(0.6f, nozzle.getPosition(), 0.0001f); + assertEquals(25.0f, nozzle.getRadius(), 0.0001f); + assertEquals(150.0f, nozzle.getLength(), 0.0001f); + assertEquals(2000.0f, nozzle.getForceMagnitude(), 0.0001f); + assertEquals(300.0f, nozzle.getDensity(), 0.0001f); + + // Force components should match 45 degree angle + double expectedForce = 2000.0f * Math.cos(Math.toRadians(45.0)); + assertEquals(expectedForce, nozzle.getForceX(), 1.0f); + assertEquals(expectedForce, nozzle.getForceY(), 1.0f); + } +} diff --git a/src/test/java/dev/fluidsim/ParamsHandlerTest.java b/src/test/java/dev/fluidsim/ParamsHandlerTest.java new file mode 100644 index 0000000..d1c601a --- /dev/null +++ b/src/test/java/dev/fluidsim/ParamsHandlerTest.java @@ -0,0 +1,289 @@ +package dev.fluidsim; + +import dev.fluidsim.io.LoadableParams; +import dev.fluidsim.io.ParamsHandler; +import dev.fluidsim.simulation.Nozzle; +import org.junit.jupiter.api.Test; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.awt.Color; +import java.io.StringReader; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test cases for ParamsHandler SAX parser + */ +public class ParamsHandlerTest { + + /** + * Test 1: Default constructor initializes with new LoadableParams + */ + @Test + public void testDefaultConstructor() { + ParamsHandler handler = new ParamsHandler(); + + assertNotNull(handler.getParams()); + assertInstanceOf(LoadableParams.class, handler.getParams()); + } + + /** + * Test 2: Constructor with LoadableParams parameter uses provided instance + */ + @Test + public void testConstructorWithParams() { + LoadableParams customParams = new LoadableParams(); + customParams.iterations = 99; + customParams.viscosity = 0.123f; + + ParamsHandler handler = new ParamsHandler(customParams); + + assertNotNull(handler.getParams()); + assertSame(customParams, handler.getParams()); + assertEquals(99, handler.getParams().iterations); + assertEquals(0.123f, handler.getParams().viscosity, 0.0001f); + } + + /** + * Test 3: Parse simulation section elements correctly + */ + @Test + public void testParseSimulationSection() throws Exception { + String xml = """ + + + + 42 + 0.007 + 0.035 + 17.5 + 0.93 + 160.0 + + + 800 + 600 + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + + LoadableParams params = handler.getParams(); + assertEquals(42, params.iterations); + assertEquals(0.007f, params.viscosity, 0.0001f); + assertEquals(0.035f, params.diffusion, 0.0001f); + assertEquals(17.5f, params.vorticity, 0.0001f); + assertEquals(0.93f, params.dissipation, 0.0001f); + assertEquals(160.0f, params.interactionRadius, 0.0001f); + } + + /** + * Test 4: Parse grid section elements correctly + */ + @Test + public void testParseGridSection() throws Exception { + String xml = """ + + + + 20 + 0.004 + 0.02 + 20.0 + 0.98 + 200.0 + + + 1280 + 720 + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + + LoadableParams params = handler.getParams(); + assertEquals(1280, params.width); + assertEquals(720, params.height); + } + + /** + * Test 5: Parse arrow attributes correctly including color + */ + @Test + public void testParseArrowAttributes() throws Exception { + String xml = """ + + + + 20 + 0.004 + 0.02 + 20.0 + 0.98 + 200.0 + + + 1000 + 1000 + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + + LoadableParams params = handler.getParams(); + assertEquals(25.5f, params.arrowDensity, 0.0001f); + assertEquals(35.0f, params.maxArrowLength, 0.0001f); + assertEquals(6.5f, params.arrowHeadLength, 0.0001f); + assertEquals(0.75f, params.arrowHeadAngle, 0.0001f); + assertEquals(1.8f, params.velocityScale, 0.0001f); + assertEquals(new Color(180, 200, 220, 240), params.arrowColor); + } + + /** + * Test 6: Parse multiple nozzles correctly + */ + @Test + public void testParseMultipleNozzles() throws Exception { + String xml = """ + + + + 20 + 0.004 + 0.02 + 20.0 + 0.98 + 200.0 + + + 1000 + 1000 + + + + + + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + + LoadableParams params = handler.getParams(); + assertNotNull(params.nozzles); + assertEquals(3, params.nozzles.size()); + + // Check first nozzle + Nozzle nozzle1 = params.nozzles.get(0); + assertEquals(Nozzle.Side.LEFT, nozzle1.getSide()); + assertEquals(0.5f, nozzle1.getPosition(), 0.0001f); + assertEquals(20.0f, nozzle1.getRadius(), 0.0001f); + assertEquals(100.0f, nozzle1.getLength(), 0.0001f); + assertEquals(200000.0f, nozzle1.getForceMagnitude(), 0.0001f); + assertEquals(250.0f, nozzle1.getDensity(), 0.0001f); + + // Check second nozzle + Nozzle nozzle2 = params.nozzles.get(1); + assertEquals(Nozzle.Side.BOTTOM, nozzle2.getSide()); + assertEquals(0.3f, nozzle2.getPosition(), 0.0001f); + + // Check third nozzle + Nozzle nozzle3 = params.nozzles.get(2); + assertEquals(Nozzle.Side.RIGHT, nozzle3.getSide()); + assertEquals(0.75f, nozzle3.getPosition(), 0.0001f); + } + + /** + * Test 7: Parse empty nozzles section (no nozzles) + */ + @Test + public void testParseEmptyNozzlesSection() throws Exception { + String xml = """ + + + + 20 + 0.004 + 0.02 + 20.0 + 0.98 + 200.0 + + + 1000 + 1000 + + + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + + LoadableParams params = handler.getParams(); + assertNotNull(params.nozzles); + assertEquals(0, params.nozzles.size()); + } + + /** + * Test 8: Parser throws exception for invalid nozzle side value + */ + @Test + public void testParseInvalidNozzleSide() { + String xml = """ + + + + 20 + 0.004 + 0.02 + 20.0 + 0.98 + 200.0 + + + 1000 + 1000 + + + + + + + """; + + ParamsHandler handler = new ParamsHandler(); + SAXParserFactory factory = SAXParserFactory.newInstance(); + + assertThrows(Exception.class, () -> { + SAXParser parser = factory.newSAXParser(); + parser.parse(new InputSource(new StringReader(xml)), handler); + }); + } +} diff --git a/src/test/java/dev/fluidsim/SimParamsTest.java b/src/test/java/dev/fluidsim/SimParamsTest.java new file mode 100644 index 0000000..c5a7968 --- /dev/null +++ b/src/test/java/dev/fluidsim/SimParamsTest.java @@ -0,0 +1,183 @@ +package dev.fluidsim; + +import dev.fluidsim.simulation.Constants; +import dev.fluidsim.simulation.SimParams; +import org.junit.jupiter.api.Test; + +import java.awt.Color; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test cases for SimParams class + */ +public class SimParamsTest { + + /** + * Test 1: Default constructor initializes with Constants values + */ + @Test + public void testDefaultConstructorUsesConstants() { + SimParams params = new SimParams(); + + assertEquals(Constants.WIDTH, params.width); + assertEquals(Constants.HEIGHT, params.height); + assertEquals(Constants.ITERATIONS, params.iterations); + assertEquals(Constants.VISCOSITY, params.viscosity, 0.0001f); + assertEquals(Constants.DIFFUSION, params.diffusion, 0.0001f); + assertEquals(Constants.VORTICITY, params.vorticity, 0.0001f); + assertEquals(Constants.DISSIPATION, params.dissipation, 0.0001f); + assertEquals(Constants.INTERACTION_RADIUS, params.interactionRadius, 0.0001f); + assertEquals(Constants.VELOCITY_VIEW, params.velocityView); + assertEquals(Constants.SHOW_ARROWS, params.showArrows); + assertEquals(Constants.ARROW_DENSITY, params.arrowDensity, 0.0001f); + assertEquals(Constants.MAX_ARROW_LENGTH, params.maxArrowLength, 0.0001f); + assertEquals(Constants.ARROWHEAD_LENGTH, params.arrowHeadLength, 0.0001f); + assertEquals(Constants.ARROWHEAD_ANGLE, params.arrowHeadAngle, 0.0001f); + assertEquals(Constants.VELOCITY_SCALE, params.velocityScale, 0.0001f); + } + + /** + * Test 2: Parameterized constructor sets values correctly + */ + @Test + public void testParameterizedConstructor() { + SimParams params = new SimParams(25, 0.008f, 0.04f, 18.0f, 0.92f, 175.0f); + + assertEquals(25, params.iterations); + assertEquals(0.008f, params.viscosity, 0.0001f); + assertEquals(0.04f, params.diffusion, 0.0001f); + assertEquals(18.0f, params.vorticity, 0.0001f); + assertEquals(0.92f, params.dissipation, 0.0001f); + assertEquals(175.0f, params.interactionRadius, 0.0001f); + } + + /** + * Test 3: Iterations setter and getter + */ + @Test + public void testIterationsSetterGetter() { + SimParams params = new SimParams(); + params.setIterations(50); + assertEquals(50, params.getIterations()); + + params.setIterations(1); + assertEquals(1, params.getIterations()); + } + + /** + * Test 4: Viscosity setter and getter + */ + @Test + public void testViscositySetterGetter() { + SimParams params = new SimParams(); + params.setViscosity(0.01f); + assertEquals(0.01f, params.getViscosity(), 0.0001f); + + params.setViscosity(0.0f); + assertEquals(0.0f, params.getViscosity(), 0.0001f); + } + + /** + * Test 5: Diffusion setter and getter + */ + @Test + public void testDiffusionSetterGetter() { + SimParams params = new SimParams(); + params.setDiffusion(0.05f); + assertEquals(0.05f, params.getDiffusion(), 0.0001f); + + params.setDiffusion(0.001f); + assertEquals(0.001f, params.getDiffusion(), 0.0001f); + } + + /** + * Test 6: Vorticity setter and getter + */ + @Test + public void testVorticitySetterGetter() { + SimParams params = new SimParams(); + params.setVorticity(30.0f); + assertEquals(30.0f, params.getVorticity(), 0.0001f); + + params.setVorticity(5.0f); + assertEquals(5.0f, params.getVorticity(), 0.0001f); + } + + /** + * Test 7: Dissipation setter and getter + */ + @Test + public void testDissipationSetterGetter() { + SimParams params = new SimParams(); + params.setDissipation(0.99f); + assertEquals(0.99f, params.getDissipation(), 0.0001f); + + params.setDissipation(0.5f); + assertEquals(0.5f, params.getDissipation(), 0.0001f); + } + + /** + * Test 8: InteractionRadius setter and getter + */ + @Test + public void testInteractionRadiusSetterGetter() { + SimParams params = new SimParams(); + params.setInteractionRadius(250.0f); + assertEquals(250.0f, params.getInteractionRadius(), 0.0001f); + + params.setInteractionRadius(100.0f); + assertEquals(100.0f, params.getInteractionRadius(), 0.0001f); + } + + /** + * Test 9: VelocityView boolean setter and getter + */ + @Test + public void testVelocityViewSetterGetter() { + SimParams params = new SimParams(); + + params.setVelocityView(true); + assertTrue(params.isVelocityView()); + + params.setVelocityView(false); + assertFalse(params.isVelocityView()); + } + + /** + * Test 10: ShowArrows boolean setter and getter + */ + @Test + public void testShowArrowsSetterGetter() { + SimParams params = new SimParams(); + + params.setShowArrows(true); + assertTrue(params.isShowArrows()); + + params.setShowArrows(false); + assertFalse(params.isShowArrows()); + } + + /** + * Test 11: Nozzles list is initialized as empty + */ + @Test + public void testNozzlesListInitialized() { + SimParams params = new SimParams(); + + assertNotNull(params.nozzles); + assertTrue(params.nozzles.isEmpty()); + assertEquals(0, params.nozzles.size()); + } + + /** + * Test 12: Arrow color is initialized with default color + */ + @Test + public void testArrowColorInitialized() { + SimParams params = new SimParams(); + + assertNotNull(params.arrowColor); + assertEquals(new Color(255, 255, 255, 255), params.arrowColor); + } +} diff --git a/test/boat.png b/test/boat.png new file mode 100644 index 0000000..a0cec5c Binary files /dev/null and b/test/boat.png differ diff --git a/test/square.png b/test/square.png new file mode 100644 index 0000000..746b8f4 Binary files /dev/null and b/test/square.png differ diff --git a/test/test.png b/test/test.png new file mode 100644 index 0000000..f663cb6 Binary files /dev/null and b/test/test.png differ