Сплошная проверка файла против дерева проекта: каждое фактическое утверждение сверено с исходниками, CMake, манифестами Cargo и git. Ветки и заголовок: - research не имеет неотправленных коммитов (все пять веток совпадают с CFDManager), main/dev стоят на коммит дальше начального импорта; - research добавляет ещё и docs/origins/Grad's_aproximation.pdf; - pipeline_cache.bin и imgui.ini лежат не в корне, а рядом с исполняемым файлом; добавлены прочие незаметные остатки; - сказано, что вне research в docs/theory/2d_solver/ остаются только артефакты, и как читать решатель через git без переключения ветки; - build/ в рабочем дереве собран из чужого каталога и содержит 262 МБ nlohmann_json, который проект не объявляет. C++-редактор: - панель называется Mesh, а не Mesh load; - версия Vulkan SDK и версии компиляторов нигде не проверяются; - 775 МБ зависимостей — измерение по чужому build/, на деле ~400 МБ; - VK_NO_PROTOTYPES задан на двух целях, а не на проекте целиком; - ASan без UBSan на MSVC; уточнён список предупреждений; - карта целей дополнена опущенными библиотеками и видимостью связей; - RecreateSwapDependent пересоздаёт ещё и буфер глубины, первым; - дефект 1: цикл существует на уровне исходников, граф CMake ацикличен — именно поэтому CMake и молчит; - дефект 2: ожидание простоя происходит до vkBeginCommandBuffer, а не посреди записи команд; - мёртвый код Buffer/Image — 362 строки, а не ~200; - glob шейдеров ловит только *.vert и *.frag, суффикс стадии остаётся; - из категорий журнала используется лишь MeshIO, рантайм-настройки не вызываются ниоткуда, весь vk/ пишет через сырой spdlog. Rust-порт: - объявленный минимум 1.82 недостижим: залоченный egui 0.36.1 требует 1.95, на 1.82 обрывается резолвинг; - перечни зависимостей крейтов приведены полностью; - строк кода 2976, а не 2962 (устарела строка mesh на 17 строк); - утверждение «ни одно измерение не отличается на порядок» неверно: 52.3/4.4 = 11.9x; - рост +22 % на 56 % объясняется заменой vk-bootstrap, а не «почти весь»; - assets/ не копируется, откат на текущий каталог сохранён; - перечислены ошибки самого docs/rust_vs_cpp.md. kbc2d: - GREL — доля от полной неравновесной нормы, den > GREL*nrm; - 0.007 % по Cd — это сходимость бэкендов для каждой модели стенки, а не согласие четырёх моделей между собой; - компенсированное суммирование только в статистике: ядро сил считает обычным f32; - раскладок привязок две и восемнадцать, switch — признак расщеплённой; - заголовки --help русские; - гифку пишут 59 прогонов из 115; ≈87 GPU-часов плюс ≈3 CPU-часа; - тег образа 1.2.0 не отражён ни в Cargo.toml, ни в Dockerfile. Python-прототип: - в каталоге четыре поколения решателя, а не одно; добавлена таблица, какое живое, какое опорное, какое мёртвое; - «только CuPy без отката» верно не для всего дерева; - ноутбук состоит из 20 markdown-ячеек без единой ячейки кода, ссылки относительные, четыре из них битые; - отличий от kbc2d три, а не два, и второе сформулировано наоборот; - README solver_2x_sdf описывает несуществующий переключатель collision и сам объявляет свои числа устаревшими. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
42 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Read this first: the file is shared across branches
CLAUDE.md is deliberately untracked on every working branch — /CLAUDE.md
sits in .gitignore on main, dev, research and rust. One physical file
therefore survives every git checkout, so these notes stay put while the tree
around them changes, and editing them never shows up in git status.
The master copy lives on the orphan branch notes, which holds this one file
and nothing else, one commit per revision — that branch is both the backup and the
change history. It shares no ancestor with any code branch, so it never merges into
anything and never appears in a diff.
git show notes:CLAUDE.md > CLAUDE.md # restore it in a fresh clone
git log --oneline notes # history of these notes
git diff notes~1 notes # what changed in the last revision
After editing this file, publish the new revision — this touches neither the working tree nor the checked-out branch:
blob=$(git hash-object -w CLAUDE.md)
tree=$(printf '100644 blob %s\tCLAUDE.md\n' "$blob" | git mktree)
git branch -f notes "$(git commit-tree "$tree" -p notes -m 'CLAUDE.md: what changed')"
git push CFDManager notes
git clean -xfd deletes the working copy, since it is untracked — restore it with
the first command above.
Drift is guarded automatically: a PostToolUse hook
(.claude/hooks/notes_guard.py, wired in .claude/settings.local.json) compares
this file against notes after every edit and demands a described commit when the
two diverge — necessary because an untracked file never shows up in git status.
Both live outside git, excluded through .git/info/exclude rather than
.gitignore, so no branch has to carry them.
The consequence to keep in mind: this file describes the whole project, but no
single branch contains all of it. Before assuming a directory exists, check
git branch --show-current against the map below — rust/ and
docs/theory/2d_solver/ each live on one branch only.
Branch map
| branch | what it adds | trees present |
|---|---|---|
main, dev |
baseline; both at the same commit, one past the initial import — and that commit only untracks CLAUDE.md |
src/, shaders/, tests/, docs/theory/ (Python) |
research |
the CFD work — kbc2d, a Rust LBM solver, its validation campaign and Docker images |
+ docs/theory/2d_solver/, + docs/origins/Grad's_aproximation.pdf |
rust |
a Rust port of the C++ editor plus a measured C++/Rust comparison | + rust/, docs/rust_vs_cpp.md |
All five branches (including notes) are fully pushed to the CFDManager remote;
verify with git ls-remote CFDManager rather than trusting a stale tracking ref.
Nothing is shared between the two Rust efforts: docs/theory/2d_solver/ (a CFD
solver, branch research) and rust/ (a port of the 3D editor, branch rust) are
unrelated projects that merely both happen to be Rust. Neither is part of the CMake
build — CMakeLists.txt never mentions either tree.
A checkout away from research does not remove docs/theory/2d_solver/ — the
tracked sources vanish but the untracked artefacts stay, so on rust that directory
contains only out/, target/, wake.gif and demo_wake.gif and looks like a
mutilated copy of the solver. To read the solver from another branch, go through
git rather than the filesystem:
git show research:docs/theory/2d_solver/src/math.rs
git archive research docs/theory/2d_solver | tar -x -C /tmp/kbc2d # to grep it
Untracked leftovers you will see regardless of branch: build/, rust/target/,
docs/theory/2d_solver/target/, the __pycache__ dirs under docs/theory/, and
docs/theory/2d_solver/out/ plus the two ~9 MB GIFs beside it (campaign results —
the user's data, do not clean). pipeline_cache.bin and imgui.ini are written
next to whichever executable ran, i.e. in build/<preset>/src/app/ and
rust/pipeline_cache.bin — not at the repo root.
build/ in this working tree is foreign and stale. Its
CMakeCache.txt records CMAKE_HOME_DIRECTORY=C:/Users/ivan/UAS/SimV4(Not_KBC)/SimVulcan,
and _deps/ carries a 262 MB nlohmann_json clone that this project never declares.
Do not measure anything from it without re-configuring first.
SimVulcan — C++/Vulkan editor (all branches)
A minimal Vulkan 1.3 / C++20 3D model editor: three reference grid planes (XY,
XZ, YZ) through the origin plus coloured X/Y/Z axes, an orbit camera, and .obj
loading with selectable display modes (solid, wireframe, solid + wireframe). It
runs no simulation — no compute pipeline or .comp shader exists anywhere, and
Renderer.cpp disables async compute explicitly. It is a rendering skeleton with
two ImGui windows, titled Viewport and Mesh (the class behind the second
is MeshLoadPanel, which is why the docs keep calling it the "mesh load panel").
Unchanged since the initial commit on every branch — git log --all -- src returns
exactly one commit.
Build
Prerequisites: Vulkan SDK (for glslangValidator), CMake 3.26+, Ninja,
a C++20 compiler. Only CMake 3.26 and C++20 are actually enforced
(CMakeLists.txt:1, :9-11). cmake/VulkanSetup.cmake:3 is
find_package(Vulkan REQUIRED COMPONENTS glslangValidator) with no version
argument, so the "SDK 1.3.290+" in README.md is a comment, not a check; the
de-facto floor comes from the pinned volk 1.3.295 and vk-bootstrap v1.3.295.
The compiler minimums (MSVC 19.36+, gcc 11+, clang 14+) are likewise README prose,
enforced nowhere. On macOS the only hard constraint is
CMAKE_OSX_ARCHITECTURES: arm64 in the preset; MoltenVK is mentioned in a
message(STATUS).
cmake --preset windows-msvc-release
cmake --build --preset windows-msvc-release
Presets: windows-msvc-debug, windows-msvc-release, linux-gcc-release,
linux-clang-release, macos-arm64-release (all Ninja, one dir per preset under
build/<presetName>/). None of them pins a compiler — they inherit whatever the
environment provides, so README.md's "MSVC / clang-cl" is aspirational.
The first configure fetches nine repositories via FetchContent (GLFW, GLM, volk,
vk-bootstrap, VulkanMemoryAllocator, Dear ImGui, spdlog, tinyobjloader, Catch2) and
needs network access. There is no GIT_SHALLOW, so each is a clone with full
history, and no shared cache is configured (the only FETCHCONTENT_* variable
set is FETCHCONTENT_QUIET OFF) — every new build directory downloads all of it
again. Budget ~400 MB of sources / ~510 MB of _deps once built, not the
775 MB quoted in docs/rust_vs_cpp.md: that figure was measured on the foreign
build/ tree described above and includes 262 MB of nlohmann_json that nothing
here declares.
VK_NO_PROTOTYPES is not project-wide — there is no add_compile_definitions
at project scope. It is set PUBLIC on exactly two targets, imgui
(CMakeLists.txt:114) and simv_vk (src/vk/CMakeLists.txt:29). Because
simv_core links simv_vk PRIVATE, the definition never reaches simv_mesh,
simv_editor or simv_tests. Vulkan entry points load through volk.
Warnings come from simv_set_warnings (cmake/CompilerWarnings.cmake), called by
all six targets: /W4 /permissive- /Zc:preprocessor /Zc:__cplusplus /wd4100 plus
the defines _CRT_SECURE_NO_WARNINGS, NOMINMAX, WIN32_LEAN_AND_MEAN on MSVC;
-Wall -Wextra -Wpedantic -Wno-unused-parameter -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-align -Wunused -Woverloaded-virtual elsewhere. They are
not errors — no /WX or -Werror anywhere. cmake/Sanitizers.cmake defines
simv_enable_sanitizers (Debug-only; ASan only on MSVC, ASan+UBSan elsewhere)
but no target calls it — wire it in manually when chasing memory bugs.
Running
The executable resolves SPIR-V relative to the working directory (FindSpvPath,
src/vk/Shader.cpp:24-33, probes spirv/<rel> then current_path()/spirv/<rel> —
the second probe is a no-op, since the first is already CWD-relative). Two
SimVulcan POST_BUILD steps copy assets/ and the compiled spirv/ tree next to
the executable, so run from the executable's own directory
(build/<preset>/src/app/). main.cpp:40-50 additionally probes four ../
ancestors for assets/meshes. Meshes load at runtime through the Mesh panel.
Running writes two files into the CWD: pipeline_cache.bin (serialised
VkPipelineCache, reloaded on the next start) and ImGui's imgui.ini (no
IniFilename is ever set, so ImGui's default applies). Both are disposable —
delete them if pipeline creation or the panel layout misbehaves.
ContextOptions::enableValidation / enableDebugUtils default to true
(src/vk/Context.h:15-16) and nothing overrides them, so the Khronos validation
layer is requested even in Release. The severity mask is error + warning only
(Context.cpp:56-58), which makes the INFO branch of the callback dead code.
Those messages go to raw spdlog::, i.e. spdlog's default logger — not the
"simv" logger that core::Logger creates.
run.bat at the repo root is stale: it launches
build/vs2022/src/app/Release/SimVulcan.exe, a path the Ninja presets never
produce — while its own error message tells the user to run those presets. Do not
point users at it without fixing the path first.
Tests
Catch2 unit tests, mesh bounds + welding. No GPU is touched at runtime, but the
binary is not free of Vulkan: static-lib propagation through
simv_mesh → simv_core → (PRIVATE) simv_vk makes simv_tests.exe link
simv_vk, vk-bootstrap, imgui, volk and glfw3.
cmake --build --preset windows-msvc-debug --target simv_tests
ctest --preset windows-msvc-debug
windows-msvc-debug is the only preset with a testPreset — for the other
configs, invoke ctest in build/<preset>/ directly.
There are exactly three TEST_CASEs, all in tests/unit/test_mesh.cpp:
Mesh::RecalculateBounds finds AABB [mesh], WeldVertices collapses near-duplicate vertices and WeldVertices drops degenerate triangles
(both [mesh][decimator]). Each is registered individually by
catch_discover_tests, so either filter works:
ctest --preset windows-msvc-debug -R "WeldVertices" -V # matches the last two
./build/windows-msvc-debug/tests/simv_tests.exe "[decimator]"
./build/windows-msvc-debug/tests/simv_tests.exe --list-tests
Architecture
Library / target map
Arrows below show the project edges; each target also links third-party libraries, and the visibility keywords matter more than they look.
SimVulcan (exe) → simv_core, simv_vk, simv_mesh, simv_editor (all PRIVATE)
+ glm, spdlog; add_dependencies(… simv_shaders)
simv_core → PUBLIC spdlog · PRIVATE simv_vk, glfw (App owns Window + Renderer)
simv_editor → PUBLIC simv_core, simv_mesh, imgui, glm (no Vulkan)
simv_mesh → PUBLIC simv_core, glm, tinyobjloader (no Vulkan)
simv_vk → PUBLIC Vulkan::Headers, volk, vk-bootstrap, VMA, glfw, glm, spdlog
PRIVATE imgui
simv_shaders → glslangValidator (GLSL → SPIR-V), an ALL custom target
The simv_core → simv_vk edge being PRIVATE is what keeps VK_NO_PROTOTYPES
(and volk) from reaching the rest of the tree — see the Build section.
Namespaces follow directories: simv::core, simv::vk, simv::mesh,
simv::editor. Each library exports src/ as its include root, so includes are
written module-qualified (#include "mesh/Mesh.h", #include "vk/Renderer.h").
Frame loop
main.cpp creates core::App, which owns the core::Window and a
vk::Renderer, then runs the loop. Each frame Renderer::DrawFrame acquires a
swapchain image, calls the UI callback (between ImGui NewFrame/Render), then
begins the command buffer and records the scene: grid + mesh into one
dynamic-rendering pass with a depth attachment, followed by ImGui, then presents
(2 frames in flight, vkQueueSubmit2 / VkImageMemoryBarrier2).
The acquire-before-callback ordering is the root of defect 2 below — keep it in mind before moving work into the callback.
main.cpp wires the editor via the UI callback: it draws the panels, applies
mouse input to the editor::Camera, and pushes the resulting state into the
renderer (SetRenderMode, SetGridVisible, SetViewProj). Mesh loads go through
MeshLoadPanel's callback → Renderer::SetMeshCpu.
Mesh load path
MeshLoadPanel lists *.obj in the mesh directory (extension match is
case-insensitive, so .OBJ shows up too) and kicks off mesh::LoadObjAsync
(std::async(std::launch::async, …), std::future). The future is drained on
the main thread at the top of MeshLoadPanel::Draw, before the first
ImGui::Begin, so the OnLoaded callback — and therefore the GPU upload — always
runs on the render thread. Loader exceptions surface as the panel's status string.
main.cpp's OnLoaded welds the mesh (WeldVertices, tolerance 1e-4), logs
the counts, uploads via SetMeshCpu, and reframes the camera to the bbox radius.
Despite the file name, mesh/MeshDecimator.h declares one function and
implements only spatial-hash vertex welding (round-to-nearest cell hash,
collapse near-duplicates, drop degenerate triangles, recompute bounds) — there is
no LOD/decimation.
Non-obvious invariants (read before editing)
- All Vulkan lives in
src/vk/.core/,mesh/,editor/andapp/make no Vulkan API calls — a grep forvolk|vulkan|Vk[A-Z]across them returns zero hits.vk::Renderer's public header is deliberately Vulkan-free (pImpl + glm/RenderModeonly) soAppcan own it without pulling in volk. Keep it that way — do not leakVk*types into the public interfaces of those modules. Note this is convention only: every library exportssrc/publicly, so nothing stops a#include <volk.h>inmesh/; the compiler will not catch it. - Single render pass with depth. Scene (grid + mesh) and ImGui draw into one
vkCmdBeginRenderingpass that has both a colour and aD32_SFLOATdepth attachment (depthstoreOpisDONT_CARE). The ImGui backend is initialised withdepthAttachmentFormatset so its pipeline matches the pass. - Wireframe needs
fillModeNonSolid.MeshRendererbuilds a fill pipeline and aVK_POLYGON_MODE_LINEpipeline; the line pipeline uses a small static depth bias (constantFactorandslopeFactorboth-1.0) so the overlay sits on top of the fill. The device feature is requested inContext. Culling is off (VK_CULL_MODE_NONE) — loaded models may have mixed winding. - Camera is fixed on the origin.
editor::Cameraorbits (yaw/pitch/distance) the world origin; loaded models are recentred there via a translate-only model matrix. Projection uses Vulkan clip space (GLM_FORCE_DEPTH_ZERO_TO_ONE+ Y flip, isolated toCamera.cpp— the macro appears nowhere else in the tree). - Buffers.
vk::GpuMeshowns the model's vertex/index buffers (staged upload on the dedicated transfer queue).GridRendererbuilds a static host-visible mapped line buffer once in its constructor. Both scene renderers create pipeline layouts with one push-constant range and nopSetLayouts— no descriptor sets at all. - Push-constant layout is a cross-file contract.
MeshRenderer.cpp's anonymousMeshPC { mat4 mvp; vec4 color; }(rangeVERTEX|FRAGMENT) must stay byte-identical to thepush_constantblock inmesh.vert/mesh.frag;color.ais a flag, not alpha —mesh.fragdoesmix(base, base*shade, pc.color.a), so 1 = flat-shaded and 0 = constant colour for wireframe.GridRendererpushes a baremat4(vertex stage only). Change either side and you must change both. - Swapchain recreation rebuilds depth and sync objects.
RecreateSwapDependentrunsWaitIdle→swap->Recreate()→CreateDepth→ per-frameimageAvailablesemaphores (a failed acquire can leave one signalled) → per- swapchain-imagerenderFinishedsemaphores (the image count may change) → re-ensure both pipelines. Keep that ordering if you touch resize handling. - Mesh upload stalls the device.
SetMeshCpu/ClearMeshcallWaitIdlebefore touchingGpuMesh— acceptable because loads are rare; do not copy that pattern into per-frame paths.SetMeshCpuearly-returns on an empty mesh before the wait.
Known defects (found by the Rust port, all still present here)
Porting the editor surfaced four real bugs that nobody was looking for. They are
unfixed in the C++ tree on every branch; full write-up in docs/rust_vs_cpp.md
(branch rust).
- A source-level dependency cycle that the CMake graph hides.
core::Appownsvk::Renderer, andvk::Renderertakes acore::Window&— so the sources are mutually dependent. The CMake graph is acyclic, which is exactly why nothing complains:simv_vklinks no project target at all, seeingcore/Window.hthroughtarget_include_directories(simv_vk PUBLIC ..)and gettingWindow's symbols at executable link time. Cargo rejects the same shape outright, which is what surfaced it. - Mesh upload runs inside the frame.
MeshLoadPanel::DrawcallsOnLoadedfrom the UI callback, whichRenderer::DrawFrameinvokes aftervkAcquireNextImageKHR; the handler callsSetMeshCpu→vkDeviceWaitIdle. Legal, and command recording has not started yet (vkBeginCommandBuffercomes later) — but it waits for device idle while holding an acquired swapchain image. - Two sources of truth for "is there a mesh".
GpuMesh::IsValid()andRenderer's separatehasMeshflag must be kept consistent by hand;MeshRenderer::Drawthen re-checksIsValid()a third time. Mesh readyis never printed.ObjLoader::LoadObjlogs to categoryMeshIO, andmain.cpp's handler logsMesh ready: …to the same category immediately after; the category throttles at 0.5 s and both areInfo, so the second message is always dropped (only Warn/Error/Critical bypass the throttle). Post-weld counts have therefore never appeared in any log.
Also dead weight: vk::Buffer and vk::Image are used by nobody — 362 lines
across four files, still compiled into simv_vk. GridRenderer, GpuMesh and the
depth attachment all call VMA directly. Image::Desc even defaults to
VK_IMAGE_TYPE_3D and an R32G32B32A32_SFLOAT storage image, a leftover from some
compute/CFD design. Note README.md:53-54 still lists both as part of the working
Vulkan stack — that line is wrong.
Shaders
GLSL under shaders/editor/ (mesh.{vert,frag}, grid.{vert,frag}). simv_shaders
compiles each to build/<preset>/spirv/editor/<name>.<stage>.spv — the stage suffix
is kept, so the real artefacts are mesh.vert.spv, mesh.frag.spv and so on —
targeting vulkan1.3, with shaders/ as the -I root (so #include "common/foo.glsl" would resolve). The glob is CONFIGURE_DEPENDS, so a new shader
is picked up automatically, but it matches only editor/*.vert and
editor/*.frag; a .comp or .geom dropped there is silently ignored.
mesh.frag reconstructs a flat normal from screen-space derivatives, so the mesh
vertex stream carries only positions (tight float3, one binding, one attribute).
The grid pipeline is different — two attributes, position + colour.
The Rust port on branch rust compiles this same directory (build.rs reaches
../../../shaders) with the same glslangValidator flags — a shader change affects
both versions.
Logging
simv::core::Logger (spdlog-backed, singleton) with category-based throttling.
Log via LogFmt(LogCategory, LogLevel, fmt, args...). Categories: Core,
Vulkan, MeshIO, UI, Test (printed as core, vk, mesh, ui, test).
Two things the API surface does not tell you:
- Only
MeshIOis ever used. The entire tree contains threeLogFmtcall sites — two inObjLoader.cpp, one inmain.cpp.Core,Vulkan,UIandTesthave zero. - The runtime knobs are never turned.
SetMinLevel/SetThrottle/SetEnabledexist but have no callers, and no flag, env var or UI control is wired to them. They are settable programmatically, not configurable.
And the whole of vk/ logs through raw spdlog::, never through LogFmt —
as do core/App.cpp and main.cpp's fatal handler. That bypasses not just the
category throttle but the "simv" logger entirely (different pattern, unaffected
by Logger::Shutdown()), which is the only reason the GPU name survives startup;
see defect 4 above.
rust/ — Rust port of the editor (branch rust only)
A port of SimVulcan from C++20 to Rust, existing for comparison: both versions
sit side by side and build independently. Same Vulkan 1.3, dynamic rendering,
synchronization2, and the same shaders/ directory.
The declared MSRV is wrong. rust/Cargo.toml:13 says rust-version = "1.82"
(and rust/README.md repeats it), but the locked egui/egui-winit/epaint
0.36.1 each declare rust-version = "1.95", so Cargo refuses to resolve on 1.82.
The real minimum is 1.95; the tree is tested on 1.97.1. The Vulkan SDK is needed
for glslangValidator only.
cd rust
cargo build --release
./target/release/SimVulcan # SimVulcan.exe on Windows; package is simv-app
cargo test # 13 tests + 1 #[ignore]d diagnostic
Work on the code with plain cargo build, not --release. The release profile
sets lto = "thin" with codegen-units = 1, which makes a one-line edit
re-optimise and relink the whole binary: 52 s versus ~5 s in debug.
The tests cover mesh bounds, welding (three cases), Cube.obj loading and its error
path, camera clip space and zoom/pitch clamping, and logger category mapping. They
need no GPU but are not pure math: the loader tests read real files from
assets/meshes via $CARGO_MANIFEST_DIR, so they require the repo checkout. The
ignored weld_counts_for_bundled_assets is the diagnostic that produced the loader
parity table below.
Five crates mirror the CMake target map, and the "all Vulkan in one place"
invariant is compiler-enforced here — simv-mesh and simv-editor have
neither ash nor simv-vk among their dependencies. Third-party deps in full
(each crate also depends on the workspace crates shown):
crates/simv-core/ logger, window → log, chrono, winit
crates/simv-mesh/ .obj, welding, bounds → glam, tobj, log, thiserror
(+ simv-core)
crates/simv-vk/ all Vulkan + build.rs → ash, ash-window, raw-window-handle,
(GLSL→SPIR-V) gpu-allocator, egui, egui-winit,
egui-ash-renderer, winit, glam,
bytemuck, log, thiserror
(+ simv-core, simv-mesh)
crates/simv-editor/ camera, input, panels → egui, glam, log
(+ simv-core, simv-mesh)
crates/simv-app/ App and entry point → the four crates above
+ egui, winit, glam, log, anyhow
Structural differences from C++, each with a reason (full list in
docs/rust_vs_cpp.md): App lives in the executable crate (Cargo rejects the
target-graph cycle); the UI closure returns a FrameState instead of calling
setters (it is invoked from a renderer method, so it cannot borrow the renderer) —
Renderer's public API has no Set* methods at all; the loaded mesh is uploaded
after draw_frame returns, not from inside it; Buffer and Image are actually
used; no pImpl (private module fields give the same isolation); winit owns the
event loop, so ShouldClose/PollEvents are gone.
SPIR-V is embedded via include_bytes! from build.rs, so there is no runtime
shader lookup. Two runtime path dependencies remain, though, so "runs from any
directory" is not quite true: assets/meshes is searched from the executable's
ancestors and then the CWD's, and pipeline_cache.bin is written next to the
exe. Unlike the C++ build, nothing copies assets/ — the port finds the repo-root
copy by walking up from rust/target/<profile>/.
The UI toolkit differs by design: egui instead of Dear ImGui, because the
imgui crate is bindings and would compile ~40k lines of C++, defeating the point
of comparing ecosystems.
Comparison result (docs/rust_vs_cpp.md)
The document deliberately picks no winner. Machine: i5-1135G7 / Iris Xe / Windows 11, both builds release.
| C++ | Rust | |
|---|---|---|
| lines of code | 2434 | 2976 (+22 %) |
| release from scratch | 114 s | 194 s (thin LTO) · 182 s (no LTO) |
| release, one-file edit | 4.4 s | 52.3 s (LTO) · 5.5 s (no LTO) |
| debug from scratch / one-file edit | 112 s / 8.0 s | 83 s / 5.2 s |
| release exe | 1.18 MB | 5.80 MB |
| dependency sources | ~400 MB per build dir | 80 MB shared registry |
| startup to renderer ready (best of 3) | 1068 ms | 754 ms |
| CPU at 60 Hz | 11.2 % | 12.6 % |
Frame rate measures nothing — both are FIFO on a 60 Hz screen. The 1.4 pp CPU gap is egui rebuilding its layout every frame, not the language. The one sharp build cell (52 s) is the price of thin LTO, not of Rust — with LTO off it is 5.5 s against 4.4 s. Note the document's summary claim that no measurement differs by an order of magnitude is false on its own numbers: 52.3 / 4.4 = 11.9×. Every other cell stays under 10×.
The +22 % line count is mostly, not almost entirely, the missing vk-bootstrap
replacement: Context + Swapchain go from 301 to 606 lines, which is 305 of the
542-line overhang (56 %); the whole Vulkan layer accounts for 346 (64 %). The rest
is real — mesh +117, editor +104, app +90, core −64, and 176 in-module test lines
against C++'s 51.
Loader parity: cow.obj matches exactly (2451 verts / 4898 tris); plane.obj
differs by 0.13 %, entirely due to the reading libraries (tobj drops the 35
vertices no face references; fan triangulation of n-gons versus tinyobjloader's
earcut).
Known errors in docs/rust_vs_cpp.md (numbers below are the measured truth):
line-count total is 2976 / 494 comments, not 2962 / 492 — the mesh row is stale by
the 17 lines of the ignored diagnostic test; "775 MB per build dir" is the foreign
build/ tree (see the Build section) and so is the matching "10 packages in the
C++ graph" — there are nine; the Rust package graph is 82, not 76; plane.obj has
119 faces with five or more vertices, not 121; "three Catch2 tests ported verbatim"
should be all three ported, two more added; and Renderer::Impl's destructor is
22 lines, not thirty.
docs/theory/2d_solver/ — kbc2d, Rust LBM solver (branch research only)
The active research work. A D2Q9 Lattice Boltzmann solver with the entropic KBC
collision operator, written from scratch in Rust (not a port): flow past bodies in
a channel, two interchangeable backends, a ×2 nested AMR patch, sub-grid wall
models, and GIF output locked to physical flow time. Cargo.toml declares
Rust 1.75+; the Dockerfile pins rust:1.97-bookworm. Physics is anchored to the
method authors' papers in docs/origins/; formula references in the code follow the
2D paper (arXiv:1507.02509), the only arXiv id in the tree, with the wall models
citing Dorschner et al. JFM 801 (2016) for grad and Malaspinas 2015 /
Coreixas et al. PRE 96 for hrr.
README.md there is the authoritative status/validation log (609 lines: what is
verified, against which equation, with what measured numbers) and bench/README.md
(248 lines) covers the validation campaign. Read them before changing physics or
defaults — most defaults are the outcome of a documented measurement, not a guess.
Build / test / run
cd docs/theory/2d_solver
cargo build --release # with the GPU backend (default feature `gpu`)
cargo build --release --no-default-features # CPU only, no wgpu
cargo test --release # 29 fast tests
cargo test --release -- --include-ignored # + 4 long CPU runs (~18 s)
cargo test --release -- --ignored taylor_green_kbc_vs_bgk --nocapture # one diagnostic
33 #[test] functions exist (18 in math.rs, 9 in cpu.rs, 6 in gif.rs); the
4 #[ignore]d ones are all in cpu.rs and are two reference benchmarks plus two
diagnostics, not four benchmarks.
Never build --no-default-features last. Both builds write the same
target/release/kbc2d, so a CPU-only build silently overwrites the wgpu one and
--backend gpu then refuses to run. Order: no-default-features first, normal
build second.
./target/release/kbc2d --shape cylinder --size 24 --re 150 \
--nx 480 --ny 240 --steps 40000 --sponge-len 32 \
--gif wake.gif --gif-field vorticity --verbose full
--help groups every key by role, under Russian headings —
Физика, Сетка, Тело, Время, Схема, Анимация, Вывод. Note --time <seconds> as an
alternative to --steps (the two conflict): a step is not a fixed slice of time
(dt = u_lat * dx / u_phys, literally math.rs's Units::new), so refining the
cell silently shortens a fixed step count.
Defaults worth knowing beyond the three discussed below: --backend is cpu,
--collision kbc, --outlet extrapolate, --case channel, --refine 2.
File roles — one concern each
src/ holds exactly these five files — no lib.rs, no benches/, no integration
tests.
| file | owns |
|---|---|
src/math.rs |
all solver mathematics, nodewise and pure: D2Q9 lattice, product-form entropic equilibrium, shear projector, γ stabiliser, collision, Zou–He, body SDFs, wall-model closures, unit conversion, spectral diagnostics (strouhal). Tests against the papers' formulas live here. pub type R = f64. |
src/cpu.rs |
CPU backend: AoS layout, rayon, and the shared topology — Geom::build (masks), Bouzidi link assembly, Patch (AMR level coupling), initial_field. Also holds the four ignored reference runs. |
src/gpu.rs |
GPU backend: wgpu + WGSL (Vulkan/DX12/Metal), SoA layout, f32. Re-implements the physics line-for-line in WGSL but imports topology from cpu rather than duplicating it. |
src/main.rs |
CLI, problem assembly, step loop, live output, report, CSV. Owns the Spec / StepRec / FieldKind contract shared by both backends. |
src/gif.rs |
encoding, palettes, normalisation, the HUD, delay dithering, and the physical-time frame timing. |
Invariants (read before editing)
- Physics is written twice, topology once.
gpu.rsmirrorsmath.rsby hand in WGSL; masks, Bouzidi links and the patch frame come fromcpu::Geom/cpu::Patch/cpu::initial_field. Keep it that way — a past regression had the GPU silently running Bouzidi for--wall staircase, caught only because two models produced bit-identical output where they had to differ. Backend parity is a hard requirement andbench/parity.pychecks it: CPU f64 vs GPU f32 agree to 4–5 significant digits, and for each wall model the two backends agree to 0.007 % on Cd. (That number is backend parity, not agreement between wall models — those differ from one another by ~1 %.) - The backends have different step contracts.
Backendinmain.rsis an enum, not a trait: CPUstep()returns oneStepRec, GPUadvance(out)/flush(out)push a batch — results accumulate in a 128-slot ring (HIST, declared identically in Rust and WGSL) and sync once per batch. Adding a per-step GPU readback outsideStepRecreintroduces amap_async+poll(Wait)per step, which cost ~8× throughput before batching (measured 863 → 6715 steps/s). GREL = 1e-8is a relative threshold. The test isden > GREL * nrm, whereden = ⟨Δh|Δh⟩andnrm = ⟨Δ|Δ⟩— so GREL is a fraction of the full non-equilibrium norm, not of ⟨Δh|Δh⟩ itself. ⟨Δh|Δh⟩ is quadratic in non-equilibrium and physically tiny (~1e-7…1e-9), so an absolute threshold fires almost everywhere and silently substitutes γ = 2, i.e. plain LBGK instead of KBC. The report prints the degenerate-node fraction; on a healthy threshold it must be ~0 (except the very first step).- GPU is f32 and cannot be otherwise — WGSL has no
f64type at all, so no hardware helps. Convergence studies therefore run on CPU, everything else on GPU; below a true error of ~1e-3 f32 diverges from f64 by an order of magnitude. The statistics reductions (ρ, ⟨γ⟩, node and degenerate counts) use compensated Kahan–Neumaier summation viakadd. The force/torque kernel does not — it accumulates in plain f32 and reduces with a naive tree. BothREADME.md:366and earlier revisions of this file claim otherwise; the code is the authority. - GPU dispatch is 2-D with a linear index rebuilt in the shader (
lin()/wlin()), lifting the 65535-workgroup limit that capped grids at ~2048². The workgroup → node mapping stays exactly linear, which is what lets the reductions keep working; verified to 4096×4096. - Population storage has two binding layouts. Normally two bindings (
fandpost). When the adapter's max binding size is small (dzn/WSL2 caps a binding at 128 MiB while allowing a 2047 MiB buffer), each array is split across nine bindings — one per direction, 18 in total — raising the ceiling from 3.73 M to 33.5 M nodes. The choice comes from adapter limits. The split layout is the one built out ofswitchstatements infget/fset/pget/pset; the combined layout has none, which is the whole reason it is kept. Both give identical numbers; the split costs 5.7 % bandwidth. Force it withKBC2D_SPLIT_POPULATIONSset to anything other than0. MAX_BODY_BUCKETS = 4. Geometry is assembled from any number of bodies, but per-body force is bucketed — bodies beyond the fourth have their forces merged (min(body, MAXB-1)in three places), and the CLI warns when that happens.- Defaults encode measurements, not taste:
--init uniform(a rest start pumps a quarter-wave channel resonance to 75 % of U and wrecks St/Cd/Cl — and the sponge cannot remove it, since a standing mode has its pressure node exactly where the sponge sits);--kbc-model n1(equal accuracy ton2, but ~4× higher bulk viscosity, which damps longitudinal acoustics);--wall hrr(provisional — chosen on scheme structure, pending campaign group C;bouzidiresolves geometry ~4× better). Accepted values:--init uniform|rest,--kbc-model n1|n2,--wall hrr|bouzidi|grad|staircase.
Validation campaign (bench/)
115 runs in nine groups (A 12, B 16, C 20, D 14, E 12, F 9, G 14, H 9, I 9), ≈90
machine-hours — ≈87 GPU-hours over 108 runs plus ≈3 CPU-hours over the 7 CPU runs
of group A. Each run writes cmd.txt, log.txt, report.txt, series.csv and
summary.json into its own folder under out/ (untracked — those are results,
never clean them). Only 59 of the 115 pass --gif; the rest produce no
animation at all.
cd bench
python preflight.py # start every scenario for two steps — catches typos
./run_campaign.sh --calibrate # measure this machine's MLUPS (hour estimates need it)
./run_campaign.sh --dry-run # cost estimate
./run_campaign.sh --resume # run, skipping what is already done
run_campaign.sh also takes --smoke, --group, --only and --budget-hours;
run_campaign.ps1 is the Windows twin. Run preflight.py for real — it caught
group I failing on a GPU limit and group F's nine runs passing --body-x twice,
both of which would otherwise have surfaced twenty hours into a server campaign.
Note the campaign has barely started: out/ currently holds four finished runs
(A01, A02, A05, C09) plus campaign.log and summary.csv.
Deployment
Published image notbigghost/kbc2d:1.2.0; the server needs only
docker-compose.server.yml (the only compose file with no build: section), not
the sources. ENTRYPOINT is the campaign driver and CMD defaults to --dry-run,
so a stray docker run prints an estimate instead of starting a 100-hour job. Check
the card first (--profile check run --rm vulkan) — a missing GPU is better
discovered in a minute than in an hour. The check profile also carries
preflight, calibrate and plan services (plus parity in the WSL file).
Two version caveats: the 1.2.0 tag lives only in the compose files and prose —
Cargo.toml still says version = "0.1.0" and the Dockerfile defaults
ARG VERSION=dev. And linux/amd64 is asserted in README.md only; no compose
file sets platform: and the Dockerfile sets no --platform.
Two environment traps, both already handled in the image but overridable from
outside: NVIDIA Container Toolkit only injects the Vulkan ICD when
NVIDIA_DRIVER_CAPABILITIES contains graphics (with compute alone wgpu sees no
adapter), and WSL2 has no NVIDIA Vulkan driver for Linux at all — the card
arrives over /dev/dxg. That path instead uses dzn (Mesa's Vulkan→D3D12
translation, why the base image is archlinux:base — Debian/Ubuntu do not build
dzn), needs no NVIDIA runtime, and requires
WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER=1 because dzn reports
conformanceVersion = 0.0.0.0 and wgpu hides such adapters by default (the variable
is read only because gpu.rs builds the instance with .with_env()). Use
docker-compose.wsl.yml there.
docs/theory/ — Python prototype (all branches)
The earlier Python/CuPy implementation of the same LBM physics — cylinder flow
with a nested AMR patch and SDF+Bouzidi boundaries. kbc2d is a deliberate rewrite
of this, not a port. Do not fold any of it into the CMake build, and do not treat it
as dead code — but do not treat it as one program either.
This directory holds four generations of the same solver. CLAUDE.md used to name only the newest, which made the rest look like clutter. Oldest to newest:
| generation | files | status |
|---|---|---|
| the original write-up | _legacy/kbc_lbm.ipynb (64 cells, executed, outputs stored) + _legacy/kbc_lbm.py |
superseded |
| CPU reference prototypes | _amr_anims.py, _amr_anims_sdf.py (pure NumPy) |
superseded, but these are the "verified CPU prototypes" the GPU core is checked against |
| GPU core + runners | amr_gpu_core.py (has a NumPy CPU fallback) driven by amr_cylinder_gpu.py (no SDF) and amr_cylinder_sdf_gpu.py (SDF+Bouzidi); both self-titled "ОСНОВНОЙ", three modes: none / 2× / nested 2×+4× |
superseded as a model, still the producer of committed figures 11e–11g and four *_cyl*.gif animations — deleting it orphans notebook images |
| optimised rewrite | amr_opt/ (*_opt.py: no NumPy fallback, cp.fuse, precomputed link masks, CUDA Graphs) |
dead intermediate — produced nothing that is committed |
| the current model | solver_2x_sdf/ (17 modules + README), demos in demos_gpu/ |
live |
So "GPU/CuPy only, no CPU fallback" is true of solver_2x_sdf/ (backend.py
raises outright), demos_gpu/ and amr_opt/ — but not of amr_gpu_core.py,
which silently falls back to NumPy, nor of _amr_anims*.py and _legacy/, which are
CPU-only.
Also committed and easy to mistake for junk: figures/ (14 PNG) and anim/
(10 GIF, 55 MB) are tracked deliberately so the notebook renders on a fresh
clone; _amr_probe_data*.npz are probe scalars one runner writes and the other
reads; factors.csv is a hand-preserved snapshot of the Experiment-№1 registry
(nothing writes that path — run_factors.py writes solver_2x_sdf/out/factors.csv,
which is gitignored).
solver_2x_sdf/README.md— maps every file to the physics it owns (17 rows, 17 modules, exact); entry pointsrun.py,run_blockage.py,run_factors.py. Two defects in it: it suggests switchingcfg.collision="bgk"or registering a TRT operator in agetregistry — neither the config field nor the registry exists, and the same README says two paragraphs later that the operator is fixed; and it declares all of its own quantitative results stale pending a re-run after theGRELfix, which also invalidates notebook §22–24.demos_gpu/— 8 scripts + README producing 17 of the notebook's artefacts. They import the model's operators fromsolver_2x_sdfand add only what the model deliberately lacks:_common.pyreimplements a polynomial equilibrium, BGK, the H-function and a γ-field for visualisation.figures_static.pyis pure matplotlib and imports no physics at all.kbc_lbm.ipynb— the write-up. It does not merely "execute nothing": it has 20 cells, all markdown, zero code cells. Artefacts are relative-path links, not embedded data, so it is only readable in place — and 4 of its 26 image links are dead, all pointing into the gitignoredsolver_2x_sdf/out/.docs/origins/— the six source PDFs behind both implementations (seven onresearch, which adds Grad's approximation).
How kbc2d differs from this prototype
Documented in one paragraph at docs/theory/2d_solver/README.md:390 — on branch
research only; nothing under docs/theory/ mentions the divergence. There are
three differences, not two:
- kbc2d does not collide inside the body (those populations are fictitious; γ
statistics are then gathered strictly over fluid). The Python solver does
collide there —
kbc_collidetakes no fluid mask. - kbc2d's restriction additionally skips coarse nodes whose fine source node lies inside the body. The Python restriction already gates on the coarse destination mask.
- kbc2d's CPU backend is f64 and its GPU backend f32; the Python side is fp32 by
default, f64 only via
AMR_FP64.