![]() |
SWMMVis
6.0.0-alpha.4
Qt6/C++ GIS-based graphical user interface for the SWMMVis engine (6.0.0-alpha.4)
|
Understand why most gestures are cheap, what happens when you open a very large model, which settings actually change run time, and how to diagnose a slowdown with logging categories and environment variables.
The canvas separates redraws into four independent dirty channels. Each channel tracks its own pending state and has its own debounce timer, so a cheap update never pays the cost of an expensive one.
| Channel | Cost | Debounce | Drives |
|---|---|---|---|
| Raster | high — a background render job recomposes basemaps and GIS rasters | 150 ms | basemap changes, raster layer add / remove / visibility / opacity, CRS change |
| Scene | medium — refresh the vector scene items for visible layers | 50 ms | network edits, theme changes, vector layer visibility |
| Overlay | low — repaint widget-coordinate decorations only | immediate | selection highlights, rubber band, measure line, cursor readout |
| Extent | very low — recompute the scale and emit signals | immediate | pan and zoom commit |
A single gesture usually touches one channel. Clicking to select an object dirties only Overlay — the basemap is untouched and the vector items do not repopulate.
Multiple invalidations of the same channel inside its window fire once. Toggling a vector layer five times quickly costs one scene refresh, not five. The debounce is deferred, not delayed: the timer starts on the first invalidation and fires at the end of the window; later invalidations in the window only re-set the pending mask.
A scene refresh that arrives during a pan or zoom gesture is re-armed rather than dropped, so an edit made mid-gesture still lands when the gesture ends.
Batch operations — loading a project with many layers, applying a multi-step undo command — bracket their work between a suspend and a resume. Every invalidation inside the bracket accumulates and fires once on the matching resume. The pair is reference-counted, so nesting is safe.
| Cache | What it holds |
|---|---|
| Map buffer | the composed raster image plus its extent; blitted with a transform during pan and zoom so the preview is instant |
| Scene buffer | a rasterised copy of the vector scene, used during gestures and reused when idle if the extent, size and device pixel ratio all still match |
| QSG frame cache | the grabbed GPU framebuffer for the SWMM, 2D-results and terrain-mesh renderers |
| Drag render timer | while panning, re-warms tile caches at the drag extent roughly every 200 ms |
| XYZ tile cache | a 400-tile LRU per tile layer |
The GPU frame is re-grabbed only when the frame is explicitly dirty, the top layer of a kind changed, the widget was resized, the renderers' content revision changed, or the cache is empty. Extent drift alone does not force a re-grab — during pan and zoom the cached frame is blitted into a transformed destination rectangle. And when no layer kind is GPU-owned, the whole GPU render and framebuffer readback is skipped, which is the single most expensive thing in a paint on a large model.
Three scene-graph renderers are stacked in an offscreen quick widget, bottom to top: the 2D terrain mesh, the 2D results, then the SWMM network. On macOS the app forces the OpenGL RHI backend and the threaded render loop unless those are already set in the environment; Windows and Linux keep Qt's native backend (Direct3D 11 or Vulkan). Multisampling is requested on the default surface format.
Two preferences control this, both in Preferences → Rendering → GPU Rendering:
| Control | Effect |
|---|---|
| Use GPU rendering for SWMM layers (recommended) | the network renderer |
| Use GPU rendering for 2D terrain mesh layers (recommended) | the terrain mesh renderer; its tooltip names the OPENSWMM_QSG_MESH=0 kill switch |
The 2D renderers apply a Far / Mid / Near bucket policy with hysteresis on both the bucket and a quantised zoom octave, so a small zoom nudge does not churn the geometry. The far bucket drops cells below about 8 px² (4 px² for the terrain mesh). Selection overlays are always drawn, whatever the bucket.
Contour recomputation switches from synchronous to asynchronous above 50 000 cells, so contours trail the frame instead of blocking it.
Preferences → Rendering → Label Rendering → Label zoom-out threshold (m11) sets the zoom scale below which labels stop drawing. On a model with a thousand junctions, raising this is the single cheapest win before you animate anything.
Opening a project reads the .inp up to four times (engine parse, map-units scan, mesh scan, 2D-output scan), applies the .oswp sidecar, then loads the mesh asynchronously.
Several open-path costs have been measured and fixed. On a large model, applying the sidecar dropped from about 22.3 s to about 250 ms once the name lookups became hash probes; CRS handling was deduplicated from five or six scene rebuilds per open down to two; a quadratic combo fill in the mesh toolbar that grew to 10.6 GB and pinned a core across 21 tab switches now costs 2.7 GB and no measurable CPU; the hover path no longer allocates on every mouse move; and the link spatial grid is outlier-proof, so a model whose links all landed in one cell now indexes into a 144 × 123 grid at roughly 16 links per cell.
What is still slow, and honestly so: roughly 100 ms of deliberate zoom retry timers before a model first appears, and the active-window change handler running three times per open with dozens of whole-widget-tree searches each time. Per-window GPU context sharing, an SRS memoisation cache and compressed cell storage are all deferred work, not features.
A mesh with hundreds of thousands of triangles loads in two visible stages. The Message Logs dock narrates them:
The mesh is usable at the coarse stage. Two parse-side costs have been removed: the coupled-vertex tag lookup is now a hash rather than a linear scan over every vertex (and an index-form map pays nothing at all), and the tokeniser no longer uses a regular expression — it was being called millions of times on a million-triangle mesh.
Mesh display state — active flag, edge and node visibility, hillshade and contours — is restored from the .oswp after the asynchronous load joins the canvas.
Two limits to plan around:
.2dm is not byte-stable. The vertex-node map is written in hash order, so two consecutive saves differ. Expect version-control noise.There is no central frame cache; caching lives in the results layers.
.out prefetches period 0 and logs a timing breakdown (output_open, header, id_maps, prefetch_step0, total) under the openswmm.load.results category..h5. There is no prefetch, so animation speed on a large mesh is bound by HDF5 read throughput and by the contour and LOD work above.[OPTIONS] THREADS controls engine parallelism. The default is 1.
| Value | Meaning |
|---|---|
0 | auto — the OpenMP maximum, lowered by OMP_NUM_THREADS, OMP_THREAD_LIMIT and CPU affinity, then by model-size gates, and on Apple Silicon by a dynamic-wave performance-core clamp |
1 | single-threaded; no OpenMP overhead |
N | exactly N; above the machine's logical processors this is honoured with an oversubscription warning, and the active spin-wait policy is disabled |
Model-size gates apply to explicit values too, with a warning: the dynamic-wave solver wants at least 100 conduits per thread, and the 2D marcher at least 4 triangles per thread. Asking for more threads than the model can use just produces warnings and no speed-up.
Simulation Options → System / Performance:
| Control | Notes |
|---|---|
| Worker threads: | 0–256, special value auto; the suffix shows / N logical, or / N logical — oversubscribed |
| *(effective label)* | computed by the engine, not by the GUI |
| *(limits summary)* | logical processors; performance cores, noting that efficiency cores slow the solvers and are avoided in auto mode; Engine built without OpenMP — every run is serial.; the OpenMP process limit when it is below the logical count; and 2D Kokkos backend already running with N threads (fixed until restart) |
| Apply fast preset | sets THREADS to the fast-preset value and MINIMUM_STEP to 1.0 s; its tooltip claims roughly 2.6× on the Bellinge benchmark with mass balance as good or better, and warns that raising MINIMUM_STEP to 2.0 s gives about 4× at the cost of continuity |
The same knob has a default on Preferences → Simulation Defaults → Worker threads (THREADS).
Three environment variables the engine inherits are reported at start-up in the Message Logs as warnings — OMP_NUM_THREADS, OMP_THREAD_LIMIT, OPENSWMM_2D_THREADS and SWMM_DW_THREADS, each with its effect and the machine's logical-processor count. SWMMVis never unsets them; it just tells you they are there, which is usually enough to explain a run that will not go parallel.
For choosing a compute backend (CPU, OpenMP, CUDA, HIP, SYCL) see A3 — Plugins.
Loading and analysis run off the main thread on the global thread pool: the layer composite render job, opening an .out, background attribute-range and statistics sweeps, the heavy mesh geometry phase, raster tiling (on its own dedicated pool), vector loading, XYZ reprojection (also on its own pool), the asynchronous mesh attach, the whole mesh-generation pipeline, cell-data assignment, profile computation, asynchronous contours, and the simulation run itself.
Preferences → Simulation → Progress-tick interval (50–10000 ms, default 1000 ms) sets how often a running simulation reports progress. Lowering it makes the status panel more responsive at a small cost.
Set these before launching. All are diagnostic; none is needed for normal use.
| Variable | Values | Effect |
|---|---|---|
SWMMVIS_LOG_REDRAW | non-zero = on | logs every invalidation, fire, suspend and resume |
OPENSWMM_2D_RENDER_DEBUG | set = on | prints the per-paint re-grab decision and warns on non-finite cell depths |
OPENSWMM_RENDER_PERF | 1 | per-frame repaint and grab timing |
SWMMVIS_RENDER_PERF | set = on | network renderer sampler — logs average rebuild and pan times every 60 frames |
OPENSWMM_QSG_MESH | 0 = off | kill switch for the GPU terrain-mesh renderer; forces the painter fallback |
OPENSWMM_QSG_INDEXED_FILL | 0 = opt out | falls back to the expanded per-corner smooth fill |
OPENSWMM_QSG_ASYNC_CONTOURS | 0 sync, ≥ 1 async, unset = auto | overrides the 50 000-cell async contour threshold |
OPENSWMM_QSG_SHADER_FILL | 1 = on | opt-in GPU scalar-fill shader path |
QSG_RHI_BACKEND, QSG_RENDER_LOOP | Qt values | honoured if already set; forced to opengl / threaded on macOS otherwise |
| Variable | Values | Effect |
|---|---|---|
SWMM_LOG_FILE | a path, or 1, or auto | tees all Qt log output to a file; 1/auto writes <app data>/logs/swmmvis-<timestamp>.log and prints the path to stderr |
QT_LOGGING_RULES | Qt rule syntax | enables the categories below |
OPENSWMM_DIALOG_STACKING | qt or native | overrides dialog stacking; beats the Window/DialogStacking setting |
SWMMVIS_OPEN_ON_STARTUP | path to an .inp | opens that model at start-up (testing hook) |
SWMMVIS_STARTUP_SNAPSHOT | output PNG path | zooms to extent after mesh load and grabs the canvas |
SWMMVIS_STARTUP_SNAPSHOT_ZOOM | a fraction in (0, 1) | zoom about the mesh centre before the grab |
SWMMVIS_SNAPSHOT_SIMOPTS | output PNG path | grabs the Simulation Options dialog off-screen |
SWMMVIS_SNAPSHOT_MESHSTYLE | output PNG path | grabs the mesh style dialog |
PROJ_DATA, GDAL_DATA | directory paths | read at start-up, and set automatically when bundled proj/ and gdal/ directories are found beside the executable |
OPENSWMM_PERF (timers), OPENSWMM_2D_THREADS, SWMM_DW_THREADS, OPENSWMM_2D_RAINFALL_MODE, OPENSWMM_2D_FLUX_DH_EPS, OPENSWMM_2D_HEAD_RAMP, OPENSWMM_2D_SYNC_SPAN, OPENSWMM_2D_MARCHER_TELEMETRY, OPENSWMM_2D_MARCHER_CHECK, OPENSWMM_FV_BACKEND, OPENSWMM_2D_BACKEND, OPENSWMM_FV_MIN_PARALLEL_CELLS, OPENSWMM_2D_MIN_PARALLEL_CELLS, OPENSWMM_2D_MIN_PARALLEL_CELLS_DEVICE, OPENSWMM_GPU_PLUGIN_PATH, and a family of SWMM_TRACE_* and OPENSWMM_*_TRACE hooks for solver debugging.
Every invalidation and every channel firing goes to stderr:
What to look for:
selection-changed reason dirtying Raster is a regression; selection should be Overlay-only.The lookup is resolved once per process, so the unset case costs nothing.
Enable with QT_LOGGING_RULES, for example QT_LOGGING_RULES="openswmm.load.*=true".
| Category | Covers |
|---|---|
openswmm.load.project | project open |
openswmm.load.mesh | mesh read and scene build timings |
openswmm.load.gui | GUI construction |
openswmm.load.window | project window setup |
openswmm.load.model | model layer |
openswmm.load.results | .out open breakdown |
openswmm.load.raster / openswmm.load.vector | GIS layers |
openswmm.render.perf | frame timing |
openswmm.mesh.perf | mesh generation stages and the boundary cache |
openswmm.save.perf | mesh–engine sync on save |
openswmm.selection.perf | selection operations |
openswmm.bulkdelete | bulk delete |
openswmm.attr-table | the attribute table |
openswmm.ts-load.* | time-series model, dialog, chart and provider |
openswmm.ui.actionregistry | action registration and shortcuts |
openswmm.examples | example seeding |
Preferences → Simulation Defaults → 2D Mesh holds the generation tolerances that decide how big a mesh you end up with: Simplify tolerance, Snap tolerance, Minimum triangle angle, Maximum triangle area (m²) (special value unconstrained), Maximum Steiner points (special value unlimited), IDW power and Node flatten radius.
The two that most affect triangle count are maximum triangle area and minimum cell size. Halving the maximum area roughly quadruples the triangle count, and everything downstream — parse time, scene build, animation frame cost, run time — scales with it. A boundary polygon that has been cached from a previous run is reused, and the log says so.