diff --git a/CHANGELOG.md b/CHANGELOG.md index d7cd1b3..c51ae70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog -## 0.2.0 (unreleased) +## 0.2.1 + +### Fixed +- `galaxy`, `math_rain` and `strange_attractors` no longer draw a blown-out first second and then an empty screen ([#7](https://github.com/Mattbusel/proof-engine/issues/7)). They used `life_function` values of 5 to 25 as glyph scale, and fed strong inverse-square and flow fields to glyphs of mass 0.01 to 0.1, so everything was huge at first and then flung off screen. The three demos now compute positions from their equations every frame (orbits, falling columns, RK4 on each attractor) and run stable indefinitely. +- `initial_state(AttractorType::Dadras)` started on the invariant line y = z = 0 and decayed to the origin; `initial_state(AttractorType::Rabinovich)` escaped to infinity within 2,000 steps. Both now start from standard seeds, and a unit test checks every attractor settles onto a finite, non-trivial state. +- `ProofEngine::run` now honours `input.quit_requested`, as `run_ui` already did, so Esc handlers in `run` demos work. + +### Added +- `quickstart` example: the README's three-step program. +- Crate docs open with a runnable doc test and the quickstart program; README and crate page show real GIFs captured with `PROOF_HIDDEN=1`. + +## 0.2.0 Engine work that was developed alongside [chaos-rpg](https://github.com/Mattbusel/chaos-rpg) and had not been pushed, plus CI and a new demo. diff --git a/Cargo.toml b/Cargo.toml index e72c4a5..2130ec8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,21 @@ [package] name = "proof-engine" -version = "0.2.0" +version = "0.2.1" edition = "2021" authors = ["Matthew Busel "] license = "MIT" -description = "A mathematical rendering engine for Rust. Every visual is the output of a mathematical function." +description = "Real-time graphics from math: glyphs and particles moved by ODEs, strange attractors and force fields, drawn with HDR bloom on OpenGL." repository = "https://github.com/Mattbusel/proof-engine" -homepage = "https://github.com/Mattbusel/proof-engine" -keywords = ["game-engine", "rendering", "mathematics", "ascii", "opengl"] -categories = ["game-engines", "graphics", "rendering"] +homepage = "https://mattbusel.github.io/proof-engine/" +documentation = "https://docs.rs/proof-engine" +keywords = ["generative-art", "creative-coding", "attractor", "game-engine", "opengl"] +categories = ["game-engines", "graphics", "rendering", "simulation", "mathematics"] readme = "README.md" # Screenshots, GIFs and repo tooling stay on GitHub; the README links resolve there. exclude = ["*.png", "*.gif", "*.jpg", "assets/", "docs/", "*.py", ".github/", "ci/"] [dependencies] -# OpenGL / windowing — glutin-winit bridges the two and handles version alignment +# OpenGL and windowing: glutin-winit bridges the two and handles version alignment glutin = "0.32" glutin-winit = "0.5" glow = "0.16" @@ -117,6 +118,9 @@ name = "sky" [[example]] name = "lorenz" +[[example]] +name = "quickstart" + [[bin]] name = "proof_editor" path = "src/bin/proof_editor.rs" diff --git a/README.md b/README.md index c114f35..bcd8192 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Proof Engine. Left: a sunset over mountains from the sky example, every cell a Rayleigh and Mie scattering integral. Right: 40,000 points on the Lorenz attractor from the lorenz example. + Proof Engine. Left: a sunset over mountains from the sky example, every cell a Rayleigh and Mie scattering integral. Right: 40,000 points on the Lorenz attractor from the lorenz example.

@@ -10,36 +10,102 @@ CI

-**Proof Engine is a Rust rendering and game engine where every visual is the output of a mathematical function.** Glyphs and particles are moved by real differential equations, force fields and spring systems, not by sprites, meshes or keyframed animation. +**Proof Engine is a Rust library for making moving pictures out of math: you write the equations, it draws what they do, in real time, with bloom and HDR light.** -A Lorenz attractor on screen looks like a Lorenz attractor because its particles are integrating the Lorenz equations. An entity is a cluster of glyphs held together by force cohesion; when it loses HP the binding weakens and it comes apart into an attractor instead of playing a death animation. If you like generative art, simulation or procedural games and want an engine built around that idea from the start, this is it. +

The galaxy example: about 3,000 glyphs on four spiral arms, each on its own circular orbit, with a hot core and dim red outer arms, seen at an angle as the camera circles.

+

The galaxy example, captured from the engine's own framebuffer with PROOF_HIDDEN=1.

-## Quick start +## Install -You need stable Rust and a GPU with OpenGL 3.3 or newer. +| You want to | Do this | +| --- | --- | +| Use it in your own Rust program | `cargo add proof-engine` | +| Watch the demos first | `git clone https://github.com/Mattbusel/proof-engine && cd proof-engine && cargo run --release --example galaxy` | +| Read the API | [docs.rs/proof-engine](https://docs.rs/proof-engine) | -```bash -git clone https://github.com/Mattbusel/proof-engine.git -cd proof-engine -cargo run --release --example sky # a day passing, every sky cell a scattering integral -cargo run --release --example lorenz # 40,000 points on the Lorenz attractor -``` +You need stable Rust and a GPU with OpenGL 3.3 or newer. - **Windows:** nothing else. - **macOS:** nothing else. macOS stops at OpenGL 4.1, so `apotheosis` (which uses 4.3 compute shaders) will not run there; every other demo does. - **Linux:** the audio backend needs the ALSA headers: `sudo apt install libasound2-dev pkg-config` (Debian/Ubuntu) or `sudo dnf install alsa-lib-devel` (Fedora). -The first build compiles the whole engine and takes a few minutes. Use `--release`: the demos simulate tens of thousands of particles per frame and a debug build is too slow to judge them by. +The first build compiles the whole engine and takes a few minutes. Always use `--release`; a debug build is far too slow for tens of thousands of particles a frame. + +## Use it in 3 steps + +**1. Make a project and add the engine.** + +```bash +cargo new lorenz-demo && cd lorenz-demo +cargo add proof-engine +``` + +**2. Replace `src/main.rs` with this.** 5,000 points start on one short line and follow the Lorenz equations. (It is also in the repo as [`examples/quickstart.rs`](https://github.com/Mattbusel/proof-engine/blob/main/examples/quickstart.rs).) + +```rust +use proof_engine::math::attractors::rk4_step; +use proof_engine::prelude::*; +use proof_engine::render::ui_layer::UiParticle; + +fn main() { + let mut engine = ProofEngine::new(EngineConfig::default()); + + // 5,000 points in a line 2 units long, each 0.0004 from the next. + let mut points: Vec = (0..5000) + .map(|i| Vec3::new(1.0 + i as f32 * 4e-4, 1.0, 1.0)) + .collect(); + + engine.run_ui(move |engine, dt| { + // Advance every point along the Lorenz equations. + for p in points.iter_mut() { + *p = rk4_step(AttractorType::Lorenz, *p, dt); + } + // Draw them: x across, z up, centred in the window. + let (w, h) = engine.render_size(); + let (cx, cy, s) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 60.0); + let color = Vec4::new(0.5, 1.2, 1.6, 1.0); + let dots = points + .iter() + .map(|p| UiParticle::new(cx + p.x * s, cy - (p.z - 25.0) * s, 3.0, 3.0, '●', color)) + .collect(); + engine.ui.draw_particles(dots); + }); +} +``` + +**3. Run it.** + +```bash +cargo run --release +``` + +A window opens. For about ten seconds the points travel together as one short streak. Then chaos pulls them apart, and by twenty seconds they have drawn the Lorenz butterfly on their own. Close the window to quit. + +## Results + +These are real frames from that program, written by the engine itself (`PROOF_HIDDEN=1 PROOF_FIXED_DT=60 PROOF_SHOT=...`, see [Capture frames](#capture-frames-from-any-program)), cropped to the centre: + +Four frames of the quickstart program. At 1 second a tiny speck; at 13.5 seconds a thin arc; at 16 seconds the points have split into loops; at 18.5 seconds they fill both wings of the Lorenz butterfly. + +The same idea at a larger scale, one command each: + +| `strange_attractors` | `math_rain` | +| --- | --- | +| Seven strange attractors, Lorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas and Dadras, 1,500 points each, turning slowly. | A hundred columns of green mathematical symbols falling at different speeds, the leading glyph of each bright white-green. | +| Seven chaotic systems, 1,500 RK4-integrated points each. Colour is speed along the flow. | A hundred columns at speeds from `abs(sin(0.13 c))`; each column's symbols flicker by its own logistic map. | +| **`lorenz`** | **`sky`** | +| 40,000 points circling the two wings of the Lorenz attractor, teal where slow and amber where fast. | The sky example in late afternoon: a blue sky fading to white near the sun over brown mountains. | +| 40,000 points on the Lorenz attractor, drawn into the HDR pass so overlaps add up to real light. | Every sky cell is a Rayleigh and Mie scattering integral, recomputed every frame as the sun moves. | ## The sky is an integral -Six frames from the sky example between 11:22 and 18:05: blue midday, a white sun low on the right, orange mountains at sunset, and dark teal dusk. +Six frames from the sky example between 11:22 and 18:05: blue midday, a white sun low on the right, orange mountains at sunset, and dark teal dusk. The `sky` example divides the sky into 120 by 56 cells. For each cell, every frame, `nishita_sky::compute_sky_color` integrates Rayleigh and Mie single scattering along the view ray and along a second ray toward the sun. The blue at noon, the orange band at sunset and the dark teal after it all come out of the same integral as the sun moves. The mountains are sums of sines, lit by the sky just above the horizon. Space pauses, Up/Down change the speed of the day. ## Forty thousand points, one equation -The Lorenz attractor drawn by 40,000 points, cool teal where they move slowly and amber where they move fast, with the equations printed at the top left. +The Lorenz attractor drawn by 40,000 points, cool teal where they move slowly and amber where they move fast, with the equations printed at the top left. Every point in the `lorenz` example is a state `(x, y, z)` advanced each frame by the engine's own RK4 integrator. Nobody drew the two wings; that is where the equations send the points. Colour is speed along the flow, and the points are drawn into the HDR world pass, so overlapping points add up to real light before bloom and the tonemap. @@ -73,27 +139,31 @@ The source tree also contains modules for more advanced lighting (a sparse voxel Every demo is `cargo run --release --example `. Close the window (or press Esc where noted) to quit. -The convergence demo: a blue particle-built fighter on the left, and a red one on the right coming apart into loose particles and rings after a hit. +The convergence demo: a blue particle-built fighter on the left, and a red one on the right coming apart into loose particles and rings after a hit. | Example | What you see | | --- | --- | | `sky` | A day passing over a mountain range. Every sky cell is the Nishita Rayleigh + Mie scattering integral for its view direction, recomputed each frame. Space pauses, Up/Down change speed, Esc quits. | | `lorenz` | 40,000 points on the Lorenz attractor, integrated with RK4 and coloured by speed. Space pauses, Left/Right turn the view, Esc quits. | +| `galaxy` | About 3,000 glyphs on four spiral arms, each on its own orbit, with a hot core, red outer arms and drifting nebula dust. The camera circles slowly. Esc quits. | +| `strange_attractors` | Lorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas and Dadras side by side, 1,500 points each. Space pauses, Esc quits. | +| `math_rain` | Digital rain made of mathematical symbols; column speeds from a sine, symbol flicker from a logistic map. Esc quits. | +| `quickstart` | The program from [Use it in 3 steps](#use-it-in-3-steps): 5,000 points that chaos tears apart into the Lorenz butterfly. | | `convergence` | Two particle-built fighters in a circular arena with an orbiting camera; combat loops forever and hits knock matter loose. | | `supernova` | A star pulses, collapses under a gravity field, explodes into debris and settles into a Lorenz-attractor nebula. | -| `hello_glyph` | The smallest program: one breathing `@` and a gravity field. Start here when reading code. | +| `hello_glyph` | The smallest program: one breathing `@` and a gravity field. | | `playground` | Interactive sandbox: place glyphs, fields and entities with the mouse, cycle attractors and palettes. | | `colossus` | GPU density entities: millions of particles derived in the vertex shader from sixteen bones. Needs a strong GPU. | | `apotheosis` | A particle-rendered character built on signed distance fields, about 10.8 million GPU particles. Needs OpenGL 4.3 and a strong GPU. | -Also: `chaos_field`, `particle_demo`, `force_fields`, `amorphous_entity`, `particle_entity`, `full_combat`, `heartbeat`, `showcase`, `sculptor`, and three older 3D-glyph demos that currently need attention: `galaxy`, `math_rain` and `strange_attractors` draw a blown-out first second and then an empty scene. +Also: `chaos_field`, `particle_demo`, `force_fields`, `amorphous_entity`, `particle_entity`, `full_combat`, `heartbeat`, `showcase` and `sculptor`.
-Animated captures (older GIFs, about 4 MB each) +More captures: supernova, and an older convergence recording (4 MB) -![Convergence demo, animated](assets/convergence-demo.gif) +The supernova example: a ring of hot glyphs, white, yellow and magenta, expanding after the explosion. -![Supernova demo](assets/supernova-demo.gif) +An older recording of the convergence demo: two particle-built fighters trading blows.
@@ -117,42 +187,9 @@ cargo run --release --example sky | `PROOF_HIDDEN` | `1` creates the window hidden and unfocused, so capturing does not interrupt whoever is using the machine. | | `PROOF_WINDOW` | Override the window size, as `WIDTHxHEIGHT`. | -## Use as a library - -```toml -[dependencies] -proof-engine = "0.2" -``` - -```rust -use proof_engine::prelude::*; - -fn main() { - let mut engine = ProofEngine::new(EngineConfig::default()); - - engine.spawn_glyph(Glyph { - character: '@', - position: Vec3::ZERO, - color: Vec4::new(0.0, 1.0, 0.8, 1.0), - emission: 1.2, - life_function: Some(MathFunction::Breathing { rate: 0.4, depth: 0.15 }), - ..Default::default() - }); - - engine.add_field(ForceField::StrangeAttractor { - attractor_type: AttractorType::Lorenz, - scale: 0.2, - strength: 0.4, - center: Vec3::ZERO, - }); - - engine.run(|_engine, _dt| {}); -} -``` - ## The screen pipeline -The screen pipeline: the 3D glyph pass and the UI world pass draw into a half-float scene buffer; emission feeds a bloom pyramid; colour and bloom meet in the composite, then optional FXAA, the screen, and the UI HUD pass painted sharp on top. +The screen pipeline: the 3D glyph pass and the UI world pass draw into a half-float scene buffer; emission feeds a bloom pyramid; colour and bloom meet in the composite, then optional FXAA, the screen, and the UI HUD pass painted sharp on top. The scene buffers are half-float, so a few hundred thousand overlapping emissive particles accumulate real light instead of clipping at white. The composite is the one place the range comes down, through ACES. @@ -210,7 +247,7 @@ stays put; `vsync` waits for the display. ## Proof Editor -![Proof Editor](assets/editor-screenshot.png) +![Proof Editor](https://raw.githubusercontent.com/Mattbusel/proof-engine/main/assets/editor-screenshot.png) Download `proof-editor.exe` (Windows) from the [releases page](https://github.com/Mattbusel/proof-engine/releases), or build it: @@ -273,11 +310,11 @@ Roughly 660,000 lines of Rust across the engine (`src/`), the editor (`editor/`) ## Status -Early (0.2) and moving fast. The public API is not stable and some subsystems are further along than others. CI builds every target and runs the unit, integration and doc tests on Linux, and builds the examples on Windows and macOS. About 4,800 library unit tests pass; the ones that do not yet are listed by name in [`ci/known-failing-tests.txt`](ci/known-failing-tests.txt), and each one fixed is a line deleted from that file. Contributions: see [CONTRIBUTING.md](CONTRIBUTING.md). +Early (0.2) and moving fast. The public API is not stable and some subsystems are further along than others. CI builds every target and runs the unit, integration and doc tests on Linux, and builds the examples on Windows and macOS. About 4,800 library unit tests pass; the ones that do not yet are listed by name in [`ci/known-failing-tests.txt`](https://github.com/Mattbusel/proof-engine/blob/main/ci/known-failing-tests.txt), and each one fixed is a line deleted from that file. Contributions: see [CONTRIBUTING.md](https://github.com/Mattbusel/proof-engine/blob/main/CONTRIBUTING.md). ## License -MIT, see [LICENSE](LICENSE). +MIT, see [LICENSE](https://github.com/Mattbusel/proof-engine/blob/main/LICENSE). ## Hire the author diff --git a/assets/gifs/galaxy.gif b/assets/gifs/galaxy.gif new file mode 100644 index 0000000..ef95f41 Binary files /dev/null and b/assets/gifs/galaxy.gif differ diff --git a/assets/gifs/lorenz.gif b/assets/gifs/lorenz.gif new file mode 100644 index 0000000..bb8949c Binary files /dev/null and b/assets/gifs/lorenz.gif differ diff --git a/assets/gifs/math_rain.gif b/assets/gifs/math_rain.gif new file mode 100644 index 0000000..46920f8 Binary files /dev/null and b/assets/gifs/math_rain.gif differ diff --git a/assets/gifs/sky.gif b/assets/gifs/sky.gif new file mode 100644 index 0000000..16be424 Binary files /dev/null and b/assets/gifs/sky.gif differ diff --git a/assets/gifs/strange_attractors.gif b/assets/gifs/strange_attractors.gif new file mode 100644 index 0000000..3d9d048 Binary files /dev/null and b/assets/gifs/strange_attractors.gif differ diff --git a/assets/gifs/supernova.gif b/assets/gifs/supernova.gif new file mode 100644 index 0000000..d5431f8 Binary files /dev/null and b/assets/gifs/supernova.gif differ diff --git a/assets/pr/issue-7-before-after.jpg b/assets/pr/issue-7-before-after.jpg new file mode 100644 index 0000000..8b26fde Binary files /dev/null and b/assets/pr/issue-7-before-after.jpg differ diff --git a/assets/quickstart-steps.jpg b/assets/quickstart-steps.jpg new file mode 100644 index 0000000..f909d20 Binary files /dev/null and b/assets/quickstart-steps.jpg differ diff --git a/assets/supernova-demo.gif b/assets/supernova-demo.gif deleted file mode 100644 index 2b4b8d1..0000000 Binary files a/assets/supernova-demo.gif and /dev/null differ diff --git a/docs/assets/galaxy-poster.jpg b/docs/assets/galaxy-poster.jpg new file mode 100644 index 0000000..bcbfa95 Binary files /dev/null and b/docs/assets/galaxy-poster.jpg differ diff --git a/docs/assets/galaxy.mp4 b/docs/assets/galaxy.mp4 new file mode 100644 index 0000000..b427fb1 Binary files /dev/null and b/docs/assets/galaxy.mp4 differ diff --git a/docs/assets/math-rain-poster.jpg b/docs/assets/math-rain-poster.jpg new file mode 100644 index 0000000..19b5b72 Binary files /dev/null and b/docs/assets/math-rain-poster.jpg differ diff --git a/docs/assets/math-rain.mp4 b/docs/assets/math-rain.mp4 new file mode 100644 index 0000000..1ed6ae0 Binary files /dev/null and b/docs/assets/math-rain.mp4 differ diff --git a/docs/assets/quickstart-steps.jpg b/docs/assets/quickstart-steps.jpg new file mode 100644 index 0000000..f909d20 Binary files /dev/null and b/docs/assets/quickstart-steps.jpg differ diff --git a/docs/assets/strange-attractors-poster.jpg b/docs/assets/strange-attractors-poster.jpg new file mode 100644 index 0000000..4d7b3e8 Binary files /dev/null and b/docs/assets/strange-attractors-poster.jpg differ diff --git a/docs/assets/strange-attractors.mp4 b/docs/assets/strange-attractors.mp4 new file mode 100644 index 0000000..0278cad Binary files /dev/null and b/docs/assets/strange-attractors.mp4 differ diff --git a/docs/index.html b/docs/index.html index c8966cd..78fbc14 100644 --- a/docs/index.html +++ b/docs/index.html @@ -116,7 +116,8 @@ .k { color: #ff9a4a; } .s { color: #a9d7a0; } .c { color: #6f7b82; } .n { color: #6cc9d8; } .f { color: #f1dfb8; } /* quick start steps */ - .steps { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 28px; margin-top: 36px; } + .steps { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 7fr); gap: 28px; margin-top: 36px; } + .steps .code pre { font-size: 13px; } .step h3 span { color: var(--hot); margin-right: 10px; } .step p { color: var(--ink-2); margin: 0 0 14px; font-size: 16px; } .note { font-size: 15px; color: var(--dim); margin-top: 8px; } @@ -137,6 +138,8 @@ .tblwrap { overflow-x: auto; } .pipe { margin-top: 36px; } + .vids { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; margin-top: 36px; } + .vids .figcap b { font-family: var(--mono); } .pipe img { width: 100%; height: auto; display: block; } footer { border-top: 1px solid var(--rule); padding: 44px 0 60px; font-size: 15px; color: var(--dim); } @@ -148,6 +151,7 @@ .split.rev > :first-child { order: 2; } .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .steps { grid-template-columns: minmax(0, 1fr); } + .vids { grid-template-columns: minmax(0, 1fr); } section { padding: 76px 0; } } @media (max-width: 600px) { @@ -269,40 +273,50 @@

01Run the demos

02Use it as a library

-

One glyph that breathes, one strange attractor pulling on it.

+

5,000 points start on one short line and follow the Lorenz equations. Also in the repo as examples/quickstart.rs.

-
Cargo.toml
-
[dependencies]
-proof-engine = "0.2"
+
shell
+
cargo new lorenz-demo && cd lorenz-demo
+cargo add proof-engine
src/main.rs
-
use proof_engine::prelude::*;
+
use proof_engine::math::attractors::rk4_step;
+use proof_engine::prelude::*;
+use proof_engine::render::ui_layer::UiParticle;
 
 fn main() {
     let mut engine = ProofEngine::new(EngineConfig::default());
 
-    engine.spawn_glyph(Glyph {
-        character: '@',
-        position: Vec3::ZERO,
-        color: Vec4::new(0.0, 1.0, 0.8, 1.0),
-        emission: 1.2,
-        life_function: Some(MathFunction::Breathing { rate: 0.4, depth: 0.15 }),
-        ..Default::default()
-    });
+    // 5,000 points in a line 2 units long, each 0.0004 from the next.
+    let mut points: Vec<Vec3> = (0..5000)
+        .map(|i| Vec3::new(1.0 + i as f32 * 4e-4, 1.0, 1.0))
+        .collect();
 
-    engine.add_field(ForceField::StrangeAttractor {
-        attractor_type: AttractorType::Lorenz,
-        scale: 0.2,
-        strength: 0.4,
-        center: Vec3::ZERO,
+    engine.run_ui(move |engine, dt| {
+        // Advance every point along the Lorenz equations.
+        for p in points.iter_mut() {
+            *p = rk4_step(AttractorType::Lorenz, *p, dt);
+        }
+        // Draw them: x across, z up, centred in the window.
+        let (w, h) = engine.render_size();
+        let (cx, cy, s) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 60.0);
+        let color = Vec4::new(0.5, 1.2, 1.6, 1.0);
+        let dots = points
+            .iter()
+            .map(|p| UiParticle::new(cx + p.x * s, cy - (p.z - 25.0) * s, 3.0, 3.0, '●', color))
+            .collect();
+        engine.ui.draw_particles(dots);
     });
-
-    engine.run(|_engine, _dt| {});
 }
+

cargo run --release. For about ten seconds the points travel as one streak; then chaos pulls them apart into the butterfly.

+
+
Four frames of the quickstart program at 1, 13.5, 16 and 18.5 seconds: a speck, a thin arc, loops splitting apart, and the full Lorenz butterfly.
+

FIG. 3BThat program, frames written by the engine at 1, 13.5, 16 and 18.5 seconds.

+
@@ -328,8 +342,22 @@

Math first, then light.

Demos

-

Twenty demos, one command each.

+

Twenty-one demos, one command each.

Every one is cargo run --release --example <name>. Close the window, or press Esc where the demo says so, to quit. These are the ones to start with.

+
+
+
+

galaxy3,000 glyphs, each on its own orbit.

+
+
+
+

strange_attractorsSeven chaotic systems, RK4, colour is speed.

+
+
+
+

math_rainColumn speeds from a sine, flicker from a logistic map.

+
+
Two particle-built fighters; the red one is coming apart after a hit
@@ -346,6 +374,10 @@

Twenty demos, one command each.

skyA day over a mountain range, every sky cell a Nishita scattering integral. Space pauses, Up/Down change speed. lorenz40,000 points on the Lorenz attractor, integrated with RK4 and drawn into the HDR world pass. Left/Right turn the view. + galaxyAbout 3,000 glyphs on four spiral arms, each on its own orbit, with a hot core and drifting nebula dust. + strange_attractorsLorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas and Dadras side by side, 1,500 points each. Space pauses. + math_rainDigital rain of mathematical symbols; speeds from a sine, flicker from a logistic map. + quickstartThe program above: 5,000 points that chaos tears apart into the Lorenz butterfly. convergenceTwo particle-built fighters in an arena with an orbiting camera; hits knock matter loose. supernovaA star pulses, collapses, explodes and settles into a Lorenz nebula. hello_glyphThe smallest program: one breathing @ and a gravity field. @@ -364,7 +396,7 @@

Twenty demos, one command each.

Record the engine’s own frames.

Any program built on Proof Engine can write its own frames, read straight back off the GPU after post-processing, with no code changes. PROOF_HIDDEN=1 keeps the window invisible and unfocused, so it runs while you use the machine.

-

The sky, Lorenz and convergence frames on this page were all written this way.

+

Every demo frame and video on this page was written this way.

diff --git a/examples/galaxy.rs b/examples/galaxy.rs index 42cc347..79a7205 100644 --- a/examples/galaxy.rs +++ b/examples/galaxy.rs @@ -1,183 +1,193 @@ -//! galaxy — a living spiral galaxy made entirely of math. +//! galaxy: a four-armed spiral galaxy made of glyphs. //! -//! 3000+ glyphs form spiral arms following golden ratio curves. -//! Each star breathes, orbits, and glows. A central black hole -//! pulls everything inward. Nebula clouds drift on Perlin noise. -//! The galaxy slowly rotates as a whole. +//! About 3,000 glyphs sit on four logarithmic spiral arms. Every star moves +//! on its own circular orbit, the inner ones a little faster than the outer +//! ones, so the pattern turns as a whole and shears slowly. A hot core of +//! bright glyphs, dim red outer arms and Perlin-drifted nebula clouds behind +//! them. The camera looks down on the disk at an angle and circles it. //! -//! Run: `cargo run --example galaxy` +//! Positions are computed from the orbit equations every frame rather than +//! integrated from forces, so the galaxy is stable for as long as it runs. +//! +//! Esc quits. +//! +//! Run: `cargo run --release --example galaxy` +use proof_engine::glyph::GlyphId; +use proof_engine::math::noise::fbm; use proof_engine::prelude::*; use std::f32::consts::{PI, TAU}; const NUM_STARS: usize = 2500; const NUM_NEBULA: usize = 500; +const NUM_CORE: usize = 60; const NUM_ARMS: usize = 4; const ARM_SPREAD: f32 = 0.4; const GALAXY_RADIUS: f32 = 25.0; +/// Angular speed of the outer disk, radians per second. +const OMEGA_OUTER: f32 = 0.10; +/// Extra angular speed toward the centre. +const OMEGA_INNER: f32 = 0.06; +/// The disk is tilted by this much toward the camera. +const TILT: f32 = 0.6; + +/// A star on a circular orbit in the disk plane. +struct Orbit { + id: GlyphId, + radius: f32, + angle0: f32, + z: f32, + /// Nebula clouds drift on noise; stars do not. + drift: f32, +} fn main() { env_logger::init(); let mut engine = ProofEngine::new(EngineConfig { - window_title: "Proof Engine — Spiral Galaxy".to_string(), + window_title: "Proof Engine: spiral galaxy".to_string(), window_width: 1400, window_height: 900, render: proof_engine::config::RenderConfig { bloom_enabled: true, - bloom_intensity: 2.0, - chromatic_aberration: 0.002, - film_grain: 0.01, + bloom_intensity: 1.1, ..Default::default() }, ..Default::default() }); - // Central supermassive black hole — strong gravity - engine.add_field(ForceField::Gravity { - center: Vec3::ZERO, - strength: 2.0, - falloff: Falloff::InverseSquare, - }); - - // Gentle rotation field (vortex) to keep the galaxy spinning - engine.add_field(ForceField::Vortex { - center: Vec3::ZERO, - axis: Vec3::Z, - strength: 0.15, - radius: 30.0, - }); - - // Star characters by type - // Use only characters Consolas definitely has let hot_stars = ['*', '+', 'x', 'o', '.']; let cool_stars = ['.', ',', '\'', '`', '-']; let bright_stars = ['@', '#', 'O', 'X', '*']; - // Spawn spiral arm stars + let mut orbits: Vec = Vec::with_capacity(NUM_STARS + NUM_NEBULA + NUM_CORE); + + // Spiral arm stars. for i in 0..NUM_STARS { let arm = i % NUM_ARMS; let arm_angle = arm as f32 * TAU / NUM_ARMS as f32; - let t = (i as f32 / NUM_STARS as f32).sqrt(); // sqrt for denser center - let r = t * GALAXY_RADIUS; + let t = (i as f32 / NUM_STARS as f32).sqrt(); // denser toward the centre + let r = 1.2 + t * GALAXY_RADIUS; - // Logarithmic spiral: angle increases with distance + // Logarithmic spiral: the angle grows with distance. let spiral_angle = arm_angle + t * 3.0 * PI + (i as f32 * 0.01).sin() * ARM_SPREAD; - let spread_r = r + ((i as f32 * 0.37).sin() * ARM_SPREAD * r * 0.3); - let spread_angle = spiral_angle + (i as f32 * 0.73).cos() * ARM_SPREAD * 0.5; - - let x = spread_r * spread_angle.cos(); - let y = spread_r * spread_angle.sin(); - let z = ((i as f32 * 0.53).sin() * 0.3) * (1.0 - t); // thin disk, thicker at center + let spread_r = r + (i as f32 * 0.37).sin() * ARM_SPREAD * r * 0.12; + let spread_angle = spiral_angle + (i as f32 * 0.73).cos() * ARM_SPREAD * 0.3; + let z = (i as f32 * 0.53).sin() * 0.6 * (1.0 - t); // thin disk, thicker in the bulge - // Star classification by distance from center - let (ch, color, emission) = if r < 3.0 { - // Core: hot blue-white stars + let (ch, color, emission, size) = if r < 4.0 { let ch = bright_stars[i % bright_stars.len()]; - let brightness = 0.8 + (i as f32 * 0.17).sin().abs() * 0.4; - (ch, Vec4::new(0.8, 0.85, 1.0, 1.0) * brightness, 2.5) - } else if r < 10.0 { - // Inner arms: yellow-white + let b = 0.8 + (i as f32 * 0.17).sin().abs() * 0.4; + (ch, Vec4::new(0.8 * b, 0.85 * b, 1.0 * b, 1.0), 1.0, 0.8) + } else if r < 11.0 { let ch = hot_stars[i % hot_stars.len()]; - let brightness = 0.4 + (i as f32 * 0.31).sin().abs() * 0.5; - (ch, Vec4::new(1.0, 0.9, 0.6, 0.9) * brightness, 1.5) + let b = 0.45 + (i as f32 * 0.31).sin().abs() * 0.5; + (ch, Vec4::new(1.0 * b, 0.9 * b, 0.6 * b, 0.95), 1.0, 0.8) } else { - // Outer arms: dim red-orange let ch = cool_stars[i % cool_stars.len()]; - let brightness = 0.15 + (i as f32 * 0.43).sin().abs() * 0.25; - (ch, Vec4::new(1.0, 0.5, 0.2, 0.7) * brightness, 0.6) + let b = 0.3 + (i as f32 * 0.43).sin().abs() * 0.35; + (ch, Vec4::new(1.0 * b, 0.5 * b, 0.25 * b, 0.9), 0.6, 0.8) }; - // Each star orbits the center - let orbit_speed = 0.02 + (1.0 - t) * 0.08; // inner stars orbit faster (Kepler) - - engine.spawn_glyph(Glyph { + let id = engine.spawn_glyph(Glyph { character: ch, - position: Vec3::new(x, y, z), + position: Vec3::ZERO, + scale: Vec2::splat(size), color, emission, glow_color: Vec3::new(color.x, color.y, color.z), - glow_radius: emission * 0.3, - mass: 0.05, + glow_radius: emission * 0.4, layer: RenderLayer::World, blend_mode: BlendMode::Additive, - life_function: Some(MathFunction::Orbit { - center: Vec3::ZERO, - radius: spread_r, - speed: orbit_speed, - eccentricity: 0.1 + (i as f32 * 0.11).sin().abs() * 0.2, - }), ..Default::default() }); + orbits.push(Orbit { id, radius: spread_r, angle0: spread_angle, z, drift: 0.0 }); } - // Nebula clouds — large, dim, colorful, drifting on noise + // Nebula clouds: large, dim, coloured, drifting on noise. let nebula_colors = [ - Vec4::new(0.3, 0.1, 0.5, 0.15), // purple - Vec4::new(0.1, 0.2, 0.5, 0.12), // blue - Vec4::new(0.5, 0.1, 0.2, 0.10), // red - Vec4::new(0.1, 0.4, 0.3, 0.08), // teal - Vec4::new(0.4, 0.3, 0.1, 0.10), // gold + Vec4::new(0.30, 0.10, 0.50, 0.35), + Vec4::new(0.10, 0.20, 0.50, 0.30), + Vec4::new(0.50, 0.10, 0.20, 0.30), + Vec4::new(0.10, 0.40, 0.30, 0.25), + Vec4::new(0.40, 0.30, 0.10, 0.30), ]; - let nebula_chars = ['░', '▒', '▓', '█', '.', 'o', 'O']; - + let nebula_chars = ['.', ':', ',', '`']; for i in 0..NUM_NEBULA { let t = i as f32 / NUM_NEBULA as f32; let arm = i % NUM_ARMS; let arm_angle = arm as f32 * TAU / NUM_ARMS as f32; - let r = t.sqrt() * GALAXY_RADIUS * 0.8; - let angle = arm_angle + t * 2.5 * PI; - let x = r * angle.cos() + (i as f32 * 0.47).sin() * 3.0; - let y = r * angle.sin() + (i as f32 * 0.31).cos() * 3.0; - + let r = 2.0 + t.sqrt() * GALAXY_RADIUS * 0.8 + (i as f32 * 0.47).sin() * 1.5; + let angle = arm_angle + t.sqrt() * 3.0 * PI + (i as f32 * 0.31).cos() * 0.25; let nc = nebula_colors[i % nebula_colors.len()]; - let ch = nebula_chars[i % nebula_chars.len()]; - - engine.spawn_glyph(Glyph { - character: ch, - position: Vec3::new(x, y, -0.5), + let id = engine.spawn_glyph(Glyph { + character: nebula_chars[i % nebula_chars.len()], + position: Vec3::ZERO, + scale: Vec2::splat(1.3), color: nc, - emission: 0.3, + emission: 0.25, glow_color: Vec3::new(nc.x, nc.y, nc.z), - glow_radius: 3.0, - mass: 0.01, + glow_radius: 1.5, layer: RenderLayer::Background, blend_mode: BlendMode::Additive, - life_function: Some(MathFunction::Perlin { - frequency: 0.1 + (i as f32 * 0.03).sin().abs() * 0.1, - octaves: 2, - amplitude: 0.5, - }), ..Default::default() }); + orbits.push(Orbit { id, radius: r, angle0: angle, z: -0.3, drift: 0.8 }); } - // Central glow — the "supermassive black hole" accretion disk - for i in 0..30 { - let angle = (i as f32 / 30.0) * TAU; - let r = 1.5; - engine.spawn_glyph(Glyph { + // The core: a hot ring of glyphs close in. + for i in 0..NUM_CORE { + let angle = (i as f32 / NUM_CORE as f32) * TAU; + let r = 0.4 + (i % 3) as f32 * 0.35; + let id = engine.spawn_glyph(Glyph { character: '#', - position: Vec3::new(r * angle.cos(), r * angle.sin(), 0.1), - color: Vec4::new(1.0, 0.8, 0.4, 0.6), - emission: 4.0, - glow_color: Vec3::new(1.0, 0.6, 0.2), - glow_radius: 5.0, - mass: 0.01, + position: Vec3::ZERO, + color: Vec4::new(0.9, 0.7, 0.45, 0.7), + emission: 0.9, + glow_color: Vec3::new(1.0, 0.6, 0.25), + glow_radius: 0.8, layer: RenderLayer::Entity, blend_mode: BlendMode::Additive, - life_function: Some(MathFunction::Orbit { - center: Vec3::ZERO, - radius: r, - speed: 0.3, - eccentricity: 0.0, - }), ..Default::default() }); + orbits.push(Orbit { id, radius: r, angle0: angle, z: 0.1, drift: 0.0 }); } - engine.run(|engine, dt| { - // Slowly increase camera distance for dramatic reveal - // (camera auto-controlled by engine based on scene bounds) + let mut time = 0.0_f32; + let mut view = 0.0_f32; + + engine.run(move |engine, dt| { + if engine.input.just_pressed(Key::Escape) { + engine.input.quit_requested = true; + } + time += dt; + view += dt * 0.04; + + for (k, o) in orbits.iter().enumerate() { + // Mild differential rotation: the core laps the rim slowly + // enough that the arms stay arms for minutes. + let omega = OMEGA_OUTER + OMEGA_INNER / (1.0 + 0.25 * o.radius); + let a = o.angle0 + omega * time; + let mut p = Vec3::new(o.radius * a.cos(), o.radius * a.sin(), o.z); + if o.drift > 0.0 { + let s = k as f32 * 7.31; + p.x += fbm(time * 0.15 + s, 0.0, 2, 0.5, 2.0) * o.drift; + p.y += fbm(0.0, time * 0.15 + s, 2, 0.5, 2.0) * o.drift; + } + // Tilt the disk plane about the x axis. + let (st, ct) = TILT.sin_cos(); + let p = Vec3::new(p.x, p.y * ct - p.z * st, p.y * st + p.z * ct); + if let Some(g) = engine.scene.glyphs.get_mut(o.id) { + g.position = p; + } + } + + // Circle the galaxy slowly at a fixed distance. + let d = 33.0; + let cam = Vec3::new(view.sin() * d * 0.25, -view.cos() * d * 0.1, d); + engine.camera.set_position_instant(cam); + engine.camera.target.x.position = 0.0; + engine.camera.target.y.position = 0.0; + engine.camera.target.z.position = 0.0; }); } diff --git a/examples/math_rain.rs b/examples/math_rain.rs index 27ac081..813b15d 100644 --- a/examples/math_rain.rs +++ b/examples/math_rain.rs @@ -1,147 +1,137 @@ -//! math_rain — mathematical symbols cascading like digital rain. +//! math_rain: columns of mathematical symbols falling like digital rain. //! -//! Thousands of equations, symbols, and numbers fall in columns, -//! each character driven by its own math function. Some columns -//! accelerate (exponential), some oscillate (sine), some are chaotic -//! (logistic map). Color shifts from green to gold to white at the -//! leading edge. Heavy bloom makes it glow. +//! A hundred columns fall at speeds set by `|sin(0.13 c)|`, so neighbouring +//! columns drift in and out of step. Each column's symbols are re-chosen by +//! its own logistic map, `x -> r x (1 - x)` with `r` near 3.9, so which +//! symbols flicker is chaotic rather than random. The leading glyphs are +//! white-green and bright enough to bloom; the tail fades to dark green. //! -//! Think Matrix, but the math is real. +//! Positions come from the column equations every frame, so the rain runs +//! steadily for as long as the window is open. //! -//! Run: `cargo run --example math_rain` +//! Esc quits. +//! +//! Run: `cargo run --release --example math_rain` +use proof_engine::glyph::GlyphId; use proof_engine::prelude::*; -use std::f32::consts::TAU; const COLUMNS: usize = 100; const CHARS_PER_COL: usize = 25; const COL_SPACING: f32 = 0.8; const ROW_SPACING: f32 = 0.7; +/// Height of the band a column falls through before it wraps to the top. +const SPAN: f32 = 72.0; +const TOP: f32 = 30.0; + +struct Column { + x: f32, + speed: f32, + phase: f32, + /// Logistic map state and parameter for this column. + lx: f32, + r: f32, + glyphs: Vec, +} fn main() { env_logger::init(); let mut engine = ProofEngine::new(EngineConfig { - window_title: "Proof Engine — Mathematical Rain".to_string(), + window_title: "Proof Engine: mathematical rain".to_string(), window_width: 1400, window_height: 900, render: proof_engine::config::RenderConfig { bloom_enabled: true, - bloom_intensity: 2.5, - film_grain: 0.03, + bloom_intensity: 1.2, ..Default::default() }, ..Default::default() }); - // ASCII + block elements that Consolas definitely renders - let symbols: Vec = "0123456789ABCDEFabcdef+-*/=<>()[]{}|&^~%#@!?.,:;xXoO░▒▓█".chars().collect(); + let symbols: Vec = "0123456789ABCDEFabcdef+-*/=<>()[]{}|&^~%#@!?:;xXoO".chars().collect(); - // Each column is a stream of falling characters + let mut columns: Vec = Vec::with_capacity(COLUMNS); for col in 0..COLUMNS { - let x = (col as f32 - COLUMNS as f32 / 2.0) * COL_SPACING; - let col_speed = 0.3 + (col as f32 * 0.13).sin().abs() * 0.7; // varying speeds - let col_phase = (col as f32 * 0.37).fract() * 20.0; // staggered start + let x = (col as f32 - COLUMNS as f32 / 2.0 + 0.5) * COL_SPACING; + let speed = 3.0 + (col as f32 * 0.13).sin().abs() * 6.0; + let phase = (col as f32 * 0.618_034).fract() * SPAN; + let mut glyphs = Vec::with_capacity(CHARS_PER_COL); for row in 0..CHARS_PER_COL { - let y = (CHARS_PER_COL as f32 / 2.0 - row as f32) * ROW_SPACING + col_phase; - let char_idx = (col * 31 + row * 7) % symbols.len(); - - // Leading characters (bottom of column) are brightest + // Row 0 is the leading edge, at the bottom of the column. let depth = row as f32 / CHARS_PER_COL as f32; - let is_leader = row < 3; - - let (color, emission) = if is_leader { - // White-green bright leader - (Vec4::new(0.8, 1.0, 0.9, 1.0), 3.0) - } else if depth < 0.3 { - // Bright green - (Vec4::new(0.1, 0.9, 0.3, 0.9), 1.5) - } else if depth < 0.7 { - // Medium green - (Vec4::new(0.05, 0.5, 0.15, 0.6), 0.8) + let (color, emission) = if row == 0 { + (Vec4::new(0.85, 1.0, 0.9, 1.0), 1.8) + } else if row < 3 { + (Vec4::new(0.4, 1.0, 0.6, 1.0), 1.1) } else { - // Dim, fading tail - (Vec4::new(0.02, 0.2, 0.08, 0.3), 0.3) + // Exponential fade along the tail. + let f = (-depth * 2.4).exp(); + (Vec4::new(0.05 * f, 0.85 * f, 0.3 * f, 0.35 + 0.6 * f), 0.2 + 0.6 * f) }; - - // Each character has its own mathematical behavior - let behavior = match (col + row) % 8 { - 0 => MathFunction::Linear { slope: -col_speed, offset: y }, - 1 => MathFunction::Sine { - amplitude: 0.3, - frequency: 0.2 + depth * 0.3, - phase: col as f32 * 0.5, - }, - 2 => MathFunction::Exponential { - start: y, - rate: -col_speed * 0.3, - target: -15.0, - }, - 3 => MathFunction::LogisticMap { - r: 3.5 + depth * 0.4, - x0: (col as f32 * 0.17).fract(), - }, - 4 => MathFunction::Collatz { - seed: (col * CHARS_PER_COL + row) as u64 + 1, - scale: 0.05, - }, - 5 => MathFunction::Perlin { - frequency: 0.3, - octaves: 2, - amplitude: 0.4, - }, - 6 => MathFunction::Breathing { - rate: 0.3 + col_speed * 0.2, - depth: 0.1, - }, - _ => MathFunction::Linear { slope: -col_speed * 1.5, offset: y }, - }; - - engine.spawn_glyph(Glyph { - character: symbols[char_idx], - position: Vec3::new(x, y, -depth * 0.5), + let id = engine.spawn_glyph(Glyph { + character: symbols[(col * 31 + row * 7) % symbols.len()], + position: Vec3::new(x, 0.0, -depth * 0.5), color, emission, glow_color: Vec3::new(0.1, 0.8, 0.3), - glow_radius: if is_leader { 2.0 } else { 0.5 }, - mass: 0.01, - layer: if is_leader { RenderLayer::Entity } else { RenderLayer::World }, + glow_radius: if row == 0 { 1.2 } else { 0.3 }, + layer: if row == 0 { RenderLayer::Entity } else { RenderLayer::World }, blend_mode: BlendMode::Additive, - life_function: Some(behavior), ..Default::default() }); + glyphs.push(id); } - } - // Occasional bright flashes — "equation solved" moments - for i in 0..20 { - let x = ((i as f32 * 3.7).sin()) * (COLUMNS as f32 * COL_SPACING * 0.4); - let y = ((i as f32 * 2.3).cos()) * 8.0; - engine.spawn_glyph(Glyph { - character: '=', - position: Vec3::new(x, y, 0.5), - color: Vec4::new(1.0, 1.0, 1.0, 0.0), // starts invisible - emission: 5.0, - glow_color: Vec3::new(0.5, 1.0, 0.7), - glow_radius: 4.0, - mass: 0.0, - layer: RenderLayer::Overlay, - blend_mode: BlendMode::Additive, - life_function: Some(MathFunction::Breathing { - rate: 0.05 + (i as f32 * 0.02), - depth: 0.8, - }), - ..Default::default() + columns.push(Column { + x, + speed, + phase, + lx: 0.1 + (col as f32 * 0.17).fract() * 0.8, + r: 3.82 + (col as f32 * 0.29).fract() * 0.17, + glyphs, }); } - // Downward flow field - engine.add_field(ForceField::Flow { - direction: Vec3::new(0.0, -1.0, 0.0), - strength: 0.3, - turbulence: 0.05, - }); + // Fit all hundred columns across the window. + let half_w = COLUMNS as f32 * COL_SPACING * 0.5; + let half_h = half_w * 900.0 / 1400.0; + let dist = half_h / (30.0_f32).to_radians().tan(); + engine.camera.set_position_instant(Vec3::new(0.0, 0.0, dist)); + + let mut time = 0.0_f32; + let mut flicker = 0.0_f32; + + engine.run(move |engine, dt| { + if engine.input.just_pressed(Key::Escape) { + engine.input.quit_requested = true; + } + time += dt; + flicker += dt; + // Step every column's logistic map about twenty times a second. + let step_maps = flicker > 0.05; + if step_maps { + flicker = 0.0; + } - engine.run(|_engine, _dt| {}); + for c in columns.iter_mut() { + let head = TOP - (c.speed * time + c.phase).rem_euclid(SPAN); + if step_maps { + c.lx = c.r * c.lx * (1.0 - c.lx); + } + // The map's value picks one row to change and what it becomes. + let row_to_change = (c.lx * CHARS_PER_COL as f32) as usize % CHARS_PER_COL; + for (row, id) in c.glyphs.iter().enumerate() { + if let Some(g) = engine.scene.glyphs.get_mut(*id) { + g.position.x = c.x; + g.position.y = head + row as f32 * ROW_SPACING; + if step_maps && (row == row_to_change || row == 0) { + let k = ((c.lx * 9973.0) as usize + row * 7) % symbols.len(); + g.character = symbols[k]; + } + } + } + } + }); } diff --git a/examples/quickstart.rs b/examples/quickstart.rs new file mode 100644 index 0000000..14b6516 --- /dev/null +++ b/examples/quickstart.rs @@ -0,0 +1,36 @@ +//! quickstart: the program from the README, step 2. +//! +//! 5,000 points start packed on one short line and follow the Lorenz +//! equations. At first they move as one bright streak; within about ten +//! seconds chaos has torn them apart and they trace out the butterfly. +//! +//! Run: `cargo run --release --example quickstart` + +use proof_engine::math::attractors::rk4_step; +use proof_engine::prelude::*; +use proof_engine::render::ui_layer::UiParticle; + +fn main() { + let mut engine = ProofEngine::new(EngineConfig::default()); + + // 5,000 points in a line 2 units long, each 0.0004 from the next. + let mut points: Vec = (0..5000) + .map(|i| Vec3::new(1.0 + i as f32 * 4e-4, 1.0, 1.0)) + .collect(); + + engine.run_ui(move |engine, dt| { + // Advance every point along the Lorenz equations. + for p in points.iter_mut() { + *p = rk4_step(AttractorType::Lorenz, *p, dt); + } + // Draw them: x across, z up, centred in the window. + let (w, h) = engine.render_size(); + let (cx, cy, s) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 60.0); + let color = Vec4::new(0.5, 1.2, 1.6, 1.0); + let dots = points + .iter() + .map(|p| UiParticle::new(cx + p.x * s, cy - (p.z - 25.0) * s, 3.0, 3.0, '●', color)) + .collect(); + engine.ui.draw_particles(dots); + }); +} diff --git a/examples/strange_attractors.rs b/examples/strange_attractors.rs index 353925e..65ae757 100644 --- a/examples/strange_attractors.rs +++ b/examples/strange_attractors.rs @@ -1,76 +1,171 @@ -//! strange_attractors — visual attractor gallery with bloom and color grading. +//! strange_attractors: seven chaotic systems side by side. //! -//! Renders all 7 strange attractors side by side as flowing particle trails. -//! Particles follow actual differential equations in real time. -//! Demonstrates: StrangeAttractor force fields, bloom post-processing, motion blur. +//! Lorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas and Dadras, each with +//! 1,500 points advanced every frame by the engine's own RK4 integrator, +//! `proof_engine::math::attractors::rk4_step`. The points start spread +//! along one long trajectory, so the shape is there from the first frame. +//! Each panel turns slowly so the shape reads in 3D; colour is speed along +//! the flow, from the panel's own hue at rest to white where it is fastest. //! -//! Run: `cargo run --example strange_attractors` +//! Every panel is fitted to the attractor's real extent, measured at start +//! up by integrating a long trajectory, so no system flies off screen. +//! +//! Controls: Space pauses, Esc quits. +//! +//! Run: `cargo run --release --example strange_attractors` +use proof_engine::math::attractors::{derivatives, initial_state, rk4_step}; use proof_engine::prelude::*; -use proof_engine::math::attractors::AttractorType; +use proof_engine::render::ui_layer::UiParticle; const ATTRACTORS: &[(AttractorType, &str, (f32, f32, f32))] = &[ - (AttractorType::Lorenz, "Lorenz", (0.0, 1.0, 0.8)), - (AttractorType::Rossler, "Rossler", (0.8, 0.3, 1.0)), - (AttractorType::Chen, "Chen", (1.0, 0.5, 0.1)), - (AttractorType::Halvorsen, "Halvorsen", (0.2, 0.8, 0.4)), - (AttractorType::Aizawa, "Aizawa", (0.9, 0.7, 0.2)), - (AttractorType::Thomas, "Thomas", (0.3, 0.9, 0.9)), - (AttractorType::Dadras, "Dadras", (1.0, 0.2, 0.6)), + (AttractorType::Lorenz, "Lorenz", (0.10, 0.85, 0.80)), + (AttractorType::Rossler, "Rossler", (0.70, 0.35, 1.00)), + (AttractorType::Chen, "Chen", (1.00, 0.50, 0.12)), + (AttractorType::Halvorsen, "Halvorsen", (0.25, 0.85, 0.40)), + (AttractorType::Aizawa, "Aizawa", (0.95, 0.75, 0.25)), + (AttractorType::Thomas, "Thomas", (0.30, 0.75, 1.00)), + (AttractorType::Dadras, "Dadras", (1.00, 0.30, 0.55)), ]; +const POINTS: usize = 1500; +/// Integration steps per frame, each of the attractor's recommended size. +const STEPS_PER_FRAME: usize = 2; +/// Radians per second each panel turns. +const SPIN: f32 = 0.18; +/// How far the view looks down onto each attractor. +const TILT: f32 = 0.45; + +struct Panel { + kind: AttractorType, + name: &'static str, + hue: Vec3, + points: Vec, + center: Vec3, + radius: f32, + /// Speed that maps to full white. + fast: f32, + h: f32, +} + fn main() { env_logger::init(); let mut engine = ProofEngine::new(EngineConfig { - window_title: "Proof Engine — Strange Attractors".to_string(), + window_title: "Proof Engine: strange attractors".to_string(), window_width: 1400, window_height: 800, render: proof_engine::config::RenderConfig { bloom_enabled: true, - bloom_intensity: 1.5, - motion_blur_enabled: true, - chromatic_aberration: 0.003, + bloom_intensity: 0.9, + bloom_threshold: 0.4, + persistence: 0.5, + vignette: 0.3, + global_illumination: false, + volumetric_fog: false, ..Default::default() }, ..Default::default() }); - let spacing = 18.0f32; + let mut panels: Vec = ATTRACTORS + .iter() + .map(|&(kind, name, (r, g, b))| { + let h = kind.recommended_dt(); + // Settle onto the attractor, then measure it. + let mut s = initial_state(kind); + for _ in 0..6000 { + s = rk4_step(kind, s, h); + } + let mut samples = Vec::with_capacity(20_000); + let mut t = s; + for _ in 0..20_000 { + t = rk4_step(kind, t, h); + samples.push(t); + } + let center = samples.iter().fold(Vec3::ZERO, |a, p| a + *p) / samples.len() as f32; + let radius = samples.iter().map(|p| (*p - center).length()).fold(0.0, f32::max); + let mut speeds: Vec = samples.iter().map(|p| derivatives(kind, *p).length()).collect(); + speeds.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let fast = speeds[speeds.len() * 95 / 100].max(1e-3); + // Spread the points along the measured trajectory, a hair apart, + // so the whole attractor is visible from the first frame. + let stride = samples.len() / POINTS; + let points = (0..POINTS) + .map(|i| samples[i * stride] + Vec3::splat(hash(i as f32) * radius * 1e-4)) + .collect(); + Panel { kind, name, hue: Vec3::new(r, g, b), points, center, radius, fast, h } + }) + .collect(); + + let mut paused = false; + let mut yaw = 0.0_f32; + let mut sim_frames: u64 = 0; - // One attractor per panel, each with 200 particles - for (idx, (attractor_type, _name, (r, g, b))) in ATTRACTORS.iter().enumerate() { - let cx = (idx as f32 - 3.0) * spacing; + engine.run_ui(move |engine, dt| { + if engine.input.just_pressed(Key::Escape) { + engine.input.quit_requested = true; + } + if engine.input.just_pressed(Key::Space) { + paused = !paused; + } + if !paused { + for p in panels.iter_mut() { + for s in p.points.iter_mut() { + for _ in 0..STEPS_PER_FRAME { + *s = rk4_step(p.kind, *s, p.h); + } + } + } + yaw += dt * SPIN; + sim_frames += 1; + } - // Force field for this attractor - engine.add_field(ForceField::StrangeAttractor { - attractor_type: *attractor_type, - scale: 0.3, - strength: 0.5, - center: Vec3::new(cx, 0.0, 0.0), - }); + let (w, h) = engine.render_size(); + let (w, h) = (w as f32, h as f32); + let top = h * 0.09; + let row_h = (h - top) / 2.0; + let (sy, cy) = yaw.sin_cos(); + let (st, ct) = TILT.sin_cos(); + let size = (h / 300.0).max(1.5); + let label = Vec4::new(0.86, 0.88, 0.92, 0.8); - // 200 particles seeded in the attractor's basin of attraction - for i in 0..200usize { - let seed_x = (i as f32 * 0.37).sin() * 0.5 + cx; - let seed_y = (i as f32 * 0.23).cos() * 0.5; - let brightness = 0.5 + (i as f32 / 200.0) * 0.5; - engine.spawn_glyph(Glyph { - character: '·', - position: Vec3::new(seed_x, seed_y, 0.0), - color: Vec4::new(r * brightness, g * brightness, b * brightness, 0.9), - emission: brightness * 1.5, - glow_color: Vec3::new(*r, *g, *b), - glow_radius: 0.5, - mass: 0.1, - layer: RenderLayer::Particle, - blend_mode: BlendMode::Additive, - ..Default::default() - }); + for (i, p) in panels.iter().enumerate() { + // Four panels on the top row, three centred below. + let (col, row, per_row) = if i < 4 { (i, 0, 4) } else { (i - 4, 1, 3) }; + let cell_w = w / 4.0; + let x0 = (w - cell_w * per_row as f32) * 0.5 + cell_w * col as f32; + let pcx = x0 + cell_w * 0.5; + let pcy = top + row_h * (row as f32 + 0.5); + let scale = (cell_w.min(row_h) * 0.44) / p.radius; + + let particles: Vec = p + .points + .iter() + .map(|s| { + let d = *s - p.center; + // Turn about the attractor's z axis, then tilt toward the viewer. + let x = d.x * cy - d.y * sy; + let depth = d.x * sy + d.y * cy; + let v = d.z * ct + depth * st; + let speed = (derivatives(p.kind, *s).length() / p.fast).clamp(0.0, 1.0); + let c = p.hue.lerp(Vec3::new(1.2, 1.15, 1.05), speed * speed); + let mut q = UiParticle::new(pcx + x * scale, pcy - v * scale, size, size, '●', c.extend(0.8)); + q.emission = 0.5 + 0.8 * speed; + q + }) + .collect(); + engine.ui.draw_particles(particles); + engine.ui.draw_text(x0 + 16.0, pcy - row_h * 0.5 + 8.0, p.name, 1.0, label); } - } - engine.run(|_engine, _dt| { - // Attractor forces drive everything — no per-frame logic needed + engine.ui.draw_text(20.0, 16.0, "Seven strange attractors RK4, 1,500 points each colour = speed along the flow", 1.0, label); + let status = format!("step {}{} [Space] pause [Esc] quit", sim_frames * STEPS_PER_FRAME as u64, if paused { " (paused)" } else { "" }); + engine.ui.draw_text(20.0, 40.0, &status, 1.0, Vec4::new(0.86, 0.88, 0.92, 0.5)); }); } + +/// Cheap deterministic hash in [-0.5, 0.5). +fn hash(x: f32) -> f32 { + ((x * 12.9898).sin() * 43_758.547).fract() - 0.5 +} diff --git a/src/lib.rs b/src/lib.rs index a52e02d..c3018f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,31 +2,76 @@ //! # Proof Engine //! -//! A mathematical rendering engine for Rust. -//! Every visual is the output of a mathematical function. -//! Every animation is a continuous function over time. -//! Every particle follows a real equation. +//! A Rust engine for real-time graphics where every point, glyph and +//! particle on screen is moved by a real equation: an ODE, a force field, a +//! spring, a noise function. //! -//! ## Philosophy +//! Seven strange attractors, each 1,500 points integrated with RK4 //! -//! Proof Engine does not render graphics. It renders mathematics. +//! ## The math runs without a window //! -//! A traditional renderer draws shapes and colors that represent game state. -//! Proof Engine computes mathematical functions and the visual IS the output. -//! A Lorenz attractor looks like a Lorenz attractor because particles are -//! following the actual differential equations in real time. +//! The integrators, attractors, fields and [`MathFunction`]s are plain Rust +//! and can be used on their own. This example runs as a doc test: //! -//! ## Quick Start +//! ```rust +//! use proof_engine::math::attractors::{rk4_step, AttractorType}; +//! use proof_engine::prelude::Vec3; +//! +//! // Two Lorenz states that start 0.0001 apart... +//! let mut a = Vec3::new(1.0, 1.0, 1.0); +//! let mut b = Vec3::new(1.0001, 1.0, 1.0); +//! for _ in 0..6000 { +//! a = rk4_step(AttractorType::Lorenz, a, 0.005); +//! b = rk4_step(AttractorType::Lorenz, b, 0.005); +//! } +//! // ...are far apart after 30 simulated seconds: that is chaos. +//! assert!((a - b).length() > 1.0); +//! ``` +//! +//! ## Open a window and draw +//! +//! The `quickstart` example, in full. It needs a GPU with OpenGL 3.3, so it +//! is compiled here but not run: //! //! ```rust,no_run +//! use proof_engine::math::attractors::rk4_step; //! use proof_engine::prelude::*; +//! use proof_engine::render::ui_layer::UiParticle; +//! +//! let mut engine = ProofEngine::new(EngineConfig::default()); +//! let mut points: Vec = (0..5000) +//! .map(|i| Vec3::new(1.0 + i as f32 * 4e-4, 1.0, 1.0)) +//! .collect(); //! -//! let config = EngineConfig::default(); -//! let mut engine = ProofEngine::new(config); -//! engine.run(|engine, _dt| { -//! // game logic +//! engine.run_ui(move |engine, dt| { +//! for p in points.iter_mut() { +//! *p = rk4_step(AttractorType::Lorenz, *p, dt); +//! } +//! let (w, h) = engine.render_size(); +//! let (cx, cy, s) = (w as f32 / 2.0, h as f32 / 2.0, h as f32 / 60.0); +//! let color = Vec4::new(0.5, 1.2, 1.6, 1.0); +//! let dots = points +//! .iter() +//! .map(|p| UiParticle::new(cx + p.x * s, cy - (p.z - 25.0) * s, 3.0, 3.0, '●', color)) +//! .collect(); +//! engine.ui.draw_particles(dots); //! }); //! ``` +//! +//! ## Where to look +//! +//! - [`ProofEngine`]: the window and main loop. [`ProofEngine::run`] for 3D +//! glyph scenes, [`ProofEngine::run_ui`] for screen-space games and plots. +//! - [`EngineConfig`] and [`config::RenderConfig`]: window size, bloom, +//! tonemap, grain and every other post-processing setting. +//! - [`Glyph`] and [`ProofEngine::spawn_glyph`]: the basic on-screen object. +//! - [`ForceField`], [`MathFunction`] and [`math::attractors`]: the math. +//! - [`render::ui_layer::UiParticle`]: fast screen-space points, as above. +//! - [`prelude`]: the common imports in one line. +//! +//! Every example in the repository can save its own frames to disk with the +//! `PROOF_SHOT` environment variables; see the +//! [README](https://github.com/Mattbusel/proof-engine#capture-frames-from-any-program). pub mod math; pub mod glyph; @@ -260,6 +305,11 @@ impl ProofEngine { } } + // Honour a quit asked for during the update, as `run_ui` does. + if self.input.quit_requested { + break; + } + // Swap if let Some(ref mut p) = self.pipeline { if !p.swap() { diff --git a/src/math/attractors.rs b/src/math/attractors.rs index 283bd28..0151f28 100644 --- a/src/math/attractors.rs +++ b/src/math/attractors.rs @@ -233,9 +233,13 @@ pub fn initial_state(attractor: AttractorType) -> Vec3 { AttractorType::Halvorsen => Vec3::new(0.1, 0.0, 0.0), AttractorType::Aizawa => Vec3::new(0.1, 0.0, 0.0), AttractorType::Thomas => Vec3::new(0.1, 0.0, 0.0), - AttractorType::Dadras => Vec3::new(0.1, 0.0, 0.0), + // (0.1, 0, 0) lies on the invariant line y = z = 0 and decays to the + // fixed point at the origin, so start from the usual seed instead. + AttractorType::Dadras => Vec3::new(1.1, 2.1, -2.0), AttractorType::Sprott => Vec3::new(1.0, 0.0, 0.5), - AttractorType::Rabinovich => Vec3::new(0.05, 0.05, 0.5), + // (0.05, 0.05, 0.5) escapes to infinity within 2,000 steps; (-1, 0, 0.5) + // is the standard seed for these parameters and stays bounded. + AttractorType::Rabinovich => Vec3::new(-1.0, 0.0, 0.5), AttractorType::Burke => Vec3::new(0.6, -0.4, 0.4), } } @@ -726,3 +730,20 @@ pub enum AttractorPalette { Neon, Greyscale, } + +#[cfg(test)] +mod initial_state_tests { + use super::*; + + /// Every seed must settle onto its attractor, not onto a fixed point or + /// off to infinity: the `strange_attractors` example and `AttractorSampler` + /// both start from `initial_state_warmed`. + #[test] + fn warmed_states_are_finite_and_off_the_origin() { + for &a in AttractorType::all() { + let s = initial_state_warmed(a); + assert!(s.is_finite(), "{} diverged: {s:?}", a.name()); + assert!(s.length() > 0.05, "{} collapsed to the origin: {s:?}", a.name()); + } + } +}