├── .gitignore ├── README.md ├── UNLICENSE ├── applet.html ├── build.xml ├── checkstyle.xml ├── ivy.xml ├── src └── liquid │ ├── Bottle.java │ ├── Controls.java │ ├── Launcher.java │ ├── LiquidApplet.java │ ├── Recorder.java │ ├── Viewer.java │ └── package-info.java └── webgl ├── index.html ├── lib ├── box2d.js └── igloo-0.0.1.js ├── liquid.css └── src ├── ball.frag ├── ball.vert ├── blur.frag ├── bottle.js ├── color.frag ├── fps.js ├── identity.vert ├── liquid.js ├── threshold.frag └── utility.js /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | cache.properties 4 | TAGS 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Liquid Simulation 2 | 3 | Simulates a simple 2D liquid using Box2D and a drawing trick. 4 | 5 | ![](http://nullprogram.s3.amazonaws.com/liquid/liquid.gif) 6 | 7 | The following two articles explain how it works: 8 | 9 | * [Cartoon Liquid Simulation](http://nullprogram.com/blog/2013/06/26/) 10 | * [Liquid Simulation in WebGL](http://nullprogram.com/blog/2012/02/03/) 11 | 12 | There are two versions in this repository: a slow CPU-only Java 13 | implementation and a faster GPU-powered WebGL version (under 14 | `webgl/`). 15 | 16 | * http://skeeto.github.io/fun-liquid/ (Java Applet) 17 | * http://skeeto.github.io/fun-liquid/webgl/ (WebGL) 18 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /applet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fun Liquid Simulation 5 | 30 | 31 | 32 |

Cartoony Liquid Simulator

33 |

34 | 36 | 37 |

38 |

39 | The idea is simple: 40 |

41 |
    42 |
  1. Do a physics simulation of balls in a container 43 | (JBox2D)
  2. 44 |
  3. Render the simulation onto a raster.
  4. 45 |
  5. Blur the image.
  6. 46 |
  7. Apply a threshold.
  8. 47 |
48 |

49 | What you're left with is a cartoon-ish, chunky liquid. Use the 50 | controls to see the components of this process. 51 |

52 |

53 | The most computationally intensive step is blurring the 54 | raster. Convolution is expensive, O(n^2), and it's being done by 55 | the CPU here, rather than the GPU. To run at full speed, you 56 | need a nice computer. 57 |

58 |

59 | See the Git 60 | repository for full source code listing. 61 |

62 |

63 | Original idea: 64 | 65 | How to simulate liquid 66 |

67 | 68 | 69 | -------------------------------------------------------------------------------- /build.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 44 | 45 | 46 | 47 | 48 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 58 | 59 | 60 | 62 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 87 | 89 | 90 | 91 | 92 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /checkstyle.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 32 | 33 | 34 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /ivy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 15 | 17 | 18 | 19 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/liquid/Bottle.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import java.awt.geom.Rectangle2D; 4 | import java.util.Observable; 5 | import java.util.Random; 6 | import java.util.concurrent.Executors; 7 | import java.util.concurrent.ScheduledExecutorService; 8 | import java.util.concurrent.TimeUnit; 9 | import lombok.Getter; 10 | import lombok.extern.java.Log; 11 | import org.jbox2d.collision.shapes.CircleShape; 12 | import org.jbox2d.collision.shapes.PolygonShape; 13 | import org.jbox2d.common.Vec2; 14 | import org.jbox2d.dynamics.Body; 15 | import org.jbox2d.dynamics.BodyDef; 16 | import org.jbox2d.dynamics.BodyType; 17 | import org.jbox2d.dynamics.FixtureDef; 18 | import org.jbox2d.dynamics.World; 19 | 20 | /** 21 | * A simulated bottle containing a chunky liquid (large solid particles). 22 | */ 23 | @Log 24 | public class Bottle extends Observable { 25 | 26 | /* Solver */ 27 | private static final int FPS = 30; 28 | private static final int V_ITERATIONS = 8; 29 | private static final int P_ITERATIONS = 3; 30 | private static final double MILLIS = 1000.0; 31 | 32 | /* World */ 33 | private static final float WIDTH = 50f; 34 | private static final float HEIGHT = 70f; 35 | private static final float THICKNESS = 0.1f; 36 | private static final Vec2 GRAVITY = new Vec2(0, -60f); 37 | private static final Rectangle2D VIEW = 38 | new Rectangle2D.Float(-WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT); 39 | private static final double FLIP_RATE = 3.5; // seconds 40 | 41 | /* Balls */ 42 | private static final int BALLS = 400; 43 | private static final float BALL_RADIUS = 0.5f; 44 | private static final float BALL_DENSITY = 1f; 45 | private static final float BALL_FRICTION = 0f; 46 | private static final float BALL_RESTITUTION = 0.3f; 47 | 48 | private static final float SPIKE_THICKNESS = 12f; 49 | private static final float SPIKE_EXTENT = 20f; 50 | 51 | @Getter private final World world; 52 | @Getter private double time = 0; // World time 53 | private boolean running = false; 54 | private static final ScheduledExecutorService EXEC = 55 | Executors.newSingleThreadScheduledExecutor(); 56 | 57 | /** 58 | * Create a new bottle. 59 | */ 60 | public Bottle() { 61 | world = new World(GRAVITY, false); 62 | /* Set up the containment box. */ 63 | buildContainer(); 64 | 65 | /* Add a ball. */ 66 | Random rng = new Random(); 67 | for (int i = 0; i < BALLS; i++) { 68 | addBall((rng.nextFloat() - 0.5f) * (WIDTH - BALL_RADIUS), 69 | (rng.nextFloat() - 0.5f) * (HEIGHT - BALL_RADIUS)); 70 | } 71 | addSpike(SPIKE_EXTENT, 0, 1); 72 | addSpike(-SPIKE_EXTENT, 0, -1); 73 | EXEC.scheduleAtFixedRate(new Runnable() { 74 | public void run() { 75 | if (running) { 76 | world.step(1f / FPS, V_ITERATIONS, P_ITERATIONS); 77 | time += 1.0 / FPS; 78 | setChanged(); 79 | notifyObservers(); 80 | if (Math.sin(time / FLIP_RATE * Math.PI) < 0) { 81 | world.setGravity(GRAVITY.negate()); 82 | } else { 83 | world.setGravity(GRAVITY); 84 | } 85 | } 86 | } 87 | }, 0L, (long) (MILLIS / FPS), TimeUnit.MILLISECONDS); 88 | } 89 | 90 | /** 91 | * Run the simulation. 92 | */ 93 | public final void start() { 94 | running = true; 95 | } 96 | 97 | /** 98 | * Stop the simulation, which can be restarted again. 99 | */ 100 | public final void stop() { 101 | running = false; 102 | } 103 | 104 | /** 105 | * Return true if the simulation is running. 106 | * @return true if the simulation is running 107 | */ 108 | public final boolean isRunning() { 109 | return running; 110 | } 111 | 112 | /** 113 | * Specify the area of interest for this world. 114 | * @return a rectangle specifying where things are happening 115 | */ 116 | public final Rectangle2D getView() { 117 | return VIEW; 118 | } 119 | 120 | /** 121 | * Build the world container. 122 | */ 123 | private void buildContainer() { 124 | BodyDef def = new BodyDef(); 125 | PolygonShape box = new PolygonShape(); 126 | Body side; 127 | 128 | def.position = new Vec2(WIDTH / 2, 0); 129 | box.setAsBox(THICKNESS / 2, HEIGHT / 2); 130 | world.createBody(def).createFixture(box, 0f); 131 | 132 | def.position = new Vec2(-WIDTH / 2, 0); 133 | box.setAsBox(THICKNESS / 2, HEIGHT / 2); 134 | world.createBody(def).createFixture(box, 0f); 135 | 136 | def.position = new Vec2(0, HEIGHT / 2); 137 | box.setAsBox(WIDTH / 2, THICKNESS / 2); 138 | world.createBody(def).createFixture(box, 0f); 139 | 140 | def.position = new Vec2(0, -HEIGHT / 2); 141 | box.setAsBox(WIDTH / 2, THICKNESS / 2); 142 | world.createBody(def).createFixture(box, 0f); 143 | } 144 | 145 | /** 146 | * Add a new ball body to the world. 147 | * @param x the x-coordinate of the ball 148 | * @param y the y-coordinate of the ball 149 | */ 150 | private void addBall(final float x, final float y) { 151 | BodyDef def = new BodyDef(); 152 | def.position = new Vec2(x, y); 153 | def.type = BodyType.DYNAMIC; 154 | CircleShape circle = new CircleShape(); 155 | circle.m_radius = BALL_RADIUS; 156 | FixtureDef mass = new FixtureDef(); 157 | mass.shape = circle; 158 | mass.density = BALL_DENSITY; 159 | mass.friction = BALL_FRICTION; 160 | mass.restitution = BALL_RESTITUTION; 161 | world.createBody(def).createFixture(mass); 162 | } 163 | 164 | /** 165 | * Add a static spike to the bottle. 166 | * @param x x-position of the point 167 | * @param y y-position of the point 168 | * @param dir direction of the point 169 | */ 170 | private void addSpike(final float x, final float y, final int dir) { 171 | BodyDef def = new BodyDef(); 172 | def.position = new Vec2(x, y); 173 | PolygonShape shape = new PolygonShape(); 174 | Vec2[] vecs = new Vec2[3]; 175 | int side = 1; 176 | vecs[0] = new Vec2(dir * WIDTH / 2 - x, dir * SPIKE_THICKNESS / 2f); 177 | vecs[1] = new Vec2(0, 0); 178 | vecs[2] = new Vec2(dir * WIDTH / 2 - x, dir * -SPIKE_THICKNESS / 2f); 179 | shape.set(vecs, vecs.length); 180 | FixtureDef fix = new FixtureDef(); 181 | fix.shape = shape; 182 | fix.density = 0f; 183 | fix.friction = 0f; 184 | world.createBody(def).createFixture(fix); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/liquid/Controls.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import java.awt.GridLayout; 4 | import java.awt.event.ActionEvent; 5 | import java.awt.event.ActionListener; 6 | import javax.swing.BorderFactory; 7 | import javax.swing.JButton; 8 | import javax.swing.JCheckBox; 9 | import javax.swing.JPanel; 10 | import lombok.val; 11 | 12 | /** 13 | * Control panel for a bottle and viewer. 14 | */ 15 | public final class Controls extends JPanel { 16 | 17 | private static final long serialVersionUID = 1L; 18 | private static final int GAP = 10; 19 | 20 | private final Bottle bottle; 21 | private final Viewer viewer; 22 | 23 | /** 24 | * Create a new control panel for the given bottle and viewer. 25 | * @param bottle the bottle to be controlled 26 | * @param viewer the viewer to be controlled 27 | */ 28 | public Controls(final Bottle bottle, final Viewer viewer) { 29 | this.bottle = bottle; 30 | this.viewer = viewer; 31 | 32 | val layout = new GridLayout(2, 2); 33 | layout.setHgap(GAP); 34 | layout.setVgap(GAP); 35 | setLayout(layout); 36 | setBorder(BorderFactory.createEmptyBorder(0, GAP, GAP, GAP)); 37 | 38 | val threshold = new JCheckBox("Threshold", true); 39 | threshold.addActionListener(new ActionListener() { 40 | public void actionPerformed(final ActionEvent e) { 41 | viewer.setThreshold(threshold.isSelected()); 42 | } 43 | }); 44 | add(threshold); 45 | 46 | val blur = new JCheckBox("Blur", true); 47 | blur.addActionListener(new ActionListener() { 48 | public void actionPerformed(final ActionEvent e) { 49 | viewer.setBlur(blur.isSelected()); 50 | threshold.setEnabled(blur.isSelected()); 51 | } 52 | }); 53 | add(blur); 54 | 55 | val pause = new JButton("Pause"); 56 | pause.addActionListener(new ActionListener() { 57 | public void actionPerformed(final ActionEvent e) { 58 | if (bottle.isRunning()) { 59 | bottle.stop(); 60 | pause.setText("Play"); 61 | } else { 62 | bottle.start(); 63 | pause.setText("Pause"); 64 | } 65 | } 66 | }); 67 | add(pause); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/liquid/Launcher.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import com.beust.jcommander.JCommander; 4 | import com.beust.jcommander.Parameter; 5 | import com.beust.jcommander.ParameterException; 6 | import javax.swing.BoxLayout; 7 | import javax.swing.JFrame; 8 | import lombok.extern.java.Log; 9 | import lombok.val; 10 | 11 | /** 12 | * Sets up the environment and drives the simulation forward. 13 | */ 14 | @Log 15 | public final class Launcher { 16 | 17 | @Parameter(names = "-record", description = "Record the simulation.") 18 | private boolean record; 19 | 20 | /** 21 | * Private constructor. 22 | */ 23 | private Launcher() { 24 | } 25 | 26 | /** 27 | * The main method, application entry point. 28 | * @param args command line arguments 29 | */ 30 | public static void main(final String[] args) { 31 | try { 32 | /* Fix for poor OpenJDK performance. */ 33 | System.setProperty("sun.java2d.pmoffscreen", "false"); 34 | } catch (java.security.AccessControlException e) { 35 | log.info("could not set sun.java2d.pmoffscreen"); 36 | } 37 | 38 | /* Check the command line arguments. */ 39 | val options = new Launcher(); 40 | try { 41 | new JCommander(options, args); 42 | } catch (ParameterException e) { 43 | System.out.println("error: " + e.getMessage()); 44 | new JCommander(options).usage(); 45 | System.exit(-1); 46 | } catch (java.security.AccessControlException e) { 47 | log.warning("could not process arguments: " + e.getMessage()); 48 | } 49 | 50 | /* Set up the frame. */ 51 | JFrame frame = new JFrame("Fun Liquid"); 52 | frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 53 | val layout = new BoxLayout(frame.getContentPane(), BoxLayout.Y_AXIS); 54 | frame.setLayout(layout); 55 | val bottle = new Bottle(); 56 | val viewer = new Viewer(bottle); 57 | frame.add(viewer); 58 | frame.add(new Controls(bottle, viewer)); 59 | frame.setResizable(false); 60 | frame.pack(); 61 | frame.setVisible(true); 62 | if (options.record) { 63 | new Recorder(viewer); 64 | } 65 | 66 | /* Begin the simulation. */ 67 | bottle.start(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/liquid/LiquidApplet.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import javax.swing.BoxLayout; 4 | import javax.swing.JApplet; 5 | import lombok.val; 6 | 7 | /** 8 | * Run the simulation as an applet. 9 | */ 10 | public class LiquidApplet extends JApplet { 11 | private static final long serialVersionUID = 1L; 12 | 13 | private Bottle bottle; 14 | private Viewer viewer; 15 | 16 | @Override 17 | public final void init() { 18 | bottle = new Bottle(); 19 | viewer = new Viewer(bottle); 20 | val layout = new BoxLayout(getContentPane(), BoxLayout.Y_AXIS); 21 | setLayout(layout); 22 | add(viewer); 23 | add(new Controls(bottle, viewer)); 24 | } 25 | 26 | @Override 27 | public final void start() { 28 | bottle.start(); 29 | } 30 | 31 | @Override 32 | public final void stop() { 33 | bottle.stop(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/liquid/Recorder.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import java.awt.image.BufferedImage; 4 | import java.io.File; 5 | import java.util.Observable; 6 | import java.util.Observer; 7 | import javax.imageio.ImageIO; 8 | import lombok.extern.java.Log; 9 | import lombok.val; 10 | 11 | /** 12 | * Latches onto a Viewer and records each frame of the 13 | * simulation. These frames can be later reassembled into a video 14 | * file. 15 | */ 16 | @Log 17 | public class Recorder implements Observer { 18 | 19 | private final Viewer viewer; 20 | private long counter = 0; 21 | private static final String PREFIX = "frame-"; 22 | 23 | /** 24 | * Make a new recorder that follows the given viewer. 25 | * @param viewer the viewer to record 26 | */ 27 | public Recorder(final Viewer viewer) { 28 | this.viewer = viewer; 29 | viewer.getBottle().addObserver(this); 30 | } 31 | 32 | @Override 33 | public final void update(final Observable o, final Object arg) { 34 | val image = new BufferedImage(viewer.getWidth(), viewer.getHeight(), 35 | BufferedImage.TYPE_INT_RGB); 36 | val g = image.createGraphics(); 37 | viewer.paintComponent(g); 38 | g.dispose(); 39 | val file = new File(String.format("%s%08d.png", PREFIX, counter++)); 40 | try { 41 | ImageIO.write(image, "PNG", file); 42 | } catch (java.io.IOException e) { 43 | log.warning("failed to write " + file + ": " + e.getMessage()); 44 | } catch (java.security.AccessControlException e) { 45 | log.warning("failed to write " + file + ": " + e.getMessage()); 46 | viewer.getBottle().deleteObserver(this); 47 | log.info("unsubscribed"); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/liquid/Viewer.java: -------------------------------------------------------------------------------- 1 | package liquid; 2 | 3 | import java.awt.Color; 4 | import java.awt.Dimension; 5 | import java.awt.Graphics2D; 6 | import java.awt.Graphics; 7 | import java.awt.RenderingHints; 8 | import java.awt.geom.AffineTransform; 9 | import java.awt.geom.Ellipse2D; 10 | import java.awt.geom.Path2D; 11 | import java.awt.image.BufferedImage; 12 | import java.awt.image.BufferedImageOp; 13 | import java.awt.image.ConvolveOp; 14 | import java.awt.image.Kernel; 15 | import java.util.Observable; 16 | import java.util.Observer; 17 | import javax.swing.JComponent; 18 | import lombok.Getter; 19 | import lombok.val; 20 | import org.jbox2d.collision.shapes.CircleShape; 21 | import org.jbox2d.collision.shapes.PolygonShape; 22 | import org.jbox2d.collision.shapes.Shape; 23 | import org.jbox2d.common.Vec2; 24 | import org.jbox2d.dynamics.Body; 25 | import org.jbox2d.dynamics.BodyType; 26 | import org.jbox2d.dynamics.Fixture; 27 | 28 | /** 29 | * Displays a view of a JBox2D world. 30 | */ 31 | public class Viewer extends JComponent implements Observer { 32 | 33 | private static final Color BACKGROUND = Color.BLACK; 34 | private static final Color FOREGROUND = Color.WHITE; 35 | private static final Color STATIC = Color.GRAY; 36 | private static final int KERNEL_SIZE = 12; 37 | private static final int THRESHOLD = 28 * 3; 38 | 39 | private static final long serialVersionUID = 1L; 40 | 41 | private static final float SCALE = 5f; 42 | 43 | @Getter private final Bottle bottle; 44 | 45 | private boolean blur = true; 46 | private boolean threshold = true; 47 | 48 | private final Kernel vkernel; 49 | private final Kernel hkernel; 50 | 51 | /** 52 | * Create a display of a world at a given location. 53 | * @param bottle the bottle to be displayed 54 | */ 55 | public Viewer(final Bottle bottle) { 56 | this.bottle = bottle; 57 | val view = bottle.getView(); 58 | Dimension size = new Dimension((int) (view.getWidth() * SCALE), 59 | (int) (view.getHeight() * SCALE)); 60 | setPreferredSize(size); 61 | vkernel = makeKernel(KERNEL_SIZE, true); 62 | hkernel = makeKernel(KERNEL_SIZE, false); 63 | bottle.addObserver(this); 64 | } 65 | 66 | @Override 67 | public final void update(final Observable o, final Object arg) { 68 | repaint(); 69 | } 70 | 71 | /** 72 | * Turn blurring on or off. 73 | * @param set the new value 74 | */ 75 | public final void setBlur(final boolean set) { 76 | blur = set; 77 | repaint(); 78 | } 79 | 80 | /** 81 | * Turn thresholding on or off. 82 | * @param set the new value 83 | */ 84 | public final void setThreshold(final boolean set) { 85 | threshold = set; 86 | repaint(); 87 | } 88 | 89 | @Override 90 | public final void paintComponent(final Graphics graphics) { 91 | Graphics2D g = (Graphics2D) graphics; 92 | if (!blur) { 93 | g.setColor(BACKGROUND); 94 | g.fillRect(0, 0, getWidth(), getHeight()); 95 | draw((Graphics2D) g.create(), getWidth(), getHeight(), true, 96 | BodyType.DYNAMIC, FOREGROUND); 97 | } else { 98 | Dimension size = getPreferredSize(); 99 | BufferedImage work; 100 | work = new BufferedImage(size.width + KERNEL_SIZE * 2, 101 | size.height + KERNEL_SIZE * 2, 102 | BufferedImage.TYPE_INT_RGB); 103 | Graphics2D wg = work.createGraphics(); 104 | wg.setColor(BACKGROUND); 105 | wg.fillRect(0, 0, work.getWidth(), work.getHeight()); 106 | draw(wg, work.getWidth(), work.getHeight(), false, 107 | BodyType.DYNAMIC, FOREGROUND); 108 | wg.dispose(); 109 | 110 | /* Blur. */ 111 | BufferedImageOp op = new ConvolveOp(vkernel); 112 | BufferedImage conv = op.filter(work, null); 113 | op = new ConvolveOp(hkernel); 114 | conv = op.filter(conv, null); 115 | /* Threshold. */ 116 | if (threshold) { 117 | threshold(conv); 118 | } 119 | /* Draw the result. */ 120 | g.drawImage(conv, -KERNEL_SIZE, -KERNEL_SIZE, null); 121 | } 122 | draw(g, getWidth(), getHeight(), true, 123 | BodyType.STATIC, STATIC); 124 | } 125 | 126 | /** 127 | * Draw the world onto the given graphics. 128 | * @param g the graphics context to use 129 | * @param width width of the drawing context 130 | * @param height height of the drawing context 131 | * @param aa enable anti-aliasing 132 | * @param type the type of body to draw 133 | * @param color the color to draw the bodies 134 | */ 135 | private void draw(final Graphics2D g, final int width, final int height, 136 | final boolean aa, 137 | final BodyType type, final Color color) { 138 | /* Set up coordinate system. */ 139 | g.translate(width / 2, height / 2); 140 | g.scale(SCALE, -SCALE); 141 | 142 | if (aa) { 143 | /* Configure rendering options. */ 144 | g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, 145 | RenderingHints.VALUE_ANTIALIAS_ON); 146 | } 147 | 148 | /* Draw each body. */ 149 | g.setColor(color); 150 | Body body = bottle.getWorld().getBodyList(); 151 | while (body != null) { 152 | Vec2 pos = body.getPosition(); 153 | float angle = body.getAngle(); 154 | Fixture fixture = body.getFixtureList(); 155 | while (body.m_type == type && fixture != null) { 156 | Shape shape = fixture.getShape(); 157 | if (shape instanceof CircleShape) { 158 | draw(g, pos, (CircleShape) shape); 159 | } else if (shape instanceof PolygonShape) { 160 | draw(g, pos, angle, (PolygonShape) shape); 161 | } else { 162 | System.out.println("Cannot draw shape: " + shape); 163 | } 164 | fixture = fixture.getNext(); 165 | } 166 | body = body.getNext(); 167 | } 168 | } 169 | 170 | /** 171 | * Draw a circle shape from the world. 172 | * @param g the graphics context to use 173 | * @param pos position of the shape 174 | * @param s the circle to be drawn 175 | */ 176 | private void draw(final Graphics2D g, final Vec2 pos, final CircleShape s) { 177 | Ellipse2D circle = new Ellipse2D.Float(pos.x - s.m_radius, 178 | pos.y - s.m_radius, 179 | s.m_radius * 2, s.m_radius * 2); 180 | g.fill(circle); 181 | } 182 | 183 | /** 184 | * Draw a polygon shape from the world. 185 | * @param g the graphics context to use 186 | * @param pos position of the shape 187 | * @param angle the rotation of the shape 188 | * @param s the polygon to be drawn 189 | */ 190 | private void draw(final Graphics2D g, final Vec2 pos, 191 | final float angle, final PolygonShape s) { 192 | Path2D path = new Path2D.Float(); 193 | Vec2 first = s.getVertex(0); 194 | path.moveTo(first.x, first.y); 195 | for (int i = 1; i < s.getVertexCount(); i++) { 196 | Vec2 v = s.getVertex(i); 197 | path.lineTo(v.x, v.y); 198 | } 199 | path.closePath(); 200 | AffineTransform at = new AffineTransform(); 201 | at.translate(pos.x, pos.y); 202 | at.rotate(angle); 203 | g.fill(at.createTransformedShape(path)); 204 | } 205 | 206 | /** 207 | * Make a blur kernel. 208 | * @param size the size of the kernel 209 | * @param vertical make the kernel vertical or horizontal 210 | * @return the specified kernel 211 | */ 212 | private static Kernel makeKernel(final int size, final boolean vertical) { 213 | float radius = size; 214 | int rows = size * 2 + 1; 215 | float[] matrix = new float[rows]; 216 | float sigma = radius / 3; 217 | float sigma22 = 2 * sigma * sigma; 218 | float sigmaPi2 = 2 * (float) Math.PI * sigma; 219 | float sqrtSigmaPi2 = (float) Math.sqrt(sigmaPi2); 220 | float radius2 = radius * radius; 221 | float total = 0; 222 | int index = 0; 223 | for (int row = -size; row <= size; row++) { 224 | float distance = row * row; 225 | if (distance > radius2) { 226 | matrix[index] = 0; 227 | } else { 228 | matrix[index] = (float) Math.exp(-(distance) / sigma22) 229 | / sqrtSigmaPi2; 230 | } 231 | total += matrix[index]; 232 | index++; 233 | } 234 | for (int i = 0; i < rows; i++) { 235 | matrix[i] /= total; 236 | } 237 | 238 | if (vertical) { 239 | return new Kernel(1, rows, matrix); 240 | } else { 241 | return new Kernel(rows, 1, matrix); 242 | } 243 | } 244 | 245 | /** 246 | * Threshold an image. 247 | * @param im the image to be thresholded 248 | */ 249 | private void threshold(final BufferedImage im) { 250 | for (int i = 0; i < im.getWidth(); i++) { 251 | for (int j = 0; j < im.getHeight(); j++) { 252 | Color c = new Color(im.getRGB(i, j)); 253 | if (c.getRed() + c.getGreen() + c.getBlue() > THRESHOLD) { 254 | im.setRGB(i, j, FOREGROUND.getRGB()); 255 | } else { 256 | im.setRGB(i, j, BACKGROUND.getRGB()); 257 | } 258 | } 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/liquid/package-info.java: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple liquid animation using a phyisics engine (JBox2D). 3 | */ 4 | package liquid; 5 | -------------------------------------------------------------------------------- /webgl/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fun Liquid 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

WebGL Liquid Simulator

16 |
17 | 18 |
19 |
    20 |
  1. 21 | Do a physics simulation of balls in a container 22 | (box2d.js) 23 |
  2. 24 |
  3. 25 | Render the simulation onto a raster 26 |
  4. 27 |
  5. 28 | Blur the image 29 | () 32 |
  6. 33 |
  7. 34 | Apply a threshold 35 | () 38 |
  8. 39 |
40 |

41 | The physics simulation is computed on the CPU (Box2D) and the 42 | blurring and thresholding is handled by the GPU using WebGL. 43 |

44 |

45 | See the Git 46 | repository for full source code listing. The original idea 47 | came from here: 48 | 49 | How to simulate liquid 50 | . 51 |

52 | 53 | 54 | -------------------------------------------------------------------------------- /webgl/lib/igloo-0.0.1.js: -------------------------------------------------------------------------------- 1 | var VecN=VecN||function(){};VecN.FIELDS="xyzwabcdefghijklmnopqrstuv".split("");VecN.make=function(c){if(c>VecN.FIELDS.length){throw new Error("VecN limited to "+VecN.FIELDS.length)}function d(q,i){var p=q.slice(0);p.push(i);p.unshift(null);var n=Function.bind.apply(Function,p);return new n()}var k=VecN.FIELDS.slice(0,c);var e=d(k,k.map(function(i){return"this."+i+" = "+i+";"}).join("\n"));e.prototype=Object.create(VecN.prototype);e.prototype.length=c;e.prototype.VecN=this;e.prototype.toString=d([],'return "[Vec'+c+' (" + '+k.map(function(i){return"this."+i}).join(' + ", " + ')+' + ")]";');function h(n,i){i=i||"this.constructor";return"return new "+i+"("+k.map(n).join(", ")+");"}function l(i){return d(["vec"],h(function(n){return"this."+n+" "+i+" vec."+n}))}e.prototype.add=l("+");e.prototype.subtract=l("-");e.prototype.multiply=l("*");e.prototype.divide=l("/");function j(i){return d(["scalar"],h(function(n){return"this."+n+" "+i+" scalar"}))}e.prototype.fadd=j("+");e.prototype.fsubtract=j("-");e.prototype.fmultiply=j("*");e.prototype.fdivide=j("/");e.prototype.magnitude=d([],"return Math.sqrt("+k.map(function(i){return"this."+i+" * this."+i}).join(" + ")+");");function o(i,n){n=n||[];return d(n,h(function(q){var p=n.slice(0);p.unshift("this."+q);return i+"("+p.join(", ")+")"}))}e.prototype.floor=o("Math.floor");e.prototype.ceil=o("Math.ceil");e.prototype.abs=o("Math.abs");e.prototype.negate=o("-1 * ");e.prototype.pow=o("Math.pow",["expt"]);e.prototype.pow2=d([],h(function(i){return"this."+i+" * this."+i}));e.prototype.pow3=d([],h(function(i){return"this."+i+" * this."+i+" * this."+i}));e.prototype.product=d([],"return "+k.map(function(i){return"this."+i}).join(" * ")+";");e.prototype.sum=d([],"return "+k.map(function(i){return"this."+i}).join(" + ")+";");e.prototype.normalize=function g(){return this.divide(this.magnitude())};e.prototype.dot=function a(i){return this.multiply(i).sum()};e.prototype.toArray=d([],"return ["+k.map(function(i){return"this."+i}).join(", ")+"]");function m(i){var n=i.map(function(p){return"this."+p}).join(", ");Object.defineProperty(e.prototype,i.join(""),{get:new Function("return new this.VecN.Vec"+i.length+"("+n+");")})}function b(i,n){k.forEach(function(p){i.push(p);if(n===1){m(i)}else{b(i,n-1)}i.pop()})}if(c<=6){for(var f=2;f<=c;f++){b([],f)}}e.random=d([],h(function(){return"Math.random()"},"this"));return e};(10);VecN.convenience=function(a){return function(){var d=[];for(var f=0;f= x 82 | */ 83 | Bottle.highest2 = function(x) { 84 | return Math.pow(2, Math.ceil(Math.log(x) / Math.LN2)); 85 | }; 86 | 87 | Bottle.prototype.texScale = function() { 88 | return vec2(Bottle.highest2(this.gl.canvas.width), 89 | Bottle.highest2(this.gl.canvas.height)); 90 | }; 91 | 92 | /** 93 | * @returns {WebGLTexture} An appropriately initialized intermediate texture 94 | */ 95 | Bottle.prototype.createTexture = function() { 96 | var gl = this.gl, tex = gl.createTexture(), 97 | scale = this.texScale(); 98 | gl.bindTexture(gl.TEXTURE_2D, tex); 99 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 100 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); 101 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); 102 | gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 103 | gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, scale.x, scale.y, 104 | 0, gl.RGBA, gl.UNSIGNED_BYTE, null); 105 | return tex; 106 | }; 107 | 108 | /** 109 | * Swaps the front and back textures and bind the back texture. 110 | */ 111 | Bottle.prototype.swap = function() { 112 | var gl = this.gl, 113 | temp = this.textures.front; 114 | this.textures.front = this.textures.back; 115 | this.textures.back = temp; 116 | gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo); 117 | gl.bindTexture(gl.TEXTURE_2D, this.textures.back); 118 | gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, 119 | gl.TEXTURE_2D, this.textures.back, 0); 120 | gl.clearColor(0, 0, 0, 1); 121 | gl.clear(gl.COLOR_BUFFER_BIT); 122 | gl.bindTexture(gl.TEXTURE_2D, this.textures.front); 123 | return this; 124 | }; 125 | 126 | Bottle.prototype.buildOuter = function() { 127 | var thickness = 0.1; 128 | var box = new B.PolygonShape(), def = new B.BodyDef(); 129 | 130 | def.set_position(new B.Vec2(this.width / 2, 0)); 131 | box.SetAsBox(thickness / 2, this.height / 2); 132 | this.world.CreateBody(def).CreateFixture(box, 0); 133 | 134 | def.set_position(new B.Vec2(-this.width / 2, 0)); 135 | box.SetAsBox(thickness / 2, this.height / 2); 136 | this.world.CreateBody(def).CreateFixture(box, 0); 137 | 138 | def.set_position(new B.Vec2(0, this.height / 2)); 139 | box.SetAsBox(this.width / 2, thickness / 2); 140 | this.world.CreateBody(def).CreateFixture(box, 0); 141 | 142 | def.set_position(new B.Vec2(0, -this.height / 2)); 143 | box.SetAsBox(this.width / 2, thickness / 2); 144 | this.world.CreateBody(def).CreateFixture(box, 0); 145 | }; 146 | 147 | Bottle.prototype.addSpike = function(pos, dir) { 148 | var thickness = Bottle.SPIKE_THICKNESS; 149 | var def = new B.BodyDef(); 150 | def.set_position(pos); 151 | var verts = [ 152 | new B.Vec2(dir * this.width / 2 - pos.get_x(), dir * thickness / 2), 153 | new B.Vec2(0, 0), 154 | new B.Vec2(dir * this.width / 2 - pos.get_x(), dir * -thickness / 2) 155 | ]; 156 | this.polys.push({pos: pos, verts: verts}); 157 | var fix = new B.FixtureDef(); 158 | fix.set_shape(createPolygonShape(verts)); 159 | fix.set_density(1.0); 160 | fix.set_friction(0); 161 | this.world.CreateBody(def).CreateFixture(fix); 162 | }; 163 | 164 | Bottle.prototype.random = function() { 165 | return new B.Vec2(Math.random() * this.width - (this.width / 2), 166 | Math.random() * this.height - (this.height / 2)); 167 | }; 168 | 169 | Bottle.prototype.addBall = function(pos) { 170 | pos = pos || this.random(); 171 | var def = new B.BodyDef(); 172 | def.set_position(pos); 173 | def.set_type(B.b2_dynamicBody); 174 | var circle = new B.CircleShape(); 175 | circle.set_m_radius(Bottle.BALL_RADIUS); 176 | var mass = new B.FixtureDef(); 177 | mass.set_shape(circle); 178 | mass.set_density(Bottle.BALL_DENSITY); 179 | mass.set_friction(Bottle.BALL_FRICTION); 180 | mass.set_restitution(Bottle.BALL_RESTITUTION); 181 | this.balls.push(this.world.CreateBody(def).CreateFixture(mass)); 182 | }; 183 | 184 | Bottle.prototype.render = function() { 185 | var gl = this.gl; 186 | var w = this.gl.canvas.width, h = this.gl.canvas.height; 187 | var sx = w / this.width * 2, sy = h / this.height * 2; 188 | 189 | /* Update balls vertex attribute. */ 190 | var pos = new Float32Array(this.balls.length * 2); 191 | for (var i = 0; i < this.balls.length; i++) { 192 | var p = this.balls[i].GetBody().GetPosition(); 193 | pos[i * 2 + 0] = p.get_x() / w * sx; 194 | pos[i * 2 + 1] = p.get_y() / h * sy; 195 | } 196 | this.buffers.balls.update(pos); 197 | 198 | this.swap(); 199 | gl.bindTexture(gl.TEXTURE_2D, this.textures.front); 200 | this.programs.balls.use() 201 | .attrib('ball', this.buffers.balls, 2) 202 | .uniform('size', Bottle.BALL_RADIUS * sx) 203 | .draw(gl.POINTS, this.balls.length); 204 | this.swap(); 205 | 206 | if (this.doBlur) { 207 | this.programs.blur.use() 208 | .attrib('position', this.buffers.quad, 2) 209 | .uniform('base', 0, true) 210 | .uniform('scale', this.texScale()) 211 | .uniform('dir', vec2(0.0, 1.0)) 212 | .draw(gl.TRIANGLE_STRIP, 4); 213 | this.swap(); 214 | 215 | this.programs.blur 216 | .uniform('dir', vec2(1.0, 0.0)) 217 | .draw(gl.TRIANGLE_STRIP, 4); 218 | this.swap(); 219 | } 220 | 221 | gl.bindFramebuffer(gl.FRAMEBUFFER, null); 222 | this.programs.threshold.use() 223 | .attrib('position', this.buffers.quad, 2) 224 | .uniform('base', 0, true) 225 | .uniform('scale', this.texScale()) 226 | .uniform('copy', !this.doThreshold, true) 227 | .uniform('threshold', this.threshold) 228 | .draw(gl.TRIANGLE_STRIP, 4); 229 | 230 | this.programs.spikes.use() 231 | .attrib('position', this.buffers.spikes, 2) 232 | .uniform('color', vec4(0.5, 0.5, 0.5, 1.0)) 233 | .draw(gl.TRIANGLES, this.polys.length * 3); 234 | }; 235 | 236 | Bottle.prototype.step = function() { 237 | this.fps.tick(); 238 | this.time += 1 / Bottle.FPS; 239 | if (Math.sin(this.time / Bottle.FLIP_RATE * Math.PI) < 0) { 240 | this.world.SetGravity(Bottle.NGRAVITY); 241 | } else { 242 | this.world.SetGravity(Bottle.GRAVITY); 243 | } 244 | this.world.Step(1 / 30, 8, 3); 245 | }; 246 | -------------------------------------------------------------------------------- /webgl/src/color.frag: -------------------------------------------------------------------------------- 1 | #ifdef GL_ES 2 | precision mediump float; 3 | #endif 4 | 5 | uniform vec4 color; 6 | 7 | void main() { 8 | gl_FragColor = color; 9 | } 10 | -------------------------------------------------------------------------------- /webgl/src/fps.js: -------------------------------------------------------------------------------- 1 | function FPS() { 2 | this.second = null; 3 | this.counter = 0; 4 | this.value = NaN; 5 | this.listeners = []; 6 | } 7 | 8 | FPS.prototype.tick = function() { 9 | var now = ~~(Date.now() / 1000); 10 | if (this.second != now) { 11 | this.value = this.count; 12 | this.second = now; 13 | this.count = 0; 14 | var value = this.value; 15 | this.listeners.forEach(function(callback) { 16 | callback(value); 17 | }); 18 | } else { 19 | this.count++; 20 | } 21 | }; 22 | 23 | FPS.prototype.listen = function(callback) { 24 | this.listeners.push(callback); 25 | }; 26 | 27 | FPS.prototype.valueOf = function() { 28 | return this.value; 29 | }; 30 | 31 | FPS.prototype.toString = function() { 32 | return '[FPS ' + this.value + ']'; 33 | }; 34 | -------------------------------------------------------------------------------- /webgl/src/identity.vert: -------------------------------------------------------------------------------- 1 | #ifdef GL_ES 2 | precision mediump float; 3 | #endif 4 | 5 | attribute vec2 position; 6 | 7 | void main() { 8 | gl_Position = vec4(position, 0, 1.0); 9 | } 10 | -------------------------------------------------------------------------------- /webgl/src/liquid.js: -------------------------------------------------------------------------------- 1 | var bottle = null; 2 | window.addEventListener('load', function() { 3 | bottle = new Bottle(document.getElementById('display')); 4 | function step() { 5 | bottle.step(); 6 | } 7 | function render() { 8 | bottle.render(); 9 | window.requestAnimationFrame(render); 10 | } 11 | window.requestAnimationFrame(render); 12 | setInterval(step, 1000 / Bottle.FPS); 13 | 14 | document.getElementById('doBlur') 15 | .addEventListener('change', function() { 16 | bottle.doBlur = this.checked; 17 | }); 18 | document.getElementById('doThreshold') 19 | .addEventListener('change', function() { 20 | bottle.doThreshold = this.checked; 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /webgl/src/threshold.frag: -------------------------------------------------------------------------------- 1 | #ifdef GL_ES 2 | precision mediump float; 3 | #endif 4 | 5 | uniform sampler2D base; 6 | uniform vec2 scale; 7 | uniform float threshold; 8 | uniform int copy; 9 | 10 | void main() { 11 | vec4 value = texture2D(base, gl_FragCoord.xy / scale); 12 | if (copy != 0) { 13 | gl_FragColor = value; 14 | } else if (value.r > threshold) { 15 | gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); 16 | } else { 17 | gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webgl/src/utility.js: -------------------------------------------------------------------------------- 1 | var B = B || {}; 2 | 3 | B.Vec2 = Box2D.b2Vec2; 4 | B.BodyDef = Box2D.b2BodyDef; 5 | B.Body = Box2D.b2Body; 6 | B.FixtureDef = Box2D.b2FixtureDef; 7 | B.Fixture = Box2D.b2Fixture; 8 | B.World = Box2D.b2World; 9 | B.MassData = Box2D.b2MassData; 10 | B.PolygonShape = Box2D.b2PolygonShape; 11 | B.CircleShape = Box2D.b2CircleShape; 12 | 13 | B.b2_dynamicBody = Box2D.b2_dynamicBody; 14 | 15 | function createPolygonShape(verts) { 16 | var shape = new Box2D.b2PolygonShape(); 17 | var buffer = Box2D.allocate(verts.length * 8, 'float', Box2D.ALLOC_STACK); 18 | var offset = 0; 19 | for (var i = 0; i < verts.length; i++) { 20 | Box2D.setValue(buffer + (offset + 0), verts[i].get_x(), 'float'); // x 21 | Box2D.setValue(buffer + (offset + 4), verts[i].get_y(), 'float'); // y 22 | offset += 8; 23 | } 24 | var ptr_wrapped = Box2D.wrapPointer(buffer, Box2D.b2Vec2); 25 | shape.Set(ptr_wrapped, verts.length); 26 | return shape; 27 | } 28 | 29 | if (window.requestAnimationFrame == null) { 30 | window.requestAnimationFrame = 31 | window.mozRequestAnimationFrame || 32 | window.webkitRequestAnimationFrame || 33 | window.msRequestAnimationFrame || 34 | window.oRequestAnimationFrame || 35 | function(f) { 36 | setTimeout(f, 16); 37 | }; 38 | } 39 | --------------------------------------------------------------------------------