fix(visualizer): create shadow-map FBO in headless mode\n\nPreviously… - #100
Open
heesup wants to merge 1 commit into
Open
fix(visualizer): create shadow-map FBO in headless mode\n\nPreviously…#100heesup wants to merge 1 commit into
heesup wants to merge 1 commit into
Conversation
Contributor
Author
|
But I'm not an expert on OpenGL, and I think it might cause any memory leaks or driver crashes. So please ignore this PR if it's not tested well or makes the visualizer unstable. Thank you! |
… shadow-map framebuffer and depth texture were only created\nin windowed mode, causing shadows to be missing in offscreen renders.\nNow the same FBO setup is performed for headless mode as well.
bnbailey-psl
added a commit
that referenced
this pull request
Aug 7, 2026
- Fixed shadows being silently absent from every headless render. `initialize()` created the shadow-map framebuffer and its depth texture inside an `if (!headless)` guard, so in headless mode `framebufferID` and `depthTexture` both stayed 0 — but the shadow pass in `plotUpdate()` is gated only on the lighting model, never on `headless`. With `LIGHTING_PHONG_SHADOWED` it therefore bound framebuffer 0, rendering the shadow depth pass into the default framebuffer with an 8192x8192 viewport, and bound texture 0 as the shadow map. Sampling an unbound texture returns 0.0, and the depth comparison in `primaryShader.frag` is `0.0 < proj.z`, which holds for essentially all geometry — so all four Poisson taps reported "occluded" and the entire scene rendered at the fully-shadowed brightness rather than merely losing its shadows. This is the other half of the v1.3.73 fix: zero-initializing those handles stopped the macOS crash from binding a stale GL name, but never made headless mode create the framebuffer. Reported with a reproducer by Heesup Yun (@heesup, GitHub #100), who also contributed the original patch. - The shadow-map framebuffer is now created lazily, on the first render that actually uses shadowed lighting, in both windowed and headless modes. Creating it unconditionally at initialization would have imposed the 8192x8192 depth texture — 256 MB — on every `Visualizer` instance, including the many that never enable shadows. Its size is now also clamped to `GL_MAX_TEXTURE_SIZE`; nothing previously validated `shadow_buffer_size` against the driver limit, even though `initialize()` already anticipates contexts reporting a maximum texture size below 1024. - The destructor released the shadow framebuffer and depth texture only in its windowed branch, so once headless mode could allocate them they would have leaked on every instance. Both are now released regardless of mode, guarded on being non-zero so the lazy path is safe. - The initial light direction is now sent to the shader in headless mode as well. It was set only when `!headless`, and the only other unconditional call site sits inside `render()` behind a `line_count > 0` check that a scene made of patches never satisfies. Because `Shader::initialize()` seeds the uniform with `(0,0,1)`, this did not blank the lighting outright — instead a headless `Visualizer` that never called `setLightDirection()` silently rendered with a directly-overhead light while a windowed one used the intended `(1,1,1)`, so the two modes shaded the same scene differently. Relatedly, `setLightDirection()` stored the normalized direction but forwarded the caller's raw vector to the shader, scaling the diffuse term by its magnitude; it now forwards the normalized vector. - `updateDepthBuffer()` no longer renders into the shadow-map framebuffer. It reused that framebuffer and re-specified the shared depth texture at the window resolution, so any shadowed render following a `plotDepthMap()` call set an 8192x8192 shadow viewport against a texture the size of the window. It now owns a separate framebuffer and texture, checks them for completeness, reads back without selecting a front/back buffer (framebuffer objects have no such concept), and restores the default framebuffer binding when done. - Added regression tests covering all of the above. None existed: `LIGHTING_PHONG_SHADOWED` appeared in the test suite only inside `CHECK_NOTHROW` setter calls, so no test had ever rendered a shadow. The new tests measure the ratio of a region's brightness rendered without versus with an occluder above it. That contrast metric is deliberate rather than an absolute darkness threshold — because the broken code rendered the whole scene uniformly dark, a test asserting "the shadowed region is dark" would have passed against the bug. The ratio is 3.60 with a working shadow map and exactly 1.00 without one, since the two renders come out byte-identical. - Documentation: the headless section promised "identical visual output to windowed mode" and support for all visual effects, which was untrue for shadows for as long as headless mode has existed; it now states shadow support explicitly with a version note. The macOS `"using zero texture because texture unloadable"` warning was documented as harmless and safe to ignore — in headless mode with shadowed lighting it was in fact this bug reporting itself, and the note now says so. - Fixed the elementary-layer transmittance returning 0 instead of 1 when the absorption coefficient `k` is exactly zero, which produced NaN reflectance and transmittance across large parts of the spectrum. The reference PROSPECT implementation initializes `tau` to 1 and overwrites it only where `k > 0`, since zero absorption means the layer transmits everything; `transmittance()` instead guarded on `k < 0` and let `k == 0` fall past both polynomial branches to the final `return 0.0`. With `tau = 0` the internal transmissivity `t` is exactly zero, so the Stokes solution's `b = (1-r²+t²+D)/(2t)` divided by zero and every downstream quantity became NaN. This is reachable with documented inputs: the protein absorption spectrum is identically zero over 400–1432 nm and PROSPECT-PRO mode forces `drymass` to zero, so a PROSPECT-PRO leaf specified with no water absorbs nothing at 652 of the 2101 wavelengths. The NaN spectra were written to global data and consumed by the radiation plug-in with no diagnostic. Every existing spectrum test kept `watermass` and `drymass` non-zero — including the "Zero Values" test, which zeroes only the four pigments — so `k` never reached zero, and no test asserted finiteness. - Fixed `transmittance()` dropping the boundary values of its approximation intervals. The two exponential-integral polynomial branches were guarded by independent range tests (`k > 0 && k < 4` and `k > 4 && k < 85`) rather than being chained, so `k` exactly equal to 4 matched neither and returned 0 — a jump from ≈0.0055 to 0 at a single point, where the two approximations otherwise agree to four decimal places. The interval tests are now chained so every non-negative `k` is covered. The `k >= 85` case still returns 0, which is correct rather than accidental: `exp(-85)` underflows to zero in double precision, so the layer is fully absorbing to within machine precision. - Fixed the zero-absorption branch of the Stokes N-layer solution testing `r + t > 1` where the reference tests `r + t >= 1`. That branch exists to catch the degenerate non-absorbing case in which `D = sqrt((1+r+t)(1+r-t)(1-r+t)(1-r-t))` collapses to zero; at `r + t == 1` exactly the factor `(1-r-t)` vanishes, so the analytic solution above is degenerate and the boundary must be included. This matters in combination with the fix above, which makes the non-absorbing limit reachable. - `getLeafSpectra()` and `PROSPECT()` now overwrite their output vectors instead of appending to them. Both took the spectra as `[out]` parameters but only ever called `push_back`, so calling either twice with the same vectors produced a 4202-element result while the conversion loop kept indexing the first 2101 entries — silently returning the first call's spectrum from the second call. The existing repeated-call test passed only because it used four separate fresh vectors and never exercised reuse. - PROSPECT inputs are now validated rather than used as given. The structure parameter `numberlayers` (N) counts the elementary layers making up the leaf and must be at least 1: `N < 1` makes the Stokes exponent `N-1` negative, which inverts the layer stack and can drive the zero-absorption denominator `t + (1-t)(N-1)` through zero and change its sign, while `N = 0` divides by zero when computing the per-layer absorption coefficient. Negative constituent contents, which would contribute negative absorption, are likewise rejected. Both errors name the offending parameter, its value, and how to correct it. - Documentation: the instructions for extending the species library pointed at `getPropertiesFromLibrary()` and described an if-else chain, but species are registered in `initializeSpeciesLibrary()` in a `std::map` keyed by lower-case name; the section now shows a correct worked example and notes the lower-casing requirement. The species table was also missing `common_bean` and `cowpea`, which have been in the library but undocumented and therefore undiscoverable for a user reading only the documentation. Added a section documenting the new input validation, and a note that a leaf with no dry matter at all warns rather than erroring. - Fixed camera images reading a factor of two too high by double-counting the emitted flux already held in the host camera-scatter accumulator. The device camera-scatter buffers are write-only `atomicFloatAdd` targets that nothing reads back during a launch, so they must be zero going into each launch for the post-launch download to yield only that launch's own contribution — but `runBand()` uploaded the host accumulator into them instead, so the download returned base + new and the base was added on top of itself. Cameras are now zeroed rather than seeded before the emission launch (what the cameras actually read is `radiation_out`, uploaded from the same host accumulator further down, which is unaffected). On the OptiX8 backend the first `runBand()` of an emission-only scene was accidentally correct, because with no sources the earlier zeroing call is skipped and the upload is guarded on device pointers that nothing had allocated yet, so only the second and later renders doubled; on OptiX 6.5 the buffers are allocated eagerly and every render doubled. Zeroing here also allocates the buffers, removing that first-call/later-call asymmetry. - `uploadCameraScatterBuffers()` has been removed from the `RayTracingBackend` interface and from the OptiX 6.5, OptiX8 and Vulkan implementations. Seeding the device camera-scatter buffers from the host accumulator was the double-counting described above, and now that `runBand()` zeroes them instead the method has no callers; leaving it on the interface would have obliged every future backend to implement an upload path that must never be used, and made it available to be called again by mistake. - Fixed the OptiX 6.5 backend tracing a second render of the same camera on top of the previous image, doubling it again on every repeat `runBand()`. `radiation_in_camera_RTbuffer` accumulates across the tiles of one camera launch, so `launchCameraRays()` zeroes it only when the camera ID or launch band count changes; nothing ever reset that latch between runs, so an unchanged camera matched the existing ID and skipped the zero. `zeroRadiationBuffers()` now resets `current_camera_launch_id` at the start of every `runBand()`, which is what the OptiX8 backend already did and the OptiX 6.5 path never had. - Added a camera self-test that compares a pixel value against a quantity known independently of Helios, which is what let both defects above survive from v1.3.64: every pre-existing camera test asserts only on the shape of the output (pixel data exists, has the right size, printed no error), so a camera rendering the correct image multiplied by a constant passed all of them. A single isolated blackbody patch with no other geometry and no sources has no inter-primitive scattering, so a pinhole camera with manual exposure — which bypasses the auto-exposure gain — must read exactly the Stefan-Boltzmann radiance `sigma*T^4/pi`. The same camera is rendered three times and every run is checked against the analytic value rather than against run 1, so neither a backend that doubles on repeat renders nor one that doubles on every render can pass. - The `getTrunkUUIDs()`, `getBranchUUIDs()`, `getLeafUUIDs()`, and `getAllUUIDs()` getters now prune UUIDs of primitives that have since been deleted from the Context before returning, via `cleanDeletedUUIDs()`. The plug-in caches the UUIDs it creates at build time and never learned about deletions made directly through the Context, so deleting tree geometry left the getters handing back dangling UUIDs — and since `getAllUUIDs()` is typically passed straight to another plug-in, that surfaced as a `does not exist in the Context` error from a call site far from the deletion. - A `<LeafAngleDist>` tag whose probability density does not integrate to 1 is now rejected with an error from `loadXML()` instead of being discarded in favor of the default leaf placement. The old behavior printed a warning and then built the tree anyway with branch-relative leaf orientations, so a user who mistyped or forgot to normalize their distribution got a tree that silently ignored it. The error message reports what the given values integrate to, what they must sum to for the number of inclination classes provided, and the factor to divide by to normalize them. The (commented-out) almond distribution shipped in `WeberPennTreeLibrary.xml` integrated to 0.9982 and has been renormalized so that it no longer trips the check if uncommented. - Fixed first-level branches being attached at the wrong length whenever exactly one branch is placed per trunk segment (i.e. `nBranches[1]` equal to `nCurveRes[0]`). The offset of each branch along the trunk divided by `stems_per_segment-1`, which is a division by zero in that case; the resulting infinity made the position ratio passed down into `recursiveBranch()` negative infinity, which was then clamped to 0, so every branch was built at the length the shape function gives at the very base of the crown rather than at its true height. No NaN ever reached the geometry, which is why the tree still looked plausible. The divisor is now `stems_per_segment`, matching the fraction already used to place the branch's attachment point on the trunk. - Tree parameters are now validated wherever they enter the plug-in — both when a library is read by `loadXML()` and when `setTreeParameters()` is called — instead of being used as given. The geometry routines index the per-level parameter arrays by recursion level and divide by several of the parameters, so an out-of-range set produced a division by zero, a bare `std::out_of_range` from deep inside the recursion, or a silently empty or degenerate tree with no diagnostic at all: `nCurveRes` of 0 built a tree with no primitives, and `Levels` of 4 indexed one past the fixed four-entry arrays and built a tree with no leaves. The check requires `Levels` between 1 and 3, every per-level array to hold at least `Levels+1` entries, `nCurveRes` of at least 1 and non-negative `nBranches` at every level, positive `Scale`, `nLength[0]`, `LeafScale` and `LeafScaleX`, and `BaseSize` in [0,1), and it names the offending parameter and its value. `setTreeParameters()` validates before storing, so a rejected set does not overwrite the library entry. Relatedly, `recursiveBranch()` no longer silently remaps a recursion level that is past the end of the parameter arrays onto `Levels-1`, and `getAllUUIDs()` now bounds-checks the trunk and branch containers it indexes rather than only the leaf container. - The empirical model now applies the temperature response function `f_T` that its documentation has always specified. `evaluateEmpiricalModel()` computed the light response `f_L` and the CO<sub>2</sub> response `f_C` but never evaluated `f_T` at all, so assimilation was `Asat*fL*fC - Rd` and the only temperature dependence in the model came through the respiration term. The `Tmin`, `Topt`, `Tref` and `q` coefficients of `EmpiricalModelCoefficients` were therefore settable, serialized to material data, and completely inert — a leaf at 5 °C and a leaf at 45 °C returned the same gross assimilation rate. `f_T` is clamped to zero at or below `Tmin` and above the upper temperature at which its second factor changes sign, so it never goes negative, and coefficients that make the reference denominator zero (`Tref <= Tmin`, or `(1+q)*Topt - Tmin - q*Tref == 0`) are now rejected with an error rather than producing an infinite or NaN assimilation rate. That coefficient check is made before the `TL <= Tmin` early return rather than after it: the coefficients are a property of the set and not of the current leaf temperature, so validating afterwards skipped the check entirely on every timestep where the leaf sat at or below `Tmin` and returned a well-formed `A = -Rd`, hiding an invalid configuration for part of a diurnal or seasonal run and surfacing it only once the leaf warmed up. - `setModelCoefficients()` now serializes the TPU and light-response-curvature `theta` temperature responses to material data, and `getCoefficientsForPrimitive_Farquhar()` reconstructs them. Both were omitted from the material round-trip, so a material saved with TPU limitation enabled or with a non-rectangular-hyperbola light response came back with those parameters reset to the struct defaults, silently changing the modeled rates. The `TPU_flag` restore was also moved to after the `setTPU()` call, since `setTPU()` enables the flag as a side effect and would otherwise overwrite a deserialized value of 0. Both new label groups are optional on read, so materials written by earlier versions still load (with the struct defaults for these two parameters). - The Farquhar species library values for Almond, Walnut and Pistachio now match the parameters published in the plug-in documentation, and Prune's `Rd25` was corrected from 1.56 to 1.65. The shipped values had diverged substantially from the documented table — Almond, for instance, used `Vcmax25` = 105.9 against a documented 72.6 — so `getFarquharCoefficientsFromLibrary()` returned a different parameter set than the one a user reading the documentation expected. The three re-fitted species now use the peaked temperature response with TPU limitation as documented. A self-test checks every library species against the documented table so the two cannot drift apart again. - A four-argument peaked temperature response constructed with `dHd <= dHa` is now rejected with an error. The peaked Arrhenius form evaluates `ln(dHd/dHa - 1)`, which is undefined in that case, so the response returned NaN and propagated it into `net_photosynthesis` with no diagnostic. The three-argument setters are unaffected, as they default `dHd` to `10*dHa`. - Calling `setVcmax()`, `setJmax()`, `setRd()` or `setQuantumEfficiency_alpha()` now resets the corresponding deprecated public scalar field (`Vcmax`, `Jmax`, `Rd`, `alpha`) to its `-1` sentinel. Those fields are still honored when assigned directly, and a value greater than zero selects the legacy non-peaked Arrhenius path in preference to the temperature-response object — so a struct that had one assigned directly and was then updated through the setter kept using the stale legacy value, and the two representations disagreed about which number the model actually used. The setter is now always authoritative. - The Bailey (`BB`) model is now coupled to the boundary layer in the same way as the other models. It previously used the air-to-cavity vapor pressure deficit directly instead of solving the water vapor flux balance at the leaf surface for `Ds`, so it was the only model in the plug-in that did not respond to the boundary-layer conductance at all. It also never applied the soil moisture factor `beta`, making it the only model that ignored `beta_soil` primitive data. `run()` now solves for the surface vapor pressure with `fzero()` as the BMF model does, and the guard-cell relation — which is implicit in `gs` but linear in it — is solved in closed form by the new `evaluate_BBconductance()` rather than by a second nested iteration. The model theory and the newly required `xylem_water_potential` and `radiation_flux_PAR` primitive data are now documented. - Non-physical inputs to `run()` now raise an error instead of being silently clipped or propagated. A negative boundary-layer conductance was clipped to zero with a warning, which then divided by zero in the surface CO<sub>2</sub> balance; it is now rejected, as are a non-positive ambient CO<sub>2</sub> concentration, a surface CO<sub>2</sub> concentration that evaluates non-positive (which flips the sign of `gs` in all three `A`-based models, and happens when the assimilation rate is too large to be supplied through the given boundary-layer conductance), and `Cs <= Gamma` in the Ball-Berry-Leuning model, which sits on that model's pole. Note that a negative `A` from dark respiration legitimately gives `Cs > Ca` and remains valid. - A boundary-layer conductance of exactly zero — which the BLConductance plug-in produces for zero wind speed or a zero-size primitive — is now handled as the physical limit it represents rather than dividing by zero: no vapor or CO<sub>2</sub> can be exchanged with the air outside the boundary layer, so the steady-state conductance is taken to be zero and a warning is issued. - `setDynamicTimeConstants()` now rejects a time constant that is zero, negative or non-finite. `tau` appears in the denominator of the forward Euler update, so zero gave a non-finite conductance and a negative value inverted the relaxation so that stomata diverged away from the steady-state value. - Added a deleted `run(std::initializer_list<uint>)` overload so that `run({UUID})` fails to compile rather than converting the braced integer to a float and being silently interpreted as `run(dt)` — a timestep — instead of as a single-primitive UUID list. - `optionalOutputPrimitiveData()` no longer appends a duplicate entry when called twice with the same label, and the Ball-Berry-Leuning missing-`Gamma_CO2` warning now reports the count of primitives missing Gamma rather than the count missing `net_photosynthesis`. - An exception thrown by a user's objective, gradient or constraint function during an L-BFGS, BOBYQA or SLSQP run is no longer discarded. All three algorithms ended their catch chains with `catch (const std::exception &e)`, which folded the error into a `result_message` string that is only ever printed inside an `if (print_progress)` block — and `print_progress` defaults to `false`. Execution then fell through to build a `Result` from `optimal_params` and `optimal_value`, which were seeded with the initial parameters and `0.0`, so a failed optimization returned a fitness of exactly zero: for a minimization, the one value most likely to be read as perfect convergence. An objective that failed on bad input, a failed sub-simulation or an unopenable file therefore produced silently wrong scientific output with no indication anything had gone wrong. This was specific to the three NLopt-backed algorithms; Adam, GA and CMA-ES have no catch blocks and always propagated. - NLopt's own configuration and internal failures are now converted to `helios_runtime_error` instead of escaping as whatever the library happened to throw. `nlopt-in.hpp`'s `mythrow()` maps `NLOPT_INVALID_ARGS` to `std::invalid_argument` and `NLOPT_OUT_OF_MEMORY` to `std::bad_alloc`, and the `nlopt::opt` constructor throws `std::bad_alloc` directly — so a genuine failure could surface as a `std::logic_error`, which slips past a caller catching `std::runtime_error` as the rest of Helios does, carrying a message that names NLopt rather than anything the caller can act on. This affected not just `optimize()` but every setup call, all of which sat outside the try block: the constructor, the bound and tolerance setters, and SLSQP's constraint registration. Exceptions originating in user code are deliberately not translated, since those are captured in the callbacks and rethrown verbatim. - SLSQP now validates its constraints up front rather than letting malformed ones reach NLopt. `Constraint` is an aggregate whose fields the user assigns directly, and none of them were checked anywhere in the plug-in. A negative `tolerance` was caught only by NLopt, as `std::invalid_argument`, reported far from the offending field and identifying no constraint index. A NaN `tolerance` was not caught at all — NLopt tests only `tol < 0`, and a NaN comparison is false — so the optimization ran to completion against a corrupt feasibility test and returned a plausible-looking result. An unset `function` or `gradient`, which is simply a default-constructed `Constraint` that the user forgot to finish populating, raised `std::bad_function_call` from inside a callback; like `std::invalid_argument`, that derives from `std::exception` but not `std::runtime_error`, so it escaped a caller following the convention used everywhere else in Helios. All four are now rejected before the optimizer is configured, naming the constraint's index. A tolerance of exactly zero remains valid and means exact satisfaction. - The `catch (const GradientValidationError &)` clauses in `runLBFGS()` and `runSLSQP()` were dead code for the case they appeared to handle — a validation error raised inside a callback was swallowed by NLopt's `catch (...)` long before reaching them, which is why the eager pre-validation before the optimization loop exists. They have been removed; the eager check still covers the missing-gradient-key case. A `nlopt::forced_stop` handler was added to BOBYQA and SLSQP, not as a user-facing cancellation feature (nothing in the plug-in calls `force_stop()`) but as the path by which an exception captured from user code now surfaces. - Existing tests could not catch any of this: every assertion in the suite used an objective that always succeeds, so no test ever entered the swallowing branch. Five new regression tests cover an objective that throws under each of the three algorithms, plus a gradient and a constraint function that throw, and each asserts on the propagated message rather than only its type — an assertion on type alone would also pass against the weaker fix that lets NLopt's own generic error escape. The gradient test deliberately succeeds on its first call, because L-BFGS evaluates the gradient once before entering the optimization loop and a gradient that always threw would propagate even on the unfixed code, proving nothing. - Added the CMake option `HELIOS_NLOPT_LUKSAN` (default `ON`, so no existing build changes) to exclude NLopt's LGPL-2.1 Luksan solvers. NLopt is otherwise MIT, and per its own `COPYING` the compiled library carries the conjunction of its components' licenses, so a default build is effectively LGPL; `src/algs/luksan/COPYRIGHT` additionally carries an ACM notice restricting redistribution of some subroutines. Building with `-DHELIOS_NLOPT_LUKSAN=OFF` compiles none of those sources — verified empirically, with 104 Luksan symbols in the default `libnlopt.a` and none in the MIT build — at the cost of L-BFGS, which is implemented entirely by that code. BOBYQA (MIT), SLSQP (BSD-3) and the four algorithms needing no NLopt are unaffected. - L-BFGS availability is now a distinct compile-time flag, `HELIOS_HAVE_LBFGS`, since `HELIOS_HAVE_NLOPT` no longer implies it. Selecting L-BFGS without it raises an error naming the CMake option and suggesting Adam or BOBYQA, rather than reaching NLopt's dispatcher, which prints to stdout and returns `NLOPT_INVALID_ARGS` — a diagnostic that surfaced far from its cause and, before the exception fix above, was itself swallowed into the same silent zero-fitness result. Whether a *system-installed* NLopt includes the Luksan solvers cannot be detected, since the flag is private to NLopt's build and `nlopt.h` declares `NLOPT_LD_LBFGS` unconditionally regardless of what was compiled; the build assumes they are present, which is correct for every mainstream distribution, and `HELIOS_SYSTEM_NLOPT_HAS_LUKSAN=OFF` asserts otherwise. - The no-NLopt fallbacks in `runLBFGS()`, `runBOBYQA()` and `runSLSQP()` now raise an error instead of writing to `std::cerr` and returning the objective evaluated at the initial point. That fabricated result was the same class of defect as the swallowed exceptions — a caller could not distinguish it from a converged optimization — and `std::cerr` is invisible under a test harness or a redirected log.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In my example code using Visualizer headless rendering, the shadow-map framebuffer and depth texture were only created in windowed mode, causing shadows to be missing in offscreen renders.
This fix will use the same FBO setup as is performed for headless mode as well.
Before


After
Thank you!