Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/wasm-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: 🌐 Build WASM & Deploy Demo

# Builds the WebAssembly module (issue #171) and publishes the browser demo to
# GitHub Pages. The .wasm is built here rather than committed: it is a megabyte
# of generated output, and a stale binary next to live sources is worse than a
# two minute build.

# Publishes from dev. The github-pages environment has to allow dev as a
# deployment branch (Settings → Environments → github-pages).
on:
push:
branches: [ "dev" ]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# One deployment at a time; queued runs supersede rather than race.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
# No LFS: the WASM build reads no test data, and emcmake turns the
# integrity check off by default.

- name: Cache Emscripten SDK
id: emsdk-cache
uses: actions/cache@v4
with:
path: ~/emsdk
key: emsdk-${{ runner.os }}-6.0.6

- name: Install Emscripten
if: steps.emsdk-cache.outputs.cache-hit != 'true'
run: |
# Pinned to the version the demo was developed and tested against.
git clone --depth 1 https://github.com/emscripten-core/emsdk.git ~/emsdk
~/emsdk/emsdk install 6.0.6
~/emsdk/emsdk activate 6.0.6

- name: Build the WebAssembly module
run: |
source ~/emsdk/emsdk_env.sh
emcmake cmake -S . -B build-wasm -DCMAKE_BUILD_TYPE=Release
cmake --build build-wasm -j"$(nproc)"

- name: Report size
run: |
{
echo "### WebAssembly build"
echo "| file | raw | gzip | brotli |"
echo "| --- | --- | --- | --- |"
for f in bin/forefire.wasm bin/forefire.mjs; do
printf '| `%s` | %s | %s | %s |\n' "$(basename "$f")" \
"$(stat -c%s "$f" | numfmt --to=iec)" \
"$(gzip -c "$f" | wc -c | numfmt --to=iec)" \
"$(brotli -c "$f" | wc -c | numfmt --to=iec)"
done
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Smoke test under Node
run: node bindings/wasm/smoke.mjs bin/forefire.mjs

- name: Assemble the site
run: |
mkdir -p _site
cp bin/forefire.mjs bin/forefire.wasm _site/
cp bindings/wasm/index.html bindings/wasm/worker.mjs \
bindings/wasm/landscape.mjs bindings/wasm/terrain3d.mjs _site/
# Pages serves through Jekyll unless told otherwise, which would
# swallow files it does not recognise.
touch _site/.nojekyll

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: _site

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ Merged since v2.5.0, not yet released.
typed arrays in place of NumPy arrays. `FOREFIRE_BUILD_CLI` and
`FOREFIRE_BUILD_WASM` control the two executables. See
`bindings/wasm/README.md`. ([#171])
- A browser demo of the WebAssembly build (`bindings/wasm/index.html`): pick
a square of ground on a map, and the page builds fuel and elevation layers
from satellite and terrain tiles, runs the fire in a worker, and plays it
back in 2D or on 3D terrain. Explore mode lights a fire where you click;
Research mode exposes the model, its tuning factors, the numerics, and the
fuel moisture and classes. ([#171])

### Fixed

Expand Down
146 changes: 143 additions & 3 deletions bindings/wasm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ This is an experimental build, not a supported one yet. It answers the three
questions the issue left open: does it link, does it run, how big is it.

`.github/workflows/wasm.yml` builds the module and runs `smoke.mjs` on every
pull request. The `.wasm` is built in CI rather than committed: it is a
megabyte of generated output, and a stale binary sitting next to live sources
is worse than a two minute build.
pull request, and `.github/workflows/wasm-pages.yml` publishes the browser
demo to GitHub Pages on every push to `dev`. The `.wasm` is built in CI rather
than committed: it is a megabyte of generated output, and a stale binary
sitting next to live sources is worse than a two minute build.

## What made it possible

Expand Down Expand Up @@ -55,6 +56,143 @@ Headless, under Node:
node bindings/wasm/smoke.mjs bin/forefire.mjs
```

In a browser — copy the four page files next to the build outputs and serve
the directory, since a `file://` page can neither fetch the `.wasm` nor start a
module worker:

```sh
cp bindings/wasm/{index.html,worker.mjs,landscape.mjs,terrain3d.mjs} bin/
python3 -m http.server -d bin
```

The solver runs in a worker (`worker.mjs`), which computes the **whole run up
front** — a few hundred milliseconds for a modest fire, a few seconds for one
that burns 800 ha — and the page then scrubs the result on a timeline. Off the
main thread is what keeps the page answering while that happens.

Each run gets a fresh worker. The engine's state is process-wide (#175), so a
second `FireDomain` in the same module keeps the first one's propagation model,
and switching models between runs would silently do nothing.

### Explore and Research

The page has two modes over the same map:

- **Explore**, the default: click the map to light a fire, pick a wind on the
dial or from a few scenarios, and watch it play back.
- **Research** adds the propagation model, `propagationSpeedAdjustmentFactor`
and `windReductionFactor`, the time step, step count, `perimeterResolution`
and `spatialIncrement`, dead and live fuel moisture and bed depths (the `Md`,
`Ml` and `e` columns of the fuel table), and the excess-green cut-offs
between fuel classes. Each control shows the ForeFire name it sets.

The models on offer are the ones that run on this fuel table with only slope
and wind: Rothermel, BalbiNov2011Curv, BalbiUnsteady, TroisPourcent and Iso.
Balbi2015, Balbi2020 and BalbiNov2011TMdMl also need moisture and temperature
layers and abort without them; BalbiNov2011 places the front far from the
ignition.

### The landscape comes from map tiles

`landscape.mjs` fetches two tiled layers for a square of ground and decodes
both into layer arrays. No NetCDF, no GIS stack: tiles in, an `Int32Array` and
a `Float64Array` out, straight into `addIndexLayer` and `addScalarLayer`.

The page opens on a 4 km square of the Ajaccio hinterland — the site of
`tests/runff/run.ff` — but the map is live: drag to pan, scroll to resize the
domain between 500 m and 30 km, and click to light a fire. The tile zoom
is chosen from the domain size so the mosaic stays around a thousand pixels and
36 tiles whatever the scale. Panning and zooming keep the run playing: each
run remembers the square it was computed on, and its fronts are drawn wherever
that square now sits on screen. The fetch is debounced, and the canvas previews
the move with the tiles it already has.

The ignition point is stored as a longitude and latitude rather than as domain
metres, so panning and zooming leave it on the same patch of ground instead of
sliding it across the map.

**Fuel**, from satellite imagery, classified by excess green `g - (r+b)/2`
into three rows of the repository's own `tests/runff/fuels.csv`:

| Class | Cut | `fuels.csv` row | Share of the site |
| --- | --- | --- | --- |
| shrub / maquis | ≥ 20 | index 5, bed depth 0.6 m | ~44% |
| sparse / grass | ≥ 10 | index 4, bed depth 0.19 m | ~32% |
| bare, built, water | below | index 0, no fuel bed | ~24% |

**Elevation**, from Terrarium-encoded SRTM, `r*256 + g + b/256 - 32768` metres.
The site runs from 11 m to 761 m.

Both are load-bearing, because the default model is **Rothermel** with the
parameters from `tests/runff/params.ff`, and its rate of spread reads the fuel bed and the
slope. Index 0 has no fuel bed, so the fire stops at clearings, tracks and
rooftops. And on a bare slope with no wind at all, ten minutes of spread grows
with the grade while the downhill side stays put:

| slope | uphill | downhill |
| --- | --- | --- |
| flat | 47 m | 45 m |
| 30% | 99 m | 45 m |
| 60% | 210 m | 43 m |

> **Layer order matters.** The `altitude` layer must be registered *before* the
> propagation layer. `DataBroker` derives the slope layer at the moment a layer
> named `altitude` is registered; a model that asks for `slope` first gets a
> constant zero-altitude stand-in, and the terrain silently does nothing.

If the tile hosts are unreachable the page falls back to uniform fuel on flat
ground and says so.

### Knowing when the fire stops

A fire that stops does not leave a stationary perimeter behind: ForeFire
discards the front and `print[]` returns nothing at all, which on a canvas is
indistinguishable from a bug. The worker detects the front list emptying, drops
that frame, and reports why it ended — `left-domain` if the last live front was
against the boundary, `burnt-out` otherwise. A slower death, where the
perimeter survives but stops advancing, is caught by a burnt-area plateau over
a twelve-step window (`stalled`), and an ignition on bare ground is reported as
`never-caught` rather than as a fire that stopped.

### 3D terrain

`terrain3d.mjs` draws the elevation field as a textured mesh in raw WebGL — no
library, since a heightfield with one directional light is not worth a megabyte
of dependency. The texture is whatever the 2D view painted, so the two views
cannot disagree. Drag to orbit, scroll to zoom the ground in or out (which
fetches new terrain, as in 2D), and the vertical exaggeration is adjustable
because 750 m of relief over 4 km is subtle at true scale. The view degrades to
2D if WebGL is unavailable.

Switching between the two views is a **camera move, not a swap**. `blendCamera`
interpolates between the orbit and a flat pose — straight down through a
near-orthographic lens (a 0.12 rad field of view from far enough back), framed
so the mesh's unit square exactly fills a square viewport, north up, with the
vertical exaggeration wound to zero. That pose draws what the plain 2D canvas
draws, to within 0.03% of a half-width, so the GL canvas can take over while
still flat and then tilt up: no cross-fade, no second renderer, and no visible
handover. Going back holds the GL canvas until the camera is flat again.

The flat pose stops a third of a degree short of vertical on purpose — straight
down puts the view direction on the up vector and `lookAt` divides by zero.

Clicking the terrain lights a fire there, which needs a ray cast rather
than a coordinate transform: `rayFor` rebuilds the world-space ray through a
pixel from the same camera basis `lookAt` uses, and `raycastHeightfield`
marches it until it drops through the surface, then bisects onto the crossing.
It stops at the first ground it meets, so a click cannot land on a slope hidden
behind a ridge. Picking samples the mesh's own vertex heights, not the
full-resolution field, or a click could land somewhere the visible surface is
not.

`mvpFor`, `blendCamera`, `rayFor`, `raycastHeightfield` and `projectPoint` are
exported so the camera can be checked without a GL context — feed the mesh's corners through and confirm they land in front of
the camera and inside the clip volume. That test exists because the first
version of `multiply` indexed its matrices row-major while `perspective`,
`lookAt` and `uniformMatrix4fv` were all column-major. It still returned a
matrix, and the mesh still drew; it just drew as streaks radiating from a
vanishing point.

## The API

`forefire.mjs` is an ES module exporting a `createForeFire()` factory. It
Expand Down Expand Up @@ -96,6 +234,8 @@ index with, the same shuffle `_pyforefire.cpp` performs.
of the worked example in the NumPy documentation. Run against a native
`pyforefire` built from the same tree, the two agree exactly: 349 front nodes,
x 1980..3905, y 1763..2308, the front stretched downwind as the doc describes.
- **Speed.** The turning-wind demo runs 15 steps of 20 s over a 4 km domain in
about 1.2 s under Node.

## Known limits

Expand Down
6 changes: 3 additions & 3 deletions bindings/wasm/forefire_wasm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ class ForeFireWasm {
/*! \brief Attach a model-backed layer, e.g. ("propagation", "WindDriven",
* "propagationModel"). Distinct from the addLayer *command*, which
* builds constant layers instead. */
void addLayer(const std::string &type, const std::string &name,
bool addLayer(const std::string &type, const std::string &name,
const std::string &key) {
FireDomain *domain = executor.getDomain();
if (domain == 0) return;
domain->addLayer(type, name, key);
if (domain == 0) return false;
return domain->addLayer(type, name, key);
}

/*! \brief Register a continuous field (altitude, wind, moisture, ...). */
Expand Down
Loading
Loading