diff --git a/.github/social-preview.png b/.github/social-preview.png new file mode 100644 index 0000000..8754fdc Binary files /dev/null and b/.github/social-preview.png differ diff --git a/Cargo.toml b/Cargo.toml index 3c34341..e72c4a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ keywords = ["game-engine", "rendering", "mathematics", "ascii", "opengl"] categories = ["game-engines", "graphics", "rendering"] readme = "README.md" # Screenshots, GIFs and repo tooling stay on GitHub; the README links resolve there. -exclude = ["*.png", "*.gif", "assets/", "*.py", ".github/", "ci/"] +exclude = ["*.png", "*.gif", "*.jpg", "assets/", "docs/", "*.py", ".github/", "ci/"] [dependencies] # OpenGL / windowing — glutin-winit bridges the two and handles version alignment @@ -114,6 +114,9 @@ name = "apotheosis" [[example]] name = "sky" +[[example]] +name = "lorenz" + [[bin]] name = "proof_editor" path = "src/bin/proof_editor.rs" diff --git a/README.md b/README.md index 961bbbf..c114f35 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,123 @@ -# Proof Engine +

+ 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. +

-[![CI](https://github.com/Mattbusel/proof-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/Mattbusel/proof-engine/actions/workflows/ci.yml) -[![crates.io](https://img.shields.io/crates/v/proof-engine.svg)](https://crates.io/crates/proof-engine) -[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +

+ Site  ·  + crates.io  ·  + docs.rs  ·  + Demos  ·  + CI +

-**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 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. 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. -![Two particle-built figures in the convergence demo](Screenshot%202026-03-25%20231046.png) - -![Convergence demo, animated](ezgif.com-video-to-gif-converter%20%284%29.gif) - -![Supernova demo](assets/supernova-demo.gif) - -## What it does - -- **OpenGL 3.3 renderer** (glutin, winit, glow) with instanced glyph rendering, bloom, chromatic aberration, film grain, vignette, scanlines and motion blur. -- **Math functions as animation**: Lorenz, Rossler, Chen, Halvorsen, Aizawa and Thomas attractors; sine, Perlin noise, logistic map, Collatz, golden spiral, Lissajous, Mandelbrot escape, spring-damper systems. Any glyph can have a `life_function` that drives its position or color. -- **Composable force fields**: gravity, vortex, electromagnetic, strange attractor, shockwave, tidal, flow, magnetic dipole, entropy and damping, with linear, inverse-square, exponential or Gaussian falloff. -- **Particle-built entities** held together by force cohesion, with HP-linked binding strength. -- **Physics**: 2D rigid bodies with SAT collision, mass-spring soft bodies, Eulerian fluid, constraints and joints. -- **Audio**: 48 kHz synthesis (rodio, cpal), ADSR, FM, music-theory helpers (scales, chords, progressions), stereo panning and reverb. -- **Scripting**: a custom bytecode VM with lexer, parser and compiler, closures and tables. -- **Procedural generation**: tectonics, erosion, climate, biomes, rivers, caves, settlements, history, language and quest generation, plus ecology models (Lotka-Volterra, SIR). -- **Proof Editor**: an egui scene editor for placing glyphs, force fields and entities, with an inspector, hierarchy, post-FX presets, undo/redo and JSON scenes. - -The source tree also contains modules for more advanced lighting (a sparse voxel octree GI cone tracer, Nishita sky scattering, tiled and deferred lighting, volumetric fog, a wgpu backend). The Nishita sky model drives the `sky` example; the others exist as code but are not yet connected to any demo. - -## The screen pipeline +## Quick start -What actually runs on the GPU every frame, in order: +You need stable Rust and a GPU with OpenGL 3.3 or newer. -```text -scene FBO (RGBA16F x2: colour, emission) at render_scale - 3D glyph pass ─┐ - UI world pass ─┤ (UiPass::World: particle clouds, filled rects, panel fills) - ├─ emission ─► bloom pyramid: soft-knee threshold, blur down, tent up - └─ colour ────► composite ─► [FXAA] ─► screen ─► UI HUD pass - (UiPass::Hud: text, borders, bars, sprites) +```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 ``` -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. +- **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). -**Two UI passes.** `engine.ui` routes every command to a pass. Particle -clouds and filled rectangles default to the world pass, which is painted into -the HDR buffer before post-processing; text, outlines, bars and sprites -default to the HUD pass, painted sharp on top afterwards. A panel splits: fill -to the world, border to the HUD. `ui.begin_world()`, `ui.begin_hud()` and -`ui.end_pass()` override the default for a run of commands. A game that draws -its whole picture as screen-space matter gets bloom, grade, lens and grain on -all of it, and a readable interface over that. +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. -**What the composite does, in order:** shockwave refraction, heat haze, barrel -lens, chromatic aberration, unsharp mask, floor reflection, exposure, -screen-space indirect light (matter near a lit thing is lit by it), bloom, -halation, light shafts, lens flare, flash, ACES tonemap, lift/gain grade, -tint, contrast, saturation, vignette, shadow-weighted grain, ordered dither, -scanlines. Every standing parameter is a field on `RenderConfig`; the moments -are on `engine.fx`. +## The sky is an integral -**`engine.fx` (ScreenFx).** Fire-and-forget effects that decay on their own: -`shockwave(x, y, strength)`, `flash(color, strength)`, -`light_shaft_at(x, y, strength)` or `auto_shafts = true` to stream from -whatever is brightest on screen, `reflect_at(y, strength, fade)` for a glossy -floor, and `haze` for heat shimmer. Coordinates are UI pixels. +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. -**Lights and shadows.** `engine.fx.light(x, y, radius, color, intensity)` -and `engine.fx.ambient`. The glyph pass writes an occluder buffer (matter, -never floors or panel fills); the light pass marches shadows through it -from every light and the composite multiplies the scene by the result. -Emissive matter lights itself. `config.persistence` keeps a decaying copy -of last frame's scene under this one, for motion trails. +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. -**GPU density entities.** `engine.init_gpu_density(n)` and -`engine.queue_gpu_density_entity(data)`: sixteen bones become millions of -particles derived in the vertex shader from the instance index, with -breathing, jitter and matter that comes loose as `hp` falls. Nothing per -particle ever leaves the GPU. See `examples/colossus.rs`. +## Forty thousand points, one equation -**Sound.** A `MathAudioSource` now carries a pitch envelope, a second -partial, a noise mix, biquad or comb filters, drive, a reverb send, and a -start delay, and the output thread honours all of it, with separate music -and effects buses, ducking, a master reverb and a soft limiter. A blow is a -crack, a thud and a ring; before, it was a sine. +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. -**Also:** `render_scale` renders the scene at a fraction of the window and -upsamples; `fxaa` runs a real FXAA 3.11 pass between the composite and the -HUD; `shake_pixels` moves the world pass with camera trauma while the HUD -stays put; `vsync` waits for the display. +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. -## Quick start +```rust +use proof_engine::math::attractors::rk4_step; -You need a Rust toolchain (stable) and a GPU with OpenGL 3.3 or newer. +for p in points.iter_mut() { + for _ in 0..SUBSTEPS { + *p = rk4_step(AttractorType::Lorenz, *p, h); + } +} +``` -- **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). +## What it does -```bash -git clone https://github.com/Mattbusel/proof-engine.git -cd proof-engine -cargo run --release --example convergence # the demo in the screenshots -``` +| Area | What is there | +| --- | --- | +| **Math functions as animation** | Lorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas and Dadras attractors; sine, Perlin noise, logistic map, Collatz, golden spiral, Lissajous, Mandelbrot escape, spring-damper systems. Any glyph can have a `life_function` that drives its position or color. | +| **Composable force fields** | Gravity, vortex, electromagnetic, strange attractor, shockwave, tidal, flow, magnetic dipole, entropy and damping, with linear, inverse-square, exponential or Gaussian falloff. | +| **Particle-built entities** | Held together by force cohesion, with HP-linked binding strength. | +| **OpenGL 3.3 HDR renderer** | glutin, winit and glow; instanced glyph rendering, half-float scene buffers, bloom, ACES, chromatic aberration, film grain, vignette, scanlines and motion blur. | +| **Physics** | 2D rigid bodies with SAT collision, mass-spring soft bodies, Eulerian fluid, constraints and joints. | +| **Audio** | 48 kHz synthesis (rodio, cpal), ADSR, FM, music-theory helpers (scales, chords, progressions), stereo panning and reverb. | +| **Scripting** | A custom bytecode VM with lexer, parser and compiler, closures and tables. | +| **Procedural generation** | Tectonics, erosion, climate, biomes, rivers, caves, settlements, history, language and quest generation, plus ecology models (Lotka-Volterra, SIR). | +| **Proof Editor** | An egui scene editor for placing glyphs, force fields and entities, with an inspector, hierarchy, post-FX presets, undo/redo and JSON scenes. | -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 source tree also contains modules for more advanced lighting (a sparse voxel octree GI cone tracer, Nishita sky scattering, tiled and deferred lighting, volumetric fog, a wgpu backend). The Nishita sky model drives the `sky` example; the others exist as code but are not yet connected to any demo. ## Demos 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. + | Example | What you see | | --- | --- | -| `convergence` | Two particle-built fighters in a circular arena with an orbiting camera; combat loops forever and hits knock matter loose. The screenshots at the top of this page. | -| `supernova` | A star pulses, collapses under a gravity field, explodes into debris and settles into a Lorenz-attractor nebula. The GIF at the top. | -| `galaxy` | 3000+ glyphs on golden-ratio spiral arms around a central black hole, with nebula clouds on Perlin noise. | | `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. | -| `math_rain` | Digital rain where each column follows a different function: linear, sine, logistic map, Collatz, Perlin. | -| `strange_attractors` | Seven attractors (Lorenz, Rossler, Chen, Halvorsen, Aizawa, Thomas, Dadras) side by side as particle trails. | +| `lorenz` | 40,000 points on the Lorenz attractor, integrated with RK4 and coloured by speed. Space pauses, Left/Right turn the view, Esc quits. | +| `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. | | `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`. +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. + +
+Animated captures (older GIFs, about 4 MB each) + +![Convergence demo, animated](assets/convergence-demo.gif) + +![Supernova demo](assets/supernova-demo.gif) + +
+ +## Capture frames from any program + +Any program built on the engine, every example included, can write its own frames to disk without a line of code changing. Frames are read back off the GPU after post-processing, so what lands on disk is exactly what the pipeline drew. The images on this page were made this way. -![Convergence, close up](assets/convergence-close.png) +```bash +PROOF_HIDDEN=1 PROOF_FIXED_DT=60 PROOF_SHOT='frames/f_{n}.bmp' \ +PROOF_SHOT_AT=560 PROOF_SHOT_EVERY=2 PROOF_SHOT_COUNT=240 \ +cargo run --release --example sky +``` -Benchmarks: `cargo bench` (Criterion: `particle_bench`, `glyph_bench`). +| Variable | Meaning | +| --- | --- | +| `PROOF_SHOT` | Output path. `{n}` becomes the capture index, zero padded to four digits. Files are 24-bit BMP. | +| `PROOF_SHOT_AT` | Frame of the first capture (default 120). | +| `PROOF_SHOT_COUNT` / `PROOF_SHOT_EVERY` | How many captures, and how many frames apart (defaults 1 and 1). | +| `PROOF_SHOT_KEEP` | `1` keeps running after the last capture instead of exiting. | +| `PROOF_FIXED_DT` | Step the simulation at this many frames per simulated second, so a sequence plays back at true speed however long each frame took. | +| `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 +## Use as a library ```toml [dependencies] @@ -164,6 +150,64 @@ fn main() { } ``` +## 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 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. + +
+Pipeline reference: UI passes, the composite, engine.fx, lights, GPU density, sound + +**Two UI passes.** `engine.ui` routes every command to a pass. Particle +clouds and filled rectangles default to the world pass, which is painted into +the HDR buffer before post-processing; text, outlines, bars and sprites +default to the HUD pass, painted sharp on top afterwards. A panel splits: fill +to the world, border to the HUD. `ui.begin_world()`, `ui.begin_hud()` and +`ui.end_pass()` override the default for a run of commands. A game that draws +its whole picture as screen-space matter gets bloom, grade, lens and grain on +all of it, and a readable interface over that. + +**What the composite does, in order:** shockwave refraction, heat haze, barrel +lens, chromatic aberration, unsharp mask, floor reflection, exposure, +screen-space indirect light (matter near a lit thing is lit by it), bloom, +halation, light shafts, lens flare, flash, ACES tonemap, lift/gain grade, +tint, contrast, saturation, vignette, shadow-weighted grain, ordered dither, +scanlines. Every standing parameter is a field on `RenderConfig`; the moments +are on `engine.fx`. + +**`engine.fx` (ScreenFx).** Fire-and-forget effects that decay on their own: +`shockwave(x, y, strength)`, `flash(color, strength)`, +`light_shaft_at(x, y, strength)` or `auto_shafts = true` to stream from +whatever is brightest on screen, `reflect_at(y, strength, fade)` for a glossy +floor, and `haze` for heat shimmer. Coordinates are UI pixels. + +**Lights and shadows.** `engine.fx.light(x, y, radius, color, intensity)` +and `engine.fx.ambient`. The glyph pass writes an occluder buffer (matter, +never floors or panel fills); the light pass marches shadows through it +from every light and the composite multiplies the scene by the result. +Emissive matter lights itself. `config.persistence` keeps a decaying copy +of last frame's scene under this one, for motion trails. + +**GPU density entities.** `engine.init_gpu_density(n)` and +`engine.queue_gpu_density_entity(data)`: sixteen bones become millions of +particles derived in the vertex shader from the instance index, with +breathing, jitter and matter that comes loose as `hp` falls. Nothing per +particle ever leaves the GPU. See `examples/colossus.rs`. + +**Sound.** A `MathAudioSource` carries a pitch envelope, a second +partial, a noise mix, biquad or comb filters, drive, a reverb send, and a +start delay, and the output thread honours all of it, with separate music +and effects buses, ducking, a master reverb and a soft limiter. A blow is a +crack, a thud and a ring. + +**Also:** `render_scale` renders the scene at a fraction of the window and +upsamples; `fxaa` runs a real FXAA 3.11 pass between the composite and the +HUD; `shake_pixels` moves the world pass with camera trauma while the HUD +stays put; `vsync` waits for the display. + +
+ ## Proof Editor ![Proof Editor](assets/editor-screenshot.png) @@ -175,6 +219,9 @@ cd proof-engine/editor cargo run --release ``` +
+Editor keys + | Key | Action | | --- | --- | | Click viewport | Place with current tool | @@ -188,9 +235,14 @@ cargo run --release | Space | Screen shake | | F1 | Help | +
+ ## Architecture -Roughly 660,000 lines of Rust across the engine (`src/`), the editor (`editor/`) and the examples. The largest engine modules: +Roughly 660,000 lines of Rust across the engine (`src/`), the editor (`editor/`) and the examples. Benchmarks: `cargo bench` (Criterion: `particle_bench`, `glyph_bench`). + +
+The largest engine modules | Module | Contents | | --- | --- | @@ -206,23 +258,27 @@ Roughly 660,000 lines of Rust across the engine (`src/`), the editor (`editor/`) | `nishita_sky` | physical sky scattering, used by the `sky` example | | `svogi`, `volumetric_fog`, `tiled_lighting`, `wgpu_backend` | advanced lighting, not yet wired into the demos | -### The `apotheosis` example +
+ +
+The apotheosis example `examples/apotheosis.rs` is a standalone showcase of a particle-rendered character built on signed distance fields instead of meshes. A 26-bone capsule skeleton is blended with Inigo Quilez's polynomial smooth minimum, particles are importance-sampled onto the SDF shell, and normals, ambient occlusion and subsurface thickness are all computed from the SDF itself. On top of that it layers per-material shading (Schlick Fresnel, Kajiya-Kay hair and fabric specular, thin-film iridescence), a strand-based hair renderer, inertial lag for loose materials, and a post stack including TAA jitter, spectral bloom, god rays, depth of field bokeh and ACES tonemapping. It targets about 10.8 million GPU particles. +
+ ## Related [chaos-rpg](https://github.com/Mattbusel/chaos-rpg) is a roguelike whose graphical frontend runs on Proof Engine. `CHAOS_RPG_API_CONTRACT.md` documents what the engine has to support for it. ## Status -Early (0.1.x) 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`](ci/known-failing-tests.txt), and each one fixed is a line deleted from that file. Contributions: see [CONTRIBUTING.md](CONTRIBUTING.md). ## License MIT, see [LICENSE](LICENSE). - ## Hire the author **Need this kind of engineering on your product?** I take on a small number of client builds: LLM features, iOS apps and performance work, fixed price. [Services and pricing](https://mattbusel.github.io/) · [Email](mailto:mattbusel@gmail.com) · [LinkedIn](https://www.linkedin.com/in/matthewbusel/) diff --git a/Screenshot 2026-03-25 100941.png b/Screenshot 2026-03-25 100941.png deleted file mode 100644 index 9230048..0000000 Binary files a/Screenshot 2026-03-25 100941.png and /dev/null differ diff --git a/Screenshot 2026-03-25 231046.png b/Screenshot 2026-03-25 231046.png deleted file mode 100644 index 0785bdb..0000000 Binary files a/Screenshot 2026-03-25 231046.png and /dev/null differ diff --git a/assets/banner.jpg b/assets/banner.jpg new file mode 100644 index 0000000..33cffce Binary files /dev/null and b/assets/banner.jpg differ diff --git a/ezgif.com-video-to-gif-converter (4).gif b/assets/convergence-demo.gif similarity index 100% rename from ezgif.com-video-to-gif-converter (4).gif rename to assets/convergence-demo.gif diff --git a/assets/fig-convergence.jpg b/assets/fig-convergence.jpg new file mode 100644 index 0000000..69f369f Binary files /dev/null and b/assets/fig-convergence.jpg differ diff --git a/assets/fig-lorenz.jpg b/assets/fig-lorenz.jpg new file mode 100644 index 0000000..f226a29 Binary files /dev/null and b/assets/fig-lorenz.jpg differ diff --git a/assets/fig-sky-day.jpg b/assets/fig-sky-day.jpg new file mode 100644 index 0000000..8d41686 Binary files /dev/null and b/assets/fig-sky-day.jpg differ diff --git a/assets/pipeline.svg b/assets/pipeline.svg new file mode 100644 index 0000000..d003e99 --- /dev/null +++ b/assets/pipeline.svg @@ -0,0 +1,85 @@ + + The Proof Engine screen pipeline + The 3D glyph pass and the UI world pass draw into a half-float scene buffer with colour and emission targets. Emission feeds a bloom pyramid. Colour and bloom meet in the composite, then an optional FXAA pass, then the screen, and finally the UI HUD pass is painted sharp on top. + + + + + + + + + + + + HDR SCENE BUFFER / RGBA16F x2 / at render_scale + + + 3D glyph pass + glyphs, particles + + + UI world pass + particle clouds, fills + + + emission + what glows + + + colour + what is lit + + + bloom pyramid + knee, down, tent up + + + + + + + Half-float targets: a few hundred thousand overlapping + emissive particles add up to real light instead of + clipping at white. The range comes down once, in ACES. + + + + composite + lens, light, bloom + ACES, grade, + grain, dither + + + + + FXAA + optional + + + + screen + back buffer + + + + + UI HUD pass + text, bars + + PAINTED SHARP ON TOP + after post-processing, so the + interface never blooms or blurs + diff --git a/docs/assets/convergence.jpg b/docs/assets/convergence.jpg new file mode 100644 index 0000000..cf11e91 Binary files /dev/null and b/docs/assets/convergence.jpg differ diff --git a/docs/assets/editor.jpg b/docs/assets/editor.jpg new file mode 100644 index 0000000..262adb1 Binary files /dev/null and b/docs/assets/editor.jpg differ diff --git a/docs/assets/lorenz-poster.jpg b/docs/assets/lorenz-poster.jpg new file mode 100644 index 0000000..6ef372a Binary files /dev/null and b/docs/assets/lorenz-poster.jpg differ diff --git a/docs/assets/lorenz.mp4 b/docs/assets/lorenz.mp4 new file mode 100644 index 0000000..97536b4 Binary files /dev/null and b/docs/assets/lorenz.mp4 differ diff --git a/docs/assets/pipeline.svg b/docs/assets/pipeline.svg new file mode 100644 index 0000000..d003e99 --- /dev/null +++ b/docs/assets/pipeline.svg @@ -0,0 +1,85 @@ + + The Proof Engine screen pipeline + The 3D glyph pass and the UI world pass draw into a half-float scene buffer with colour and emission targets. Emission feeds a bloom pyramid. Colour and bloom meet in the composite, then an optional FXAA pass, then the screen, and finally the UI HUD pass is painted sharp on top. + + + + + + + + + + + + HDR SCENE BUFFER / RGBA16F x2 / at render_scale + + + 3D glyph pass + glyphs, particles + + + UI world pass + particle clouds, fills + + + emission + what glows + + + colour + what is lit + + + bloom pyramid + knee, down, tent up + + + + + + + Half-float targets: a few hundred thousand overlapping + emissive particles add up to real light instead of + clipping at white. The range comes down once, in ACES. + + + + composite + lens, light, bloom + ACES, grade, + grain, dither + + + + + FXAA + optional + + + + screen + back buffer + + + + + UI HUD pass + text, bars + + PAINTED SHARP ON TOP + after post-processing, so the + interface never blooms or blurs + diff --git a/docs/assets/sky-poster.jpg b/docs/assets/sky-poster.jpg new file mode 100644 index 0000000..c92acc9 Binary files /dev/null and b/docs/assets/sky-poster.jpg differ diff --git a/docs/assets/sky.mp4 b/docs/assets/sky.mp4 new file mode 100644 index 0000000..f67ead9 Binary files /dev/null and b/docs/assets/sky.mp4 differ diff --git a/docs/assets/sky.webm b/docs/assets/sky.webm new file mode 100644 index 0000000..1957404 Binary files /dev/null and b/docs/assets/sky.webm differ diff --git a/docs/assets/sun/00.jpg b/docs/assets/sun/00.jpg new file mode 100644 index 0000000..b8553d1 Binary files /dev/null and b/docs/assets/sun/00.jpg differ diff --git a/docs/assets/sun/01.jpg b/docs/assets/sun/01.jpg new file mode 100644 index 0000000..4af57af Binary files /dev/null and b/docs/assets/sun/01.jpg differ diff --git a/docs/assets/sun/02.jpg b/docs/assets/sun/02.jpg new file mode 100644 index 0000000..89a6ac2 Binary files /dev/null and b/docs/assets/sun/02.jpg differ diff --git a/docs/assets/sun/03.jpg b/docs/assets/sun/03.jpg new file mode 100644 index 0000000..01436b1 Binary files /dev/null and b/docs/assets/sun/03.jpg differ diff --git a/docs/assets/sun/04.jpg b/docs/assets/sun/04.jpg new file mode 100644 index 0000000..f9e6f96 Binary files /dev/null and b/docs/assets/sun/04.jpg differ diff --git a/docs/assets/sun/05.jpg b/docs/assets/sun/05.jpg new file mode 100644 index 0000000..9f691e5 Binary files /dev/null and b/docs/assets/sun/05.jpg differ diff --git a/docs/assets/sun/06.jpg b/docs/assets/sun/06.jpg new file mode 100644 index 0000000..47a8f5e Binary files /dev/null and b/docs/assets/sun/06.jpg differ diff --git a/docs/assets/sun/07.jpg b/docs/assets/sun/07.jpg new file mode 100644 index 0000000..fa02668 Binary files /dev/null and b/docs/assets/sun/07.jpg differ diff --git a/docs/assets/sun/08.jpg b/docs/assets/sun/08.jpg new file mode 100644 index 0000000..444b8a2 Binary files /dev/null and b/docs/assets/sun/08.jpg differ diff --git a/docs/assets/sun/09.jpg b/docs/assets/sun/09.jpg new file mode 100644 index 0000000..ec09ae0 Binary files /dev/null and b/docs/assets/sun/09.jpg differ diff --git a/docs/assets/sun/10.jpg b/docs/assets/sun/10.jpg new file mode 100644 index 0000000..0c54cb7 Binary files /dev/null and b/docs/assets/sun/10.jpg differ diff --git a/docs/assets/sun/11.jpg b/docs/assets/sun/11.jpg new file mode 100644 index 0000000..1939f4d Binary files /dev/null and b/docs/assets/sun/11.jpg differ diff --git a/docs/assets/sun/12.jpg b/docs/assets/sun/12.jpg new file mode 100644 index 0000000..8c80866 Binary files /dev/null and b/docs/assets/sun/12.jpg differ diff --git a/docs/assets/sun/13.jpg b/docs/assets/sun/13.jpg new file mode 100644 index 0000000..130dd63 Binary files /dev/null and b/docs/assets/sun/13.jpg differ diff --git a/docs/assets/sun/14.jpg b/docs/assets/sun/14.jpg new file mode 100644 index 0000000..19a6dba Binary files /dev/null and b/docs/assets/sun/14.jpg differ diff --git a/docs/assets/sun/15.jpg b/docs/assets/sun/15.jpg new file mode 100644 index 0000000..34b1739 Binary files /dev/null and b/docs/assets/sun/15.jpg differ diff --git a/docs/assets/sun/16.jpg b/docs/assets/sun/16.jpg new file mode 100644 index 0000000..ab8571b Binary files /dev/null and b/docs/assets/sun/16.jpg differ diff --git a/docs/assets/sun/17.jpg b/docs/assets/sun/17.jpg new file mode 100644 index 0000000..fc94979 Binary files /dev/null and b/docs/assets/sun/17.jpg differ diff --git a/docs/assets/sun/18.jpg b/docs/assets/sun/18.jpg new file mode 100644 index 0000000..1492029 Binary files /dev/null and b/docs/assets/sun/18.jpg differ diff --git a/docs/assets/sun/19.jpg b/docs/assets/sun/19.jpg new file mode 100644 index 0000000..6e84798 Binary files /dev/null and b/docs/assets/sun/19.jpg differ diff --git a/docs/assets/sun/20.jpg b/docs/assets/sun/20.jpg new file mode 100644 index 0000000..3a1cfea Binary files /dev/null and b/docs/assets/sun/20.jpg differ diff --git a/docs/assets/sun/21.jpg b/docs/assets/sun/21.jpg new file mode 100644 index 0000000..6d50c28 Binary files /dev/null and b/docs/assets/sun/21.jpg differ diff --git a/docs/assets/sun/22.jpg b/docs/assets/sun/22.jpg new file mode 100644 index 0000000..c92f6fb Binary files /dev/null and b/docs/assets/sun/22.jpg differ diff --git a/docs/assets/sun/23.jpg b/docs/assets/sun/23.jpg new file mode 100644 index 0000000..f2e5d07 Binary files /dev/null and b/docs/assets/sun/23.jpg differ diff --git a/docs/assets/sun/24.jpg b/docs/assets/sun/24.jpg new file mode 100644 index 0000000..bc6b226 Binary files /dev/null and b/docs/assets/sun/24.jpg differ diff --git a/docs/assets/sun/25.jpg b/docs/assets/sun/25.jpg new file mode 100644 index 0000000..cf6db75 Binary files /dev/null and b/docs/assets/sun/25.jpg differ diff --git a/docs/assets/sun/26.jpg b/docs/assets/sun/26.jpg new file mode 100644 index 0000000..905b4a2 Binary files /dev/null and b/docs/assets/sun/26.jpg differ diff --git a/docs/assets/sun/27.jpg b/docs/assets/sun/27.jpg new file mode 100644 index 0000000..9a9b2f7 Binary files /dev/null and b/docs/assets/sun/27.jpg differ diff --git a/docs/assets/sun/28.jpg b/docs/assets/sun/28.jpg new file mode 100644 index 0000000..a68019e Binary files /dev/null and b/docs/assets/sun/28.jpg differ diff --git a/docs/assets/sun/29.jpg b/docs/assets/sun/29.jpg new file mode 100644 index 0000000..88a7621 Binary files /dev/null and b/docs/assets/sun/29.jpg differ diff --git a/docs/assets/sun/30.jpg b/docs/assets/sun/30.jpg new file mode 100644 index 0000000..6592114 Binary files /dev/null and b/docs/assets/sun/30.jpg differ diff --git a/docs/assets/sun/31.jpg b/docs/assets/sun/31.jpg new file mode 100644 index 0000000..a7715e3 Binary files /dev/null and b/docs/assets/sun/31.jpg differ diff --git a/docs/assets/sun/32.jpg b/docs/assets/sun/32.jpg new file mode 100644 index 0000000..0f7f6e6 Binary files /dev/null and b/docs/assets/sun/32.jpg differ diff --git a/docs/assets/sun/33.jpg b/docs/assets/sun/33.jpg new file mode 100644 index 0000000..8e0bf2e Binary files /dev/null and b/docs/assets/sun/33.jpg differ diff --git a/docs/assets/sun/34.jpg b/docs/assets/sun/34.jpg new file mode 100644 index 0000000..869e199 Binary files /dev/null and b/docs/assets/sun/34.jpg differ diff --git a/docs/assets/sun/35.jpg b/docs/assets/sun/35.jpg new file mode 100644 index 0000000..0bcb61d Binary files /dev/null and b/docs/assets/sun/35.jpg differ diff --git a/docs/assets/sun/36.jpg b/docs/assets/sun/36.jpg new file mode 100644 index 0000000..abbde74 Binary files /dev/null and b/docs/assets/sun/36.jpg differ diff --git a/docs/assets/sun/37.jpg b/docs/assets/sun/37.jpg new file mode 100644 index 0000000..6701b7e Binary files /dev/null and b/docs/assets/sun/37.jpg differ diff --git a/docs/assets/sun/38.jpg b/docs/assets/sun/38.jpg new file mode 100644 index 0000000..9eec5fa Binary files /dev/null and b/docs/assets/sun/38.jpg differ diff --git a/docs/assets/sun/39.jpg b/docs/assets/sun/39.jpg new file mode 100644 index 0000000..3190c23 Binary files /dev/null and b/docs/assets/sun/39.jpg differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..c8966cd --- /dev/null +++ b/docs/index.html @@ -0,0 +1,421 @@ + + + + + +Proof Engine + + + + + + + + + + + + + + +
+
+ proof-engine + +
+
+ +
+
+ + +
+

Proof Engine

+

A Rust rendering engine where every visual is the output of a mathematical function. Glyphs and particles are moved by differential equations and force fields, and drawn in HDR.

+
+
cargo add proof-engine
+ Run a demo + Source +
+

FIG. 1The sky example from 16:20 to dusk, captured frame by frame with PROOF_SHOT and played forward and back. Every cell of that sky is a scattering integral.

+
+
+ +
+
+
+

Nishita sky

+

Drag the sun.

+
+

The sky is 120 by 56 cells. For each one, 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.

+

No gradient is painted anywhere. The blue at three o'clock, the orange band at sunset and the dark teal after it are the same integral with the sun somewhere else. The mountains are sums of sines lit by the sky just above the horizon.

+
+
+
+
The sky demo at 14:48, sun elevation 36.8 degrees
+
+
14:48sun elevation +36.8°
+ + +
afternoondusk
+
+

FIG. 2Forty real frames from cargo run --release --example sky. Times and elevations are the ones the demo prints.

+
+
+
+ +
+
+
+
+

FIG. 3cargo run --release --example lorenz. Teal is slow, amber is fast.

+
+
+

Strange attractors

+

Forty thousand points, one equation.

+
dx/dt = 10 (y − x)
dy/dt = x (28 − z) − y
dz/dt = x y − 8⁄3 z
+

Each point is a state advanced every frame by the engine's own RK4 step. Nobody drew the two wings; that is where the equations send the points. The same module carries Rossler, Chen, Halvorsen, Aizawa, Thomas, Dadras and more, and any of them can drive a force field.

+
+
examples/lorenz.rs
+
for p in points.iter_mut() {
+    for _ in 0..SUBSTEPS {
+        *p = rk4_step(lorenz, *p, h);
+    }
+}
+
+
+
+
+ +
+
+

Quick start

+

Running in two commands.

+

You need stable Rust and a GPU with OpenGL 3.3. Windows and macOS need nothing else. On Linux, install the ALSA headers first: sudo apt install libasound2-dev pkg-config.

+
+
+

01Run the demos

+

The first build compiles the whole engine and takes a few minutes. Use --release; the demos simulate tens of thousands of particles a frame.

+
+
shell
+
git clone https://github.com/Mattbusel/proof-engine.git
+cd proof-engine
+cargo run --release --example sky
+cargo run --release --example lorenz
+
+
+
+

02Use it as a library

+

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

+
+
Cargo.toml
+
[dependencies]
+proof-engine = "0.2"
+
+
+
src/main.rs
+
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| {});
+}
+
+
+
+
+
+ +
+
+

What is inside

+

Math first, then light.

+
+
math

Functions as animation

Attractors, Perlin noise, logistic map, Collatz, Lissajous, Mandelbrot escape, springs. Any glyph can carry a life_function that drives its position or colour.

+
math::fields

Composable force fields

Gravity, vortex, electromagnetic, strange attractor, shockwave, tidal, flow, dipole, with linear, inverse-square, exponential or Gaussian falloff.

+
entity

Particle-built bodies

Entities are glyph clusters held by force cohesion. Binding strength follows HP, so a hit knocks matter loose instead of playing an animation.

+
render

An HDR pipeline

Half-float scene buffers, a bloom pyramid, ACES, lens, light shafts, shadows from matter, grain and dither. Every standing parameter is a field on RenderConfig.

+
physics · audio

Simulation and sound

Rigid bodies with SAT, soft bodies, Eulerian fluid. 48 kHz synthesis with FM, filters, drive and reverb. Sound is synthesised, not sampled.

+
worldgen · scripting

Procedural everything

Tectonics, erosion, climate, rivers, settlements and history; Lotka-Volterra and SIR ecology; a bytecode VM with closures.

+
+
+ The screen pipeline: the 3D glyph pass and UI world pass draw into an HDR buffer, emission feeds a bloom pyramid, both meet in the composite, then FXAA, the screen, and the HUD painted sharp on top. +

FIG. 4What runs on the GPU every frame. A game drawn as screen-space matter gets bloom, grade and grain on all of it, with a readable HUD over the top.

+
+
+
+ +
+
+

Demos

+

Twenty 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.

+
+
+
Two particle-built fighters; the red one is coming apart after a hit
+

FIG. 5convergence: the red fighter has taken damage and its binding is failing.

+
+
+
The Proof Editor, an egui scene editor with a viewport, inspector and hierarchy
+

FIG. 6Proof Editor: place glyphs, fields and entities; save scenes as JSON.

+
+
+
+ + + + + + + + + + + +
ExampleWhat you see
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.
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.
colossusGPU density entities: millions of particles from sixteen bones. Needs a strong GPU.
apotheosisA character built on signed distance fields, about 10.8 million particles. Needs OpenGL 4.3.
+
+
+
+ +
+
+
+

Capture

+

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.

+
+
+
+
+
shell
+
PROOF_HIDDEN=1 PROOF_FIXED_DT=60 \
+PROOF_SHOT='frames/f_{n}.bmp' PROOF_SHOT_AT=560 \
+PROOF_SHOT_EVERY=2 PROOF_SHOT_COUNT=240 \
+cargo run --release --example sky
+
+

PROOF_FIXED_DT steps the simulation at a fixed rate, so a sequence plays back at true speed however long each frame took to capture. PROOF_WINDOW=1920x1080 sets the size.

+
+
+
+
+ + + + + + diff --git a/examples/lorenz.rs b/examples/lorenz.rs new file mode 100644 index 0000000..ae4a5d1 --- /dev/null +++ b/examples/lorenz.rs @@ -0,0 +1,157 @@ +//! lorenz: forty thousand points riding the Lorenz equations. +//! +//! Every point is a state `(x, y, z)` advanced each frame by the engine's own +//! RK4 integrator, `proof_engine::math::attractors::rk4_step`, on +//! +//! ```text +//! dx/dt = 10 (y - x) +//! dy/dt = x (28 - z) - y +//! dz/dt = x y - 8/3 z +//! ``` +//! +//! Nothing is keyframed and nothing is a precomputed curve. The two wings +//! appear because that is where the equations send the points. Colour is the +//! point's speed along the flow, cool where it drifts and hot where it is +//! flung across from one wing to the other. Points are drawn into the HDR +//! world pass, so they add up to real light before bloom and the tonemap. +//! +//! Controls: Space pauses, Left/Right turn the view, Esc quits. +//! +//! Run: `cargo run --release --example lorenz` + +use proof_engine::math::attractors::{derivatives, rk4_step}; +use proof_engine::prelude::*; +use proof_engine::render::ui_layer::UiParticle; + +const POINTS: usize = 40_000; +/// Simulated seconds per real second. +const TIME_SCALE: f32 = 0.22; +/// Integration substeps per frame, so a slow frame never takes a big step. +const SUBSTEPS: usize = 2; +/// Radians per second the view turns on its own. +const SPIN: f32 = 0.05; + +fn main() { + env_logger::init(); + + let mut engine = ProofEngine::new(EngineConfig { + window_title: "Proof Engine: Lorenz attractor".to_string(), + window_width: 1280, + window_height: 720, + render: proof_engine::config::RenderConfig { + bloom_enabled: true, + bloom_intensity: 1.1, + bloom_threshold: 0.35, + tonemap: 1.0, + exposure: 1.1, + persistence: 0.55, + vignette: 0.35, + // Everything here is drawn in the UI layer; the 3D lighting + // solves would have nothing to light. + global_illumination: false, + volumetric_fog: false, + ..Default::default() + }, + ..Default::default() + }); + + // Spread the points along one long trajectory that has already settled + // onto the attractor, then separate them by a hair. Chaos does the rest. + let lorenz = AttractorType::Lorenz; + let mut s = Vec3::new(0.1, 0.0, 0.0); + for _ in 0..3000 { + s = rk4_step(lorenz, s, 0.005); + } + let mut points: Vec = (0..POINTS) + .map(|i| { + for _ in 0..3 { + s = rk4_step(lorenz, s, 0.005); + } + s + Vec3::splat(hash(i as f32) * 1e-3) + }) + .collect(); + + // Start looking across both wings; they line up behind each other at +45 degrees. + let mut yaw = -1.05_f32; + let mut paused = false; + let mut sim_t = 0.0_f32; + + 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 engine.input.is_pressed(Key::Left) { + yaw -= dt * 1.2; + } + if engine.input.is_pressed(Key::Right) { + yaw += dt * 1.2; + } + + if !paused { + let h = dt * TIME_SCALE / SUBSTEPS as f32; + for p in points.iter_mut() { + for _ in 0..SUBSTEPS { + *p = rk4_step(lorenz, *p, h); + } + } + sim_t += dt * TIME_SCALE; + yaw += dt * SPIN; + } + + let (w, h) = engine.render_size(); + let (w, h) = (w as f32, h as f32); + // The attractor spans about 50 units in z and 45 across; fit it. + let scale = h / 58.0; + let (cx, cy) = (w * 0.5, h * 0.5); + let (sy, cyaw) = yaw.sin_cos(); + let size = (h / 360.0).max(1.5); + + let particles: Vec = points + .iter() + .map(|p| { + // Turn about the vertical (z) axis, then look from the side. + let x = p.x * cyaw - p.y * sy; + let depth = p.x * sy + p.y * cyaw; + let px = cx + x * scale; + let py = cy - (p.z - 25.0) * scale; + let speed = derivatives(lorenz, *p).length(); + let c = palette((speed / 190.0).clamp(0.0, 1.0)); + // Nearer points a little brighter, so the wings read as depth. + let near = 0.75 + 0.25 * (depth / 25.0).clamp(-1.0, 1.0); + let mut q = UiParticle::new(px, py, size, size, '●', (c * near).extend(0.85)); + q.emission = 0.6 + 0.9 * (speed / 190.0).min(1.0); + q + }) + .collect(); + engine.ui.draw_particles(particles); + + let dim = Vec4::new(0.85, 0.88, 0.92, 0.75); + engine.ui.draw_text(20.0, 18.0, "Lorenz attractor dx/dt = 10(y - x) dy/dt = x(28 - z) - y dz/dt = xy - 8z/3", 1.0, dim); + let status = format!( + "{POINTS} points RK4 t = {sim_t:.2}{} [Space] pause [Left/Right] turn", + if paused { " (paused)" } else { "" } + ); + engine.ui.draw_text(20.0, 42.0, &status, 1.0, Vec4::new(0.85, 0.88, 0.92, 0.55)); + }); +} + +/// Slow is a cool teal, fast is a hot amber: the same two colours the Nishita +/// sky produces at dusk. +fn palette(t: f32) -> Vec3 { + let cool = Vec3::new(0.10, 0.55, 0.70); + let mid = Vec3::new(0.85, 0.80, 0.70); + let hot = Vec3::new(1.40, 0.55, 0.16); + if t < 0.5 { + cool.lerp(mid, t * 2.0) + } else { + mid.lerp(hot, (t - 0.5) * 2.0) + } +} + +/// Cheap deterministic hash in [0, 1). +fn hash(x: f32) -> f32 { + ((x * 12.9898).sin() * 43_758.547).fract().abs() +} diff --git a/src/capture.rs b/src/capture.rs new file mode 100644 index 0000000..b0e9040 --- /dev/null +++ b/src/capture.rs @@ -0,0 +1,142 @@ +//! Frame capture driven by environment variables. +//! +//! Any program built on the engine, every example included, can write its +//! own frames to disk without a line of code changing: +//! +//! ```text +//! PROOF_SHOT=sky.bmp PROOF_SHOT_AT=300 cargo run --release --example sky +//! ``` +//! +//! | Variable | Meaning | +//! | --- | --- | +//! | `PROOF_SHOT` | Output path. `{n}` is replaced by the capture index, zero padded to four digits. | +//! | `PROOF_SHOT_AT` | Frame of the first capture (default 120). | +//! | `PROOF_SHOT_COUNT` | How many captures to take (default 1). | +//! | `PROOF_SHOT_EVERY` | Frames between captures (default 1). | +//! | `PROOF_SHOT_KEEP` | Set to `1` to keep running after the last capture instead of exiting. | +//! | `PROOF_FIXED_DT` | Step the simulation at this many frames per second of simulated time, whatever the real frame rate. Makes a capture sequence play back at true speed. | +//! | `PROOF_HIDDEN` | Set to `1` to create the window hidden and unfocused, for capturing on a machine someone is using. | +//! | `PROOF_WINDOW` | Override the window size, as `WIDTHxHEIGHT`. | +//! +//! Frames are read back off the GPU with [`crate::ProofEngine::save_frame`], +//! so what lands on disk is exactly what the pipeline drew, post-processing +//! included. The files are 24-bit BMP whatever the extension. + +use std::env; + +fn var(name: &str) -> Option { + env::var(name).ok().filter(|v| !v.trim().is_empty()) +} + +fn flag(name: &str) -> bool { + matches!(var(name).as_deref(), Some("1") | Some("true") | Some("yes")) +} + +/// `PROOF_HIDDEN`: create the window invisible and without focus. +pub(crate) fn hidden_window() -> bool { + flag("PROOF_HIDDEN") +} + +/// `PROOF_FIXED_DT`: a fixed simulation step, in seconds. +pub(crate) fn fixed_dt() -> Option { + let fps: f32 = var("PROOF_FIXED_DT")?.parse().ok()?; + (fps > 0.0).then(|| 1.0 / fps) +} + +/// `PROOF_WINDOW`: a window size override. +pub(crate) fn window_size() -> Option<(u32, u32)> { + let v = var("PROOF_WINDOW")?; + let (w, h) = v.to_ascii_lowercase().split_once('x').map(|(a, b)| (a.to_string(), b.to_string()))?; + let (w, h) = (w.trim().parse().ok()?, h.trim().parse().ok()?); + (w > 0 && h > 0).then_some((w, h)) +} + +/// A capture schedule read from `PROOF_SHOT*`. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Capture { + path: String, + at: u64, + count: u32, + every: u64, + exit: bool, + frame: u64, + taken: u32, +} + +impl Capture { + pub(crate) fn from_env() -> Option { + Some(Self::new( + var("PROOF_SHOT")?, + var("PROOF_SHOT_AT").and_then(|v| v.parse().ok()).unwrap_or(120), + var("PROOF_SHOT_COUNT").and_then(|v| v.parse().ok()).unwrap_or(1), + var("PROOF_SHOT_EVERY").and_then(|v| v.parse().ok()).unwrap_or(1), + !flag("PROOF_SHOT_KEEP"), + )) + } + + pub(crate) fn new(path: String, at: u64, count: u32, every: u64, exit: bool) -> Self { + Self { path, at, count: count.max(1), every: every.max(1), exit, frame: 0, taken: 0 } + } + + /// The path for capture number `n`. + pub(crate) fn path_for(&self, n: u32) -> String { + self.path.replace("{n}", &format!("{n:04}")) + } + + /// Advance one frame. Returns the path to write if this frame is captured. + pub(crate) fn tick(&mut self) -> Option { + let f = self.frame; + self.frame += 1; + if self.taken >= self.count || f < self.at || (f - self.at) % self.every != 0 { + return None; + } + let p = self.path_for(self.taken); + self.taken += 1; + Some(p) + } + + /// True once every capture is written and the run should end. + pub(crate) fn finished(&self) -> bool { + self.exit && self.taken >= self.count + } + + /// Call after the frame is drawn and before the swap. Returns true when + /// the run loop should stop. + pub(crate) fn after_draw(&mut self, engine: &crate::ProofEngine) -> bool { + if let Some(path) = self.tick() { + match engine.save_frame(&path) { + Ok(()) => log::info!("captured frame {} to {path}", self.frame - 1), + Err(e) => eprintln!("proof-engine: could not write {path}: {e}"), + } + } + self.finished() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_capture_fires_once_at_the_requested_frame() { + let mut c = Capture::new("a.bmp".into(), 3, 1, 1, true); + let hits: Vec<_> = (0..10).map(|_| c.tick()).collect(); + assert_eq!(hits.iter().filter(|h| h.is_some()).count(), 1); + assert_eq!(hits[3].as_deref(), Some("a.bmp")); + assert!(c.finished()); + } + + #[test] + fn sequences_number_their_files_and_respect_the_stride() { + let mut c = Capture::new("f_{n}.bmp".into(), 2, 3, 2, true); + let hits: Vec<_> = (0..12).filter_map(|_| c.tick()).collect(); + assert_eq!(hits, vec!["f_0000.bmp", "f_0001.bmp", "f_0002.bmp"]); + } + + #[test] + fn keep_running_never_finishes() { + let mut c = Capture::new("a.bmp".into(), 0, 1, 1, false); + c.tick(); + assert!(!c.finished()); + } +} diff --git a/src/lib.rs b/src/lib.rs index cc125d2..a52e02d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -99,6 +99,7 @@ pub mod curves; pub mod nishita_sky; pub mod volumetric_fog; pub mod tiled_lighting; +mod capture; pub use config::EngineConfig; pub use math::{MathFunction, ForceField, Falloff, AttractorType}; @@ -134,7 +135,11 @@ pub struct ProofEngine { } impl ProofEngine { - pub fn new(config: EngineConfig) -> Self { + pub fn new(mut config: EngineConfig) -> Self { + if let Some((w, h)) = capture::window_size() { + config.window_width = w; + config.window_height = h; + } let audio = if config.audio.enabled { audio::AudioEngine::try_new() } else { @@ -204,11 +209,13 @@ impl ProofEngine { { let pipeline = render::Pipeline::init(&self.config); self.pipeline = Some(pipeline); + let mut capture = capture::Capture::from_env(); + let fixed_dt = capture::fixed_dt(); let mut last = std::time::Instant::now(); loop { let now = std::time::Instant::now(); - let dt = now.duration_since(last).as_secs_f32().min(0.1); + let dt = fixed_dt.unwrap_or_else(|| now.duration_since(last).as_secs_f32().min(0.1)); last = now; // Poll input @@ -246,6 +253,13 @@ impl ProofEngine { update(self, dt, gl_ref); } + // PROOF_SHOT: read the finished frame back before it is swapped away. + if let Some(ref mut c) = capture { + if c.after_draw(self) { + break; + } + } + // Swap if let Some(ref mut p) = self.pipeline { if !p.swap() { @@ -276,11 +290,14 @@ impl ProofEngine { let (w, h) = self.render_size(); self.ui.resize(w as f32, h as f32); + let mut capture = capture::Capture::from_env(); + let fixed_dt = capture::fixed_dt(); + let mut last = std::time::Instant::now(); let mut last_size = (w, h); loop { let now = std::time::Instant::now(); - let dt = now.duration_since(last).as_secs_f32().min(0.1); + let dt = fixed_dt.unwrap_or_else(|| now.duration_since(last).as_secs_f32().min(0.1)); last = now; if let Some(ref mut p) = self.pipeline { @@ -330,6 +347,12 @@ impl ProofEngine { self.density_queue.clear(); self.fx.lights.clear(); + if let Some(ref mut c) = capture { + if c.after_draw(self) { + break; + } + } + if let Some(ref mut p) = self.pipeline { if !p.swap() { break; diff --git a/src/render/pipeline.rs b/src/render/pipeline.rs index 269fa3c..92ccbc1 100644 --- a/src/render/pipeline.rs +++ b/src/render/pipeline.rs @@ -358,7 +358,11 @@ impl Pipeline { let window_attrs = Window::default_attributes() .with_title(&config.window_title) .with_inner_size(LogicalSize::new(config.window_width, config.window_height)) - .with_resizable(true); + .with_resizable(true) + // PROOF_HIDDEN: capture frames without a window appearing or + // taking focus from whatever the person at the machine is doing. + .with_visible(!crate::capture::hidden_window()) + .with_active(!crate::capture::hidden_window()); // ── 3. GL config via DisplayBuilder (glutin-winit 0.5) ──────────────── let template = ConfigTemplateBuilder::new()