├── .gitignore
├── Dockerfile
├── README.md
├── next.config.js
├── now.json
├── package.json
├── pages
└── index.js
├── rustLoader.js
├── screenshot.png
├── src
├── BlobCanvas.js
├── BlobSVG.js
├── SoftBody.js
└── physics.rs
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .next
2 | node_modules
3 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:9.2
2 |
3 | # Nightly Rust with wasm32-unknown-unknown and wasm-gc
4 | ENV RUSTUP_HOME=/usr/local/rustup \
5 | CARGO_HOME=/usr/local/cargo \
6 | PATH=/usr/local/cargo/bin:$PATH
7 |
8 | RUN set -eux; \
9 | \
10 | dpkgArch="$(dpkg --print-architecture)"; \
11 | case "${dpkgArch##*-}" in \
12 | amd64) rustArch='x86_64-unknown-linux-gnu'; rustupSha256='5a38dbaf7ab2e4335a3dfc42698a5b15e7167c93b0b06fc95f53c1da6379bf1a' ;; \
13 | armhf) rustArch='armv7-unknown-linux-gnueabihf'; rustupSha256='f7ffec8a9cfe3096d535576e79cbd501766fda3769e9ed755cf1f18d7a3ba49c' ;; \
14 | arm64) rustArch='aarch64-unknown-linux-gnu'; rustupSha256='bc513fbd0d221166d3aa612907016d417f8642448d1727c1446876ec9326ab2c' ;; \
15 | i386) rustArch='i686-unknown-linux-gnu'; rustupSha256='82b7ca05ce20e7b8f8dff4a406ef3610d21feb1476fa6fd8959355ac11474ce5' ;; \
16 | *) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \
17 | esac; \
18 | \
19 | url="https://static.rust-lang.org/rustup/archive/1.7.0/${rustArch}/rustup-init"; \
20 | wget "$url"; \
21 | echo "${rustupSha256} *rustup-init" | sha256sum -c -; \
22 | chmod +x rustup-init; \
23 | ./rustup-init -y --no-modify-path --default-toolchain nightly; \
24 | rm rustup-init; \
25 | chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \
26 | rustup --version; \
27 | cargo --version; \
28 | rustc --version; \
29 | rustup target add wasm32-unknown-unknown --toolchain nightly && \
30 | cargo install --git https://github.com/alexcrichton/wasm-gc
31 |
32 | WORKDIR /app
33 | ADD . /app
34 | RUN yarn && /app/node_modules/.bin/next build
35 | EXPOSE 3000
36 | CMD /app/node_modules/.bin/next start
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Blob, a softbody physics simulation in Rust + WASM
2 |
3 | [View Demo Here](https://blob.gkaemmer.com)
4 |
5 |
6 |
7 | Blob is simulated by calls to WASM. The coordinates and velocities of each vertex are shared between Rust and Javascript. Every frame, the physics code is run 40 times, and the positions of each vertex are sampled for rendering.
8 |
9 | Rendering is done with a few SVG polygons in React.
10 |
11 | The site uses Next.js and doesn't really need to, but it really cuts down boilerplate.
12 |
13 | ### `rustLoader.js`
14 |
15 | SoftBody.js `require()`s the rust source code directly, and the import is managed by a webpack loader that runs the rust compiler (to the `wasm32-unknown-unknown` target). It grabs the WASM byte code and creates Javascript glue code automatically.
16 |
17 | ### Sharing data with WASM
18 |
19 | For a blob with 50 sides, the shared data is a Float64Array with 250 (50 * 5) numbers in it. In rust, this memory is passed in as a `*mut Vertex`, and turned into a `&mut [Vertex]` with `slice::from_raw_parts_mut`. Then, the `init` and `step` functions can edit the data freely.
20 |
21 | The javascript must know how that array is structured. To make interaction and rendering easier, the coordinates are copied every frame:
22 | ```js
23 | for (let i = 0; i < this.vertexCount; i++) {
24 | // Vertex is stored as five floats at vertexData[i * 5];
25 | this.vertices[i].x = this.vertexData[i * 5 + 0];
26 | this.vertices[i].y = this.vertexData[i * 5 + 1];
27 | }
28 | ```
29 |
30 | From there, Javascript can handle the rendering and events, while Rust handles all the number crunching.
31 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const path = require("path");
2 |
3 | module.exports = {
4 | webpack: config => {
5 | config.module.rules.push({
6 | test: /\.rs$/,
7 | use: {
8 | loader: "rust-loader"
9 | }
10 | })
11 |
12 | config.resolveLoader = config.resolveLoader || {};
13 | config.resolveLoader.alias = config.resolveLoader.alias || {};
14 | config.resolveLoader.alias["rust-loader"] = path.join(__dirname, './rustLoader');
15 |
16 | return config;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "docker",
3 | "alias": ["softbody.now.sh", "blob.gkaemmer.com"]
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "start": "next start"
4 | },
5 | "dependencies": {
6 | "gyronorm": "^2.0.6",
7 | "next": "^10.1.3",
8 | "react": "^17.0.2",
9 | "react-dom": "^17.0.2"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import SoftBody from "../src/SoftBody";
2 | import Head from "next/head";
3 |
4 | // Bootleg feature detection
5 | const innerWidth = process.browser ? window.innerWidth : 401;
6 |
7 | export default () => (
8 |
9 |
10 |
The Blob
11 |
12 |
13 |
18 |
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/rustLoader.js:
--------------------------------------------------------------------------------
1 | const child_process = require("child_process");
2 | const fs = require("fs");
3 | const path = require("path");
4 |
5 | // Loader for rust files, compiles them and exports a function "prepare", which
6 | // resolves to a Wasm instance
7 | module.exports = function(source) {
8 | const callback = this.async();
9 |
10 | const wasmFile = __dirname + "/out.wasm"; // can be anywhere writable
11 | const wasmFileTemp = __dirname + "/out-temp.wasm"; // can be anywhere writable
12 |
13 | const cmd = `rustc +nightly --crate-type=cdylib --target=wasm32-unknown-unknown -O ${
14 | this.resourcePath
15 | } -o ${wasmFileTemp} && wasm-gc ${wasmFileTemp} ${wasmFile}`;
16 | const self = this;
17 | child_process.exec(cmd, {}, function(error, stdout, stderr) {
18 | if (error)
19 | return callback(error, null);
20 |
21 | const content = fs.readFileSync(wasmFile);
22 | const content64 = content.toString("base64");
23 |
24 | const code = `module.exports = (function(data) {
25 | return {
26 | prepare: function(options) {
27 | if (!options) options = {};
28 | const bytes = new Buffer(data, 'base64');
29 | return WebAssembly.compile(bytes)
30 | .then(function(wasmModule) {
31 | return WebAssembly.instantiate(wasmModule, options);
32 | });
33 | }
34 | }
35 | })("${content64}")`;
36 |
37 | fs.unlinkSync(wasmFile);
38 | fs.unlinkSync(wasmFileTemp);
39 |
40 | return callback(null, code);
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gkaemmer/rust-wasm-blob/2ff7a7740066b54bc41a750ac46622ac3f5df334/screenshot.png
--------------------------------------------------------------------------------
/src/BlobCanvas.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | function isInside(body, x, y) {
4 | const dx = body.centerX - x;
5 | const dy = body.centerY - y;
6 | return dx * dx + dy * dy < body.radius * body.radius;
7 | }
8 |
9 | let ratio = 1, windowSize = {width: 800, height: 600};
10 |
11 | function sx(x) {
12 | return (x + windowSize.width / 2) * ratio;
13 | }
14 |
15 | function sy(y) {
16 | return (y + windowSize.height / 2) * ratio;
17 | }
18 |
19 | export default class BlobSVG extends React.Component {
20 | tryDragStart = e => {
21 | e.preventDefault();
22 | if (
23 | isInside(
24 | this.props.body,
25 | e.clientX - windowSize.width / 2,
26 | e.clientY - windowSize.height / 2
27 | )
28 | ) {
29 | this.props.onDragStart(e);
30 | }
31 | };
32 |
33 | draw = () => {
34 | const { body } = this.props;
35 | const { vertices } = body;
36 | this.ctx.fillStyle = "rgba(255, 255, 255, 0.5)";
37 | this.ctx.fillRect(
38 | 0,
39 | 0,
40 | windowSize.width * ratio,
41 | windowSize.height * ratio
42 | );
43 | this.ctx.fillStyle = "#f43";
44 | this.ctx.beginPath();
45 | this.ctx.moveTo(sx(vertices[0].x), sy(vertices[0].y));
46 | for (let vertex of vertices) {
47 | this.ctx.lineTo(sx(vertex.x), sy(vertex.y));
48 | }
49 | this.ctx.fill();
50 |
51 | const eyeVertex1 = 0;
52 | const eyeVertex2 = Math.floor(2 * body.vertexCount / 3);
53 | const mouthVertex = Math.floor(body.vertexCount / 3);
54 | this.ctx.fillStyle = "#333";
55 | // Eyes
56 | this.ctx.beginPath();
57 | this.ctx.arc(
58 | sx((vertices[eyeVertex1].x + body.centerX * 2) / 3),
59 | sy((vertices[eyeVertex1].y + body.centerY * 2) / 3),
60 | body.radius / 8 * ratio,
61 | 0,
62 | Math.PI * 2,
63 | false
64 | );
65 | this.ctx.fill();
66 | this.ctx.beginPath();
67 | this.ctx.arc(
68 | sx((vertices[eyeVertex2].x + body.centerX * 2) / 3),
69 | sy((vertices[eyeVertex2].y + body.centerY * 2) / 3),
70 | body.radius / 8 * ratio,
71 | 0,
72 | Math.PI * 2,
73 | false
74 | );
75 | this.ctx.fill();
76 | // Mouth
77 | this.ctx.beginPath();
78 | this.ctx.arc(
79 | sx((vertices[mouthVertex].x * 2 + body.centerX * 3) / 5),
80 | sy((vertices[mouthVertex].y * 2 + body.centerY * 3) / 5),
81 | body.radius / 4 * ratio,
82 | 0,
83 | Math.PI * 2,
84 | false
85 | );
86 | this.ctx.fill();
87 |
88 | if (!this.unmount)
89 | requestAnimationFrame(this.draw);
90 | };
91 |
92 | handleResize = () => {
93 | windowSize.width = window.innerWidth;
94 | windowSize.height = window.innerHeight;
95 | const devicePixelRatio = window.devicePixelRatio || 1;
96 | const backingStoreRatio =
97 | this.ctx.webkitBackingStorePixelRatio ||
98 | this.ctx.mozBackingStorePixelRatio ||
99 | this.ctx.msBackingStorePixelRatio ||
100 | this.ctx.oBackingStorePixelRatio ||
101 | this.ctx.backingStorePixelRatio ||
102 | 1;
103 | ratio = devicePixelRatio / backingStoreRatio;
104 | this.forceUpdate();
105 | }
106 |
107 | componentDidMount() {
108 | this.ctx = this.canvas.getContext("2d");
109 | window.addEventListener("resize", this.handleResize);
110 | this.handleResize();
111 | requestAnimationFrame(this.draw);
112 | }
113 |
114 | componentWillUnmount() {
115 | window.removeEventListener("resize", this.handleResize);
116 | this.unmount = true;
117 | }
118 |
119 | render() {
120 | return (
121 |