diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ecbb6b1..ffe797ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -701,6 +701,26 @@ jobs: -E "${STRESS_REGEX}" fi + # --output-on-failure keeps a passing test's output out of this log, so + # a flake that passes on retry leaves no numbers behind, and a tolerance + # that always passes is never measured. LastTest.log keeps every + # attempt's output: print cvcgl_shadow_casters' on-screen section (GL + # renderer, background-pixel counts, luma, differing bytes) from each + # attempt, pass or fail. A Release push runs a second ctest after the + # first, which replaces LastTest.log, so there it finds nothing. + - name: cvcgl_shadow_casters on-screen numbers + if: always() && matrix.kind == 'libcvc' + shell: bash + run: | + log=build/Testing/Temporary/LastTest.log + [ -f "$log" ] || { echo "no $log"; exit 0; } + awk '/^"cvcgl_shadow_casters" start time:/ { run = 1; print "-- attempt " ++n } + run && /^D\. on screen/ { d = 1 } + run && /^(F\. |)/ { d = 0 } + d + run && /^Test (Passed|Failed)/ { print " " $0 } + /^"cvcgl_shadow_casters" end time:/ { run = 0; d = 0 }' "$log" + - name: Pack libcvc zip if: matrix.kind == 'libcvc' working-directory: build diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 9f30ef78..7f73df2d 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -644,6 +644,27 @@ add_executable(cvcgl_sdl_input test/cvcgl_sdl_input.cpp) target_link_libraries(cvcgl_sdl_input PRIVATE cvcGL) add_test(NAME cvcgl_sdl_input COMMAND cvcgl_sdl_input) +# ── GL tests: one at a time ── +# Every cvcGL test that renders through a GL context takes the CTest resource +# lock cvcgl_gl_context, so ctest never runs two of them at once; the headless +# tests listed below keep running in parallel. Why: on the macOS runners every +# failed attempt of cvcgl_shadow_casters (4 of 12 seen) ran alongside +# cvcgl_shadow_caster_growth and other GL tests under ctest --parallel, no +# passing attempt overlapped growth, and each retry passed. On every platform, +# on purpose: it costs only wall time (the GL tests run end to end instead of +# overlapped) and takes GL contention out of every run. A test added above is +# locked unless it is listed here as headless. +get_property(_cvcgl_gl_tests DIRECTORY PROPERTY TESTS) +list(REMOVE_ITEM _cvcgl_gl_tests + cvcgl_smoke cvcgl_metadata_mirror cvcgl_volume_range cvcgl_traversal + cvcgl_grid_bounds cvcgl_bounds_walk cvcgl_scene_chrome cvcgl_remove + cvcgl_teardown cvcgl_light_state_thread cvcgl_pose_echo + cvcgl_node_outlives_scene cvcgl_texture cvcgl_texture_zerocopy + cvcgl_stream_texture cvcgl_state_publisher cvcgl_nav_stats_publish + cvcgl_camera cvcgl_track_parity cvcgl_fps_hud cvcgl_stage_setters + cvcgl_streaming_races cvcgl_sdl_input) +set_tests_properties(${_cvcgl_gl_tests} PROPERTIES RESOURCE_LOCK cvcgl_gl_context) + # ── examples (opt-in, OFF by default) ── # Standalone C++ programs that drive cvcGL directly — SceneGraph + SceneRenderer # (the onscreen "basic window") + CameraController (orbit / Quake-fly / cinematic diff --git a/src/cvcGL/test/cvcgl_shadow_casters.cpp b/src/cvcGL/test/cvcgl_shadow_casters.cpp index 138d6de9..229b015f 100644 --- a/src/cvcGL/test/cvcgl_shadow_casters.cpp +++ b/src/cvcGL/test/cvcgl_shadow_casters.cpp @@ -13,8 +13,10 @@ // ones added later -- and readable from the bare vtkProp; // B. moving / restyling a non-caster never bakes; moving a caster does; // C. a node starting or stopping casting bakes; a light change bakes; -// D. on screen: a non-caster casts no shadow (same pixels as no object at -// all), a caster does; +// D. on screen: a non-caster casts no shadow, a caster does, and only inside +// its footprint; judged against reference renders of the same run, on +// frames that show the whole ground; byte for byte where rendering is +// deterministic (on Apple's GL, master's 1-luma check, carried over); // E. the update interval still strides bakes of a moving caster; // F. a caster deformed in place (updateVertices / updateNormals / // updateColors) bakes, a non-caster does not; a RibbonNode does not cast @@ -24,6 +26,7 @@ // (endpoints, width, heights) re-bakes while uploading nothing. // B-F render for real and skip where nothing rasterises (fatal under // CVC_REQUIRE_RENDER=1). +#include #include #include #include @@ -44,10 +47,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -78,6 +83,30 @@ static cvc::geometry box(double x0, double y0, double z0, double x1, double y1, return g; } +// A flat, upward-facing ground at height z, cut into step x step tiles rather +// than a box top's two triangles. A guard against one HYPOTHESIS for the macOS +// flake (see D), not a confirmed cause: that Apple's GL now and then drops +// triangles clipped far outside the frustum -- as two triangles spanning a +// 120-unit ground are, in the 16-unit view and in the shadow bake, whose light +// frustum is fitted to the scene bounds so the ground's edges sit on it. +// Nothing has shown that it does; the tiles cost nothing and remove the case. +static cvc::geometry tiledGround(double x0, double y0, double x1, double y1, double z, + double step) { + cvc::geometry g; + const int nx = static_cast(std::lround((x1 - x0) / step)); + const int ny = static_cast(std::lround((y1 - y0) / step)); + for (int j = 0; j <= ny; ++j) + for (int i = 0; i <= nx; ++i) + g.points().push_back({x0 + (x1 - x0) * i / nx, y0 + (y1 - y0) * j / ny, z}); + auto at = [nx](int i, int j) { return static_cast(j * (nx + 1) + i); }; + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + g.tris().push_back({at(i, j), at(i + 1, j), at(i + 1, j + 1)}); + g.tris().push_back({at(i, j), at(i + 1, j + 1), at(i, j + 1)}); + } + return g; +} + static vtkShadowMapBakerPass *findBaker(vtkRenderPass *p) { if (!p) return nullptr; @@ -138,7 +167,11 @@ static void flags(cvc::app &app) { chk(GraphicsNode::propCastsShadow(nullptr), "null is harmless"); } +// Mean luma over columns [x0, x1) of the middle half of the rows; -1 for a +// frame that is not w x h. static double meanLuma(const std::vector &rgb, int w, int h, int x0, int x1) { + if (rgb.size() != static_cast(w) * h * 3) + return -1.0; double sum = 0.0; long n = 0; for (int y = h / 4; y < 3 * h / 4; ++y) @@ -150,6 +183,59 @@ static double meanLuma(const std::vector &rgb, int w, int h, int return n ? sum / n : 0.0; } +// How two w x h frames differ over columns [x0, x1) of rows [y0, y1): how many +// bytes, and the largest step. bytes is -1 when either frame is not w x h. +struct Diff { + long bytes = -1; + int step = 0; +}; +static Diff diffRegion(const std::vector &a, const std::vector &b, + int w, int h, int x0, int x1, int y0, int y1) { + Diff d; + const std::size_t n = static_cast(w) * h * 3; + if (a.size() != n || b.size() != n) + return d; + d.bytes = 0; + for (int y = y0; y < y1; ++y) + for (int x = x0; x < x1; ++x) + for (int c = 0; c < 3; ++c) { + const std::size_t o = (static_cast(y) * w + x) * 3 + c; + const int s = std::abs(static_cast(a[o]) - static_cast(b[o])); + if (s) { + ++d.bytes; + d.step = std::max(d.step, s); + } + } + return d; +} + +// Pixels showing the background. It is pure blue; the white ground is grey at +// any light level, black included, so these are pixels where the ground is +// MISSING -- not merely in shadow. +static long backgroundPixels(const std::vector &rgb) { + long n = 0; + for (std::size_t o = 0; o + 2 < rgb.size(); o += 3) + if (rgb[o + 2] > rgb[o] + 64 && rgb[o + 2] > rgb[o + 1] + 64) + ++n; + return n; +} + +// The "OpenGL renderer string" line of the window's capability report. +static std::string glRendererName(vtkRenderWindow *w) { + auto *gl = vtkOpenGLRenderWindow::SafeDownCast(w); + const char *caps = gl ? gl->ReportCapabilities() : nullptr; + const std::string all = caps ? caps : ""; + const std::string key = "renderer string:"; + const std::size_t at = all.find(key); + if (at == std::string::npos) + return "unknown"; + std::size_t b = all.find_first_not_of(' ', at + key.size()); + const std::size_t e = all.find('\n', at); + if (b == std::string::npos || b > e) + b = e; + return all.substr(b, e == std::string::npos ? std::string::npos : e - b); +} + static bool canRasterise(cvc::app &app) { SceneGraph sg(app, "castcontrol"); sg.setDiagnosticChromeVisible(false); @@ -179,11 +265,12 @@ static void rendered(cvc::app &app) { // A white ground seen from straight above; a slab high up and OUT of view to // the +x side; a sun from +x at 45 degrees throws the slab's shadow onto the // right half of the view. A building (caster) and a vehicle (non-caster) - // stand elsewhere, out of view too. + // stand elsewhere, out of view too. The ground is tiled (tiledGround) and the + // background blue, so a frame that lost the ground cannot pass for shadow. SceneGraph sg(app, "casters"); sg.setDiagnosticChromeVisible(false); auto ground = sg.getGraphicsRoot()->addGraphicsChild("ground"); - ground->setGeometry(box(-60, -60, -1, 60, 60, 0)); + ground->setGeometry(tiledGround(-60, -60, 60, 60, 0.0, 2.0)); ground->setColor(1.0, 1.0, 1.0); auto slab = sg.getGraphicsRoot()->addGraphicsChild("slab"); slab->setGeometry(box(20, -6, 20, 30, 6, 21)); @@ -195,7 +282,9 @@ static void rendered(cvc::app &app) { const int W = 96, H = 96; SceneRenderer view(sg, W, H, /*offscreen=*/true, "main"); - view.setBackground(0.0, 0.0, 0.0); + // Red 0, as before: VTK clears the shadow maps to the background, and the + // maps are single-channel (red). + view.setBackground(0.0, 0.0, 1.0); view.setCamera(0, 0, 30, 0, 0, 0, 0, 1, 0, 30.0, 1.0, 200.0); const int sun = sg.addDirectionalLight(90.0, 45.0); // from +x if (!sg.setShadowsEnabled(true)) { @@ -246,34 +335,98 @@ static void rendered(cvc::app &app) { sg.setLightDirection(sun, 90.0, 45.0); frame(); - std::printf("D. on screen\n"); - const std::vector casting = view.frameRGB(); + // Frames until one does not bake: the maps then match the scene. + auto settle = [&]() { + for (int i = 0; i < 6; ++i) + if (!frame()) + return true; + return false; + }; + + const std::string renderer = glRendererName(view.renderer()->GetRenderWindow()); + std::printf("D. on screen (%s)\n", renderer.c_str()); + // Judged against reference renders of this same run, not absolute levels: + // the right half lies in the slab's shadow footprint, the left half outside + // it and is each frame's lit reference. Casting must darken the footprint + // well below it, not casting must leave the footprint lit like it, `absent` + // must match `notCasting`, and casting must leave the left half alone. + // + // On the macOS runners the first ctest attempt of this test failed in every + // Debug job seen (#521 x2, #522, #523) with luma 0.0 in every value it + // printed (the three footprints and the casting frame's left half); each + // retry passed. The leading explanation is contention: each failing attempt + // ran while cvcgl_shadow_caster_growth (it grows to six shadow-casting spots) + // and cvcgl_stage_caster_truth were rendering, and no passing attempt did -- + // so the GL tests now hold one CTest resource lock (src/cvcGL/CMakeLists.txt). + // In those Debug jobs the first attempt and that overlap always coincided, so + // a first-run effect is not ruled out. No Mac has reproduced it, so the next + // log has to tell: every capture must + // show the ground, whole, on its FIRST draw. The background is blue, so a + // frame that lost the ground counts background pixels, while a ground drawn + // black (a shadow map read as empty) counts none and reads luma 0. All of it + // prints, pass or fail, and CI prints this section from passing runs too. + auto capture = [&](const std::string &what) { + const bool settled = settle(); + std::vector px = view.frameRGB(); + const long missing = backgroundPixels(px); + chk(settled && view.frameWidth() == W && view.frameHeight() == H && missing == 0, + what + ": a settled " + std::to_string(view.frameWidth()) + "x" + + std::to_string(view.frameHeight()) + " frame, the ground in all of it (" + + std::to_string(missing) + " background pixels)"); + return px; + }; + const std::vector casting = capture("slab casting"); slab->setCastsShadow(false); - frame(); - const std::vector notCasting = view.frameRGB(); + const std::vector notCasting = capture("slab not casting"); slab->setVisible(false); - frame(); - const std::vector absent = view.frameRGB(); + const std::vector absent = capture("slab absent"); slab->setVisible(true); slab->setCastsShadow(true); frame(); - const double lCast = meanLuma(casting, W, H, W / 2 + 8, W - 4); - const double lNot = meanLuma(notCasting, W, H, W / 2 + 8, W - 4); - const double lAbsent = meanLuma(absent, W, H, W / 2 + 8, W - 4); - const double lLeft = meanLuma(casting, W, H, 4, W / 2 - 8); - std::printf(" luma, right half: caster %.1f non-caster %.1f absent %.1f (left half %.1f)\n", - lCast, lNot, lAbsent, lLeft); + const int rx0 = W / 2 + 8, rx1 = W - 4, lx0 = 4, lx1 = W / 2 - 8; + const double lCast = meanLuma(casting, W, H, rx0, rx1); + const double lNot = meanLuma(notCasting, W, H, rx0, rx1); + const double lAbsent = meanLuma(absent, W, H, rx0, rx1); + const double lLeft = meanLuma(casting, W, H, lx0, lx1); + const double lLeftNot = meanLuma(notCasting, W, H, lx0, lx1); + // The whole frame, and the left half's rows meanLuma reads. + const Diff dAbsent = diffRegion(notCasting, absent, W, H, 0, W, 0, H); + const Diff dLeft = diffRegion(casting, notCasting, W, H, lx0, lx1, H / 4, 3 * H / 4); + std::printf(" luma, right half: caster %.1f non-caster %.1f absent %.1f (left half %.1f, " + "%.1f without the caster)\n", + lCast, lNot, lAbsent, lLeft, lLeftNot); + std::printf(" bytes differing: non-caster vs absent %ld (max step %d), whole frame; caster vs " + "non-caster %ld (max step %d), left half\n", + dAbsent.bytes, dAbsent.step, dLeft.bytes, dLeft.step); + // The left half is the lit reference of the same frame. chk(lLeft > 20.0, "the ground is lit"); - chk(lCast < lNot - 5.0, "a casting slab darkens the ground"); - chk(std::fabs(lNot - lAbsent) < 1.0, "a non-casting slab leaves it as if the slab were absent"); + chk(lCast < 0.5 * lLeft, "a casting slab darkens the ground (to under half its lit level)"); + chk(lNot > 0.9 * lLeftNot, "a non-casting slab does not (its footprint is lit like the rest)"); + // Byte for byte wherever rendering is deterministic (NVIDIA, Mesa llvmpipe). + // Apple's GL has drawn frames a few bytes apart from identical GL call + // traces with shadows on (cvcgl_lowmem_fastdraw, PR #522's macOS CI), so + // there -- an Apple build whose renderer string says Apple -- this keeps + // master's check: the footprint's mean luma within 1 of the slab-absent + // frame's. That tolerance is carried over, not measured: ctest kept passing + // runs' output out of the log, and the failing runs read 0.0 everywhere. The + // left-half claim is not judged there until a macOS log shows its numbers + // (printed above). +#ifdef __APPLE__ + const bool appleGL = renderer.find("Apple") != std::string::npos; +#else + const bool appleGL = false; +#endif + if (appleGL) { + chk(std::fabs(lNot - lAbsent) < 1.0, "a non-casting slab leaves the footprint as if the slab " + "were absent (Apple's GL: master's 1-luma check)"); + } else { + chk(dAbsent.bytes == 0, + "a non-casting slab leaves the frame as if the slab were absent (byte-exact)"); + chk(dLeft.bytes == 0, "the shadow stays in its footprint: casting leaves the left half as it " + "was (byte-exact)"); + } std::printf("F. deformed in place, and streamed\n"); - auto settle = [&]() { - for (int i = 0; i < 6; ++i) - if (!frame()) - return true; - return false; - }; cvc::geometry tg = box(-50, 40, 0, -45, 45, 6); for (std::size_t i = 0; i < tg.points().size(); ++i) tg.colors().push_back({0.2 + 0.1 * static_cast(i % 3), 0.5, 0.3});