From ae82da2a888ad9f8bc323d64ede85badae39454e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 1 Oct 2026 22:17:35 -0500 Subject: [PATCH 1/5] cvcGL test: shadow_casters D checks survive Apple's software renderer On the macOS runners (Apple Software Renderer, GL 4.1) the first attempt of cvcgl_shadow_casters failed in every Debug package job seen (#521 x2, #522, #523), always the same way: luma 0.0 in BOTH halves of all three captures (casting, non-casting, absent), so "the ground is lit" and "a casting slab darkens the ground" failed together while every bake-decision check passed; ctest's retry then passed. Not a threshold problem: no image of the ground was read at all. The ground was a 120x120 box: two top triangles viewed through a 16-unit window, so they were clipped far outside the frustum, and the shadow bake's light frustum is fitted to the scene bounds, so the ground's edges lay on it too. Apple's software renderer intermittently drops triangles like that. With a black background, a ground lost from the frame and a ground lost from the shadow maps (which VTK's exponential maps read as total shadow -- confirmed here: a map without the ground blacks out the whole ground) both read 0.0. - The ground is now 2-unit tiles (tiledGround), so no triangle reaches far outside either frustum. - The background is blue (red still 0, so the map clear is unchanged): every capture must show the ground in all of its pixels, and a capture that lost it says so instead of passing for shadow. On Apple only, such a capture is redrawn (same maps, no bake) up to 3 times, each redraw logged; elsewhere the first draw must be whole. - Each capture follows a settle (frames until no bake), and the frame size must be 96x96 before any pixel is indexed. - Thresholds come from the same run: casting must fall under half of the same frame's lit left half; not casting must stay within 10% of it. - Strict where rendering is deterministic: non-casting is byte-identical to absent over the whole frame, and casting leaves the left half byte-identical. On Apple only, the same claims are held to the 1-luma bound the absent/non-casting comparison always met there. - D prints the GL renderer string, for the next macOS log. Linux: 30/30 NVIDIA and 30/30 llvmpipe (plus 24 concurrent llvmpipe and ctest --repeat until-fail:30), all byte-exact. Breaking the caster filter or the caster re-bake fails D; a ground smaller than the view fails the coverage check. --- src/cvcGL/test/cvcgl_shadow_casters.cpp | 172 ++++++++++++++++++++---- 1 file changed, 148 insertions(+), 24 deletions(-) diff --git a/src/cvcGL/test/cvcgl_shadow_casters.cpp b/src/cvcGL/test/cvcgl_shadow_casters.cpp index 138d6de9..56638658 100644 --- a/src/cvcGL/test/cvcgl_shadow_casters.cpp +++ b/src/cvcGL/test/cvcgl_shadow_casters.cpp @@ -14,7 +14,9 @@ // 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; +// all -- byte for byte, except on Apple's renderer, within 1 luma), a +// caster does, and only inside its footprint; judged against reference +// renders of the same run, on frames that show the whole ground; // 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 @@ -44,10 +46,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -78,6 +82,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. Not two +// big triangles: Apple's software renderer (GitHub's arm64 macOS runners) +// intermittently drops triangles that are clipped far outside the frustum, and +// two triangles spanning a 120-unit ground are exactly that, both 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. Lost from the frame, or from the maps (which +// VTK then reads as total shadow), the ground read luma 0.0 in both halves. +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 +166,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 +182,48 @@ static double meanLuma(const std::vector &rgb, int w, int h, int return n ? sum / n : 0.0; } +// Whether two w x h frames agree byte for byte over the region meanLuma reads. +static bool sameRegion(const std::vector &a, const std::vector &b, + int w, int h, int x0, int x1) { + if (a.size() != static_cast(w) * h * 3 || b.size() != a.size()) + return false; + for (int y = h / 4; y < 3 * h / 4; ++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; + if (a[o] != b[o]) + return false; + } + return true; +} + +// 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 +253,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 +270,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 +323,81 @@ 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; + }; + + std::printf("D. on screen (%s)\n", glRendererName(view.renderer()->GetRenderWindow()).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. + // + // First, every capture must show the ground, whole (see tiledGround). Any + // renderer but Apple's must manage that on its first draw; on Apple's, a + // capture that still lost some is redrawn -- same scene, same maps, no bake + // -- at most kRedraws times, each redraw reported. +#ifdef __APPLE__ + const int kRedraws = 3; +#else + const int kRedraws = 0; +#endif + auto capture = [&](const std::string &what) { + const bool settled = settle(); + std::vector px = view.frameRGB(); + long missing = backgroundPixels(px); + for (int i = 0; missing && i < kRedraws; ++i) { + std::printf(" (%s: %ld pixel(s) without the ground; redraw %d of %d)\n", what.c_str(), + missing, i + 1, kRedraws); + px = view.frameRGB(); + 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); + 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); + // 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)"); +#ifdef __APPLE__ + // Apple's renderer: within the 1-luma bound the absent / non-casting + // comparison has always met there, rather than byte for byte. + chk(std::fabs(lNot - lAbsent) < 1.0 && std::fabs(lLeft - lLeftNot) < 1.0, + "a non-casting slab leaves the ground as if the slab were absent; the shadow stays in " + "its footprint (within 1 luma)"); +#else + chk(!notCasting.empty() && notCasting == absent && + sameRegion(casting, notCasting, W, H, lx0, lx1), + "a non-casting slab leaves the ground as if the slab were absent; the shadow stays in " + "its footprint (byte-exact)"); +#endif 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}); From b19d0db2102b91b89e37de2f6d5e9536cf174a23 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 1 Oct 2026 22:51:51 -0500 Subject: [PATCH 2/5] cvcGL tests: one GL test at a time (CTest RESOURCE_LOCK cvcgl_gl_context) Every cvcGL test that renders through a GL context now takes the CTest resource lock cvcgl_gl_context, so ctest never runs two of them at once; the 23 headless tests (listed by name) keep running in parallel. The lock is assigned by exclusion -- every test in the directory minus the headless list -- so a GL test added later is locked unless someone lists it as headless. Why: on the macOS runners every failed attempt of cvcgl_shadow_casters (4 of the 12 attempts across the 8 package-macos jobs of #521-#523) ran alongside cvcgl_shadow_caster_growth and other GL tests under ctest --parallel; no passing attempt overlapped growth, and each retry passed. That is a correlation, not a reproduction -- no Mac reproduced the flake. On every platform, on purpose: the cost is wall time only. On macOS Debug the GL tests' own times summed to ~370 s (cvcgl_volren_node 230 s of it) where they overlapped into ~250 s, so that ctest step can grow by up to ~2 minutes of ~9. --- src/cvcGL/CMakeLists.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 661388ca..362e26d8 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -602,6 +602,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 From 325661903de0a9e37802f32e89a6348c84d3c0eb Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 1 Oct 2026 22:52:04 -0500 Subject: [PATCH 3/5] cvcGL test: shadow_casters D -- restate the macOS diagnosis; no Apple redraws Corrects ae82da2a8, which stated a cause it had not shown. Diagnosis, restated. On the macOS runners the first ctest attempt of cvcgl_shadow_casters failed in every Debug job seen (#521 x2, #522, #523), luma 0.0 everywhere in D; each retry passed; Release never failed. The leading explanation is contention: all 4 failing attempts ran while cvcgl_shadow_caster_growth was rendering, and none of the 8 passing attempts did (retries and Release runs overlapped other GL tests, but not growth). The previous commit's cause -- Apple's GL dropping large triangles clipped far outside the frustum -- is a hypothesis nothing has confirmed; it does not explain why a retry of the same binary seconds later always passes. A shadow map sampled as empty fits "0.0 everywhere" as well. The GL tests now hold one CTest resource lock (previous commit); whether that ends it is for the next macOS Debug runs to show. - The tiled ground stays, described as a guard against that hypothesis, not as the fix. - The Apple-only redraw loop (up to 3 redraws of a frame missing ground) is gone. Its cap was a guess, and ctest hid the redraw lines of any passing run, so it could never be measured. Every capture must now show the whole ground on its first draw, on every renderer; a frame that does not fails, and ctest's retry runs the test again. The blue background still tells the two failure modes apart: ground missing from the frame counts background pixels, ground drawn black (shadow map read as empty) counts none and reads luma 0. - D prints the renderer string, each capture's background-pixel count, the luma values and, new, the differing-byte count and largest step of non-caster vs absent (whole frame) and caster vs non-caster (left half), pass or fail. The next commit prints this section from passing macOS runs. - The Apple relaxation is keyed on the renderer string containing "Apple" inside the __APPLE__ guard, not on the platform alone. There it is master's check, unchanged: the footprint's mean luma within 1 of the slab-absent frame's. That tolerance is carried over, not measured -- no passing run ever printed the values, and the failing runs read 0.0 vs 0.0. The previous commit's new left-half claim (|lLeft - lLeftNot| < 1) is not made on Apple; its numbers are printed for a later bound. - Everywhere else, the two claims are byte-exact, now as separate checks: non-caster frame == absent frame, and casting leaves the left half as it was. Linux, CVC_REQUIRE_RENDER=1: 30/30 NVIDIA GTX 1650 and 30/30 llvmpipe (LLVM 20.1.2), 0 differing bytes in every run, identical luma lines. The Apple branch, forced on with a renderer-string key that matches llvmpipe and not NVIDIA, passes on llvmpipe while NVIDIA stays byte-exact. Mutants fail D: capturing "not casting" with the slab still casting (non-caster footprint 0.0, 11882 bytes differ); a 10x10 ground (5616 background pixels in each capture); the light moved for the casting capture only (5184 bytes differ in the left half). --- src/cvcGL/test/cvcgl_shadow_casters.cpp | 118 +++++++++++++++--------- 1 file changed, 72 insertions(+), 46 deletions(-) diff --git a/src/cvcGL/test/cvcgl_shadow_casters.cpp b/src/cvcGL/test/cvcgl_shadow_casters.cpp index 56638658..17635d20 100644 --- a/src/cvcGL/test/cvcgl_shadow_casters.cpp +++ b/src/cvcGL/test/cvcgl_shadow_casters.cpp @@ -13,10 +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 -- byte for byte, except on Apple's renderer, within 1 luma), a -// caster does, and only inside its footprint; judged against reference -// renders of the same run, on frames that show the whole ground; +// 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 @@ -26,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 @@ -82,13 +83,13 @@ 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. Not two -// big triangles: Apple's software renderer (GitHub's arm64 macOS runners) -// intermittently drops triangles that are clipped far outside the frustum, and -// two triangles spanning a 120-unit ground are exactly that, both 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. Lost from the frame, or from the maps (which -// VTK then reads as total shadow), the ground read luma 0.0 in both halves. +// 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; @@ -182,19 +183,30 @@ static double meanLuma(const std::vector &rgb, int w, int h, int return n ? sum / n : 0.0; } -// Whether two w x h frames agree byte for byte over the region meanLuma reads. -static bool sameRegion(const std::vector &a, const std::vector &b, - int w, int h, int x0, int x1) { - if (a.size() != static_cast(w) * h * 3 || b.size() != a.size()) - return false; - for (int y = h / 4; y < 3 * h / 4; ++y) +// 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; - if (a[o] != b[o]) - return false; + 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 true; + return d; } // Pixels showing the background. It is pure blue; the white ground is grey at @@ -331,32 +343,29 @@ static void rendered(cvc::app &app) { return false; }; - std::printf("D. on screen (%s)\n", glRendererName(view.renderer()->GetRenderWindow()).c_str()); + 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. // - // First, every capture must show the ground, whole (see tiledGround). Any - // renderer but Apple's must manage that on its first draw; on Apple's, a - // capture that still lost some is redrawn -- same scene, same maps, no bake - // -- at most kRedraws times, each redraw reported. -#ifdef __APPLE__ - const int kRedraws = 3; -#else - const int kRedraws = 0; -#endif + // 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 both halves of all + // three captures; each retry passed. The leading explanation is contention: + // each failing attempt ran while cvcgl_shadow_caster_growth (it grows to six + // shadow-casting spots) was rendering, and no passing attempt did -- so the + // GL tests now hold one CTest resource lock (src/cvcGL/CMakeLists.txt). 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(); - long missing = backgroundPixels(px); - for (int i = 0; missing && i < kRedraws; ++i) { - std::printf(" (%s: %ld pixel(s) without the ground; redraw %d of %d)\n", what.c_str(), - missing, i + 1, kRedraws); - px = view.frameRGB(); - missing = backgroundPixels(px); - } + 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 (" + @@ -377,25 +386,42 @@ static void rendered(cvc::app &app) { 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 < 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__ - // Apple's renderer: within the 1-luma bound the absent / non-casting - // comparison has always met there, rather than byte for byte. - chk(std::fabs(lNot - lAbsent) < 1.0 && std::fabs(lLeft - lLeftNot) < 1.0, - "a non-casting slab leaves the ground as if the slab were absent; the shadow stays in " - "its footprint (within 1 luma)"); + const bool appleGL = renderer.find("Apple") != std::string::npos; #else - chk(!notCasting.empty() && notCasting == absent && - sameRegion(casting, notCasting, W, H, lx0, lx1), - "a non-casting slab leaves the ground as if the slab were absent; the shadow stays in " - "its footprint (byte-exact)"); + 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"); cvc::geometry tg = box(-50, 40, 0, -45, 45, 6); From 776302a8296fcc11bbff43d2f89c0b652a5ab8af Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 1 Oct 2026 22:52:10 -0500 Subject: [PATCH 4/5] ci: print cvcgl_shadow_casters' on-screen numbers from passing macOS runs ctest --output-on-failure keeps a passing test's output out of the job log. A flake that passes on retry then leaves no numbers behind, and a tolerance that always passes is never measured. For cvcgl_shadow_casters that hid the renderer string, the background-pixel counts, the luma values and the differing-byte counts on macOS, where D keeps master's carried-over 1-luma check and does not yet judge the left half. ctest's LastTest.log keeps every attempt's output. A new package-macos step, run always(), prints D from each cvcgl_shadow_casters attempt in it, with its verdict. A Release push runs a second ctest after the first, which replaces LastTest.log, so that one case prints nothing; PRs and Debug pushes run a single ctest. The awk program gives the same output under gawk, mawk, nawk and busybox awk on a LastTest.log with three attempts. --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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 From 000d31f94bf9016b902925b415bf1f5f6e21e395 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 1 Oct 2026 23:16:21 -0500 Subject: [PATCH 5/5] cvcGL test: shadow_casters D comment states only what the macOS logs printed The failing runs printed the three footprints and the casting frame's left half, not both halves of every capture. Also names cvcgl_stage_caster_truth next to growth, and notes that in the Debug jobs the first attempt and the overlap always coincided, so a first-run effect is not ruled out. --- src/cvcGL/test/cvcgl_shadow_casters.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cvcGL/test/cvcgl_shadow_casters.cpp b/src/cvcGL/test/cvcgl_shadow_casters.cpp index 17635d20..229b015f 100644 --- a/src/cvcGL/test/cvcgl_shadow_casters.cpp +++ b/src/cvcGL/test/cvcgl_shadow_casters.cpp @@ -352,12 +352,15 @@ static void rendered(cvc::app &app) { // 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 both halves of all - // three captures; each retry passed. The leading explanation is contention: - // each failing attempt ran while cvcgl_shadow_caster_growth (it grows to six - // shadow-casting spots) was rendering, and no passing attempt did -- so the - // GL tests now hold one CTest resource lock (src/cvcGL/CMakeLists.txt). No - // Mac has reproduced it, so the next log has to tell: every capture must + // 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