From 91f00b9764215b9dbdbc13c77537d382b048a6ff Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 25 Jul 2026 19:57:13 +0200 Subject: [PATCH 1/6] Add physically-based lens flares Implements the Lee & Eisemann 2013 matrix method for camera lens flares: every ordered pair of refractive surfaces in a lens prescription produces a two-reflection "ghost" image of the iris, and the starburst is the Fraunhofer transform of that same iris. Ghost enumeration, the per-ghost paraxial ray-transfer matrices and their coated-Fresnel tints are computed once at table load, so a frame only has to place one textured quad per ghost. There is one lens, because there is one camera: every sun in a background is imaged through the same glass, which is what keeps their flares consistent with each other. Which lens that is comes from "$Default Lens:" in lens_flares.tbl, unset by default so retail content is unaffected. graphics/lens_flare.h the module's whole API graphics/lens_flare_optics.cpp ray-transfer matrices, ghost enumeration, coated-Fresnel reflectance graphics/lens_flare_aperture.cpp iris mask + its Fraunhofer starburst graphics/lens_flare_table.cpp lens_flares.tbl / *-lens.tbm parsing graphics/lens_flare.cpp module state, the camera lens, the texture cache, and the per-frame build The optics and image-synthesis halves are pure functions of a prescription or an iris -- no engine state -- with one exception: ghost enumeration stops at the shader's instance budget, since a ghost with no instance slot to draw it in is not worth keeping. Rendering is one instanced draw per visible sun, sharing the mounted lens's iris and starburst textures for the whole pass, composited additively into the HDR scene colour immediately before bloom so bloom and the tonemapper treat the flare's energy like any other scene light. Both backends are covered (opengl_post_pass_lens_flare / VulkanLensFlare); Vulkan needs the negative-viewport Y flip that the other post passes use. Because the flare is fused into the pre-tonemap buffer, the SDR and HDR tonemappers would otherwise render an identically-tuned flare at very different brightness, so the HDR path is rescaled by Hdr_flare_headroom / LENS_FLARE_SDR_REFERENCE_WHITE with SDR as the calibration reference. A mounted lens suppresses the legacy sprite $Flare: path, and when it has a starburst the bitmap sun glow is skipped too, so the two never stack. Per-sun opt-out is the pre-existing $NoGlare:. Co-Authored-By: Claude Opus 5 --- code/def_files/data/effects/lensflare-f.sdr | 79 +++ code/def_files/data/effects/lensflare-v.sdr | 75 +++ code/def_files/data/tables/lens_flares.tbl | 511 +++++++++++++++ code/graphics/2d.h | 2 + code/graphics/lens_flare.cpp | 603 ++++++++++++++++++ code/graphics/lens_flare.h | 304 +++++++++ code/graphics/lens_flare_aperture.cpp | 409 ++++++++++++ code/graphics/lens_flare_internal.h | 39 ++ code/graphics/lens_flare_optics.cpp | 290 +++++++++ code/graphics/lens_flare_table.cpp | 229 +++++++ .../opengl/gropenglpostprocessing.cpp | 151 +++++ code/graphics/shader_types.cpp | 3 + code/graphics/util/uniform_structs.h | 26 + code/graphics/vulkan/VulkanPostProcessing.cpp | 11 + code/graphics/vulkan/VulkanPostProcessing.h | 104 ++- .../vulkan/VulkanPostProcessingLensFlare.cpp | 371 +++++++++++ code/graphics/vulkan/VulkanRendererLoop.cpp | 1 + code/source_groups.cmake | 10 + code/starfield/starfield.cpp | 46 +- code/starfield/starfield.h | 11 + code/tracing/categories.cpp | 1 + code/tracing/categories.h | 1 + test/src/graphics/test_lens_flare.cpp | 552 ++++++++++++++++ test/src/source_groups.cmake | 1 + .../aperture_fields/data/tables/test-lens.tbm | 47 ++ 25 files changed, 3874 insertions(+), 3 deletions(-) create mode 100644 code/def_files/data/effects/lensflare-f.sdr create mode 100644 code/def_files/data/effects/lensflare-v.sdr create mode 100644 code/def_files/data/tables/lens_flares.tbl create mode 100644 code/graphics/lens_flare.cpp create mode 100644 code/graphics/lens_flare.h create mode 100644 code/graphics/lens_flare_aperture.cpp create mode 100644 code/graphics/lens_flare_internal.h create mode 100644 code/graphics/lens_flare_optics.cpp create mode 100644 code/graphics/lens_flare_table.cpp create mode 100644 code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp create mode 100644 test/src/graphics/test_lens_flare.cpp create mode 100644 test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm diff --git a/code/def_files/data/effects/lensflare-f.sdr b/code/def_files/data/effects/lensflare-f.sdr new file mode 100644 index 00000000000..5bb1d86208b --- /dev/null +++ b/code/def_files/data/effects/lensflare-f.sdr @@ -0,0 +1,79 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Each ghost is the image of the iris (aperture texture) clipped by the image +// of the circular entrance pupil, evaluated per color channel for chromatic +// fringing. The starburst instance samples the precomputed FFT texture instead. + +struct lens_flare_instance { + vec4 center; + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +#ifdef VULKAN +layout(location = 0) in vec2 sensorPos; +layout(location = 1) flat in vec4 g_center; +layout(location = 2) flat in vec4 g_halfext; +layout(location = 3) flat in vec4 g_apscale; +layout(location = 4) flat in vec4 g_apoff; +layout(location = 5) flat in vec4 g_color; + +layout(location = 0) out vec4 fragOut0; + +layout(set = 1, binding = 1) uniform sampler2D textures[16]; +#define apertureMap textures[0] +#define starburstMap textures[1] +#else +in vec2 sensorPos; +flat in vec4 g_center; +flat in vec4 g_halfext; +flat in vec4 g_apscale; +flat in vec4 g_apoff; +flat in vec4 g_color; + +out vec4 fragOut0; + +uniform sampler2D apertureMap; +uniform sampler2D starburstMap; +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; + vec2 ndc_scale; + vec4 tint; + int n_instances; + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +float ghost_channel(float center, float halfext, float apscale, float apoff, float intensity) +{ + vec2 q = (sensorPos - axis * center) / halfext; + // image of the circular entrance pupil clips the bundle + float pupil = clamp((1.0 - length(q)) * 8.0, 0.0, 1.0); + vec2 uv = q * apscale + axis * apoff; + return texture(apertureMap, uv * 0.5 + 0.5).r * pupil * intensity; +} + +void main() +{ + vec3 result; + + if (g_center.w > 0.5) { + // starburst billboard centered on the sun + vec2 q = (sensorPos - axis * g_center.x) / g_halfext.x; + result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; + } else { + result.r = ghost_channel(g_center.x, g_halfext.x, g_apscale.x, g_apoff.x, g_color.x); + result.g = ghost_channel(g_center.y, g_halfext.y, g_apscale.y, g_apoff.y, g_color.y); + result.b = ghost_channel(g_center.z, g_halfext.z, g_apscale.z, g_apoff.z, g_color.z); + } + + fragOut0 = vec4(result * tint.rgb, 1.0); +} diff --git a/code/def_files/data/effects/lensflare-v.sdr b/code/def_files/data/effects/lensflare-v.sdr new file mode 100644 index 00000000000..d4e8ce01339 --- /dev/null +++ b/code/def_files/data/effects/lensflare-v.sdr @@ -0,0 +1,75 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Instanced draw: one 4-vertex triangle-strip quad per ghost, plus one for the +// starburst billboard. All placement math was precomputed on the CPU into the +// per-instance data below; positions are in sensor-plane millimeters. + +struct lens_flare_instance { + vec4 center; // xyz = per-channel quad center along the flare axis; w > 0.5 = starburst + vec4 halfext; // xyz = per-channel quad half-extent + vec4 apscale; // xyz = per-channel aperture-plane scale + vec4 apoff; // xyz = per-channel aperture-plane offset along the axis + vec4 color; // rgb = per-channel intensity +}; + +#ifdef VULKAN +layout(location = 0) out vec2 sensorPos; +layout(location = 1) flat out vec4 g_center; +layout(location = 2) flat out vec4 g_halfext; +layout(location = 3) flat out vec4 g_apscale; +layout(location = 4) flat out vec4 g_apoff; +layout(location = 5) flat out vec4 g_color; +#define INSTANCE_INDEX gl_InstanceIndex +#else +in vec4 vertPosition; +out vec2 sensorPos; +flat out vec4 g_center; +flat out vec4 g_halfext; +flat out vec4 g_apscale; +flat out vec4 g_apoff; +flat out vec4 g_color; +#define INSTANCE_INDEX gl_InstanceID +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; // unit flare axis in sensor space + vec2 ndc_scale; // sensor mm -> NDC + vec4 tint; // sun color * visibility * lens intensity + int n_instances; + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +void main() +{ +#ifdef VULKAN + vec2 corner = vec2(float(gl_VertexIndex & 1), float((gl_VertexIndex >> 1) & 1)) * 2.0 - 1.0; +#else + vec2 corner = vertPosition.xy; +#endif + + lens_flare_instance inst = instances[INSTANCE_INDEX]; + + // Bounds of the union of the three chromatic quads, along/around the axis + float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); + float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); + float caxis = 0.5 * (cmin + cmax); + float haxis = 0.5 * (cmax - cmin); + float hperp = max(inst.halfext.x, max(inst.halfext.y, inst.halfext.z)); + + vec2 perp = vec2(-axis.y, axis.x); + vec2 p = axis * (caxis + corner.x * haxis) + perp * (corner.y * hperp); + + sensorPos = p; + g_center = inst.center; + g_halfext = inst.halfext; + g_apscale = inst.apscale; + g_apoff = inst.apoff; + g_color = inst.color; + + gl_Position = vec4(p * ndc_scale, 0.0, 1.0); +} diff --git a/code/def_files/data/tables/lens_flares.tbl b/code/def_files/data/tables/lens_flares.tbl new file mode 100644 index 00000000000..33c8b449650 --- /dev/null +++ b/code/def_files/data/tables/lens_flares.tbl @@ -0,0 +1,511 @@ +; Default physically-based lens systems for the "$Camera Lens:" option in a +; mission's info section (and the set-camera-lens sexp). +; +; A mission mounts ONE of these as its camera lens, and every sun in its +; background flares through it -- one camera, one lens, so the flares of two +; suns can never disagree about the glass they came through. Missions that name +; no lens fall back to "$Default Lens:" below, which the shipped table leaves +; unset, so retail content is unaffected until a mod opts in. +; +; Format: each $Surface: is ( curvature radius mm, thickness to the next surface +; mm, refractive index of the glass BEHIND the surface, 1.0 = air ), listed +; front-to-back; $Stop: ( thickness mm ) marks the iris plane. See +; graphics/lens_flare.cpp for the full syntax and the precompute math. +; +; Where these numbers come from and how to derive your own: +; +; - The radius/thickness/index triples are "lens prescriptions" as published in +; lens patents and optical-design references. Any such prescription can be +; transcribed directly into $Surface: rows; scale all radii/thicknesses +; uniformly to change the focal length. The engine derives everything else +; (effective focal length, sensor distance, ghost enumeration, per-ghost +; matrices) from the surface stack at table load. +; - +Abbe: dispersion (V-number) values are not usually part of patent claims; +; the ones below are typical catalog values for optical glasses of the given +; index (crown glasses ~55-60, dense flints ~30-40). They only drive the +; chromatic fringing, so approximate values are fine. +; - $Entrance Pupil Radius: is focal length / (2 * f-number) of the design; +; $Aperture Radius: is the iris half-opening (smaller = darker, crisper +; ghosts). Both may be fudged for looks, but $Aperture Radius: is the setting +; that decides whether a ghost is lit at all, so derive it before fudging: +; each ghost slides across the iris as the sun moves off-axis, by an amount +; that grows with the field angle and shrinks with $Aperture Radius:, and a +; ghost that slides off the iris goes black. Too small a value therefore +; confines the whole flare to a sun near the frame center, and too large a one +; shrinks the ghosts within the iris until the blades stop shaping them. The +; physical value is the paraxial marginal-ray height where it crosses the stop +; (trace height = entrance pupil radius, angle = 0 through the surfaces ahead +; of the $Stop:); 1.25x that is what angenieux_100mm uses (marginal height +; 12.79mm, tabled 16.0) and what the lenses below are derived from. +; - $Coating Wavelength: is the quarter-wave anti-reflection tuning; 500-570nm +; (green-centered, like real broadband coatings) gives the classic +; magenta/cyan ghost tints. 0 disables coatings (uncoated vintage look: +; brighter, neutral-grey ghosts). +; - $Intensity:, blade counts/rotation/curvature and the starburst settings are +; artistic; the shipped values were calibrated visually in the F3 lab +; (Render options -> Lens flare options) against retail suns. +; +; The iris +; -------- +; +; A lens has exactly ONE aperture definition, and it drives both the ghosts and +; the starburst: every ghost is an image of this mask, and the starburst is its +; Fraunhofer transform. Changing a blade count therefore restyles both at once, +; and they can never disagree about what the iris looks like. +; +; $Aperture Blades: number of iris blades; fewer than 3 means a round iris. +; +Blade Rotation: degrees. +; +Blade Curvature: 0 leaves the blades straight, 1 bows their midpoints out +; to the corner radius (a circular iris), and negative +; values bow them inward into a star. +; +Edge Softness: iris edge feather, as a fraction of the iris radius. The +; default, 0.0039, is also the floor it is clamped to (a +; ~2px ramp, so the mask never aliases) and matches the edge +; the iris has always had; asking for less has no effect. +; Softening the edge visibly weakens the starburst spikes, +; because they come from that edge's sharpness. +; +; Fields are parsed in sequence, so they must appear in the order listed here +; (as everywhere else in an entry) -- a $Aperture Dust: placed after $Intensity: +; is not just ignored, it breaks the whole table. There is a worked example +; covering every field in test/test_data/graphics/lens_flare/. +; +; Three optional imperfection layers darken the mask on top of the shape. All +; are off (strength 0) by default, which is what every lens below uses -- turn +; them on for a dirty or damaged lens. Each takes its strength on the $-line and +; then optional +sub-options: +; +; $Aperture Grating: radial ridges around the iris rim, which +; throw extra spikes into the starburst. +; +Density: fraction of the 360 possible ridges. +; +Length: how far in the ridges reach, as a fraction of the iris. +; +Width: ridge width as a duty cycle of the spacing between +; ridges, so raising the density thins the ridges rather +; than merging them into a solid ring. +; +Softness: +; $Aperture Scratches: randomly placed slivers. +; +Density: fraction of the 1000 possible scratches. +; +Length: +Width: +; +Rotation: degrees. +; +Rotation Variation: 0 leaves every scratch parallel, 1 fully random. +; +Softness: +; $Aperture Dust: randomly placed specks. +; +Density: fraction of the 1000 possible specks. +; +Radius: +Softness: +; +; Be aware that the starburst is normalized against its own brightest value, so +; adding grating/scratches/dust dims the core spikes as it adds speckle around +; them, rather than only adding to what was there. +; +; The layers are ported from realflare's aperture kernels (see below), minus its +; texture-mask layer, and minus its split between a ghost aperture and a +; separate starburst aperture. +; +; The lenses below the first two were adapted from the lens models bundled with +; realflare (https://github.com/beatreichenbach/realflare, MIT), an offline +; renderer built on the same Hullin/Lee research; the prescriptions themselves +; are the published patent data cited per entry, transcribed one lens element +; per $Surface: row. Their $Intensity: values are not independent guesses: the +; total lit ghost energy (per-ghost Fresnel reflectance and footprint, times the +; fraction of the pupil image still inside the iris) was computed for each with +; the sun at 10%, 50% and 90% of the frame half-width, and kept inside the range +; the two hand-calibrated entries already span. Only color_heliar_105mm needed a +; non-default value, because uncoated glass reflects far more than a coated +; surface (~75x the total ghost energy, for that lens). +; +; Note that $Name: is the nominal focal length of the design, not the paraxial +; focal length the engine computes from the surfaces - the two differ for every +; shipped lens (angenieux_100mm solves to 63mm), and for the zooms the +; transcription freezes one configuration of a variable air gap. What the flare +; looks like follows the surfaces, not the name. + +#Lens Systems + +; The lens missions get when they don't name one themselves. Left unset here; +; a mod that wants its whole campaign shot on the same glass sets it in a +; *-lens.tbm, e.g. +; +; $Default Lens: tessar_50mm + +; Double-Gauss cine prime, f/2.2, f=100mm. Surface data follows the Angenieux +; double-Gauss prescription published with Hullin, Eisemann, Seidel, Lee, +; "Physically-Based Real-Time Lens Flare Rendering" (SIGGRAPH 2011) and reused +; by Lee & Eisemann 2013 (the paper this renderer implements); it traces back +; to P. Angenieux's 1950s double-Gauss patents. Abbe numbers estimated from +; glass catalogs as described above. Rich ghost set with strongly tinted +; coatings. +$Name: angenieux_100mm +$Entrance Pupil Radius: 22.0 +$Aperture Radius: 16.0 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 6 ++Blade Rotation: 15.0 ++Blade Curvature: 0.15 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 48 +$Surface: ( 164.13, 10.99, 1.6751 ) ++Abbe: 47.0 +$Surface: ( 559.20, 0.23, 1.0 ) +$Surface: ( 100.12, 11.45, 1.6689 ) ++Abbe: 44.0 +$Surface: ( 213.54, 0.23, 1.0 ) +$Surface: ( 58.04, 22.95, 1.6913 ) ++Abbe: 42.0 +$Surface: ( 2551.10, 2.58, 1.6751 ) ++Abbe: 33.0 +$Surface: ( 32.39, 15.66, 1.0 ) +$Stop: ( 15.00 ) +$Surface: ( -40.42, 2.74, 1.6992 ) ++Abbe: 30.0 +$Surface: ( 192.98, 27.92, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -55.53, 0.23, 1.0 ) +$Surface: ( 192.98, 7.98, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -92.62, 0.23, 1.0 ) +$Surface: ( 355.02, 8.86, 1.6751 ) ++Abbe: 47.0 +$Surface: ( -52.78, 60.0, 1.0 ) + +; Classic Tessar, f/3.5, f=50mm. Surface data transcribed from the textbook +; Zeiss Tessar prescription (Paul Rudolph's 1902 design, as reproduced in +; optical-design literature), uniformly scaled to a 50mm focal length; Abbe +; numbers are catalog values for the usual Tessar crown/flint glass pairing. +; Fewer elements, so a sparser and subtler ghost set. +$Name: tessar_50mm +$Entrance Pupil Radius: 7.0 +$Aperture Radius: 5.0 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 8 ++Blade Rotation: 0.0 ++Blade Curvature: 0.3 +$Starburst: YES ++Starburst Scale: 0.8 +$Intensity: 0.2 +$Max Ghosts: 24 +$Surface: ( 16.25, 2.90, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -285.90, 0.29, 1.0 ) +$Surface: ( -30.05, 1.20, 1.6053 ) ++Abbe: 43.6 +$Surface: ( 17.47, 1.38, 1.0 ) +$Stop: ( 1.15 ) +$Surface: ( 31.55, 1.20, 1.5123 ) ++Abbe: 51.0 +$Surface: ( 21.30, 3.50, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -23.70, 40.0, 1.0 ) + +; Modern multicoated telephoto zoom, f/2.8, nominally 70-200mm; the transcribed +; configuration solves to f=72mm. Prescription is the sixth embodiment of Canon +; patent US5537259 (1995), the design behind the EF 70-200mm f/2.8L USM. 33 +; refractive surfaces enumerate ~460 usable ghost pairs, so this is the busiest +; shipped lens: a dense, tightly clustered, strongly coated ghost train. +$Name: canon_70_200mm +$Entrance Pupil Radius: 12.32 +$Aperture Radius: 21.33 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 8 ++Blade Rotation: 22.5 ++Blade Curvature: 0.35 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 56 +$Surface: ( 355.855, 2.8, 1.75 ) ++Abbe: 35.0 +$Surface: ( 121.211, 0.42, 1.0 ) +$Surface: ( 131.256, 8.62, 1.497 ) ++Abbe: 81.6 +$Surface: ( -259.209, 0.1, 1.0 ) +$Surface: ( 80.584, 6.01, 1.497 ) ++Abbe: 81.6 +$Surface: ( 234.8, 8.69, 1.0 ) +$Surface: ( 51.45, 2.2, 1.847 ) ++Abbe: 23.8 +$Surface: ( 43.769, 1.28, 1.0 ) +$Surface: ( 49.946, 8.87, 1.487 ) ++Abbe: 70.2 +$Surface: ( 12148.909, 1.57, 1.0 ) +$Surface: ( -600.368, 1.4, 1.804 ) ++Abbe: 46.6 +$Surface: ( 34.801, 5.98, 1.0 ) +$Surface: ( -75.966, 1.4, 1.487 ) ++Abbe: 70.2 +$Surface: ( 37.777, 4.97, 1.847 ) ++Abbe: 23.9 +$Surface: ( 413.301, 2.64, 1.0 ) +$Surface: ( -66.4, 1.4, 1.729 ) ++Abbe: 54.7 +$Surface: ( 3021.469, 30.32, 1.0 ) +$Surface: ( 230.258, 3.51, 1.698 ) ++Abbe: 55.5 +$Surface: ( -98.917, 0.15, 1.0 ) +$Surface: ( -172.378, 4.66, 1.497 ) ++Abbe: 81.6 +$Surface: ( -40.226, 1.45, 1.834 ) ++Abbe: 37.2 +$Surface: ( -76.185, 13.86, 1.0 ) +$Surface: ( 57.653, 3.73, 1.804 ) ++Abbe: 46.6 +$Surface: ( 128.671, 3.05, 1.0 ) +$Stop: ( 0.34 ) +$Surface: ( 33.882, 6.26, 1.497 ) ++Abbe: 81.6 +$Surface: ( 1455.342, 3.99, 1.62 ) ++Abbe: 36.3 +$Surface: ( 31.129, 26.85, 1.0 ) +$Surface: ( 117.922, 5.91, 1.517 ) ++Abbe: 52.4 +$Surface: ( -81.244, 14.02, 1.0 ) +$Surface: ( -38.692, 1.8, 1.834 ) ++Abbe: 37.2 +$Surface: ( -102.301, 0.15, 1.0 ) +$Surface: ( 183.092, 3.91, 1.743 ) ++Abbe: 49.3 +$Surface: ( -129.948, 10.0, 1.0 ) + +; Symmetric double Gauss, f/3.8, f=100mm, from Kodak patent US2823583 (1958). +; Single-coated era: $Coating Wavelength: 550 models one MgF2 quarter-wave layer, +; which is exactly what lenses of this vintage carried. Few elements and a +; symmetric layout give a sparse, orderly ghost set strung along the sun axis, and +; the long focal length keeps it stable as the sun moves off-axis. +$Name: kodak_100mm +$Entrance Pupil Radius: 13.16 +$Aperture Radius: 8.88 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 10 ++Blade Rotation: 0.0 ++Blade Curvature: 0.50 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 28 +$Surface: ( 36.02, 3.1, 1.517 ) ++Abbe: 64.5 +$Surface: ( 418.3, 0.7, 1.0 ) +$Surface: ( 24.59, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -45.33, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( -44.52, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 13.42, 6.9, 1.0 ) +$Stop: ( 6.9 ) +$Surface: ( -13.42, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 44.52, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( 45.33, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -24.59, 0.7, 1.0 ) +$Surface: ( -74.42, 3.1, 1.72 ) ++Abbe: 29.3 +$Surface: ( -32.2, 50.0, 1.0 ) + +; Fast aspherical wide-angle prime, f/1.4, f=35mm, from Leica patent US5161060 +; (1992), fig. 1 - the Summilux-M 35mm f/1.4 ASPH design. The shortest focal +; length shipped (~54 degrees across the frame), so its ghosts sweep the furthest +; as the sun moves off-axis; the fast aperture keeps them large and soft. +$Name: leica_35mm +$Entrance Pupil Radius: 12.50 +$Aperture Radius: 12.05 +$Sensor Width: 36.0 +$Coating Wavelength: 530 +$Aperture Blades: 9 ++Blade Rotation: 10.0 ++Blade Curvature: 0.40 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 40 +$Surface: ( -110.114, 2.01, 1.503 ) ++Abbe: 56.1 +$Surface: ( 24.92, 7.4, 1.82 ) ++Abbe: 45.1 +$Surface: ( -305.0, 0.1, 1.0 ) +$Surface: ( 28.346, 6.07, 1.82 ) ++Abbe: 45.1 +$Surface: ( -57.56, 1.61, 1.694 ) ++Abbe: 31.0 +$Surface: ( 16.624, 4.34, 1.0 ) +$Stop: ( 1.66 ) +$Surface: ( -197.204, 6.07, 1.792 ) ++Abbe: 47.2 +$Surface: ( -38.628, 1.5, 1.0 ) +$Surface: ( -21.142, 1.72, 1.652 ) ++Abbe: 33.6 +$Surface: ( 101.985, 5.86, 1.82 ) ++Abbe: 45.1 +$Surface: ( -21.905, 0.11, 1.0 ) +$Surface: ( 60.026, 5.94, 1.82 ) ++Abbe: 45.1 +$Surface: ( -31.325, 2.05, 1.624 ) ++Abbe: 36.1 +$Surface: ( 31.325, 19.595, 1.0 ) + +; Multicoated telephoto zoom, f/3.5, nominally 50-135mm, from Nikon patent +; US4497547A (1981) - the AI Zoom-Nikkor 50-135mm f/3.5. The two variable zoom +; spacings are frozen at the transcribed values, which paraxially solve to f=36mm +; rather than any point in the marked 50-135mm range; the engine derives the focal +; length from the surfaces, so the flare matches the transcription, not the label +; (the shipped angenieux_100mm and tessar_50mm are named the same way). +$Name: nikon_50_135mm +$Entrance Pupil Radius: 12.86 +$Aperture Radius: 27.37 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 7 ++Blade Rotation: 0.0 ++Blade Curvature: 0.25 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 48 +$Surface: ( 95.858, 1.7, 1.805 ) ++Abbe: 25.4 +$Surface: ( 49.02, 8.0, 1.678 ) ++Abbe: 55.6 +$Surface: ( 214.552, 0.1, 1.0 ) +$Surface: ( 75.769, 5.0, 1.667 ) ++Abbe: 48.4 +$Surface: ( 691.304, 2.959, 1.0 ) +$Surface: ( -708.168, 1.25, 1.697 ) ++Abbe: 55.6 +$Surface: ( 22.809, 5.0, 1.0 ) +$Surface: ( -175.109, 1.15, 1.788 ) ++Abbe: 47.5 +$Surface: ( 87.266, 0.5, 1.0 ) +$Surface: ( 35.758, 3.1, 1.805 ) ++Abbe: 25.4 +$Surface: ( 165.776, 27.727, 1.0 ) +$Surface: ( -51.423, 1.15, 1.67 ) ++Abbe: 57.6 +$Surface: ( 81.327, 2.95, 1.672 ) ++Abbe: 38.9 +$Surface: ( -169.527, 8.846, 1.0 ) +$Stop: ( 1.0 ) +$Surface: ( 174.041, 3.25, 1.713 ) ++Abbe: 54.0 +$Surface: ( -63.18, 0.1, 1.0 ) +$Surface: ( 50.356, 5.0, 1.564 ) ++Abbe: 60.8 +$Surface: ( -70.071, 1.1, 1.796 ) ++Abbe: 41.0 +$Surface: ( 229.755, 0.1, 1.0 ) +$Surface: ( 25.187, 5.6, 1.518 ) ++Abbe: 59.0 +$Surface: ( -745.542, 1.0, 1.0 ) +$Surface: ( 262.417, 2.0, 1.795 ) ++Abbe: 28.6 +$Surface: ( 37.552, 10.15, 1.0 ) +$Surface: ( 111.689, 3.0, 1.517 ) ++Abbe: 64.1 +$Surface: ( -97.52, 20.85, 1.0 ) +$Surface: ( -18.386, 2.0, 1.67 ) ++Abbe: 47.1 +$Surface: ( -31.592, 0.1, 1.0 ) +$Surface: ( 941.473, 4.55, 1.702 ) ++Abbe: 41.0 +$Surface: ( -72.586, 14.0, 1.0 ) + +; Uncoated vintage prime, f/3.5, f=105mm, from A. W. Tronnier's patent US2645156 +; (1950) for the Voigtlander Color-Heliar. This is the table's uncoated reference: +; $Coating Wavelength: 0 gives bare-glass Fresnel reflections, ~75x stronger in +; total than the same prescription coated, and neutral grey rather than +; magenta/cyan. $Intensity: is scaled down by that factor so the lens lands at the +; bright end of the shipped calibration band instead of blowing out - the +; character (few, large, colourless, obvious ghosts, barely fading off-axis) is +; what the uncoated model buys, not raw brightness. The flat sixth surface is in +; the source prescription. +$Name: color_heliar_105mm +$Entrance Pupil Radius: 15.00 +$Aperture Radius: 15.08 +$Sensor Width: 36.0 +$Coating Wavelength: 0 +$Aperture Blades: 12 ++Blade Rotation: 0.0 ++Blade Curvature: 0.60 +$Starburst: YES ++Starburst Scale: 0.7 +$Intensity: 0.015 +$Max Ghosts: 16 +$Surface: ( 30.809, 7.702, 1.651 ) ++Abbe: 58.6 +$Surface: ( -89.35, 1.855, 1.603 ) ++Abbe: 38.4 +$Surface: ( 580.0, 3.521, 1.0 ) +$Surface: ( -80.063, 1.849, 1.643 ) ++Abbe: 47.9 +$Surface: ( 28.34, 4.625, 1.0 ) +$Stop: ( 2.554 ) +$Surface: ( 0.0, 1.849, 1.582 ) ++Abbe: 40.6 +$Surface: ( 32.19, 7.271, 1.693 ) ++Abbe: 53.5 +$Surface: ( -52.99, 92.03, 1.0 ) + +; Modern multicoated cine prime, T1.3, nominally 50mm (solves to f=65mm), from +; Zeiss patent US7446944B2 (2008) - the Master Prime 50mm. Large entrance pupil +; and 24 refractive surfaces: many ghosts, large and bright, with the heavy +; broadband coating pushing them well into magenta/cyan. The closest match to a +; contemporary cinema look. +$Name: zeiss_master_prime_50mm +$Entrance Pupil Radius: 19.23 +$Aperture Radius: 15.23 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 9 ++Blade Rotation: 20.0 ++Blade Curvature: 0.45 +$Starburst: YES ++Starburst Scale: 1.1 +$Intensity: 0.2 +$Max Ghosts: 56 +$Surface: ( 554.31, 4.31, 1.699 ) ++Abbe: 30.13 +$Surface: ( 82.937, 7.67, 1.0 ) +$Surface: ( 2539.9, 8.05, 1.805 ) ++Abbe: 25.42 +$Surface: ( -185.67, 4.67, 1.816 ) ++Abbe: 46.62 +$Surface: ( -188.36, 7.281, 1.0 ) +$Surface: ( 52.33, 16.11, 1.618 ) ++Abbe: 63.33 +$Surface: ( 12548.0, 0.11, 1.0 ) +$Surface: ( 70.795, 4.2, 1.717 ) ++Abbe: 29.62 +$Surface: ( 55.033, 2.534, 1.0 ) +$Surface: ( 42.474, 4.27, 1.805 ) ++Abbe: 25.42 +$Surface: ( 35.481, 7.82, 1.816 ) ++Abbe: 46.62 +$Surface: ( 46.639, 4.79, 1.0 ) +$Surface: ( 183.02, 4.2, 1.558 ) ++Abbe: 54.01 +$Surface: ( 25.119, 9.8, 1.0 ) +$Stop: ( 9.71 ) +$Surface: ( -23.041, 4.2, 1.654 ) ++Abbe: 39.63 +$Surface: ( 39.525, 16.23, 1.618 ) ++Abbe: 63.33 +$Surface: ( -44.668, 0.35, 1.0 ) +$Surface: ( 66.473, 10.02, 1.603 ) ++Abbe: 65.44 +$Surface: ( -240.57, 0.21, 1.0 ) +$Surface: ( 466.39, 7.51, 1.603 ) ++Abbe: 65.44 +$Surface: ( -88.453, 0.1, 1.0 ) +$Surface: ( 91.728, 4.2, 1.816 ) ++Abbe: 46.62 +$Surface: ( 27.982, 16.46, 1.618 ) ++Abbe: 63.33 +$Surface: ( -128.64, 39.014, 1.0 ) + +#End diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 9bf045e8931..859efcfd034 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -232,6 +232,8 @@ enum shader_type { SDR_TYPE_GAMMA_BLIT, + SDR_TYPE_LENS_FLARE, + NUM_SHADER_TYPES }; diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp new file mode 100644 index 00000000000..1d9492ec4ca --- /dev/null +++ b/code/graphics/lens_flare.cpp @@ -0,0 +1,603 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "graphics/2d.h" +#include "graphics/openxr.h" +#include "graphics/util/uniform_structs.h" +#include "io/timer.h" +#include "lighting/lighting.h" +#include "mission/missionparse.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/shipfx.h" +#include "starfield/starfield.h" + +#include +#include + +extern int Game_subspace_effect; + +namespace graphics { +namespace { + +// Overall brightness calibration of the ghost/starburst energy model relative +// to the HDR scene (defaults; runtime-tunable from the lab, and scaled per +// lens via $Intensity:) +float Ghost_brightness = 64.0f; +float Starburst_brightness = 1.6f; + +// SDR/HDR consistency (see lens_flare_output_scale()). The flare composites +// additively into the pre-tonemap HDR scene buffer, so the tonemapper is the +// only thing that differs between the two output paths. In SDR a compressive +// curve (default = Uncharted2) squashes the flare's large linear values toward +// display white; in HDR the forced pass-through HdrScene tonemapper preserves +// them and the encode pass scales by paper-white nits, so a flare calibrated in +// SDR blows out. We rescale the HDR contribution by HEADROOM / reference-white +// so the flare's fraction of SDR display-white maps to the same fraction of HDR +// paper-white, with a little headroom left so it still reads as a highlight. +// +// Reference white is the linear input the default SDR tonemapper (Uncharted2, +// the reset default in lighting_profiles.cpp) maps to display white -- its +// W constant. HDR forces its own tonemapper, so this is a fixed calibration +// reference, not the live SDR curve. +constexpr float LENS_FLARE_SDR_REFERENCE_WHITE = 11.2f; +float Hdr_flare_headroom = 2.5f; // flare may reach ~this multiple of paper white in HDR + +// Cap on the (entrance pupil / ghost size)^2 energy concentration so nearly +// focused ghosts can't blow out to infinity +constexpr float GHOST_ENERGY_CAP = 400.0f; + +SCP_vector Lens_systems; + +// Bumped whenever a lens's cached textures are dropped, so the render backends +// can tell a re-generated aperture from the one they already uploaded +unsigned int Texture_generation = 1; + +// Live aperture editing in the lab: coalesce a slider drag into one +// regeneration every APERTURE_REGEN_INTERVAL ms +constexpr int APERTURE_REGEN_INTERVAL = 100; +bool Aperture_dirty = false; +int Aperture_dirty_lens = -1; +UI_TIMESTAMP Aperture_dirty_stamp; + +// Per-sun occlusion visibility, smoothed over frames. Touched only by +// sun_visibility() below. +SCP_vector Sun_visibility; +UI_TIMESTAMP Sun_visibility_stamp; + +// The camera lens: what the table declares as the default, what the mission +// mounted (its "$Camera Lens:", possibly changed by set-camera-lens), and the +// lab's live override of that. -1 means no lens, i.e. no flares. +// +// "$Default Lens:" is kept as a name until every table has been read, since a +// *-lens.tbm may name the default before (or without) defining it. +SCP_string Default_lens_name; +int Default_lens = -1; +int Mission_lens = -1; +std::optional Lab_lens; + +// Per-frame flare data of every drawing sun. Kept here (rather than handed out +// by value) because one lens_flare_data is several kilobytes; the draws only +// carry pointers into this, valid until the next build. +SCP_vector Frame_data; +SCP_vector Frame_draws; + +// Lens the "flares are running through X" breadcrumb last reported. Logged from +// the frame build rather than from lens_flare_switch_to(), because mission-info +// scans (FRED opening a file dialog) mount lenses they never render with. +int Logged_lens = -2; + +// True while the global conditions allow the flare pass to render at all +// (matching backends' pass reachability, independent of any particular sun) +bool pass_globally_possible() +{ + if (gr_screen.mode == GraphicsAPI::Stub || Lens_systems.empty()) { + return false; + } + if (!Gr_post_processing_enabled || PostProcessing_override) { + return false; + } + if (Game_subspace_effect) { + return false; + } + if (openxr_enabled()) { + // A per-eye camera-lens artifact is wrong in VR + return false; + } + // same conditions under which the sun sprites themselves are drawn + if (The_mission.flags[Mission::Mission_Flags::Fullneb] || !Detail.planets_suns) { + return false; + } + return true; +} + +// How visible a sun is to the flare, in 0..1: the eye-in-shadow occlusion test +// smoothed over a few frames so shadow transitions fade instead of popping, +// times the off-axis falloff that takes the whole effect out as the sun leaves +// the frame. `dot` is the sun direction against the view axis, `dt` the frame +// time, and `snap` skips the smoothing when the pass hasn't run for a while. +float sun_visibility(int sun_n, int light_idx, float dot, float dt, bool snap) +{ + if (static_cast(Sun_visibility.size()) <= sun_n) { + Sun_visibility.resize(sun_n + 1, 0.0f); + } + + bool occluded = (dot <= 0.0f) || + (light_idx >= 0 && shipfx_eye_in_shadow(&Eye_position, Viewer_obj, light_idx)); + + float target = occluded ? 0.0f : 1.0f; + float& vis = Sun_visibility[sun_n]; + if (snap) { + vis = target; + } else { + vis += (target - vis) * MIN(dt * 8.0f, 1.0f); + } + + float axis_fade = std::clamp((dot - 0.2f) / 0.3f, 0.0f, 1.0f); + return vis * axis_fade; +} + +// The camera's film gate for this frame: the sensor half-extents the lens +// declares, and the screen the projection lands on. One gate for every sun, +// because the gate belongs to the camera. +struct film_gate { + float clip_w = 0.0f, clip_h = 0.0f; // pixels + float half_w = 0.0f, half_h = 0.0f; // mm +}; + +// Where a sun's image lands on the film. +struct sun_image { + float dist_mm = 0.0f; // distance from the sensor centre + float theta = 0.0f; // matching paraxial field angle + float axis_x = 1.0f, axis_y = 0.0f; // unit direction the flare is strung along +}; + +// Project a sun onto the film gate, the same way stars_draw_sun() projects its +// sprite. False when the sun isn't imaged onto the sensor at all. +bool project_sun(const vec3d& sun_pos, const film_gate& gate, float efl, sun_image* out) +{ + vertex sun_vex; + memset(&sun_vex, 0, sizeof(vertex)); + g3_rotate_faraway_vertex(&sun_vex, &sun_pos); + if (sun_vex.codes & CC_BEHIND) { + return false; + } + if (!(sun_vex.flags & PF_PROJECTED)) { + g3_project_vertex(&sun_vex); + } + if (sun_vex.flags & PF_OVERFLOW) { + return false; + } + + float sx = sun_vex.screen.xyw.x / gate.clip_w * 2.0f - 1.0f; + float sy = 1.0f - sun_vex.screen.xyw.y / gate.clip_h * 2.0f; + + float smx = sx * gate.half_w; + float smy = sy * gate.half_h; + + out->dist_mm = sqrtf(smx * smx + smy * smy); + out->theta = out->dist_mm / efl; + out->axis_x = 1.0f; + out->axis_y = 0.0f; + if (out->dist_mm > 1e-4f) { + out->axis_x = smx / out->dist_mm; + out->axis_y = smy / out->dist_mm; + } + return true; +} + +// Apply a pending live aperture edit, at most once per APERTURE_REGEN_INTERVAL. +// Regenerating means a 512^2 mask plus its starburst FFT (a good fraction of a +// second in a debug build), while ImGui sliders fire every frame a drag is +// held, so a drag has to be coalesced into a few rebuilds rather than sixty. +void flush_pending_aperture_edit() +{ + if (!Aperture_dirty) { + return; + } + if (Aperture_dirty_stamp.isValid() && !ui_timestamp_elapsed(Aperture_dirty_stamp)) { + return; + } + lens_flare_invalidate_textures(Aperture_dirty_lens); + Aperture_dirty = false; + Aperture_dirty_stamp = ui_timestamp(APERTURE_REGEN_INTERVAL); +} + +} // namespace + +void lens_flare_init() +{ + lens_flare_close(); + + lens_flare_parse_tables(Lens_systems, Default_lens_name); + + // Resolved once every table has been read, so the default may be named + // before it is defined + if (!Default_lens_name.empty()) { + Default_lens = lens_flare_lookup(Default_lens_name.c_str()); + if (Default_lens < 0) { + Warning(LOCATION, "$Default Lens: names '%s', which no lens table defines.", Default_lens_name.c_str()); + } + } + Mission_lens = Default_lens; + + mprintf(("Lens flares: %d lens system(s) loaded, default lens '%s'\n", static_cast(Lens_systems.size()), + lens_flare_default_name())); +} + +void lens_flare_close() +{ + Lens_systems.clear(); + Sun_visibility.clear(); + Default_lens_name.clear(); + Default_lens = -1; + Mission_lens = -1; + lens_flare_clear_lab_lens(); + Frame_data.clear(); + Frame_draws.clear(); + Logged_lens = -2; +} + +int lens_flare_lookup(const char* name) +{ + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (!stricmp(Lens_systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +int lens_flare_num_systems() +{ + return static_cast(Lens_systems.size()); +} + +const lens_system* lens_flare_get_system(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +lens_system* lens_flare_get_system_mutable(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +float lens_flare_get_ghost_brightness() { return Ghost_brightness; } +void lens_flare_set_ghost_brightness(float value) { Ghost_brightness = MAX(value, 0.0f); } +float lens_flare_get_starburst_brightness() { return Starburst_brightness; } +void lens_flare_set_starburst_brightness(float value) { Starburst_brightness = MAX(value, 0.0f); } +float lens_flare_get_hdr_headroom() { return Hdr_flare_headroom; } +void lens_flare_set_hdr_headroom(float value) { Hdr_flare_headroom = MAX(value, 0.0f); } + +// Extra multiplier applied to the whole flare so its brightness reads +// consistently in SDR and HDR output without per-lens re-tuning. SDR is the +// reference (calibration was done there), so it is left at 1.0; HDR is rescaled +// down to sit near paper white. See LENS_FLARE_SDR_REFERENCE_WHITE. +static float lens_flare_output_scale() +{ + if (Gr_hdr_output_active) { + return Hdr_flare_headroom / LENS_FLARE_SDR_REFERENCE_WHITE; + } + return 1.0f; +} + +const char* lens_flare_default_name() +{ + return SCP_vector_inbounds(Lens_systems, Default_lens) ? Lens_systems[Default_lens].name.c_str() : ""; +} + +void lens_flare_switch_to(const char* lens_name) +{ + if (lens_name == nullptr || *lens_name == '\0') { + Mission_lens = -1; + return; + } + + Mission_lens = lens_flare_lookup(lens_name); + if (Mission_lens < 0) { + // An unknown lens falls back to the table default rather than to no + // flares: a typo shouldn't silently look like "the mod wanted none" + Warning(LOCATION, "No lens system named '%s' is defined in lens_flares.tbl; using the default lens.", + lens_name); + Mission_lens = Default_lens; + } +} + +int lens_flare_active_lens() +{ + int lens_idx = Lab_lens.value_or(Mission_lens); + return SCP_vector_inbounds(Lens_systems, lens_idx) ? lens_idx : -1; +} + +const char* lens_flare_mission_lens_name() +{ + return SCP_vector_inbounds(Lens_systems, Mission_lens) ? Lens_systems[Mission_lens].name.c_str() : ""; +} + +void lens_flare_set_lab_lens(int lens_idx) +{ + Lab_lens = lens_idx; +} + +void lens_flare_clear_lab_lens() +{ + Lab_lens.reset(); +} + +std::optional lens_flare_get_lab_lens() +{ + return Lab_lens; +} + +const SCP_vector& lens_flare_get_frame_draws() +{ + return Frame_draws; +} + +bool lens_flare_sun_starburst_active(int sun_n) +{ + if (!pass_globally_possible()) { + return false; + } + + int lens_idx = lens_flare_active_lens(); + if (lens_idx < 0 || !Lens_systems[lens_idx].starburst) { + return false; + } + + // mirrors the glare requirement of the flare pass + int light_idx = light_find_for_sun(sun_n); + return light_idx < 0 || light_has_glare(light_idx); +} + +const lens_flare_textures* lens_flare_get_textures(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + lens_system& lens = Lens_systems[lens_idx]; + if (!lens.textures) { + auto tex = std::make_unique(); + lens_flare_generate_textures(lens.aperture, tex.get()); + lens.textures = std::move(tex); + } + return lens.textures.get(); +} + +void lens_flare_reset_for_level() +{ + // Unmount: the mission being loaded sets its own $Camera Lens: right after + // this (see parse_mission_info), and the lab sets its override on demand + Mission_lens = Default_lens; + lens_flare_clear_lab_lens(); + Logged_lens = -2; + + // drop any pending live edit first; it belongs to the mission being left + Aperture_dirty = false; + Aperture_dirty_lens = -1; + + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (Lens_systems[i].aperture != Lens_systems[i].tabled_aperture) { + Lens_systems[i].aperture = Lens_systems[i].tabled_aperture; + lens_flare_invalidate_textures(i); + } + } +} + +void lens_flare_invalidate_textures(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return; + } + Lens_systems[lens_idx].textures.reset(); + Texture_generation++; +} + +unsigned int lens_flare_get_texture_generation() { return Texture_generation; } + +bool lens_flare_aperture_edit_pending() { return Aperture_dirty; } + +void lens_flare_aperture_changed(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return; + } + if (Aperture_dirty && Aperture_dirty_lens != lens_idx) { + // switching lenses mid-edit: flush the pending one first + lens_flare_invalidate_textures(Aperture_dirty_lens); + } + Aperture_dirty = true; + Aperture_dirty_lens = lens_idx; + + // Apply straight away unless we just did; the flush below picks up the rest + if (!Aperture_dirty_stamp.isValid() || ui_timestamp_elapsed(Aperture_dirty_stamp)) { + flush_pending_aperture_edit(); + } +} + +// Pack one sun's ghost quads (plus the starburst, if the lens has one) into a +// uniform block and return how many instances were written. Depends only on the +// lens and where the sun's image lands on the sensor -- `sdist` is that image's +// distance from the sensor centre in mm, `theta` its paraxial field angle. +static int pack_sun_instances(const lens_system& lens, float theta, float sdist, + generic_data::lens_flare_data* out) +{ + const float pupil = lens.entrance_radius; + const float min_halfext = lens.sensor_width * 0.004f; // sub-pixel guard for focused ghosts + + int count = 0; + for (const auto& ghost : lens.ghosts) { + // one slot is kept in reserve for the starburst + if (count >= generic_data::MAX_LENS_FLARE_INSTANCES - 1) { + break; + } + auto& inst = out->instances[count]; + + for (int k = 0; k < 3; k++) { + // Full path matrix F = Ms * Ma; only row 0 (heights) is needed + float f_a = ghost.ms[k][0] * ghost.ma[k][0] + ghost.ms[k][1] * ghost.ma[k][2]; + float f_b = ghost.ms[k][0] * ghost.ma[k][1] + ghost.ms[k][1] * ghost.ma[k][3]; + + float halfext = MAX(fabsf(f_a) * pupil, min_halfext); + float energy = MIN((pupil * pupil) / (halfext * halfext), GHOST_ENERGY_CAP); + + inst.center.a1d[k] = f_b * theta; + inst.halfext.a1d[k] = halfext; + inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; + inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; + inst.color.a1d[k] = ghost.reflectance[k] * energy * Ghost_brightness; + } + inst.center.xyzw.w = 0.0f; + inst.halfext.xyzw.w = 0.0f; + inst.apscale.xyzw.w = 0.0f; + inst.apoff.xyzw.w = 0.0f; + inst.color.xyzw.w = 0.0f; + count++; + } + + if (lens.starburst && count < generic_data::MAX_LENS_FLARE_INSTANCES) { + auto& inst = out->instances[count]; + float halfext = lens.starburst_scale * lens.sensor_width * 0.12f; + for (int k = 0; k < 3; k++) { + inst.center.a1d[k] = sdist; // exactly at the sun's image + inst.halfext.a1d[k] = halfext; + inst.apscale.a1d[k] = 0.0f; + inst.apoff.a1d[k] = 0.0f; + inst.color.a1d[k] = Starburst_brightness; + } + inst.center.xyzw.w = 1.0f; // starburst marker + inst.halfext.xyzw.w = 0.0f; + inst.apscale.xyzw.w = 0.0f; + inst.apoff.xyzw.w = 0.0f; + inst.color.xyzw.w = 0.0f; + count++; + } + + out->n_instances = count; + out->pad[0] = out->pad[1] = out->pad[2] = 0.0f; + return count; +} + +const SCP_vector& lens_flare_build_frame_data() +{ + Frame_draws.clear(); + + // live aperture edits from the lab land here, throttled + flush_pending_aperture_edit(); + + const int lens_idx = lens_flare_active_lens(); + if (lens_idx < 0 || !pass_globally_possible()) { + return Frame_draws; + } + const lens_system& lens = Lens_systems[lens_idx]; + + // Frame time for visibility smoothing (snap if we haven't run for a while) + float dt = 0.25f; + if (Sun_visibility_stamp.isValid()) { + dt = ui_timestamp_since(Sun_visibility_stamp) * 0.001f; + } + Sun_visibility_stamp = ui_timestamp(); + bool snap = (dt > 1.0f) || (dt < 0.0f); + + int num_suns = stars_get_num_suns(); + // Sized up front so the pointers handed out below stay valid: the loop only + // writes into slots, it never grows this + if (static_cast(Frame_data.size()) < num_suns) { + Frame_data.resize(num_suns); + } + + film_gate gate; + gate.clip_w = i2fl(gr_screen.clip_width); + gate.clip_h = i2fl(gr_screen.clip_height); + if (gate.clip_w <= 0.0f || gate.clip_h <= 0.0f) { + return Frame_draws; + } + gate.half_w = lens.sensor_width * 0.5f; + gate.half_h = gate.half_w * gate.clip_h / gate.clip_w; + + // Also the camera's, not any sun's, so it is a frame constant too + const float out_scale = lens_flare_output_scale(); + + for (int sun_n = 0; sun_n < num_suns; sun_n++) { + const auto sun_light = stars_get_sun_rgbi(sun_n); + if (!sun_light) { + continue; + } + + vec3d sun_pos = vmd_zero_vector; + sun_pos.xyz.y = 1.0f; + stars_get_sun_pos(sun_n, &sun_pos); + vec3d sun_dir = sun_pos; + vm_vec_normalize(&sun_dir); + + float dot = vm_vec_dot(&sun_dir, &Eye_matrix.vec.fvec); + + // a sun the engine gives no glare gets no flare either + int light_idx = light_find_for_sun(sun_n); + if (light_idx >= 0 && !light_has_glare(light_idx)) { + continue; + } + + float total_vis = sun_visibility(sun_n, light_idx, dot, dt, snap); + if (total_vis < 0.005f) { + continue; + } + + sun_image image; + if (!project_sun(sun_pos, gate, lens.efl, &image)) { + continue; + } + + // The slot is only committed by the push_back below, so a sun that packs + // nothing leaves it to the next one + generic_data::lens_flare_data* out = &Frame_data[Frame_draws.size()]; + + out->axis.x = image.axis_x; + out->axis.y = image.axis_y; + out->ndc_scale.x = 1.0f / gate.half_w; + out->ndc_scale.y = 1.0f / gate.half_h; + // Master multiplier for every ghost and the starburst (lensflare-f.sdr + // applies tint.rgb to both paths). The output scale keeps SDR and HDR + // visually consistent without per-lens re-tuning. + const float tint_scale = sun_light->intensity * total_vis * lens.intensity * out_scale; + out->tint.xyzw.x = sun_light->color.xyz.x * tint_scale; + out->tint.xyzw.y = sun_light->color.xyz.y * tint_scale; + out->tint.xyzw.z = sun_light->color.xyz.z * tint_scale; + out->tint.xyzw.w = 0.0f; + + int count = pack_sun_instances(lens, image.theta, image.dist_mm, out); + if (count == 0) { + continue; + } + + lens_flare_draw draw; + draw.sun_index = sun_n; + draw.instances = count; + draw.data = out; + draw.visibility = total_vis; + draw.off_axis_deg = image.theta * (180.0f / PI); + draw.output_scale = out_scale; + Frame_draws.push_back(draw); + } + + if (!Frame_draws.empty() && lens_idx != Logged_lens) { + Logged_lens = lens_idx; + mprintf(("Lens flare: rendering through lens '%s' (%d ghosts + %s)\n", lens.name.c_str(), + static_cast(lens.ghosts.size()), lens.starburst ? "starburst" : "no starburst")); + } + + return Frame_draws; +} + + +} // namespace graphics diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h new file mode 100644 index 00000000000..8da6943d53b --- /dev/null +++ b/code/graphics/lens_flare.h @@ -0,0 +1,304 @@ +#pragma once + +#include "globalincs/pstypes.h" + +#include +#include +#include + +// Physically-based lens flares (Lee & Eisemann 2013 matrix approximation). +// +// A lens system is an ordered stack of spherical surfaces parsed from +// lens_flares.tbl / *-lens.tbm. Every ordered pair of refractive surfaces +// produces one two-reflection "ghost" image of the aperture; each ghost is +// reduced at table-load time to a handful of paraxial ray-transfer matrices +// so the render backends only have to draw one textured quad per ghost. +// +// A mission mounts exactly one of these as the camera lens (see "the camera +// lens" below); with none mounted nothing changes. + +namespace graphics { + +namespace generic_data { +struct lens_flare_data; // graphics/util/uniform_structs.h +} + +struct lens_surface { + float radius = 0.0f; // signed curvature radius in mm, 0 = flat + float thickness = 0.0f; // distance to the next surface in mm + float n = 1.0f; // refractive index behind the surface (1.0 = air) + float abbe = 0.0f; // Abbe V-number for dispersion, 0 = dispersion-free + float coating_wavelength = -1.0f; // AR coating tuning in nm; < 0 = use lens default, 0 = uncoated + bool is_stop = false; // aperture stop (flat, non-refracting) +}; + +// The iris: a stack of multiplicative layers rendered into one R8 transmission +// mask. Ported from realflare's aperture kernels, with one deliberate +// difference: realflare keeps a separate aperture for ghosts and for the +// starburst, while here a single definition drives both (the starburst is the +// Fraunhofer transform of this very mask), so a lens has one iris and cannot +// contradict itself. +// +// Every layer below the shape defaults to strength 0 (off), which reproduces +// the plain-iris look of tables written before they existed. +struct lens_aperture { + // Iris opening. Blade count/rotation/curvature are the original fields; + // curvature 0 = straight blades, 1 = circular, and negative values bow the + // blades inward for a star-shaped iris. + int blades = 6; + float rotation = 0.0f; // degrees + float curvature = 0.0f; // -1 = concave .. 0 = straight .. 1 = circular + // Edge feather, as a fraction of the iris radius. The default is also the + // floor the generator clamps to (a ~2px ramp, so the mask never aliases), + // and reproduces the edge the iris had before this was tunable. A soft edge + // visibly weakens the starburst spikes, so it is not a free parameter. + float softness = 0.0039f; + + // Diffraction grating around the rim: fine radial ridges that throw extra + // spikes into the starburst. + struct { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 360 possible ridges + float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius + float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges + float softness = 0.0f; + } grating; + + // Scratches on the glass: randomly placed and oriented slivers. + struct { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible scratches + float length = 0.5f; + float width = 0.25f; + float rotation = 0.0f; // degrees + float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random + float softness = 0.0f; + } scratches; + + // Dust on the glass: randomly placed specks. + struct { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible specks + float radius = 0.5f; + float softness = 0.0f; + } dust; + + // Compared field by field so a caller that reassigns the whole aperture can + // tell whether anything actually moved -- regenerating costs a 512^2 mask + // plus an FFT, which a repeating mission event must not pay every frame. + bool operator==(const lens_aperture& o) const + { + return blades == o.blades && rotation == o.rotation && curvature == o.curvature && + softness == o.softness && grating.strength == o.grating.strength && + grating.density == o.grating.density && grating.length == o.grating.length && + grating.width == o.grating.width && grating.softness == o.grating.softness && + scratches.strength == o.scratches.strength && scratches.density == o.scratches.density && + scratches.length == o.scratches.length && scratches.width == o.scratches.width && + scratches.rotation == o.scratches.rotation && + scratches.rotation_variation == o.scratches.rotation_variation && + scratches.softness == o.scratches.softness && dust.strength == o.dust.strength && + dust.density == o.dust.density && dust.radius == o.dust.radius && dust.softness == o.dust.softness; + } + bool operator!=(const lens_aperture& o) const { return !(*this == o); } +}; + +// One two-reflection ghost, precomputed per wavelength (index 0 = red 656nm, +// 1 = green 588nm, 2 = blue 486nm). Matrices are row-major 2x2 ray-transfer +// matrices acting on [height; angle] column vectors: [0]=A [1]=B [2]=C [3]=D. +struct lens_flare_ghost { + float ma[3][4]; // entrance plane -> aperture stop (last stop crossing) + float ms[3][4]; // aperture stop -> sensor plane + float reflectance[3]; // product of the two (coated) Fresnel reflectances + int surf_first = -1; // surface indices of the reflection pair (diagnostics) + int surf_second = -1; +}; + +// CPU-generated texture payloads for one lens system (created on demand by +// lens_flare_get_textures(), uploaded by each render backend). +struct lens_flare_textures { + int aperture_size = 0; + SCP_vector aperture; // R8 iris transmission mask + int starburst_size = 0; + SCP_vector starburst; // RGBA32F Fraunhofer starburst +}; + +struct lens_system { + SCP_string name; + SCP_vector surfaces; + + float entrance_radius = 10.0f; // entrance pupil (front element) radius, mm + float aperture_radius = 5.0f; // iris half-opening, mm + float sensor_width = 36.0f; // film-gate width, mm + float coating_wavelength = 540.0f; // default AR coating tuning, nm (0 = uncoated) + + lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst + + bool starburst = true; + float starburst_scale = 1.0f; + float intensity = 0.2f; + int max_ghosts = 40; + + // The aperture exactly as the table declared it. Missions and the lab edit + // `aperture` in place, so this is what lens_flare_reset_for_level() restores + // between missions -- one mission's lens must never carry into the next. + lens_aperture tabled_aperture; + + // --- filled by lens_flare_precompute() --- + SCP_vector ghosts; + float efl = 50.0f; // effective focal length (green), mm + float bfd = 40.0f; // back focal distance last surface -> sensor (green), mm + + // Iris/starburst textures of this lens, generated on demand by + // lens_flare_get_textures() and dropped by lens_flare_invalidate_textures(). + // Owning them here rather than in a vector alongside Lens_systems is what + // keeps them from ever going out of step with the lens they belong to; it + // makes lens_system move-only, which is all the engine ever needs. + std::unique_ptr textures; +}; + +// Parse lens_flares.tbl + *-lens.tbm (embedded default as fallback) and +// precompute all ghost data. Called once from stars_init(); safe to call again +// (reloads). +void lens_flare_init(); +void lens_flare_close(); + +// Index of a tabled lens system by name, -1 if unknown. +int lens_flare_lookup(const char* name); + +int lens_flare_num_systems(); +const lens_system* lens_flare_get_system(int lens_idx); + +// Lazily generate (and cache) the aperture/starburst textures of a lens. +// Returns nullptr for an invalid index. +const lens_flare_textures* lens_flare_get_textures(int lens_idx); + +// Drop a lens's cached textures so the next lens_flare_get_textures() rebuilds +// them (after its lens_aperture was edited). +void lens_flare_invalidate_textures(int lens_idx); + +// Bumped on every invalidation. Render backends cache the uploaded textures of +// the mounted lens, so they must key that cache on this as well to notice a +// rebuild. +unsigned int lens_flare_get_texture_generation(); + +// Live aperture editing (lab): note that a lens's aperture changed, and poll +// whether a regeneration is still pending. Regenerating a 512^2 mask and its +// starburst FFT is far too slow to do on every frame of a slider drag, so edits +// are coalesced and applied a few times a second. Call the poll once per frame. +void lens_flare_aperture_changed(int lens_idx); +bool lens_flare_aperture_edit_pending(); + +// Undo everything a mission or the lab did to the lens state: unmount whatever +// lens was mounted (back to $Default Lens:) and restore every lens's aperture to +// what its table declared, so one mission's camera can't carry into the next. +// Called from stars_pre_level_init(), which runs before the mission's +// $Camera Lens: is parsed. +void lens_flare_reset_for_level(); + +// Mutable access for live tuning in the lab (e.g. a lens's $Intensity:). +// Changes take effect the next frame; they are lost on table reload. +lens_system* lens_flare_get_system_mutable(int lens_idx); + +// Global brightness calibration multipliers applied to every ghost/starburst +// (defaults match the shipped calibration; tunable live from the lab) +float lens_flare_get_ghost_brightness(); +void lens_flare_set_ghost_brightness(float value); +float lens_flare_get_starburst_brightness(); +void lens_flare_set_starburst_brightness(float value); + +// HDR consistency headroom: how many multiples of paper white the flare is +// allowed to reach in HDR output. SDR is the calibration reference and is +// unaffected; this only rescales the HDR path so an SDR-tuned flare doesn't +// blow out. Defaults to a value that keeps a little HDR "pop". +float lens_flare_get_hdr_headroom(); +void lens_flare_set_hdr_headroom(float value); + +// ---- the camera lens ---- +// +// There is one lens, because there is one camera: every light source in the +// scene is imaged through the same glass, so the flares of all suns share a +// prescription, an iris and a starburst. What differs per sun is only where it +// sits in the frame and how bright it is. +// +// The mounted lens comes from the mission's "$Camera Lens:" (defaulting to +// "$Default Lens:" in lens_flares.tbl), can be changed at runtime by the +// set-camera-lens sexp, and can be overridden live in the lab. + +// The name in "$Default Lens:", or "" when the table declares no default (in +// which case missions without a "$Camera Lens:" render no flares at all). +const char* lens_flare_default_name(); + +// Mount a lens by name: "" / for no flares, or an unknown name +// for the table default (unknown names warn). Called from mission parse and by +// the set-camera-lens sexp. +void lens_flare_switch_to(const char* lens_name); + +// The lens actually in use (a lab override beats the mission's), -1 = none. +int lens_flare_active_lens(); + +// Name of the mission's own camera lens, ignoring any lab override. What the lab +// shows as the entry to fall back to, and what restores. +const char* lens_flare_mission_lens_name(); + +// Lab override of the mission's camera lens: unset means the mission's choice +// stands, a value of -1 forces "no flares". Cleared by +// lens_flare_reset_for_level(). +void lens_flare_set_lab_lens(int lens_idx); +void lens_flare_clear_lab_lens(); +std::optional lens_flare_get_lab_lens(); + +// True when this sun's flare will include the physically-based starburst (a lens +// is mounted, its starburst is enabled, the sun has glare, and the flare pass is +// reachable this frame). Used by the sun renderer to skip the bitmap sun glow so +// the two starbursts don't stack. +bool lens_flare_sun_starburst_active(int sun_n); + +// One sun's worth of flare quads. All suns share the mounted lens (hence one +// aperture/starburst texture for the whole pass), but each has its own flare axis +// and tint, so each gets its own uniform block and instanced draw. +// +// The trailing fields are diagnostics for the lab; the renderer ignores them. +struct lens_flare_draw { + int sun_index = -1; + int instances = 0; // quads to draw (ghosts + optional starburst) + // Uniform block for this draw, owned by lens_flare.cpp; valid until the + // next lens_flare_build_frame_data() call. + const generic_data::lens_flare_data* data = nullptr; + + float visibility = 0.0f; // smoothed occlusion * off-axis fade + float off_axis_deg = 0.0f; // paraxial field angle of the sun + float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame +}; + +// Build the per-frame uniform data of every visible sun. Does all game-state +// access (sun projection, occlusion raycast, gates) so the render backends stay +// free of it. Returns one entry per sun that has something to draw, in sun order +// (empty = skip the pass entirely); every entry is drawn with the textures of +// lens_flare_active_lens(). +const SCP_vector& lens_flare_build_frame_data(); + +// The draws built by the last lens_flare_build_frame_data() call (lab +// instrumentation; empty when the pass was inactive). +const SCP_vector& lens_flare_get_frame_draws(); + +// ---- internals exposed for unit testing ---- + +// Run the ghost/matrix precompute on a hand-built lens_system. +// Returns false (with ghosts cleared) if the prescription is unusable. +bool lens_flare_precompute(lens_system& lens); + +// Normal-incidence reflectance of an interface n1 -> n2 with an optional +// quarter-wave AR coating tuned to lambda0_nm (0 = uncoated), evaluated at +// lambda_nm. Coating index is max(1.38, sqrt(n1*n2)). +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm); + +// In-place radix-2 2D FFT of a size x size complex grid (size must be a power +// of two). Used for the starburst; exposed for tests. +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse); + +// Render just the iris mask of an aperture, skipping the starburst transform +// the full generation path would also run. Tests that only care about the mask +// use this to avoid paying for the FFT. +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out); + +} // namespace graphics diff --git a/code/graphics/lens_flare_aperture.cpp b/code/graphics/lens_flare_aperture.cpp new file mode 100644 index 00000000000..bc5291daf11 --- /dev/null +++ b/code/graphics/lens_flare_aperture.cpp @@ -0,0 +1,409 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include +#include +#include + +// Image synthesis for the iris: one aperture definition is rasterized into an R8 +// transmission mask (blade shape plus the optional grating/scratch/dust layers, +// ported from realflare's kernels), and the starburst is that mask's Fraunhofer +// transform, i.e. |FFT(mask)|^2. Like the optics, none of this touches engine +// state -- it is a pure function of a lens_aperture. + +namespace graphics { +namespace { + +constexpr int APERTURE_TEXTURE_SIZE = 512; + +// ---- texture generation ---- + +const float IRIS_RADIUS = 0.9f; // in normalized [-1,1] texture space; keeps the border black + +// Hash used by realflare's aperture kernels to scatter scratches and dust. +// Kept bit-for-bit so a given density reproduces its layout. +float aperture_noise(float x, float y, float z) +{ + float ignored; + return modff(sinf(x * 112.9898f + y * 179.233f + z * 237.212f) * 43758.5453f, &ignored); +} + +// Signed distance to an axis-aligned rectangle of the given half-extents +float sdf_rectangle(float px, float py, float hx, float hy) +{ + float ex = fabsf(px) - hx; + float ey = fabsf(py) - hy; + float outside = sqrtf(MAX(ex, 0.0f) * MAX(ex, 0.0f) + MAX(ey, 0.0f) * MAX(ey, 0.0f)); + float inside = MIN(MAX(ex, ey), 0.0f); + return outside + inside; +} + +float smoothstep01(float edge0, float edge1, float x) +{ + if (edge0 == edge1) { + return (x < edge0) ? 0.0f : 1.0f; + } + float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +// The iris opening (realflare's aperture_shape kernel). Returns transmission in +// [0,1] at a point in normalized texture space. +// +// The polygon is the intersection of `blades` half-planes; curvature bows each +// blade by adding a per-blade sine bulge to the distance field, exactly as +// realflare's "roundness" does, but scaled so that our curvature keeps its +// original meaning: 0 leaves the blades straight, 1 pushes each blade's midpoint +// out to the corner radius (a circular iris), and negative values bow the blades +// inward into a star. +float aperture_shape(const lens_aperture& ap, float px, float py) +{ + const int blades = ap.blades; + const float curvature = std::clamp(ap.curvature, -1.0f, 1.0f); + const float softness = MAX(ap.softness, 2.0f / APERTURE_TEXTURE_SIZE); // never sub-pixel + + if (blades < 3 || curvature >= 0.999f) { + // exact circle; the polygon path only approaches one + float r = sqrtf(px * px + py * py) / IRIS_RADIUS; + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, r); + } + + const float rot = ap.rotation * (PI / 180.0f); + const float c = cosf(rot), s = sinf(rot); + const float rx = px * c + py * s; + const float ry = py * c - px * s; + + // Half-plane intersection, normalized so the corners (not the blade + // midpoints) sit at the iris radius, matching the pre-curvature look. + // The half-sector phase keeps a corner on the +x axis at rotation 0, which + // is where the polygon used to put one. + const float sector = 2.0f * PI / blades; + const float apothem = IRIS_RADIUS * cosf(PI / blades); + float sdf = 0.0f; + for (int i = 0; i < blades; i++) { + float angle = (static_cast(i) + 0.5f) * sector; + sdf = MAX(sdf, (cosf(angle) * rx + sinf(angle) * ry) / apothem); + } + + // Per-blade bulge: realflare's sine gradient, phrased in our own frame -- + // 0 at the corners, 1 at each blade's midpoint + float t = atan2f(ry, rx) / sector; + t -= floorf(t); + float bulge = sinf(t * PI); + + // curvature 1 must lift the midpoints (sdf 1) to the corner radius + sdf -= bulge * curvature * (1.0f / cosf(PI / blades) - 1.0f); + + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, sdf); +} + +// Multiply an occlusion primitive into the mask over its bounding box only. +// realflare evaluates min(sdf) over every primitive at every pixel, which is +// fine on a GPU but far too slow on the CPU; taking the max of the smoothstepped +// occlusion instead is equivalent (smoothstep is monotonically decreasing in +// sdf) and lets each primitive touch only the pixels it covers. +template +void aperture_stamp(SCP_vector& occlusion, float cx, float cy, float reach, float softness, Sdf&& sdf) +{ + const int size = APERTURE_TEXTURE_SIZE; + const float to_px = size * 0.5f; + int x0 = static_cast(floorf((cx - reach + 1.0f) * to_px)); + int x1 = static_cast(ceilf((cx + reach + 1.0f) * to_px)); + int y0 = static_cast(floorf((cy - reach + 1.0f) * to_px)); + int y1 = static_cast(ceilf((cy + reach + 1.0f) * to_px)); + x0 = MAX(x0, 0); + y0 = MAX(y0, 0); + x1 = MIN(x1, size - 1); + y1 = MIN(y1, size - 1); + + for (int y = y0; y <= y1; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = x0; x <= x1; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + float cover = smoothstep01(-softness, softness, -sdf(px, py)); + float& dst = occlusion[static_cast(y) * size + x]; + dst = MAX(dst, cover); + } + } +} + +// Rim diffraction grating (realflare's aperture_grating): radial ridges evenly +// spaced around the iris. Because they are evenly spaced in angle, each pixel +// only has to test the few ridges nearest its own bearing. +// +// realflare anchors the ridges at a fixed distance that lines up with the rim of +// *its* aperture; ours sits at IRIS_RADIUS, so the ridges are anchored there and +// `length` is how far in they reach as a fraction of the iris. `width` is a duty +// cycle of the spacing between neighbouring ridges rather than an absolute size, +// so raising the density thins the ridges instead of merging them into a ring. +void aperture_apply_grating(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.grating.density, 1.0f) * 360.0f); + if (count <= 0 || ap.grating.length <= 0.0f) { + return; + } + + const float step = 2.0f * PI / count; + const float hl = 0.5f * std::clamp(ap.grating.length, 0.0f, 1.0f) * IRIS_RADIUS; + const float centre = IRIS_RADIUS - hl; // outer end of every ridge sits on the rim + const float hw = 0.5f * std::clamp(ap.grating.width, 0.0f, 1.0f) * (step * IRIS_RADIUS); + const float softness = MAX(ap.grating.softness, 1.0f / size); + + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + + // nearest ridge to this pixel's bearing, plus neighbours: ridges + // converge towards the centre, so +-2 avoids gaps between them + int k = static_cast(lroundf(atan2f(py, px) / step)); + float cover = 0.0f; + for (int d = -2; d <= 2; d++) { + float angle = (k + d) * step; + float c = cosf(angle), s = sinf(angle); + // into the ridge's own frame, where it lies along +x + float rx = px * c + py * s; + float ry = py * c - px * s; + float sdf = sdf_rectangle(rx - centre, ry, hl, hw); + cover = MAX(cover, smoothstep01(-softness, softness, -sdf)); + } + mask[static_cast(y) * size + x] *= 1.0f - ap.grating.strength * cover; + } + } +} + +void aperture_apply_scratches(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.scratches.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float hw = ap.scratches.width * 0.1f * 0.5f; + const float hl = ap.scratches.length * 0.5f; + const float softness = MAX(ap.scratches.softness, 1.0f / size); + const float rot = ap.scratches.rotation * (PI / 180.0f); + const float rot_var = ap.scratches.rotation_variation * PI; + const float reach = sqrtf(hw * hw + hl * hl) + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + float angle = rot + (aperture_noise(fi, fc, 2.0f) - 0.5f) * rot_var; + float c = cosf(angle), s = sinf(angle); + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + // rotate about the scratch centre, then measure against the sliver + float dx = px - cx, dy = py - cy; + return sdf_rectangle(dx * c + dy * s, dy * c - dx * s, hw, hl); + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.scratches.strength * occlusion[i]; + } +} + +void aperture_apply_dust(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.dust.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float radius = ap.dust.radius * 0.1f; + const float softness = MAX(ap.dust.softness, 1.0f / size); + const float reach = radius + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + return sqrtf((px - cx) * (px - cx) + (py - cy) * (py - cy)) - radius; + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.dust.strength * occlusion[i]; + } +} + +void generate_aperture(const lens_aperture& ap, lens_flare_textures* tex) +{ + const int size = APERTURE_TEXTURE_SIZE; + + tex->aperture_size = size; + tex->aperture.resize(static_cast(size) * size); + + SCP_vector mask(static_cast(size) * size); + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + mask[static_cast(y) * size + x] = aperture_shape(ap, px, py); + } + } + + // Imperfection layers, in realflare's order. All default to strength 0. + if (ap.grating.strength > 0.0f) { + aperture_apply_grating(ap, mask); + } + if (ap.scratches.strength > 0.0f) { + aperture_apply_scratches(ap, mask); + } + if (ap.dust.strength > 0.0f) { + aperture_apply_dust(ap, mask); + } + + for (size_t i = 0; i < mask.size(); i++) { + tex->aperture[i] = static_cast(std::clamp(mask[i], 0.0f, 1.0f) * 255.0f + 0.5f); + } +} + +void generate_starburst(const lens_flare_textures* apert, lens_flare_textures* tex) +{ + const int size = apert->aperture_size; + tex->starburst_size = size; + tex->starburst.resize(static_cast(size) * size * 4); + + // Fraunhofer diffraction pattern: |FFT(aperture)|^2. The (-1)^(x+y) + // modulation shifts the DC term to the texture center. + SCP_vector> grid(static_cast(size) * size); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float v = apert->aperture[static_cast(y) * size + x] * (1.0f / 255.0f); + if ((x + y) & 1) { + v = -v; + } + grid[static_cast(y) * size + x] = v; + } + } + lens_flare_fft2d(grid, size, false); + + SCP_vector power(static_cast(size) * size); + for (size_t i = 0; i < power.size(); i++) { + power[i] = std::norm(grid[i]); + } + + // Normalize against the brightest off-DC value so the streaks (not the + // gigantic central spike) span the useful range + const int c = size / 2; + float pmax = 0.0f; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (abs(x - c) <= 2 && abs(y - c) <= 2) { + continue; + } + pmax = MAX(pmax, power[static_cast(y) * size + x]); + } + } + if (pmax <= 0.0f) { + pmax = 1.0f; + } + + auto sample_power = [&](float fx, float fy) -> float { + fx = std::clamp(fx, 0.0f, size - 1.001f); + fy = std::clamp(fy, 0.0f, size - 1.001f); + int x0 = static_cast(fx), y0 = static_cast(fy); + float tx = fx - x0, ty = fy - y0; + float p00 = power[static_cast(y0) * size + x0]; + float p10 = power[static_cast(y0) * size + x0 + 1]; + float p01 = power[static_cast(y0 + 1) * size + x0]; + float p11 = power[static_cast(y0 + 1) * size + x0 + 1]; + return (p00 * (1 - tx) + p10 * tx) * (1 - ty) + (p01 * (1 - tx) + p11 * tx) * ty; + }; + + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float* out = &tex->starburst[(static_cast(y) * size + x) * 4]; + + // Radial fade so the pattern reaches zero before the texture border + float nx = (x - c) / static_cast(c); + float ny = (y - c) / static_cast(c); + float rn = sqrtf(nx * nx + ny * ny); + float fade = std::clamp((1.0f - rn) / 0.15f, 0.0f, 1.0f); + + for (int k = 0; k < 3; k++) { + // Diffraction angles scale with wavelength: resample the green + // pattern per channel + float scale = Wavelengths_um[1] / Wavelengths_um[k]; + float p = sample_power(c + (x - c) * scale, c + (y - c) * scale) * scale * scale; + out[k] = sqrtf(MIN(p / pmax, 1.0f)) * fade; + } + out[3] = 1.0f; + } + } +} + +} // namespace + +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); + generate_starburst(out, out); +} + +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse) +{ + Assertion((size & (size - 1)) == 0, "FFT size must be a power of two, got %d", size); + Assertion(static_cast(data.size()) == size * size, "FFT data size mismatch"); + + auto fft_1d = [&](std::complex* base, int stride) { + // bit-reversal permutation + for (int i = 1, j = 0; i < size; i++) { + int bit = size >> 1; + for (; j & bit; bit >>= 1) { + j ^= bit; + } + j ^= bit; + if (i < j) { + std::swap(base[static_cast(i) * stride], base[static_cast(j) * stride]); + } + } + for (int len = 2; len <= size; len <<= 1) { + float ang = 2.0f * PI / len * (inverse ? 1.0f : -1.0f); + std::complex wlen(cosf(ang), sinf(ang)); + for (int i = 0; i < size; i += len) { + std::complex w(1.0f, 0.0f); + for (int k = 0; k < len / 2; k++) { + auto& lhs = base[static_cast(i + k) * stride]; + auto& rhs = base[static_cast(i + k + len / 2) * stride]; + std::complex u = lhs; + std::complex v = rhs * w; + lhs = u + v; + rhs = u - v; + w *= wlen; + } + } + } + if (inverse) { + for (int i = 0; i < size; i++) { + base[static_cast(i) * stride] /= static_cast(size); + } + } + }; + + for (int row = 0; row < size; row++) { + fft_1d(&data[static_cast(row) * size], 1); + } + for (int col = 0; col < size; col++) { + fft_1d(&data[col], size); + } +} + +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_internal.h b/code/graphics/lens_flare_internal.h new file mode 100644 index 00000000000..442bb306c0c --- /dev/null +++ b/code/graphics/lens_flare_internal.h @@ -0,0 +1,39 @@ +#pragma once + +#include "graphics/lens_flare.h" + +// Private interface between the four lens-flare translation units. Nothing here +// is part of the module's API -- see graphics/lens_flare.h for that. +// +// lens_flare.cpp module state, the camera lens, texture cache, and +// the per-frame build the render backends consume +// lens_flare_optics.cpp the paraxial model: ray-transfer matrices, ghost +// enumeration, coated-Fresnel reflectance +// lens_flare_aperture.cpp image synthesis: the iris mask and the starburst +// that is its Fraunhofer transform +// lens_flare_table.cpp lens_flares.tbl / *-lens.tbm parsing +// +// The optics and image-synthesis halves touch no engine state at all; they are +// pure functions of a lens_system / lens_aperture. + +namespace graphics { + +// Fraunhofer C / d / F lines (red / green / blue), in micrometers. The three +// wavelengths everything chromatic in the flare is evaluated at: dispersion and +// coating reflectance in the optics, diffraction scaling in the starburst. +constexpr float Wavelengths_um[3] = {0.65627f, 0.58756f, 0.48613f}; + +// Render the iris mask of an aperture and the starburst that follows from it, +// filling both halves of `out`. This is the expensive one: a 512^2 mask plus a +// 2D FFT of it. (graphics/lens_flare.h's lens_flare_generate_aperture_mask() +// stops after the mask, for callers that don't need the transform.) +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out); + +// Parse lens_flares.tbl (falling back to the embedded default) plus every +// *-lens.tbm, appending to `systems` -- a later table redefining a lens by name +// replaces the earlier entry -- and precomputing each one's ghosts. Also reports +// the "$Default Lens:" name, unresolved: it may be declared before, or by a +// different table than, the lens it names. +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name); + +} // namespace graphics diff --git a/code/graphics/lens_flare_optics.cpp b/code/graphics/lens_flare_optics.cpp new file mode 100644 index 00000000000..d8d5fb86d5a --- /dev/null +++ b/code/graphics/lens_flare_optics.cpp @@ -0,0 +1,290 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +// for MAX_LENS_FLARE_INSTANCES: a ghost the shader has no instance slot for is +// not worth enumerating, so the budget bounds the precompute +#include "graphics/util/uniform_structs.h" + +#include +#include + +// The paraxial optics behind the flare: a lens_system is reduced here to a set +// of two-reflection ghost paths, each with its own ray-transfer matrices and +// coated-Fresnel tint, once at table load. Everything in this file is a pure +// function of the prescription -- no engine state, no frame, no screen -- with +// one exception: the ghost count is capped at the shader's instance budget, +// since a ghost with no instance slot to draw it in is not worth enumerating. + +namespace graphics { +namespace { + +// ---- 2x2 ray-transfer matrix helpers ([A B; C D] acting on [height; angle]) ---- + +struct mat2 { + float a, b, c, d; +}; + +mat2 m2_identity() { return {1.0f, 0.0f, 0.0f, 1.0f}; } + +mat2 m2_mul(const mat2& m, const mat2& n) +{ + return {m.a * n.a + m.b * n.c, m.a * n.b + m.b * n.d, m.c * n.a + m.d * n.c, m.c * n.b + m.d * n.d}; +} + +mat2 m2_translate(float t) { return {1.0f, t, 0.0f, 1.0f}; } + +// Refraction at a spherical interface with signed curvature 1/R, from index n1 into n2 +mat2 m2_refract(float n1, float n2, float inv_r) { return {1.0f, 0.0f, (n1 - n2) * inv_r / n2, n1 / n2}; } + +// Mirror reflection at a spherical surface with signed curvature 1/R +mat2 m2_reflect(float inv_r) { return {1.0f, 0.0f, 2.0f * inv_r, 1.0f}; } + +mat2 m2_inverse(const mat2& m) +{ + float det = m.a * m.d - m.b * m.c; + return {m.d / det, -m.b / det, -m.c / det, m.a / det}; +} + +float surface_inv_radius(const lens_surface& s) +{ + return (s.radius != 0.0f) ? 1.0f / s.radius : 0.0f; +} + +// Refractive index behind a surface at one of the three design wavelengths, +// with Cauchy 2-term dispersion fitted through n_d and the Abbe number. +float surface_index(const lens_surface& s, int wl) +{ + if (s.n <= 1.0005f || s.abbe <= 0.0f) { + return s.n; + } + constexpr float lF = 0.48613f, lC = 0.65627f, lD = 0.58756f; + float B = (s.n - 1.0f) / (s.abbe * (1.0f / (lF * lF) - 1.0f / (lC * lC))); + float A = s.n - B / (lD * lD); + float l = Wavelengths_um[wl]; + return A + B / (l * l); +} + +float index_after(const lens_system& lens, int surf, int wl) +{ + return surface_index(lens.surfaces[surf], wl); +} + +float index_before(const lens_system& lens, int surf, int wl) +{ + return (surf == 0) ? 1.0f : surface_index(lens.surfaces[surf - 1], wl); +} + +int find_stop_index(const lens_system& lens) +{ + for (int i = 0; i < static_cast(lens.surfaces.size()); i++) { + if (lens.surfaces[i].is_stop) { + return i; + } + } + return -1; +} + +// Forward system matrix (no reflections) from the first surface to just after +// the last surface, at the given wavelength. +mat2 system_matrix(const lens_system& lens, int wl) +{ + mat2 m = m2_identity(); + int n = static_cast(lens.surfaces.size()); + for (int s = 0; s < n; s++) { + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(lens.surfaces[s])), m); + if (s < n - 1) { + m = m2_mul(m2_translate(lens.surfaces[s].thickness), m); + } + } + return m; +} + +// Compose the ray-transfer matrices of one two-reflection ghost path +// (first reflection at surface hi going forward, second at surface lo going +// backward, lo < hi), splitting at the LAST aperture-stop crossing. +void trace_ghost_path(const lens_system& lens, int hi, int lo, int stop, int wl, float bfd, + float out_ma[4], float out_ms[4]) +{ + mat2 m = m2_identity(); + mat2 ma = m2_identity(); + bool have_ma = false; + + auto note_stop = [&]() { + ma = m; + have_ma = true; + }; + + const auto& surf = lens.surfaces; + int n = static_cast(surf.size()); + + // Phase 1: forward from surface 0 up to surface hi + for (int s = 0; s < hi; s++) { + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + m = m2_mul(m2_translate(surf[s].thickness), m); + } + + // Reflect at surface hi (now travelling backward; radii of surfaces crossed + // backward flip sign in the unfolded system) + m = m2_mul(m2_reflect(surface_inv_radius(surf[hi])), m); + + // Phase 2: backward from surface hi down to surface lo + for (int s = hi - 1; s > lo; s--) { + m = m2_mul(m2_translate(surf[s].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_after(lens, s, wl), index_before(lens, s, wl), -surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(surf[lo].thickness), m); + + // Reflect at surface lo (hit from behind; forward again) + m = m2_mul(m2_reflect(-surface_inv_radius(surf[lo])), m); + + // Phase 3: forward from surface lo to the sensor + for (int s = lo + 1; s < n; s++) { + m = m2_mul(m2_translate(surf[s - 1].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(bfd), m); + + if (!have_ma) { + // Degenerate prescription (no stop crossing); treat the whole path as Ms + ma = m2_identity(); + } + + mat2 ms = m2_mul(m, m2_inverse(ma)); + + out_ma[0] = ma.a; + out_ma[1] = ma.b; + out_ma[2] = ma.c; + out_ma[3] = ma.d; + out_ms[0] = ms.a; + out_ms[1] = ms.b; + out_ms[2] = ms.c; + out_ms[3] = ms.d; +} + +float ghost_coating_wavelength(const lens_system& lens, const lens_surface& s) +{ + return (s.coating_wavelength < 0.0f) ? lens.coating_wavelength : s.coating_wavelength; +} + +} // namespace + +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm) +{ + if (lambda0_nm <= 0.0f) { + float r = (n1 - n2) / (n1 + n2); + return r * r; + } + + // Single quarter-wave layer (tuned to lambda0) between n1 and n2, ideally + // index sqrt(n1*n2) but no better than MgF2 (1.38), at normal incidence + float nc = MAX(1.38f, sqrtf(n1 * n2)); + float r1 = (n1 - nc) / (n1 + nc); + float r2 = (nc - n2) / (nc + n2); + float cphi = cosf(PI * lambda0_nm / lambda_nm); + float num = r1 * r1 + r2 * r2 + 2.0f * r1 * r2 * cphi; + float den = 1.0f + r1 * r1 * r2 * r2 + 2.0f * r1 * r2 * cphi; + return num / den; +} + +bool lens_flare_precompute(lens_system& lens) +{ + lens.ghosts.clear(); + + int n = static_cast(lens.surfaces.size()); + if (n < 2) { + return false; + } + + // Normalize stop surfaces: flat, index-continuous with the preceding medium + for (int i = 0; i < n; i++) { + if (lens.surfaces[i].is_stop) { + lens.surfaces[i].radius = 0.0f; + lens.surfaces[i].n = (i == 0) ? 1.0f : lens.surfaces[i - 1].n; + lens.surfaces[i].abbe = (i == 0) ? 0.0f : lens.surfaces[i - 1].abbe; + } + } + + int stop = find_stop_index(lens); + if (stop < 0) { + // No explicit stop: use the middle surface's plane for aperture clipping + stop = n / 2; + } + + // Effective focal length and back focal distance (green), sensor placed at + // the infinity focus + mat2 sys = system_matrix(lens, 1); + if (fabsf(sys.c) < 1e-6f) { + return false; // afocal; can't image onto a sensor + } + lens.efl = -1.0f / sys.c; + lens.bfd = -sys.a / sys.c; + if (lens.efl <= 0.0f || lens.bfd <= 0.0f) { + return false; + } + + // Enumerate all two-reflection ghost paths between refractive surfaces + struct scored_ghost { + lens_flare_ghost g; + float key; + }; + SCP_vector scored; + + for (int hi = 1; hi < n; hi++) { + if (fabsf(index_before(lens, hi, 1) - index_after(lens, hi, 1)) < 1e-4f) { + continue; // no index step -> no reflection (also skips the stop) + } + for (int lo = 0; lo < hi; lo++) { + if (fabsf(index_before(lens, lo, 1) - index_after(lens, lo, 1)) < 1e-4f) { + continue; + } + + scored_ghost sg; + sg.g.surf_first = hi; + sg.g.surf_second = lo; + + for (int wl = 0; wl < 3; wl++) { + trace_ghost_path(lens, hi, lo, stop, wl, lens.bfd, sg.g.ma[wl], sg.g.ms[wl]); + + float lambda_nm = Wavelengths_um[wl] * 1000.0f; + float r_first = lens_flare_fresnel_reflectance(index_before(lens, hi, wl), index_after(lens, hi, wl), + ghost_coating_wavelength(lens, lens.surfaces[hi]), lambda_nm); + float r_second = lens_flare_fresnel_reflectance(index_after(lens, lo, wl), index_before(lens, lo, wl), + ghost_coating_wavelength(lens, lens.surfaces[lo]), lambda_nm); + sg.g.reflectance[wl] = r_first * r_second; + } + + if (sg.g.reflectance[1] < 1e-6f) { + continue; + } + + // Brightness-ish sort key: reflectance, boosted for concentrated + // (small-footprint) ghosts + float a_g = sg.g.ms[1][0] * sg.g.ma[1][0] + sg.g.ms[1][1] * sg.g.ma[1][2]; + sg.key = sg.g.reflectance[1] * MIN(1.0f / (a_g * a_g + 1e-3f), 100.0f); + scored.push_back(sg); + } + } + + std::sort(scored.begin(), scored.end(), [](const scored_ghost& x, const scored_ghost& y) { return x.key > y.key; }); + + int cap = std::clamp(lens.max_ghosts, 1, generic_data::MAX_LENS_FLARE_INSTANCES - 1); + for (const auto& sg : scored) { + if (static_cast(lens.ghosts.size()) >= cap) { + break; + } + lens.ghosts.push_back(sg.g); + } + + return !lens.ghosts.empty(); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_table.cpp b/code/graphics/lens_flare_table.cpp new file mode 100644 index 00000000000..e6fa0a25def --- /dev/null +++ b/code/graphics/lens_flare_table.cpp @@ -0,0 +1,229 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +#include + +// lens_flares.tbl / *-lens.tbm parsing. Owns no state: the systems it reads are +// appended to the vector the caller hands over, and each is precomputed on the +// way in so an unusable prescription never reaches the renderer. + +namespace graphics { +namespace { + +// Where the parsed lenses go for the duration of one parse run. File-scope +// pointers rather than parameters because parse_modular_table() takes a plain +// function pointer, so the per-file callback cannot capture anything. Set and +// cleared by lens_flare_parse_tables(), which is the only entry point. +SCP_vector* Parse_systems = nullptr; +SCP_string* Parse_default_name = nullptr; + +int find_system(const SCP_vector& systems, const char* name) +{ + for (int i = 0; i < static_cast(systems.size()); i++) { + if (!stricmp(systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +// ---- table parsing ---- + +void parse_lens_table_core() +{ + Assertion(Parse_systems != nullptr && Parse_default_name != nullptr, + "Lens tables parsed outside lens_flare_parse_tables()!"); + auto& systems = *Parse_systems; + auto& default_name = *Parse_default_name; + + reset_parse(); + + required_string("#Lens Systems"); + + // The lens a mission gets when it doesn't name one itself. Left empty by the + // shipped table, so content that never asks for a lens keeps the retail look; + // a mod opts its whole campaign in with one line in a *-lens.tbm. + if (optional_string("$Default Lens:")) { + stuff_string(default_name, F_NAME); + } + + while (optional_string("$Name:")) { + lens_system ls; + stuff_string(ls.name, F_NAME); + + if (optional_string("$Entrance Pupil Radius:")) { + stuff_float(&ls.entrance_radius); + } + if (optional_string("$Aperture Radius:")) { + stuff_float(&ls.aperture_radius); + } + if (optional_string("$Sensor Width:")) { + stuff_float(&ls.sensor_width); + } + if (optional_string("$Coating Wavelength:")) { + stuff_float(&ls.coating_wavelength); + } + if (optional_string("$Aperture Blades:")) { + stuff_int(&ls.aperture.blades); + } + if (optional_string("+Blade Rotation:")) { + stuff_float(&ls.aperture.rotation); + } + if (optional_string("+Blade Curvature:")) { + stuff_float(&ls.aperture.curvature); + } + if (optional_string("+Edge Softness:")) { + stuff_float(&ls.aperture.softness); + } + if (optional_string("$Aperture Grating:")) { + stuff_float(&ls.aperture.grating.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.grating.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.grating.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.grating.width); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.grating.softness); + } + } + if (optional_string("$Aperture Scratches:")) { + stuff_float(&ls.aperture.scratches.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.scratches.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.scratches.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.scratches.width); + } + if (optional_string("+Rotation:")) { + stuff_float(&ls.aperture.scratches.rotation); + } + if (optional_string("+Rotation Variation:")) { + stuff_float(&ls.aperture.scratches.rotation_variation); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.scratches.softness); + } + } + if (optional_string("$Aperture Dust:")) { + stuff_float(&ls.aperture.dust.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.dust.density); + } + if (optional_string("+Radius:")) { + stuff_float(&ls.aperture.dust.radius); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.dust.softness); + } + } + if (optional_string("$Starburst:")) { + stuff_boolean(&ls.starburst); + } + if (optional_string("+Starburst Scale:")) { + stuff_float(&ls.starburst_scale); + } + if (optional_string("$Intensity:")) { + stuff_float(&ls.intensity); + } + if (optional_string("$Max Ghosts:")) { + stuff_int(&ls.max_ghosts); + } + + while (true) { + if (optional_string("$Surface:")) { + float vals[3] = {0.0f, 0.0f, 1.0f}; + size_t count = stuff_float_list(vals, 3); + if (count != 3) { + error_display(0, "Lens system '%s': $Surface: needs ( radius, thickness, index )", ls.name.c_str()); + } + lens_surface s; + s.radius = vals[0]; + s.thickness = vals[1]; + s.n = vals[2]; + if (optional_string("+Abbe:")) { + stuff_float(&s.abbe); + } + if (optional_string("+Coating Wavelength:")) { + stuff_float(&s.coating_wavelength); + } + ls.surfaces.push_back(s); + } else if (optional_string("$Stop:")) { + float d = 0.0f; + size_t count = stuff_float_list(&d, 1); + if (count != 1) { + error_display(0, "Lens system '%s': $Stop: needs ( thickness )", ls.name.c_str()); + } + lens_surface s; + s.thickness = d; + s.is_stop = true; + ls.surfaces.push_back(s); + } else { + break; + } + } + + if (!lens_flare_precompute(ls)) { + error_display(0, "Lens system '%s' has an unusable prescription and will be ignored", ls.name.c_str()); + continue; + } + + // what a mission's sexps (or the lab) will be reset back to + ls.tabled_aperture = ls.aperture; + + // a later table may redefine a lens the engine (or an earlier tbm) shipped + int existing = find_system(systems, ls.name.c_str()); + if (existing >= 0) { + systems[existing] = std::move(ls); + } else { + systems.push_back(std::move(ls)); + } + } + + required_string("#End"); +} + +void parse_lens_table_file(const char* filename) +{ + try { + if (filename == nullptr) { + read_file_text_from_default(defaults_get_file("lens_flares.tbl")); + } else { + read_file_text(filename, CF_TYPE_TABLES); + } + parse_lens_table_core(); + } catch (const parse::ParseException& e) { + mprintf(("Unable to parse '%s'! Error message = %s.\n", (filename != nullptr) ? filename : "", e.what())); + } +} + +} // namespace + +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name) +{ + Parse_systems = &systems; + Parse_default_name = &default_lens_name; + + if (cf_exists_full("lens_flares.tbl", CF_TYPE_TABLES)) { + parse_lens_table_file("lens_flares.tbl"); + } else { + parse_lens_table_file(nullptr); + } + + parse_modular_table("*-lens.tbm", [](const char* filename) { parse_lens_table_file(filename); }); + + Parse_systems = nullptr; + Parse_default_name = nullptr; +} + +} // namespace graphics diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..adcd3cb6d4b 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -9,12 +9,15 @@ #include "gropengldraw.h" #include "gropenglshader.h" #include "gropenglstate.h" +#include "gropengltnl.h" #include "cmdline/cmdline.h" #include "def_files/def_files.h" +#include "graphics/lens_flare.h" #include "graphics/shader_types.h" #include "graphics/grinternal.h" #include "graphics/openxr.h" +#include "graphics/render.h" #include "graphics/util/uniform_structs.h" #include "io/timer.h" #include "lighting/lighting.h" @@ -63,6 +66,14 @@ static GLuint Smaa_output_tex = 0; static GLuint Smaa_search_tex = 0; static GLuint Smaa_area_tex = 0; +// physically-based lens flare resources (created lazily on first use). There is +// one camera lens, so one iris mask and one starburst serve every sun. +static GLuint Lens_flare_framebuffer = 0; +static GLuint Lens_flare_aperture_tex = 0; +static GLuint Lens_flare_starburst_tex = 0; +static int Lens_flare_tex_lens_idx = -1; +static unsigned int Lens_flare_tex_generation = 0; + namespace ltp = lighting_profiles; using namespace ltp; @@ -500,6 +511,137 @@ void opengl_post_lightshafts() } } +// drop the uploaded aperture/starburst textures (regenerated lazily on demand) +static void opengl_lens_flare_release_textures() +{ + if (Lens_flare_aperture_tex) { + glDeleteTextures(1, &Lens_flare_aperture_tex); + Lens_flare_aperture_tex = 0; + } + if (Lens_flare_starburst_tex) { + glDeleteTextures(1, &Lens_flare_starburst_tex); + Lens_flare_starburst_tex = 0; + } + Lens_flare_tex_lens_idx = -1; +} + +// Upload the CPU-generated aperture/starburst textures of the mounted lens, or +// keep the ones already uploaded for it +static bool opengl_lens_flare_ensure_textures(int lens_idx) +{ + const unsigned int generation = graphics::lens_flare_get_texture_generation(); + if (lens_idx == Lens_flare_tex_lens_idx && generation == Lens_flare_tex_generation && + Lens_flare_aperture_tex != 0) { + return true; + } + + const auto* tex = graphics::lens_flare_get_textures(lens_idx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return false; + } + + opengl_lens_flare_release_textures(); + + auto create_tex = [](GLsizei size, GLenum internal_format, GLenum format, GLenum type, const void* pixels, + const char* name) { + GLuint handle; + glGenTextures(1, &handle); + + GL_state.Texture.SetActiveUnit(0); + GL_state.Texture.SetTarget(GL_TEXTURE_2D); + GL_state.Texture.Enable(handle); + + opengl_set_object_label(GL_TEXTURE, handle, name); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + glTexImage2D(GL_TEXTURE_2D, 0, internal_format, size, size, 0, format, type, pixels); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + + return handle; + }; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + Lens_flare_aperture_tex = create_tex(tex->aperture_size, GL_R8, GL_RED, GL_UNSIGNED_BYTE, tex->aperture.data(), + "Lens flare aperture"); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Lens_flare_starburst_tex = create_tex(tex->starburst_size, GL_RGBA32F, GL_RGBA, GL_FLOAT, tex->starburst.data(), + "Lens flare starburst"); + + Lens_flare_tex_lens_idx = lens_idx; + Lens_flare_tex_generation = generation; + return true; +} + +// physically-based lens flares: additive instanced ghost quads on the HDR +// scene color, immediately before bloom (so bloom/tonemap treat the flare +// energy like any other scene light). One draw per visible sun: they share the +// camera lens (hence its textures), but each has its own flare axis and tint. +static void opengl_post_pass_lens_flare() +{ + if (GL_rendering_to_texture) { + // no camera-lens artifacts in environment maps and other RTT passes + return; + } + + const auto& flare_draws = graphics::lens_flare_build_frame_data(); + if (flare_draws.empty()) { + return; + } + + if (!opengl_lens_flare_ensure_textures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + TRACE_SCOPE(tracing::LensFlare); + + if (Lens_flare_framebuffer == 0) { + glGenFramebuffers(1, &Lens_flare_framebuffer); + } + GL_state.BindFrameBuffer(Lens_flare_framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, Scene_color_texture, 0); + glDrawBuffer(GL_COLOR_ATTACHMENT0); + + glViewport(0, 0, gr_screen.max_w, gr_screen.max_h); + + opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_LENS_FLARE, 0)); + + Current_shader->program->Uniforms.setTextureUniform("apertureMap", 0); + Current_shader->program->Uniforms.setTextureUniform("starburstMap", 1); + + GL_state.Texture.Enable(0, GL_TEXTURE_2D, Lens_flare_aperture_tex); + GL_state.Texture.Enable(1, GL_TEXTURE_2D, Lens_flare_starburst_tex); + + GLboolean scissor_test = GL_state.ScissorTest(GL_FALSE); + GL_state.Blend(GL_TRUE); + GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); + + // one 4-vertex triangle-strip quad, instanced per ghost/starburst + GLfloat corners[4][2] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}}; + + vertex_layout layout; + layout.add_vertex_component(vertex_format_data::POSITION2, sizeof(GLfloat) * 2, 0); + + size_t offset = gr_add_to_immediate_buffer(sizeof(corners), corners); + opengl_bind_vertex_layout(layout, opengl_buffer_get_id(GL_ARRAY_BUFFER, gr_immediate_buffer_handle), 0, offset); + + for (const auto& draw : flare_draws) { + opengl_set_generic_uniform_data( + [&](graphics::generic_data::lens_flare_data* data) { *data = *draw.data; }); + + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, draw.instances); + } + + GL_state.SetAlphaBlendMode(ALPHA_BLEND_NONE); + GL_state.Blend(GL_FALSE); + GL_state.ScissorTest(scissor_test); +} + void gr_opengl_post_process_end() { GR_DEBUG_SCOPE("Draw scene texture"); @@ -515,6 +657,9 @@ void gr_opengl_post_process_end() GL_state.PushFramebufferState(); + // physically-based lens flares composite into the HDR scene before bloom + opengl_post_pass_lens_flare(); + // do bloom, hopefully ;) opengl_post_pass_bloom(); @@ -1156,6 +1301,12 @@ void opengl_post_process_shutdown() opengl_post_process_shutdown_bloom(); + if (Lens_flare_framebuffer) { + glDeleteFramebuffers(1, &Lens_flare_framebuffer); + Lens_flare_framebuffer = 0; + } + opengl_lens_flare_release_textures(); + Post_in_frame = false; Post_active_shader_index = 0; diff --git a/code/graphics/shader_types.cpp b/code/graphics/shader_types.cpp index 80a733b7c2a..727fc70ff63 100644 --- a/code/graphics/shader_types.cpp +++ b/code/graphics/shader_types.cpp @@ -116,6 +116,9 @@ static ShaderTypeInfo SHADER_TYPES[] = { { SDR_TYPE_GAMMA_BLIT, "post-v.sdr", "gamma-correct-f.sdr", nullptr, { VATTRIB_POSITION, VATTRIB_TEXCOORD }, "Gamma correct blit", false }, + + { SDR_TYPE_LENS_FLARE, "lensflare-v.sdr", "lensflare-f.sdr", nullptr, + { VATTRIB_POSITION }, "Physically-based lens flare", false }, }; // clang-format on diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..087f7810640 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -289,6 +289,32 @@ struct fxaa_data { float pad[2]; }; +// Keep in sync with the literal array size in lensflare-v.sdr / lensflare-f.sdr! +constexpr int MAX_LENS_FLARE_INSTANCES = 64; + +// One ghost (or starburst) quad of the physically-based lens flare pass. +// Per-channel values are for red/green/blue in x/y/z; all positions/extents +// are in sensor-plane units (mm) along/around the flare axis. +struct lens_flare_instance_data { + vec4 center; // xyz = per-channel quad center along the flare axis; w > 0.5 marks the starburst + vec4 halfext; // xyz = per-channel quad half-extent + vec4 apscale; // xyz = per-channel aperture-plane scale of the quad parametrization + vec4 apoff; // xyz = per-channel aperture-plane offset along the flare axis + vec4 color; // rgb = per-channel ghost intensity (reflectance * energy scale) +}; + +struct lens_flare_data { + vec2d axis; // unit flare axis in sensor space (sun -> screen center line) + vec2d ndc_scale; // sensor units -> NDC (x, y incl. aspect) + + vec4 tint; // rgb = sun color * visibility * lens intensity + + int n_instances; + float pad[3]; + + lens_flare_instance_data instances[MAX_LENS_FLARE_INSTANCES]; +}; + struct fog_data { vec3d fog_color; float fog_start; diff --git a/code/graphics/vulkan/VulkanPostProcessing.cpp b/code/graphics/vulkan/VulkanPostProcessing.cpp index 13d13675437..6c9dae4a5e0 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.cpp +++ b/code/graphics/vulkan/VulkanPostProcessing.cpp @@ -327,6 +327,11 @@ bool VulkanPostProcessor::init(vk::Device device, vk::PhysicalDevice physDevice, nprintf(("vulkan", "VulkanPostProcessor: Bloom initialization failed (non-fatal)\n")); } + // Initialize the physically-based lens flare pass (non-fatal if it fails) + if (!m_lensFlare.init(m_ctx, m_sceneColor)) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare initialization failed (non-fatal)\n")); + } + // Initialize LDR targets for tonemapping + FXAA (non-fatal if it fails) if (!m_ldr.init(m_ctx, m_sceneColor, m_sceneDepth, m_bloom)) { nprintf(("vulkan", "VulkanPostProcessor: LDR target initialization failed (non-fatal)\n")); @@ -376,6 +381,7 @@ void VulkanPostProcessor::shutdown() shutdownGBuffer(); m_smaa.shutdown(); m_ldr.shutdown(); + m_lensFlare.shutdown(); shutdownBloom(); m_ctx.shutdownScratchUBO(); @@ -526,6 +532,11 @@ bool VulkanPostProcessor::resize(vk::Extent2D newExtent) nprintf(("vulkan", "VulkanPostProcessor: Bloom resize failed, disabling bloom\n")); m_bloom.shutdown(); } + // The lens flare framebuffer attaches the (just recreated) scene color view. + if (m_lensFlare.isInitialized() && !m_lensFlare.resize()) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare resize failed, disabling lens flares\n")); + m_lensFlare.shutdown(); + } if (m_ldr.isInitialized() && !m_ldr.resize()) { nprintf(("vulkan", "VulkanPostProcessor: LDR resize failed, disabling LDR + SMAA\n")); m_smaa.shutdown(); diff --git a/code/graphics/vulkan/VulkanPostProcessing.h b/code/graphics/vulkan/VulkanPostProcessing.h index d505d70acde..bad6f012709 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.h +++ b/code/graphics/vulkan/VulkanPostProcessing.h @@ -273,6 +273,90 @@ class VulkanBloom { bool m_initialized = false; }; +/** + * @brief Physically-based lens flares (ghost quads + starburst billboard) + * + * Self-contained subsystem that additively composites the precomputed lens + * flare instances (see graphics/lens_flare.h) onto the HDR scene color, + * immediately before bloom. Owns a loadOp=eLoad render pass on the scene + * color, a small dedicated per-frame UBO ring (the per-ghost array exceeds + * the shared scratch ring's slot size), and the static aperture/starburst + * textures uploaded from the CPU-generated pixel data of the active lens. + */ +class VulkanLensFlare { +public: + /** + * @brief Create render pass/framebuffer/UBO resources + * @param sceneColor Scene HDR color target to composite into (must outlive this) + */ + bool init(PostProcessContext& ctx, const RenderTarget& sceneColor); + void shutdown(); + + /** + * @brief Recreate the scene-color framebuffer after a resize (render pass kept) + * + * Device must be idle. Returns false on failure (caller should shut the + * subsystem down). + */ + bool resize(); + + /** + * @brief Per-frame UBO ring cursor reset (called from VulkanPostProcessor::beginFrame) + */ + void beginFrame(uint32_t frameIndex) + { + if (m_ubo.isValid()) { + m_ubo.resetCursor(frameIndex); + } + } + + /** + * @brief Draw the flare instances of every flaring sun additively onto the scene color + * + * No-op when no lens-equipped sun is visible. Scene color must be in + * eShaderReadOnlyOptimal (the state after the scene render pass ends) and + * is returned to eShaderReadOnlyOptimal, matching what bloom expects. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void execute(vk::CommandBuffer cmd); + + bool isInitialized() const { return m_initialized; } + +private: + bool createFramebuffer(); + + /** + * @brief Upload the iris/starburst textures of the mounted lens, if not already uploaded + */ + bool ensureTextures(int lensIdx); + void releaseTextures(bool deferred); + + PostProcessContext* m_ctx = nullptr; + const RenderTarget* m_sceneColor = nullptr; + + vk::RenderPass m_renderPass; // Color-only RGBA16F, loadOp=eLoad (additive to scene) + vk::Framebuffer m_sceneColorFB; // Scene color as attachment 0 + + // Dedicated per-frame UBO ring: lens_flare_data (~5 KB) exceeds the shared + // scratch ring's slot size (see PostProcessContext::SCRATCH_UBO_SLOT_SIZE). + // One slot per visible sun per scene render (see LENS_FLARE_UBO_SLOTS). + PerFrameUboRing m_ubo; + + // Static iris/starburst textures of the mounted camera lens, shared by every + // sun's flare + vk::Image m_apertureImage; + vk::ImageView m_apertureView; + VulkanAllocation m_apertureAlloc; + vk::Image m_starburstImage; + vk::ImageView m_starburstView; + VulkanAllocation m_starburstAlloc; + int m_texLensIdx = -1; + unsigned int m_texGeneration = 0; + + bool m_initialized = false; +}; + /** * @brief Deferred geometry buffer (G-buffer) + optional MSAA G-buffer & resolve * @@ -784,7 +868,11 @@ class VulkanPostProcessor { * fullscreen pass of the frame (mid-scene fog included), so subsystems must * not reset it themselves. */ - void beginFrame(uint32_t frameIndex) { m_ctx.scratchRing.resetCursor(frameIndex); } + void beginFrame(uint32_t frameIndex) + { + m_ctx.scratchRing.resetCursor(frameIndex); + m_lensFlare.beginFrame(frameIndex); + } /** * @brief Get the HDR scene render pass (for 3D scene rendering) @@ -884,6 +972,17 @@ class VulkanPostProcessor { */ void executeBloom(vk::CommandBuffer cmd) { m_bloom.execute(cmd); } + /** + * @brief Execute the physically-based lens flare pass + * + * Called immediately before executeBloom() so the flare energy is bloomed + * and tonemapped like any other HDR scene content. No-op when no + * lens-equipped sun is visible. Must be called outside a render pass. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void executeLensFlare(vk::CommandBuffer cmd) { m_lensFlare.execute(cmd); } + /** * @brief Execute tonemapping pass (HDR scene → LDR) * @@ -1165,6 +1264,9 @@ class VulkanPostProcessor { // ---- Bloom (self-contained subsystem) ---- VulkanBloom m_bloom; + // ---- Physically-based lens flares (self-contained subsystem) ---- + VulkanLensFlare m_lensFlare; + // ---- LDR / FXAA / post-effects / lightshafts (self-contained subsystem) ---- VulkanLDR m_ldr; diff --git a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp new file mode 100644 index 00000000000..02f30d2a397 --- /dev/null +++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp @@ -0,0 +1,371 @@ +#include "VulkanPostProcessing.h" + +#include + +#include "gr_vulkan.h" +#include "VulkanRenderer.h" +#include "VulkanPipeline.h" +#include "VulkanDescriptorManager.h" +#include "VulkanTexture.h" +#include "VulkanDeletionQueue.h" +#include "graphics/2d.h" +#include "graphics/grinternal.h" +#include "graphics/lens_flare.h" +#include "graphics/util/uniform_structs.h" + +namespace graphics::vulkan { + +// ===== Physically-based lens flare pass ===== + +namespace { +// One UBO slot per visible sun per scene render. Backgrounds have single-digit +// sun counts and each slot is only ~5 KB, so this is deliberately generous; the +// draw loop bails out rather than overflowing the ring if one ever exceeds it. +constexpr uint32_t LENS_FLARE_UBO_SLOTS = 32; +} // namespace + +bool VulkanLensFlare::init(PostProcessContext& ctx, const RenderTarget& sceneColor) +{ + m_ctx = &ctx; + m_sceneColor = &sceneColor; + + // Additive render pass on the scene color: identical shape to the bloom + // composite pass (loadOp=eLoad, ends in eShaderReadOnlyOptimal so the + // following bloom bright pass can sample the scene as usual) + { + vk::AttachmentDescription att; + att.format = HDR_COLOR_FORMAT; + att.samples = vk::SampleCountFlagBits::e1; + att.loadOp = vk::AttachmentLoadOp::eLoad; + att.storeOp = vk::AttachmentStoreOp::eStore; + att.stencilLoadOp = vk::AttachmentLoadOp::eDontCare; + att.stencilStoreOp = vk::AttachmentStoreOp::eDontCare; + att.initialLayout = vk::ImageLayout::eColorAttachmentOptimal; + att.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + + vk::AttachmentReference colorRef; + colorRef.attachment = 0; + colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal; + + vk::SubpassDescription subpass; + subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + + vk::SubpassDependency dep; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.dstStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.srcAccessMask = vk::AccessFlagBits::eShaderRead + | vk::AccessFlagBits::eColorAttachmentWrite; + dep.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + + vk::RenderPassCreateInfo rpInfo; + rpInfo.attachmentCount = 1; + rpInfo.pAttachments = &att; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dep; + + try { + m_renderPass = m_ctx->device.createRenderPass(rpInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create render pass: %s\n", e.what())); + return false; + } + } + + if (!createFramebuffer()) { + return false; + } + + // Dedicated per-frame UBO ring: lens_flare_data exceeds the shared scratch + // ring's slot size. One slot per visible sun, with room for the scene being + // rendered more than once per frame. + vk::DeviceSize slotSize = (sizeof(generic_data::lens_flare_data) + 255) & ~static_cast(255); + if (!m_ubo.init(m_ctx->device, m_ctx->memoryManager, LENS_FLARE_UBO_SLOTS, slotSize)) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create UBO ring!\n")); + shutdown(); + return false; + } + + m_initialized = true; + nprintf(("vulkan", "VulkanLensFlare: Initialized\n")); + return true; +} + +bool VulkanLensFlare::createFramebuffer() +{ + vk::FramebufferCreateInfo fbInfo; + fbInfo.renderPass = m_renderPass; + fbInfo.attachmentCount = 1; + fbInfo.pAttachments = &m_sceneColor->view; + fbInfo.width = m_ctx->sceneExtent.width; + fbInfo.height = m_ctx->sceneExtent.height; + fbInfo.layers = 1; + + try { + m_sceneColorFB = m_ctx->device.createFramebuffer(fbInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create framebuffer: %s\n", e.what())); + return false; + } + return true; +} + +bool VulkanLensFlare::resize() +{ + if (!m_initialized) { + return true; + } + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + return createFramebuffer(); +} + +void VulkanLensFlare::releaseTextures(bool deferred) +{ + auto* deletionQueue = deferred ? getDeletionQueue() : nullptr; + + auto release = [&](vk::Image& image, vk::ImageView& view, VulkanAllocation& alloc) { + if (view) { + if (deletionQueue) { + deletionQueue->queueImageView(view); + } else { + m_ctx->device.destroyImageView(view); + } + view = nullptr; + } + if (image) { + if (deletionQueue) { + deletionQueue->queueImage(image, alloc); + } else { + m_ctx->device.destroyImage(image); + m_ctx->memoryManager->freeAllocation(alloc); + } + image = nullptr; + alloc = {}; + } + }; + + release(m_apertureImage, m_apertureView, m_apertureAlloc); + release(m_starburstImage, m_starburstView, m_starburstAlloc); + + m_texLensIdx = -1; +} + +bool VulkanLensFlare::ensureTextures(int lensIdx) +{ + const unsigned int generation = graphics::lens_flare_get_texture_generation(); + if (lensIdx == m_texLensIdx && generation == m_texGeneration && m_apertureView) { + return true; + } + + const auto* tex = graphics::lens_flare_get_textures(lensIdx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return false; + } + + auto* texMgr = getTextureManager(); + if (texMgr == nullptr) { + return false; + } + + // The outgoing textures may still be referenced by in-flight frames + releaseTextures(true); + + if (!texMgr->createStaticTexture2D(tex->aperture_size, tex->aperture_size, vk::Format::eR8Unorm, + tex->aperture.data(), tex->aperture.size(), "Lens flare aperture", + m_apertureImage, m_apertureView, m_apertureAlloc)) { + return false; + } + + if (!texMgr->createStaticTexture2D(tex->starburst_size, tex->starburst_size, vk::Format::eR32G32B32A32Sfloat, + tex->starburst.data(), tex->starburst.size() * sizeof(float), "Lens flare starburst", + m_starburstImage, m_starburstView, m_starburstAlloc)) { + releaseTextures(false); + return false; + } + + m_texLensIdx = lensIdx; + m_texGeneration = generation; + return true; +} + +void VulkanLensFlare::execute(vk::CommandBuffer cmd) +{ + if (!m_initialized) { + return; + } + + const auto& flareDraws = graphics::lens_flare_build_frame_data(); + if (flareDraws.empty()) { + return; + } + + auto* pipelineMgr = getPipelineManager(); + auto* descriptorMgr = getDescriptorManager(); + if (pipelineMgr == nullptr || descriptorMgr == nullptr || !m_ubo.isValid()) { + return; + } + + // Uploaded before the render pass starts, since that path submits its own + // command buffer and waits + if (!ensureTextures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + + // Instanced ghost-quad pipeline (corners from gl_VertexIndex, no vertex input) + PipelineConfig config; + config.shaderType = SDR_TYPE_LENS_FLARE; + config.shaderFlags = 0; + config.vertexLayoutHash = 0; + config.primitiveType = PRIM_TYPE_TRISTRIP; + config.depthMode = ZBUFFER_TYPE_NONE; + config.blendMode = ALPHA_BLEND_ADDITIVE; + config.cullEnabled = false; + config.depthWriteEnabled = false; + config.renderPass = m_renderPass; + + vertex_layout emptyLayout; + vk::Pipeline pipeline = pipelineMgr->getPipeline(config, emptyLayout); + if (!pipeline) { + return; + } + + // Scene color: eShaderReadOnlyOptimal (after scene pass) -> eColorAttachmentOptimal + { + vk::ImageMemoryBarrier barrier; + barrier.srcAccessMask = vk::AccessFlagBits::eShaderRead; + barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + barrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + barrier.newLayout = vk::ImageLayout::eColorAttachmentOptimal; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_sceneColor->image; + barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + + cmd.pipelineBarrier( + vk::PipelineStageFlagBits::eFragmentShader, + vk::PipelineStageFlagBits::eColorAttachmentOutput, + {}, {}, {}, barrier); + } + + vk::PipelineLayout pipelineLayout = pipelineMgr->getPipelineLayout(); + + vk::RenderPassBeginInfo rpBegin; + rpBegin.renderPass = m_renderPass; + rpBegin.framebuffer = m_sceneColorFB; + rpBegin.renderArea.offset = vk::Offset2D(0, 0); + rpBegin.renderArea.extent = m_ctx->sceneExtent; + + cmd.beginRenderPass(rpBegin, vk::SubpassContents::eInline); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); + + // Negative viewport height (VK_KHR_maintenance1) for OpenGL-compatible + // Y-up NDC: the shader emits GL-convention positions, and the scene color + // image stores the screen top at row 0 (it was rendered with the same flip) + vk::Viewport viewport; + viewport.x = 0.0f; + viewport.y = static_cast(m_ctx->sceneExtent.height); + viewport.width = static_cast(m_ctx->sceneExtent.width); + viewport.height = -static_cast(m_ctx->sceneExtent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + cmd.setViewport(0, viewport); + + vk::Rect2D scissor; + scissor.offset = vk::Offset2D(0, 0); + scissor.extent = m_ctx->sceneExtent; + cmd.setScissor(0, scissor); + + // Set 1: Material -- the mounted lens's iris + starburst, shared by every sun, + // so this is written and bound once for the whole pass + DescriptorWriter writer; + writer.reset(m_ctx->device, descriptorMgr->getFallbacks()); + + vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); + Verify(materialSet); + writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + { + std::array texArrayInfos; + texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); + texArrayInfos[0] = {m_ctx->linearSampler, m_apertureView, vk::ImageLayout::eShaderReadOnlyOptimal}; + texArrayInfos[1] = {m_ctx->linearSampler, m_starburstView, vk::ImageLayout::eShaderReadOnlyOptimal}; + writer.setImageArray(MaterialBinding::TextureArray, texArrayInfos); + } + + // One draw per visible sun: they share the lens, but each has its own flare + // axis and tint, hence its own uniform block + const uint32_t frameIndex = descriptorMgr->getCurrentFrame(); + for (size_t i = 0; i < flareDraws.size(); i++) { + if (m_ubo.cursor(frameIndex) >= m_ubo.slotsPerFrame()) { + // More flaring suns than the ring can hold this frame; drop the rest + // rather than trip the ring's overflow assertion + nprintf(("vulkan", "VulkanLensFlare: out of UBO slots, skipping %d flare draw(s)\n", + static_cast(flareDraws.size() - i))); + break; + } + + // Set 2: PerDraw -- this sun's flare data from the dedicated UBO ring + vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); + Verify(perDrawSet); + writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + { + vk::DeviceSize slotOffset = m_ubo.alloc(frameIndex, flareDraws[i].data, + sizeof(generic_data::lens_flare_data)); + writer.setBuffer(PerDrawBinding::GenericData, {m_ubo.buffer(), slotOffset, m_ubo.slotSize()}); + } + writer.flush(); + + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, + static_cast(DescriptorSetIndex::Material), + {materialSet, perDrawSet}, {}); + + cmd.draw(4, flareDraws[i].instances, 0, 0); + } + + cmd.endRenderPass(); + + // Scene color is back in eShaderReadOnlyOptimal (render pass finalLayout), + // exactly what the following bloom bright pass expects +} + +void VulkanLensFlare::shutdown() +{ + if (m_ctx == nullptr) { + return; + } + + // Called with the device idle (VulkanPostProcessor::shutdown waits) + releaseTextures(false); + + m_ubo.shutdown(); + + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + if (m_renderPass) { + m_ctx->device.destroyRenderPass(m_renderPass); + m_renderPass = nullptr; + } + + m_initialized = false; +} + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRendererLoop.cpp b/code/graphics/vulkan/VulkanRendererLoop.cpp index 601dad04485..3675e360a98 100644 --- a/code/graphics/vulkan/VulkanRendererLoop.cpp +++ b/code/graphics/vulkan/VulkanRendererLoop.cpp @@ -328,6 +328,7 @@ void VulkanRenderer::endSceneRendering() } // Execute post-processing passes (all between HDR scene pass and swap chain pass) + m_postProcessor->executeLensFlare(m_currentCommandBuffer); m_postProcessor->executeBloom(m_currentCommandBuffer); m_postProcessor->executeTonemap(m_currentCommandBuffer); m_postProcessor->executeFXAA(m_currentCommandBuffer); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..cf951b01700 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -251,6 +251,8 @@ add_file_folder("Default files\\\\data\\\\effects" def_files/data/effects/gamma.sdr def_files/data/effects/gamma-correct-f.sdr def_files/data/effects/irrmap-f.sdr + def_files/data/effects/lensflare-f.sdr + def_files/data/effects/lensflare-v.sdr def_files/data/effects/lighting.sdr def_files/data/effects/ls-f.sdr def_files/data/effects/main-f.sdr @@ -310,6 +312,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/fonts.tbl def_files/data/tables/game_settings.tbl def_files/data/tables/iff_defs.tbl + def_files/data/tables/lens_flares.tbl def_files/data/tables/objecttypes.tbl def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl @@ -461,6 +464,12 @@ add_file_folder("Graphics" graphics/grbatch.h graphics/grinternal.cpp graphics/grinternal.h + graphics/lens_flare.cpp + graphics/lens_flare.h + graphics/lens_flare_aperture.cpp + graphics/lens_flare_internal.h + graphics/lens_flare_optics.cpp + graphics/lens_flare_table.cpp graphics/light.cpp graphics/light.h graphics/line_draw_list.cpp @@ -617,6 +626,7 @@ if (FSO_BUILD_WITH_VULKAN) graphics/vulkan/VulkanPostProcessing.cpp graphics/vulkan/VulkanPostProcessing.h graphics/vulkan/VulkanPostProcessingBloom.cpp + graphics/vulkan/VulkanPostProcessingLensFlare.cpp graphics/vulkan/VulkanPostProcessingCommon.cpp graphics/vulkan/VulkanPostProcessingDistortion.cpp graphics/vulkan/VulkanPostProcessingFog.cpp diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a90ede83880..4045ffe0394 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -16,6 +16,7 @@ #include "freespace.h" #include "cmdline/cmdline.h" #include "debugconsole/console.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/paths/PathRenderer.h" #include "hud/hud.h" @@ -793,6 +794,9 @@ void stars_clear_instances() // call on game startup void stars_init() { + // lens systems must be known before a mission's $Camera Lens: names one + graphics::lens_flare_init(); + // parse stars.tbl parse_startbl("stars.tbl"); @@ -815,7 +819,7 @@ void stars_close() { stars_clear_instances(); - // any other code goes here + graphics::lens_flare_close(); } // called before mission parse so we can clear out all of the old stuff @@ -829,6 +833,11 @@ void stars_pre_level_init(bool clear_backgrounds) stars_clear_instances(); + // The camera lens and any aperture edits belong to the mission being left: + // unmount and restore the tabled irises so nothing carries into the next one. + // The mission's own $Camera Lens: is parsed after this (parse_mission_info). + graphics::lens_flare_reset_for_level(); + stars_set_background_model(nullptr, nullptr); stars_set_background_orientation(); @@ -1261,6 +1270,23 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n) || Suns[sun_n].star_bitmap_index < 0) { + return std::nullopt; + } + + const starfield_bitmap* bm = &Sun_bitmaps[Suns[sun_n].star_bitmap_index]; + + sun_rgbi rgbi; + rgbi.color.xyz.x = bm->r; + rgbi.color.xyz.y = bm->g; + rgbi.color.xyz.z = bm->b; + rgbi.intensity = bm->i; + return rgbi; +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1341,6 +1367,13 @@ void stars_draw_sun(int show_sun) continue; } + // when a physically-based lens with a starburst is active for this sun, the + // post-processing flare pass draws the starburst; skip this sun's bitmap so + // the two don't stack (the remaining suns are unaffected) + if (graphics::lens_flare_sun_starburst_active(idx)) { + continue; + } + material mat_params; material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); @@ -1434,6 +1467,13 @@ void stars_draw_sun_glow(int sun_n) if (bm->glow_bitmap < 0) return; + // when a physically-based lens with a starburst is active for this sun, the + // post-processing flare pass draws the starburst; skip the bitmap glow so + // the two don't stack + if (graphics::lens_flare_sun_starburst_active(sun_n)) { + return; + } + memset( &sun_vex, 0, sizeof(vertex) ); // get sun pos @@ -1466,7 +1506,9 @@ void stars_draw_sun_glow(int sun_n) material_set_unlit(&mat_params, bitmap_id, 0.5f, true, false); g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.10f * Suns[sun_n].scale_x * local_scale, true); - if (bm->flare) { + // legacy sprite flares; suppressed while a physically-based camera lens is + // mounted, since that models the same artifact properly + if (bm->flare && graphics::lens_flare_active_lens() < 0) { vec3d light_dir; vec3d local_light_dir; light_get_global_dir(&light_dir, sun_n); diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h index 0e443a00b72..40ed7ec04dc 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -18,6 +18,8 @@ #include "model/model.h" #include "starfield/starfield_flags.h" +#include + #define DEFAULT_NMODEL_FLAGS (MR_NO_ZBUFFER | MR_NO_CULL | MR_ALL_XPARENT | MR_NO_LIGHTING) #define MAX_STARFIELD_BITMAP_LISTS 1 @@ -165,6 +167,15 @@ int stars_find_sun(const char *name); // get the world coords of the sun pos on the unit sphere. void stars_get_sun_pos(int sun_n, vec3d *pos); +// A sun's tabled light, as $SunRGBI: declares it in stars.tbl. +struct sun_rgbi { + vec3d color = vmd_zero_vector; // 0..1 per channel + float intensity = 0.0f; +}; + +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n); + // for SEXP stuff so that we can mark a bitmap as being used regardless of whether // or not there is an instance for it yet void stars_preload_background(const char *token); diff --git a/code/tracing/categories.cpp b/code/tracing/categories.cpp index d297bcda4b0..ae2dd7a9e9a 100644 --- a/code/tracing/categories.cpp +++ b/code/tracing/categories.cpp @@ -32,6 +32,7 @@ Category SMAACalculateBlendingWeights("SMAA Calculate BLending Weights", true); Category SMAANeighborhoodBlending("SMAA Neighborhood Blending", true); Category SMAAResolve("SMAA Resolve", true); Category Lightshafts("Lightshafts", true); +Category LensFlare("Lens flare", true); Category DrawPostEffects("Draw post effects", true); Category RenderBatchItem("Render batch item", true); diff --git a/code/tracing/categories.h b/code/tracing/categories.h index 892f9414a4f..71a75dad7e3 100644 --- a/code/tracing/categories.h +++ b/code/tracing/categories.h @@ -45,6 +45,7 @@ extern Category SMAACalculateBlendingWeights; extern Category SMAANeighborhoodBlending; extern Category SMAAResolve; extern Category Lightshafts; +extern Category LensFlare; extern Category DrawPostEffects; extern Category RenderBatchItem; diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp new file mode 100644 index 00000000000..9a868e7a8e1 --- /dev/null +++ b/test/src/graphics/test_lens_flare.cpp @@ -0,0 +1,552 @@ +#include + +#include + +#include + +#include +#include +#include +#include + +using namespace graphics; + +namespace { + +// Thick biconvex singlet (R1=100, R2=-100, n=1.5, d=2) with the stop embedded +// mid-glass so no extra air gaps skew the analytic reference values. +lens_system make_singlet() +{ + lens_system ls; + ls.name = "test_singlet"; + ls.coating_wavelength = 0.0f; // uncoated + + lens_surface front; + front.radius = 100.0f; + front.thickness = 1.0f; + front.n = 1.5f; + ls.surfaces.push_back(front); + + lens_surface stop; + stop.thickness = 1.0f; + stop.is_stop = true; + ls.surfaces.push_back(stop); + + lens_surface back; + back.radius = -100.0f; + back.thickness = 10.0f; // ignored; sensor distance is computed + back.n = 1.0f; + ls.surfaces.push_back(back); + + return ls; +} + +} // namespace + +TEST(LensFlare, FresnelUncoated) +{ + // Air -> glass at normal incidence: R = ((n1-n2)/(n1+n2))^2 = 0.04 + EXPECT_NEAR(lens_flare_fresnel_reflectance(1.0f, 1.5f, 0.0f, 550.0f), 0.04f, 1e-5f); + // Symmetric in the direction of travel + EXPECT_NEAR(lens_flare_fresnel_reflectance(1.5f, 1.0f, 0.0f, 550.0f), 0.04f, 1e-5f); +} + +TEST(LensFlare, FresnelIdealQuarterWaveCoating) +{ + // With nc = sqrt(n1*n2) exactly (here 1.38 = sqrt(1.9044)) a quarter-wave + // coating cancels reflection completely at its design wavelength + float r = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 550.0f, 550.0f); + EXPECT_NEAR(r, 0.0f, 1e-6f); + + // Away from the design wavelength some reflection returns, but still far + // below the uncoated value + float uncoated = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 0.0f, 400.0f); + float coated = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 550.0f, 400.0f); + EXPECT_GT(coated, 0.0f); + EXPECT_LT(coated, uncoated); +} + +TEST(LensFlare, FftDeltaAndRoundtrip) +{ + const int size = 16; + + // FFT of a delta at the origin is a constant + SCP_vector> data(size * size, {0.0f, 0.0f}); + data[0] = {1.0f, 0.0f}; + lens_flare_fft2d(data, size, false); + for (const auto& v : data) { + EXPECT_NEAR(v.real(), 1.0f, 1e-4f); + EXPECT_NEAR(v.imag(), 0.0f, 1e-4f); + } + + // Forward + inverse returns the input + SCP_vector> orig(size * size); + for (int i = 0; i < size * size; i++) { + orig[i] = {sinf(i * 0.37f), cosf(i * 0.11f)}; + } + SCP_vector> work = orig; + lens_flare_fft2d(work, size, false); + lens_flare_fft2d(work, size, true); + for (int i = 0; i < size * size; i++) { + EXPECT_NEAR(work[i].real(), orig[i].real(), 1e-3f); + EXPECT_NEAR(work[i].imag(), orig[i].imag(), 1e-3f); + } +} + +TEST(LensFlare, SingletFocalLengthAndGhostCount) +{ + lens_system ls = make_singlet(); + ASSERT_TRUE(lens_flare_precompute(ls)); + + // Thick-lens references: 1/f = (n-1)*(1/R1 - 1/R2 + (n-1)*d/(n*R1*R2)), + // BFD = f*(1 - (n-1)*d/(n*R1)) + EXPECT_NEAR(ls.efl, 100.3344f, 0.01f); + EXPECT_NEAR(ls.bfd, 99.6656f, 0.01f); + + // Two refractive surfaces (the stop doesn't reflect) -> exactly one + // two-reflection ghost + ASSERT_EQ(ls.ghosts.size(), 1u); + EXPECT_EQ(ls.ghosts[0].surf_first, 2); + EXPECT_EQ(ls.ghosts[0].surf_second, 0); + + // Uncoated air/glass reflections: R = 0.04 each, product 1.6e-3 + EXPECT_NEAR(ls.ghosts[0].reflectance[1], 0.04f * 0.04f, 1e-5f); +} + +TEST(LensFlare, GhostMatricesAreUnimodular) +{ + // Ghost paths start and end in air, so det(Ms * Ma) == 1 must hold for + // every ghost and wavelength (ray-transfer matrix invariant) + lens_system ls = make_singlet(); + + // Add a cemented doublet behind the stop for more surfaces/ghost paths; + // the exit of the doublet flows into the original back surface (1.58 -> 1.0) + // so all four glass interfaces stay refractive + lens_surface extra1; + extra1.radius = 50.0f; + extra1.thickness = 3.0f; + extra1.n = 1.62f; + extra1.abbe = 36.0f; + lens_surface extra2; + extra2.radius = -75.0f; + extra2.thickness = 10.0f; + extra2.n = 1.58f; + ls.surfaces.insert(ls.surfaces.end() - 1, extra1); + ls.surfaces.insert(ls.surfaces.end() - 1, extra2); + + ASSERT_TRUE(lens_flare_precompute(ls)); + + // Four refractive surfaces -> C(4,2) = 6 ghost paths, but the pairing of + // the two faint cement interfaces (R ~ 1e-4 * 1e-3) falls below the + // reflectance culling floor, leaving 5 + EXPECT_EQ(ls.ghosts.size(), 5u); + + for (const auto& ghost : ls.ghosts) { + for (int wl = 0; wl < 3; wl++) { + const float* ma = ghost.ma[wl]; + const float* ms = ghost.ms[wl]; + float a = ms[0] * ma[0] + ms[1] * ma[2]; + float b = ms[0] * ma[1] + ms[1] * ma[3]; + float c = ms[2] * ma[0] + ms[3] * ma[2]; + float d = ms[2] * ma[1] + ms[3] * ma[3]; + EXPECT_NEAR(a * d - b * c, 1.0f, 1e-3f); + } + } +} + +class LensFlareTableTest : public test::FSTestFixture { + public: + LensFlareTableTest() : test::FSTestFixture(INIT_CFILE) {} + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +// Guards the shipped prescriptions in def_files/data/tables/lens_flares.tbl: a +// lens whose surface stack doesn't image onto a sensor is dropped at load with +// only a warning, so a bad transcription would otherwise go unnoticed. +TEST_F(LensFlareTableTest, ShippedLensesParseAndPrecompute) +{ + // Named individually because an unusable prescription is only warned about + // and then skipped -- a lens that stopped loading would still leave a + // well-formed (just smaller) table behind + static const char* const shipped[] = {"angenieux_100mm", "tessar_50mm", "canon_70_200mm", "kodak_100mm", + "leica_35mm", "nikon_50_135mm", "color_heliar_105mm", "zeiss_master_prime_50mm"}; + for (const char* name : shipped) { + EXPECT_GE(lens_flare_lookup(name), 0) << "lens '" << name << "' is missing from lens_flares.tbl"; + } + + const int count = lens_flare_num_systems(); + ASSERT_GE(count, static_cast(std::size(shipped))); + + for (int i = 0; i < count; i++) { + const lens_system* ls = lens_flare_get_system(i); + ASSERT_NE(ls, nullptr); + SCOPED_TRACE(ls->name); + + // precompute() already rejects these, but a rejected lens is simply + // absent, so assert on what did load + EXPECT_GT(ls->efl, 0.0f); + EXPECT_GT(ls->bfd, 0.0f); + EXPECT_FALSE(ls->ghosts.empty()); + EXPECT_LE(static_cast(ls->ghosts.size()), ls->max_ghosts); + EXPECT_GT(ls->entrance_radius, 0.0f); + EXPECT_GT(ls->aperture_radius, 0.0f); + EXPECT_GT(ls->sensor_width, 0.0f); + + for (const auto& ghost : ls->ghosts) { + for (int wl = 0; wl < 3; wl++) { + const float* ma = ghost.ma[wl]; + const float* ms = ghost.ms[wl]; + for (int k = 0; k < 4; k++) { + ASSERT_TRUE(std::isfinite(ma[k])); + ASSERT_TRUE(std::isfinite(ms[k])); + } + // ghost paths start and end in air (see GhostMatricesAreUnimodular) + float a = ms[0] * ma[0] + ms[1] * ma[2]; + float b = ms[0] * ma[1] + ms[1] * ma[3]; + float c = ms[2] * ma[0] + ms[3] * ma[2]; + float d = ms[2] * ma[1] + ms[3] * ma[3]; + EXPECT_NEAR(a * d - b * c, 1.0f, 1e-2f); + EXPECT_GT(ghost.reflectance[wl], 0.0f); + EXPECT_LT(ghost.reflectance[wl], 1.0f); + } + } + } +} + +// The iris shape is the one definition behind both the ghosts and the +// starburst, so its geometry is worth pinning down: the polygon's corners sit +// on the iris radius, its blade midpoints pull in to the apothem, and curvature +// interpolates the midpoints out to a circle. (Mask only -- no FFT needed.) +TEST(LensFlareAperture, ShapeGeometry) +{ + lens_flare_textures tex; + + // probe the mask along a ray and report where transmission crosses 0.5 + auto boundary = [&tex](float angle_deg) { + const int size = tex.aperture_size; + float dx = cosf(fl_radians(angle_deg)); + float dy = sinf(fl_radians(angle_deg)); + float prev = 1.0f; + for (int i = 1; i < 2000; i++) { + float r = i / 2000.0f * 1.4f; + int x = static_cast((r * dx + 1.0f) * 0.5f * size); + int y = static_cast((r * dy + 1.0f) * 0.5f * size); + if (x < 0 || x >= size || y < 0 || y >= size) { + return r; + } + float v = tex.aperture[static_cast(y) * size + x] / 255.0f; + if (prev >= 0.5f && v < 0.5f) { + return r; + } + prev = v; + } + return -1.0f; + }; + + const float iris = 0.9f; + const float apothem = iris * cosf(PI / 6.0f); + + lens_aperture ap; + ap.blades = 6; + ap.rotation = 0.0f; + ap.curvature = 0.0f; + + // straight blades: corners on the +x axis (and every 60 deg from it), + // midpoints pulled in to the apothem + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(0.0f), iris, 0.01f); + EXPECT_NEAR(boundary(60.0f), iris, 0.01f); + EXPECT_NEAR(boundary(30.0f), apothem, 0.01f); + + // full curvature lifts the midpoints to the corner radius (a circle) + ap.curvature = 1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); + + // negative curvature bows them inward instead, past the straight-blade case + ap.curvature = -1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_LT(boundary(30.0f), apothem - 0.05f); + + // rotation carries the corners with it + ap.curvature = 0.0f; + ap.rotation = 30.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); +} + +// Every imperfection layer must actually occlude, and must leave the mask +// usable rather than blacking it out. +TEST(LensFlareAperture, ImperfectionLayers) +{ + lens_flare_textures tex; + auto transmission = [&tex](const lens_aperture& ap) { + lens_flare_generate_aperture_mask(ap, &tex); + double sum = 0.0; + for (auto v : tex.aperture) { + sum += v; + } + return sum / tex.aperture.size() / 255.0; + }; + + lens_aperture ap; // defaults: every layer off + const double base = transmission(ap); + EXPECT_GT(base, 0.1); + EXPECT_LT(base, 1.0); + + ap.grating.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.grating.strength = 0.0f; + + ap.scratches.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.scratches.strength = 0.0f; + + ap.dust.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.dust.strength = 0.0f; + + // strength 0 is exactly "off", whatever the other knobs say + ap.grating.density = 1.0f; + ap.scratches.density = 1.0f; + ap.dust.density = 1.0f; + EXPECT_NEAR(transmission(ap), base, 1e-9); +} + +// One aperture definition drives both outputs, so editing it has to rebuild the +// starburst as well as the ghost mask. +TEST_F(LensFlareTableTest, ApertureEditRebuildsStarburst) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + auto* lens = lens_flare_get_system_mutable(idx); + ASSERT_NE(lens, nullptr); + + const auto* before = lens_flare_get_textures(idx); + ASSERT_NE(before, nullptr); + SCP_vector starburst_before = before->starburst; + ASSERT_FALSE(starburst_before.empty()); + + lens->aperture.blades = 3; + lens->aperture.rotation = 40.0f; + lens_flare_invalidate_textures(idx); + + const auto* after = lens_flare_get_textures(idx); + ASSERT_NE(after, nullptr); + ASSERT_EQ(after->starburst.size(), starburst_before.size()); + EXPECT_NE(after->starburst, starburst_before) << "starburst did not follow the aperture"; +} + +// The backends cache their uploaded copy per lens, so an edit has to change the +// generation counter or the new mask never reaches the GPU. +TEST_F(LensFlareTableTest, TextureInvalidationBumpsGeneration) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int before = lens_flare_get_texture_generation(); + + lens_flare_invalidate_textures(idx); + EXPECT_NE(lens_flare_get_texture_generation(), before); + + // out-of-range invalidation is a no-op, not a bump + const unsigned int after = lens_flare_get_texture_generation(); + lens_flare_invalidate_textures(lens_flare_num_systems() + 5); + EXPECT_EQ(lens_flare_get_texture_generation(), after); +} + +// Same engine, but reached through the table parser and the *-lens.tbm modular +// path rather than by setting the struct directly. Every aperture field is +// wired up by hand, so only running a table through it proves each one lands in +// the member it names. +class LensFlareApertureTbmTest : public test::FSTestFixture { + public: + LensFlareApertureTbmTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("aperture_fields"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(LensFlareApertureTbmTest, ParsesEveryApertureField) +{ + const int idx = lens_flare_lookup("aperture_test_lens"); + ASSERT_GE(idx, 0) << "the modular *-lens.tbm was not picked up at all"; + + const lens_system* ls = lens_flare_get_system(idx); + ASSERT_NE(ls, nullptr); + const lens_aperture& ap = ls->aperture; + + EXPECT_EQ(ap.blades, 11); + EXPECT_FLOAT_EQ(ap.rotation, 21.0f); + EXPECT_FLOAT_EQ(ap.curvature, 0.31f); + EXPECT_FLOAT_EQ(ap.softness, 0.041f); + + EXPECT_FLOAT_EQ(ap.grating.strength, 0.51f); + EXPECT_FLOAT_EQ(ap.grating.density, 0.52f); + EXPECT_FLOAT_EQ(ap.grating.length, 0.53f); + EXPECT_FLOAT_EQ(ap.grating.width, 0.54f); + EXPECT_FLOAT_EQ(ap.grating.softness, 0.055f); + + EXPECT_FLOAT_EQ(ap.scratches.strength, 0.61f); + EXPECT_FLOAT_EQ(ap.scratches.density, 0.62f); + EXPECT_FLOAT_EQ(ap.scratches.length, 0.63f); + EXPECT_FLOAT_EQ(ap.scratches.width, 0.64f); + EXPECT_FLOAT_EQ(ap.scratches.rotation, 65.0f); + EXPECT_FLOAT_EQ(ap.scratches.rotation_variation, 0.66f); + EXPECT_FLOAT_EQ(ap.scratches.softness, 0.067f); + + EXPECT_FLOAT_EQ(ap.dust.strength, 0.71f); + EXPECT_FLOAT_EQ(ap.dust.density, 0.72f); + EXPECT_FLOAT_EQ(ap.dust.radius, 0.73f); + EXPECT_FLOAT_EQ(ap.dust.softness, 0.074f); + + // the fields that follow the aperture block must still be reachable -- a + // mis-ordered optional_string would swallow the rest of the entry + EXPECT_FALSE(ls->starburst); + EXPECT_FLOAT_EQ(ls->intensity, 0.25f); + EXPECT_EQ(ls->max_ghosts, 7); + EXPECT_FLOAT_EQ(ls->entrance_radius, 20.0f); + EXPECT_FLOAT_EQ(ls->aperture_radius, 14.0f); + + // and the tbm must not have disturbed the built-in table + EXPECT_GE(lens_flare_lookup("angenieux_100mm"), 0); +} + +// A mission's set-lens-* sexps edit the tabled lens in place, so the reset that +// stars_pre_level_init() performs is the only thing stopping one mission's lens +// from carrying into the next. +TEST_F(LensFlareTableTest, ResetForLevelRestoresTabledValues) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + auto* lens = lens_flare_get_system_mutable(idx); + ASSERT_NE(lens, nullptr); + + const lens_aperture tabled = lens->tabled_aperture; + ASSERT_EQ(lens->aperture, tabled) << "a freshly parsed lens must match its own snapshot"; + + // make sure the textures exist, so the reset has something to invalidate + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int generation = lens_flare_get_texture_generation(); + + // what a mission's sexps would do + lens->aperture.blades = 3; + lens->aperture.dust.strength = 0.8f; + lens->aperture.scratches.strength = 0.4f; + ASSERT_NE(lens->aperture, tabled); + + lens_flare_reset_for_level(); + + EXPECT_EQ(lens->aperture, tabled); + EXPECT_NE(lens_flare_get_texture_generation(), generation) << "backends would keep the edited textures"; + + // a second reset has nothing to do, and must not churn the backends' caches + const unsigned int settled = lens_flare_get_texture_generation(); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_get_texture_generation(), settled); +} + +// The sexps apply their arguments to a copy and only rebuild when the result +// differs, so that an unguarded repeating mission event doesn't regenerate a +// 512^2 mask and its FFT every frame. That guard is only as good as this +// comparison. +TEST(LensFlareAperture, EqualityCoversEveryField) +{ + lens_aperture a; + EXPECT_EQ(a, lens_aperture()); + + // every field, including the ones nested in the imperfection layers + SCP_vector> mutators = { + [](lens_aperture& x) { x.blades += 1; }, + [](lens_aperture& x) { x.rotation += 1.0f; }, + [](lens_aperture& x) { x.curvature += 0.5f; }, + [](lens_aperture& x) { x.softness += 0.5f; }, + [](lens_aperture& x) { x.grating.strength += 0.5f; }, + [](lens_aperture& x) { x.grating.density += 0.25f; }, + [](lens_aperture& x) { x.grating.length += 0.25f; }, + [](lens_aperture& x) { x.grating.width += 0.25f; }, + [](lens_aperture& x) { x.grating.softness += 0.25f; }, + [](lens_aperture& x) { x.scratches.strength += 0.5f; }, + [](lens_aperture& x) { x.scratches.density += 0.25f; }, + [](lens_aperture& x) { x.scratches.length += 0.25f; }, + [](lens_aperture& x) { x.scratches.width += 0.25f; }, + [](lens_aperture& x) { x.scratches.rotation += 1.0f; }, + [](lens_aperture& x) { x.scratches.rotation_variation += 0.25f; }, + [](lens_aperture& x) { x.scratches.softness += 0.25f; }, + [](lens_aperture& x) { x.dust.strength += 0.5f; }, + [](lens_aperture& x) { x.dust.density += 0.25f; }, + [](lens_aperture& x) { x.dust.radius += 0.25f; }, + [](lens_aperture& x) { x.dust.softness += 0.25f; }, + }; + + for (size_t i = 0; i < mutators.size(); i++) { + lens_aperture changed; + mutators[i](changed); + SCOPED_TRACE("field index " + std::to_string(i)); + EXPECT_NE(changed, a) << "a changed field is not covered by operator=="; + EXPECT_FALSE(changed == a); + } +} + +// The mounted lens is the mission's, so it has to survive nothing but a level +// change: mounting is what parse_mission_info() does with "$Camera Lens:", and +// the reset before it is what stops the previous mission's camera from leaking. +TEST_F(LensFlareTableTest, MountingAndOverridingTheCameraLens) +{ + const int tessar = lens_flare_lookup("tessar_50mm"); + const int angenieux = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(tessar, 0); + ASSERT_GE(angenieux, 0); + + // the shipped table declares no default, so a mission that asks for nothing + // renders no flares at all + EXPECT_STREQ(lens_flare_default_name(), ""); + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), -1); + + lens_flare_switch_to("tessar_50mm"); + EXPECT_EQ(lens_flare_active_lens(), tessar); + EXPECT_STREQ(lens_flare_mission_lens_name(), "tessar_50mm"); + + // the lab's override wins while it is set, and reveals the mission's lens + // again once cleared -- that is what its "Mission default (...)" entry means + lens_flare_set_lab_lens(angenieux); + EXPECT_EQ(lens_flare_active_lens(), angenieux); + EXPECT_STREQ(lens_flare_mission_lens_name(), "tessar_50mm"); + lens_flare_set_lab_lens(-1); + EXPECT_EQ(lens_flare_active_lens(), -1) << "a -1 override means 'no flares', not 'no override'"; + lens_flare_clear_lab_lens(); + EXPECT_EQ(lens_flare_active_lens(), tessar); + + // leaving the mission unmounts the lens and drops the lab override + lens_flare_set_lab_lens(angenieux); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_active_lens(), -1); + EXPECT_STREQ(lens_flare_mission_lens_name(), ""); +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index fa4c1c81d39..99c0af00b66 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -25,6 +25,7 @@ add_file_folder("Globalincs" add_file_folder("Graphics" graphics/test_font.cpp + graphics/test_lens_flare.cpp ) if (FSO_BUILD_WITH_VULKAN) diff --git a/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm new file mode 100644 index 00000000000..bf20f1a438b --- /dev/null +++ b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm @@ -0,0 +1,47 @@ +; Exercises every aperture field of a lens entry (see the shipped +; lens_flares.tbl for what they mean). Fields are parsed sequentially, so this +; doubles as the reference for the order they have to appear in. +; +; Every value here is deliberately distinct and non-default so a mis-wired +; stuff_float() shows up as a specific field carrying the wrong number. + +#Lens Systems + +$Name: aperture_test_lens +$Entrance Pupil Radius: 20.0 +$Aperture Radius: 14.0 +$Sensor Width: 36.0 +$Coating Wavelength: 500 +$Aperture Blades: 11 ++Blade Rotation: 21.0 ++Blade Curvature: 0.31 ++Edge Softness: 0.041 +$Aperture Grating: 0.51 ++Density: 0.52 ++Length: 0.53 ++Width: 0.54 ++Softness: 0.055 +$Aperture Scratches: 0.61 ++Density: 0.62 ++Length: 0.63 ++Width: 0.64 ++Rotation: 65.0 ++Rotation Variation: 0.66 ++Softness: 0.067 +$Aperture Dust: 0.71 ++Density: 0.72 ++Radius: 0.73 ++Softness: 0.074 +$Starburst: NO ++Starburst Scale: 0.9 +$Intensity: 0.25 +$Max Ghosts: 7 +$Surface: ( 100.0, 5.0, 1.6 ) ++Abbe: 55.0 +$Surface: ( -200.0, 3.0, 1.0 ) +$Stop: ( 4.0 ) +$Surface: ( 150.0, 4.0, 1.62 ) ++Abbe: 45.0 +$Surface: ( -120.0, 40.0, 1.0 ) + +#End From d08f3e3c56920564d21f4a5340eee1f785abbc41 Mon Sep 17 00:00:00 2001 From: the-e Date: Sat, 25 Jul 2026 19:57:33 +0200 Subject: [PATCH 2/6] Expose the camera lens to mission designers and the lab Gives content authors a way to pick the lens a mission is shot through, and tooling to see what it does. Mission side, following $Lighting Profile: exactly since it is the same kind of setting -- a global render choice belonging to one mission: $Camera Lens: in the mission info section (missionparse), written back by missionsave behind a version bypass comment, and edited in the background editors of both FRED (IDC_CAMERA_LENS) and qtFRED (cameraLensCombo) set-camera-lens swaps the mounted lens at runtime; for no flares, for what lens_flares.tbl declares set-lens-aperture / -grating / -scratches / -dust restyle the mounted lens's iris. They take no lens name, so a mission cannot edit a lens it isn't looking through. The lens sexps all edit tabled state in place, which lens_flare_reset_for_level() undoes when the mission ends, so nothing leaks into the next one. Like the other background operators they are not packed for multiplayer, so in a networked game they affect the host only. The lab gets a "Lens flare options" panel (its own translation unit, since lab_ui.cpp has no room for another one): an override of the mission's lens, live iris editing coalesced so a slider drag doesn't rebuild a 512^2 mask and its FFT every frame, the global brightness/HDR-headroom calibration, and a per-sun readout of what the last flare pass actually drew. The lab's background loader parses $Camera Lens: the same way it parses the lighting profile, so switching backgrounds there mounts the right lens. Co-Authored-By: Claude Opus 5 --- code/lab/dialogs/lab_ui.cpp | 10 + code/lab/dialogs/lab_ui.h | 3 + code/lab/dialogs/lab_ui_lens_flare.cpp | 167 +++++++++ code/lab/renderer/lab_renderer.cpp | 18 +- code/mission/missionparse.cpp | 15 + code/mission/missionparse.h | 4 + code/missioneditor/missionsave.cpp | 16 + code/missioneditor/sexp_tree_opf.cpp | 25 ++ code/missioneditor/sexp_tree_opf.h | 1 + code/parse/sexp.cpp | 317 +++++++++++++++++- code/parse/sexp.h | 13 +- code/source_groups.cmake | 1 + fred2/bgbitmapdlg.cpp | 22 ++ fred2/bgbitmapdlg.h | 1 + fred2/fred.rc | 6 +- fred2/resource.h | 3 +- .../dialogs/BackgroundEditorDialogModel.cpp | 30 ++ .../dialogs/BackgroundEditorDialogModel.h | 6 + .../src/ui/dialogs/BackgroundEditorDialog.cpp | 14 + .../src/ui/dialogs/BackgroundEditorDialog.h | 1 + qtfred/ui/BackgroundEditor.ui | 11 + test/src/parse/test_sexp_lens.cpp | 147 ++++++++ test/src/source_groups.cmake | 1 + 23 files changed, 821 insertions(+), 11 deletions(-) create mode 100644 code/lab/dialogs/lab_ui_lens_flare.cpp create mode 100644 test/src/parse/test_sexp_lens.cpp diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index a534a02aef4..b3af25649d5 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -6,10 +6,12 @@ #include "asteroid/asteroid.h" #include "graphics/2d.h" #include "graphics/debug_sphere.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/shadows.h" #include "lab/labv2_internal.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" #include "ship/shiphit.h" #include "weapon/weapon.h" #include "mission/missionload.h" @@ -730,6 +732,14 @@ void LabUi::show_render_options() } } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING && + stars_get_num_suns() > 0) { + with_CollapsingHeader("Lens flare options") + { + build_lens_flare_options(); + } + } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING) { if (Button("Export environment cubemap", ImVec2(-FLT_MIN, GetTextLineHeight()*2))) { gr_dump_envmap(getLabManager()->Renderer->currentMissionBackground.c_str()); diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..57bb5eb21ac 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -1,5 +1,6 @@ #pragma once +#include "graphics/lens_flare.h" #include "model/model.h" #include "model/animation/modelanimation.h" #include "species_defs/species_defs.h" @@ -47,6 +48,8 @@ class LabUi { static void build_max_rt_shadow_lights_slider(); static void build_rt_shadow_bias_sliders(); void build_tone_mapper_combobox(); + static void build_lens_flare_options(); + static void build_lens_aperture_options(int lens_idx, graphics::lens_aperture& ap); void build_model_info_box(ship_info* sip, polymodel* pm) const; void build_subsystem_list(object* objp, ship* shipp) const; void build_subsystem_list_entry(SCP_string& subsys_name, diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp new file mode 100644 index 00000000000..5127ea149d6 --- /dev/null +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -0,0 +1,167 @@ +#include "lab_ui.h" + +#include "graphics/2d.h" +#include "graphics/lens_flare.h" +#include "lab/labv2_internal.h" +#include "starfield/starfield.h" + +using namespace ImGui; + +// The lab's "Lens flare options" panel: the camera lens the scene is shot +// through, live iris editing, the global brightness calibration, and what the +// last flare pass actually did. Split out of lab_ui.cpp, which has no room for +// another feature panel. + +// Live iris controls. One aperture drives both the ghosts and the starburst +// (the starburst is the Fraunhofer transform of this mask), so every slider +// here changes both at once. Edits are coalesced by lens_flare.cpp -- the mask +// and its FFT are far too expensive to rebuild on every frame of a drag. +void LabUi::build_lens_aperture_options(int lens_idx, graphics::lens_aperture& ap) +{ + bool changed = false; + + with_TreeNode("Aperture") + { + TextDisabled("Shared by ghosts and starburst"); + + changed |= SliderInt("Blades", &ap.blades, 2, 16); + changed |= SliderFloat("Blade rotation", &ap.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Blade curvature", &ap.curvature, -1.0f, 1.0f); + changed |= SliderFloat("Edge softness", &ap.softness, 0.0f, 0.5f); + + Separator(); + TextDisabled("Rim diffraction grating"); + changed |= SliderFloat("Grating strength", &ap.grating.strength, 0.0f, 1.0f); + if (ap.grating.strength > 0.0f) { + changed |= SliderFloat("Grating density", &ap.grating.density, 0.0f, 1.0f); + changed |= SliderFloat("Grating length", &ap.grating.length, 0.0f, 1.0f); + changed |= SliderFloat("Grating width", &ap.grating.width, 0.0f, 1.0f); + changed |= SliderFloat("Grating softness", &ap.grating.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Scratches"); + changed |= SliderFloat("Scratch strength", &ap.scratches.strength, 0.0f, 1.0f); + if (ap.scratches.strength > 0.0f) { + changed |= SliderFloat("Scratch density", &ap.scratches.density, 0.0f, 1.0f); + changed |= SliderFloat("Scratch length", &ap.scratches.length, 0.0f, 1.0f); + changed |= SliderFloat("Scratch width", &ap.scratches.width, 0.0f, 1.0f); + changed |= SliderFloat("Scratch rotation", &ap.scratches.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Scratch rot variation", &ap.scratches.rotation_variation, 0.0f, 1.0f); + changed |= SliderFloat("Scratch softness", &ap.scratches.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Dust"); + changed |= SliderFloat("Dust strength", &ap.dust.strength, 0.0f, 1.0f); + if (ap.dust.strength > 0.0f) { + changed |= SliderFloat("Dust density", &ap.dust.density, 0.0f, 1.0f); + changed |= SliderFloat("Dust radius", &ap.dust.radius, 0.0f, 1.0f); + changed |= SliderFloat("Dust softness", &ap.dust.softness, 0.0f, 0.5f); + } + + if (graphics::lens_flare_aperture_edit_pending()) { + TextDisabled("Rebuilding aperture + starburst..."); + } + TextDisabled("Grating/scratches/dust add off-axis energy, which the"); + TextDisabled("starburst normalizes against -- expect the core to dim"); + TextDisabled("as they come up. Changes are undone on table reload and"); + TextDisabled("whenever the background changes (same as set-lens-* sexps)."); + } + + if (changed) { + graphics::lens_flare_aperture_changed(lens_idx); + } +} + +void LabUi::build_lens_flare_options() +{ + // global calibration multipliers (see graphics/lens_flare.cpp) + float ghost_brightness = graphics::lens_flare_get_ghost_brightness(); + if (SliderFloat("Ghost brightness", &ghost_brightness, 0.0f, 500.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) { + graphics::lens_flare_set_ghost_brightness(ghost_brightness); + } + + float starburst_brightness = graphics::lens_flare_get_starburst_brightness(); + if (SliderFloat("Starburst brightness", &starburst_brightness, 0.0f, 10.0f)) { + graphics::lens_flare_set_starburst_brightness(starburst_brightness); + } + + float hdr_headroom = graphics::lens_flare_get_hdr_headroom(); + if (SliderFloat("HDR headroom (x paper white)", &hdr_headroom, 0.0f, 8.0f)) { + graphics::lens_flare_set_hdr_headroom(hdr_headroom); + } + if (Gr_hdr_output_active) { + TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", hdr_headroom); + } else { + TextDisabled("SDR output active: HDR headroom has no effect right now"); + } + + // The camera lens: one for the whole scene, so every sun flares through it + Separator(); + const auto lab_lens = graphics::lens_flare_get_lab_lens(); + const int active_lens = graphics::lens_flare_active_lens(); + + const char* mission_lens_name = graphics::lens_flare_mission_lens_name(); + SCP_string mission_label = "Mission default ("; + mission_label += (*mission_lens_name != '\0') ? mission_lens_name : "none"; + mission_label += ")"; + + const auto* active_system = graphics::lens_flare_get_system(active_lens); + const char* preview = mission_label.c_str(); + if (lab_lens) { + preview = (active_system != nullptr) ? active_system->name.c_str() : "None"; + } + + with_Combo("Camera lens", preview) + { + if (Selectable(mission_label.c_str(), !lab_lens)) { + graphics::lens_flare_clear_lab_lens(); + } + if (Selectable("None", lab_lens && *lab_lens < 0)) { + graphics::lens_flare_set_lab_lens(-1); + } + for (int lens_idx = 0; lens_idx < graphics::lens_flare_num_systems(); lens_idx++) { + bool is_selected = (lab_lens == lens_idx); + + if (Selectable(graphics::lens_flare_get_system(lens_idx)->name.c_str(), is_selected)) { + graphics::lens_flare_set_lab_lens(lens_idx); + } + + if (is_selected) + SetItemDefaultFocus(); + } + } + + if (auto* lens = graphics::lens_flare_get_system_mutable(active_lens)) { + SliderFloat("Lens intensity", &lens->intensity, 0.0f, 10.0f); + Text("%d ghosts | EFL %.1f mm | f/%.1f | %s", + static_cast(lens->ghosts.size()), + lens->efl, + lens->efl / (2.0f * lens->entrance_radius), + lens->starburst ? "starburst" : "no starburst"); + + build_lens_aperture_options(active_lens, lens->aperture); + } else { + TextDisabled("No lens mounted: this background renders no physically-based flares"); + } + + // live pass state, refreshed every frame by lens_flare_build_frame_data(): + // one entry per sun that got a draw + Separator(); + const auto& draws = graphics::lens_flare_get_frame_draws(); + if (draws.empty()) { + TextUnformatted("Last pass: inactive (no visible sun, or no lens mounted)"); + } else { + Text("Last pass: %d sun(s) drawn", static_cast(draws.size())); + for (const auto& draw : draws) { + Text(" Sun %d (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + draw.sun_index, + stars_get_sun_name(draw.sun_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + } + } +} diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index eec2c2d1813..490e2359cfc 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -2,6 +2,7 @@ #include "asteroid/asteroid.h" #include "globalincs/vmallocator.h" #include "graphics/2d.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "graphics/matrix.h" #include "lab/labv2_internal.h" @@ -401,7 +402,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { if (optional_string("+Flags:")) stuff_flagset(&flags); - skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); if (optional_string("+Volumetric Nebula:")) { //Rendering usually happens in post-mission-init, just do it now in the lab The_mission.volumetrics.emplace().parse_volumetric_nebula().renderVolumeBitmap(); @@ -413,7 +414,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { // Are we using a skybox? //skip will skip to the end of the file (or to the 'end' string) if any string is absent, //so be sure to include any section that might be found - skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); strcpy_s(skybox_model, ""); if (optional_string("$Skybox Model:")) { stuff_string(skybox_model, F_NAME, MAX_FILENAME_LEN); @@ -434,7 +435,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { stars_set_background_orientation(&skybox_orientation); } - skip_to_start_of_string_either("$Lighting Profile:", "#Background bitmaps"); + skip_to_start_of_string_one_of(SCP_vector{ "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); ltp_name = ltp::default_name(); if(optional_string("$Lighting Profile:")){ stuff_string(ltp_name,F_NAME); @@ -445,6 +446,17 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { ltp::switch_to(ltp_name); } + // the camera lens all sun flares are imaged through, same fall-back to + // the tabled default as parse_mission_info() + skip_to_start_of_string_either("$Camera Lens:", "#Background bitmaps"); + SCP_string lens_name = graphics::lens_flare_default_name(); + if (optional_string("$Camera Lens:")) { + stuff_string(lens_name, F_NAME); + if (lens_name.empty()) + lens_name = graphics::lens_flare_default_name(); + } + graphics::lens_flare_switch_to(lens_name.c_str()); + // Mission headers include additional fields between lighting profile and the // background section. If we stopped at the lighting profile, we need to seek again. skip_to_start_of_string("#Background bitmaps"); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index b5090b26fd0..97ae051ea5d 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -34,6 +34,7 @@ #include "io/timer.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/localize.h" #include "math/bitarray.h" @@ -1118,6 +1119,19 @@ void parse_mission_info(mission *pm, bool basic = false) The_mission.lighting_profile_name = lighting_profiles::default_name(); lighting_profiles::switch_to(The_mission.lighting_profile_name); + // The camera lens every sun's flare is imaged through (graphics/lens_flare.h). + // Empty means no physically-based flares, which is what the shipped table + // defaults to, so existing missions are unaffected. + if (optional_string("$Camera Lens:")) + { + stuff_string(The_mission.camera_lens_name, F_NAME); + if (The_mission.camera_lens_name.empty()) + The_mission.camera_lens_name = graphics::lens_flare_default_name(); + } + else + The_mission.camera_lens_name = graphics::lens_flare_default_name(); + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); + if (optional_string("$Sound Environment:")) { char preset[65] = { '\0' }; stuff_string(preset, F_NAME, sizeof(preset)-1); @@ -7395,6 +7409,7 @@ void mission::Reset() ai_profile = &Ai_profiles[Default_ai_profile]; lighting_profile_name = lighting_profiles::default_name(); + camera_lens_name = graphics::lens_flare_default_name(); cutscenes.clear( ); diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 8e917787108..4a904719f09 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -236,6 +236,10 @@ typedef struct mission { SCP_string lighting_profile_name; + // the camera lens all sun flares are imaged through (graphics/lens_flare.h); + // empty = no physically-based flares + SCP_string camera_lens_name; + SCP_vector cutscenes; SCP_map custom_data; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 72201544116..20cc83e26d0 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -22,6 +22,7 @@ #include "iff_defs/iff_defs.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/fhash.h" #include "localization/localize.h" @@ -3128,6 +3129,21 @@ int Fred_mission_save::save_mission_info() bypass_comment(";;FSO 23.1.0;; $Lighting Profile:"); } + // the-e's camera lens for physically-based flares + if (!The_mission.camera_lens_name.empty() && + The_mission.camera_lens_name != graphics::lens_flare_default_name()) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Camera Lens:")) { + parse_comments(2); + fout(" %s", The_mission.camera_lens_name.c_str()); + } else { + fout_version("\n\n$Camera Lens: %s", The_mission.camera_lens_name.c_str()); + } + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Camera Lens:"); + } + // sound environment (EFX/EAX) - taylor sound_env* m_env = &The_mission.sound_environment; if ((m_env->id >= 0) && (m_env->id < static_cast(EFX_presets.size()))) { diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index dc29f09fa04..2d2357f70f8 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -18,6 +18,7 @@ #include "model/model.h" #include "sound/ds.h" #include "hud/hud.h" +#include "graphics/lens_flare.h" #include "graphics/software/FontManager.h" #include "hud/hudsquadmsg.h" #include "controlconfig/controlsconfig.h" @@ -1154,6 +1155,21 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_post_effect() return head.next; } +sexp_list_item *SexpTreeOPF::get_listing_opf_lens_system() +{ + sexp_list_item head; + + // the two magic values set-sun-lens accepts in place of a real lens + head.add_data(""); + head.add_data(""); + + for (int i = 0; i < graphics::lens_flare_num_systems(); i++) { + head.add_data(graphics::lens_flare_get_system(i)->name.c_str()); + } + + return head.next; +} + sexp_list_item *SexpTreeOPF::get_listing_opf_turret_target_priorities() { sexp_list_item head; @@ -2318,6 +2334,10 @@ sexp_list_item *SexpTreeOPF::get_listing_opf(int opf, int parent_node, int arg_i list = get_listing_opf_post_effect(); break; + case OPF_LENS_SYSTEM: + list = get_listing_opf_lens_system(); + break; + case OPF_FONT: list = get_listing_opf_font(); break; @@ -2584,6 +2604,7 @@ int SexpTreeOPF::query_default_argument_available(int op, int i) const case OPF_TURRET_TARGET_ORDER: case OPF_TURRET_TYPE: case OPF_POST_EFFECT: + case OPF_LENS_SYSTEM: case OPF_TARGET_PRIORITIES: case OPF_ARMOR_TYPE: case OPF_DAMAGE_TYPE: @@ -3209,6 +3230,10 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const str = ""; break; + case OPF_LENS_SYSTEM: + str = ""; + break; + case OPF_CUSTOM_HUD_GAUGE: str = ""; break; diff --git a/code/missioneditor/sexp_tree_opf.h b/code/missioneditor/sexp_tree_opf.h index 8c7b6ac09dd..d30b6744c4f 100644 --- a/code/missioneditor/sexp_tree_opf.h +++ b/code/missioneditor/sexp_tree_opf.h @@ -110,6 +110,7 @@ class SexpTreeOPF { static sexp_list_item* get_listing_opf_turret_target_order(); static sexp_list_item* get_listing_opf_turret_types(); static sexp_list_item* get_listing_opf_post_effect(); + static sexp_list_item* get_listing_opf_lens_system(); static sexp_list_item* get_listing_opf_turret_target_priorities(); static sexp_list_item* get_listing_opf_armor_type(); static sexp_list_item* get_listing_opf_damage_type(); diff --git a/code/parse/sexp.cpp b/code/parse/sexp.cpp index 7c03ab42b83..68838868b29 100644 --- a/code/parse/sexp.cpp +++ b/code/parse/sexp.cpp @@ -42,6 +42,7 @@ #include "globalincs/version.h" #include "graphics/2d.h" #include "graphics/font.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "hud/hud.h" #include "hud/hudartillery.h" @@ -782,6 +783,11 @@ SCP_vector Operators = { { "set-skybox-orientation", OP_SET_SKYBOX_ORIENT, 3, 3, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-skybox-alpha", OP_SET_SKYBOX_ALPHA, 1, 1, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-ambient-light", OP_SET_AMBIENT_LIGHT, 3, 3, SEXP_ACTION_OPERATOR, }, // Karajorma + { "set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-grating", OP_SET_LENS_GRATING, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-dust", OP_SET_LENS_DUST, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e { "toggle-asteroid-field", OP_TOGGLE_ASTEROID_FIELD, 1, 1, SEXP_ACTION_OPERATOR, }, // MjnMixael { "set-asteroid-field", OP_SET_ASTEROID_FIELD, 1, INT_MAX, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated { "set-debris-field", OP_SET_DEBRIS_FIELD, 1, 12, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated @@ -1151,6 +1157,33 @@ int check_dynamic_value_node_type(int node, bool is_string, bool is_number); // hud-display-gauge magic values #define SEXP_HUD_GAUGE_WARPOUT "warpout" +// Magic values accepted by set-camera-lens in place of a lens name. Checked +// before the table is searched, so a lens that somehow carried one of these +// names could not shadow them. +#define SEXP_LENS_NONE "" +#define SEXP_LENS_DEFAULT "" + +// Whether an OPF_LENS_SYSTEM argument names something the engine can resolve. +// +// This is checked at mission load, where a failure aborts the load, so it is +// only safe because lens_flares.tbl is an engine default: the built-in lenses +// always resolve no matter what is installed, stars_init() parses the table +// before any mission is loaded (in the game, the standalone server, FRED and +// qtFRED alike), and a mod adding lenses via *-lens.tbm ships that table +// alongside the missions that name them. +bool sexp_lens_name_is_valid(const char* lens_name) +{ + if (lens_name == nullptr) { + return false; + } + // set-camera-lens is the only operator taking a lens name, so the sentinels + // are always meaningful here + if (!stricmp(lens_name, SEXP_LENS_NONE) || !stricmp(lens_name, SEXP_LENS_DEFAULT)) { + return true; + } + return graphics::lens_flare_lookup(lens_name) >= 0; +} + // event log stuff SCP_vector *Current_event_log_buffer; SCP_vector *Current_event_log_variable_buffer; @@ -3930,6 +3963,14 @@ int check_sexp_syntax(int node, int desired_return_type, int recursive, int *bad } break; + case OPF_LENS_SYSTEM: + if (node_subtype != SEXP_ATOM_STRING) { + return SEXP_CHECK_TYPE_MISMATCH; + } else if (!sexp_lens_name_is_valid(CTEXT(node))) { + return SEXP_CHECK_INVALID_LENS_SYSTEM; + } + break; + case OPF_HUD_ELEMENT: if (node_subtype != SEXP_ATOM_STRING) { return SEXP_CHECK_TYPE_MISMATCH; @@ -16743,11 +16784,152 @@ void sexp_remove_background_bitmap(int n, bool is_sun) } } +// --- physically-based lens flares (see graphics/lens_flare.h) --------------- +// +// There is one camera lens for the whole mission, so these operators need no sun +// or lens argument: set-camera-lens swaps the mounted lens, and the four aperture +// operators restyle the iris of whatever is mounted. Both kinds of change are +// undone by lens_flare_reset_for_level() from stars_pre_level_init(), so a +// mission cannot leak its camera into the next one. Like the other +// background/visual operators (set-post-effect, the nebula ones) they are not +// packed for multiplayer, so in a networked game they only affect the host. + +void sexp_set_camera_lens(int n) +{ + const char* lens_name = CTEXT(n); + + if (!stricmp(lens_name, SEXP_LENS_NONE)) { + graphics::lens_flare_switch_to(""); + } else if (!stricmp(lens_name, SEXP_LENS_DEFAULT)) { + graphics::lens_flare_switch_to(graphics::lens_flare_default_name()); + } else { + // unknown names warn and fall back to the default there + graphics::lens_flare_switch_to(lens_name); + } +} + +// Shared front end of the four aperture operators: hand the mounted lens's +// (mutable) aperture to the caller, then rebuild its textures only if something +// actually changed. A mission event without a guard re-evaluates constantly, and +// a rebuild is a 512^2 mask plus an FFT. +template +void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit) +{ + int lens_idx = graphics::lens_flare_active_lens(); + if (lens_idx < 0) { + Warning(LOCATION, "%s: this mission has no camera lens mounted; use set-camera-lens first.", op_name); + return; + } + + auto* lens = graphics::lens_flare_get_system_mutable(lens_idx); + if (lens == nullptr) + return; + + graphics::lens_aperture edited = lens->aperture; + edit(edited); + + if (edited != lens->aperture) { + lens->aperture = edited; + graphics::lens_flare_aperture_changed(lens_idx); + } +} + +// Read an optional percentage argument into `dest` as a 0..1 fraction, leaving +// it alone when the argument was omitted or is nan. Advances the node. +void sexp_lens_next_pct(int& n, float& dest, float min_val, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f, min_val, max_val); + + n = CDR(n); +} + +// Same, for a plain integer argument. +void sexp_lens_next_int(int& n, int& dest, int min_val, int max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int val = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(val, min_val, max_val); + + n = CDR(n); +} + +void sexp_lens_next_degrees(int& n, float& dest) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int deg = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = i2fl(deg); + + n = CDR(n); +} + +void sexp_set_lens_aperture(int node) +{ + sexp_edit_lens_aperture("set-lens-aperture", [node](graphics::lens_aperture& ap) { + int n = node; + + sexp_lens_next_int(n, ap.blades, 0, 64); + sexp_lens_next_degrees(n, ap.rotation); + sexp_lens_next_pct(n, ap.curvature, -1.0f, 1.0f); + sexp_lens_next_pct(n, ap.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_grating(int node) +{ + sexp_edit_lens_aperture("set-lens-grating", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.grating.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.width, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_scratches(int node) +{ + sexp_edit_lens_aperture("set-lens-scratches", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.scratches.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.width, 0.0f, 1.0f); + sexp_lens_next_degrees(n, ap.scratches.rotation); + sexp_lens_next_pct(n, ap.scratches.rotation_variation, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_dust(int node) +{ + sexp_edit_lens_aperture("set-lens-dust", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.dust.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.radius, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.softness, 0.0f, 1.0f); + }); +} + void sexp_nebula_change_storm(int n) { if (!(The_mission.flags[Mission::Mission_Flags::Fullneb])) return; - + nebl_set_storm(CTEXT(n)); } @@ -30285,8 +30467,33 @@ int eval_sexp(int cur_node, int referenced_node) sexp_val = SEXP_TRUE; break; - case OP_SET_AMBIENT_LIGHT: - sexp_set_ambient_light(node); + case OP_SET_AMBIENT_LIGHT: + sexp_set_ambient_light(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_CAMERA_LENS: + sexp_set_camera_lens(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_APERTURE: + sexp_set_lens_aperture(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_GRATING: + sexp_set_lens_grating(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_SCRATCHES: + sexp_set_lens_scratches(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_DUST: + sexp_set_lens_dust(node); sexp_val = SEXP_TRUE; break; @@ -31988,6 +32195,11 @@ int query_operator_return_type(int op) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_SET_POST_EFFECT: case OP_RESET_POST_EFFECTS: case OP_CHANGE_IFF_COLOR: @@ -34300,7 +34512,23 @@ int query_operator_argument_type(int op_index, int argnum) case OP_SET_AMBIENT_LIGHT: return OPF_POSITIVE; - + + case OP_SET_CAMERA_LENS: + return OPF_LENS_SYSTEM; + + case OP_SET_LENS_APERTURE: + // blade count and rotation (both non-negative), then curvature, + // which may bow the blades inward + if (argnum == 2) + return OPF_NUMBER; + else + return OPF_POSITIVE; + + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + return OPF_POSITIVE; + case OP_SET_POST_EFFECT: if (argnum == 0) return OPF_POST_EFFECT; @@ -35577,6 +35805,9 @@ const char *sexp_error_message(int num) case SEXP_CHECK_INVALID_ANIMATION_TYPE: return "Invalid animation type"; + case SEXP_CHECK_INVALID_LENS_SYSTEM: + return "Invalid lens system"; + case SEXP_CHECK_INVALID_MISSION_MOOD: return "Invalid mission mood"; @@ -37112,6 +37343,11 @@ int get_category(int op_id) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_CHANGE_IFF_COLOR: case OP_TURRET_SUBSYS_TARGET_DISABLE: case OP_TURRET_SUBSYS_TARGET_ENABLE: @@ -37727,6 +37963,11 @@ int get_subcategory(int op_id) case OP_NEBULA_SET_RANGE: case OP_VOLUMETRICS_TOGGLE: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_TOGGLE_ASTEROID_FIELD: case OP_SET_ASTEROID_FIELD: case OP_SET_DEBRIS_FIELD: @@ -41870,6 +42111,74 @@ SCP_vector Sexp_help = { "\t3: Blue (0 - 255)." }, + { OP_SET_CAMERA_LENS, "set-camera-lens\r\n" + "\tMounts a physically-based camera lens, replacing the mission's $Camera Lens:.\r\n" + "\tThere is one lens for the whole mission, because there is one camera: every sun\r\n" + "\tin the background flares through the same glass, which is what keeps their\r\n" + "\tflares consistent with each other.\r\n\r\n" + "\tThe lens goes back to the mission's own when the mission ends. Not sent over\r\n" + "\tthe network, so in multiplayer this only affects the host.\r\n\r\n" + "\tTakes 1 argument...\r\n" + "\t1:\tLens system from lens_flares.tbl, or for no flares at all, or\r\n" + "\t\t for the one lens_flares.tbl declares as $Default Lens:." + }, + + { OP_SET_LENS_APERTURE, "set-lens-aperture\r\n" + "\tChanges the iris shape of the mounted camera lens. A lens has exactly one\r\n" + "\taperture and it drives both the ghosts and the starburst, so this restyles both\r\n" + "\tat once, for every sun.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. The iris goes back to its\r\n" + "\ttabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tNumber of iris blades; fewer than 3 gives a round iris.\r\n" + "\t2:\t(optional) Blade rotation in degrees.\r\n" + "\t3:\t(optional) Blade curvature as a percentage: 0 leaves the blades straight,\r\n" + "\t\t100 bows them out into a circle, and negative values bow them inward.\r\n" + "\t4:\t(optional) Edge softness as a percentage of the iris radius. 0 keeps the\r\n" + "\t\tsharp default; even a few percent visibly weakens the starburst spikes,\r\n" + "\t\tsince those come from the sharpness of that edge." + }, + + { OP_SET_LENS_GRATING, "set-lens-grating\r\n" + "\tSets the diffraction grating of the mounted lens's iris: radial ridges around\r\n" + "\tthe rim that throw extra spikes into the starburst. See set-lens-aperture for\r\n" + "\thow iris edits behave.\r\n\r\n" + "\tNote that the starburst is normalized against its own brightest value, so\r\n" + "\tadding grating dims the core spikes as it adds new ones.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the grating off.\r\n" + "\t2:\t(optional) Density as a percentage of the 360 possible ridges.\r\n" + "\t3:\t(optional) Length as a percentage: how far in from the rim they reach.\r\n" + "\t4:\t(optional) Width as a percentage of the spacing between ridges.\r\n" + "\t5:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_SCRATCHES, "set-lens-scratches\r\n" + "\tSets the scratches on the mounted lens's iris: randomly placed slivers, for a\r\n" + "\tworn or damaged lens. See set-lens-aperture for how iris edits behave, and\r\n" + "\tset-lens-grating for the note about starburst normalization.\r\n\r\n" + "\tTakes 1 to 7 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the scratches off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible scratches.\r\n" + "\t3:\t(optional) Length as a percentage.\r\n" + "\t4:\t(optional) Width as a percentage.\r\n" + "\t5:\t(optional) Rotation in degrees.\r\n" + "\t6:\t(optional) Rotation variation as a percentage; 0 leaves every scratch\r\n" + "\t\tparallel, 100 scatters them completely.\r\n" + "\t7:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_DUST, "set-lens-dust\r\n" + "\tSets the dust on the mounted lens's iris: randomly placed specks, for a dirty\r\n" + "\tlens. See set-lens-aperture for how iris edits behave, and set-lens-grating\r\n" + "\tfor the note about starburst normalization.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the dust off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible specks.\r\n" + "\t3:\t(optional) Speck radius as a percentage.\r\n" + "\t4:\t(optional) Softness as a percentage." + }, + { OP_SET_GRAVITY_ACCEL, "set-gravity-accel\r\n" "\tSets the gravity acceleration rate in units of 0.01 m/s^2\r\n" "\te.g. '981' would be earth gravity, 9.81 m/s^2.\r\n" diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 68299082412..e412c8710aa 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -150,6 +150,7 @@ enum sexp_opf_t : int { OPF_CHILD_LUA_ENUM, // MjnMixael - Used to let Lua Enums reference Enums OPF_MISSION_CUSTOM_STRING, // MjnMixael - The custom strings as defined in FRED OPF_MESSAGE_TYPE, // naomimyselfandi - A message type (Attack Target et al.) + OPF_LENS_SYSTEM, // the-e - a lens system from lens_flares.tbl, or / //Must always be at the end of the list First_available_opf_id @@ -941,7 +942,12 @@ enum : int { OP_SET_SKYBOX_ALPHA, // Goober5000 OP_NEBULA_SET_RANGE, // Goober5000 OP_SET_SQUADRON_WINGS, // Goober5000 - + OP_SET_CAMERA_LENS, // the-e + OP_SET_LENS_APERTURE, // the-e + OP_SET_LENS_GRATING, // the-e + OP_SET_LENS_SCRATCHES, // the-e + OP_SET_LENS_DUST, // the-e + // OP_CATEGORY_AI // defined for AI goals @@ -1304,6 +1310,7 @@ enum sexp_error_check SEXP_CHECK_MUST_BE_INTEGER, SEXP_CHECK_INVALID_CUSTOM_STRING, SEXP_CHECK_INVALID_MESSAGE_TYPE, + SEXP_CHECK_INVALID_LENS_SYSTEM, SEXP_CHECK_POTENTIAL_ISSUE, }; @@ -1488,6 +1495,10 @@ extern bool map_opf_to_opr(sexp_opf_t opf_type, sexp_opr_t &opr_type); const char *opr_type_name(sexp_opr_t opr_type); extern int query_operator_return_type(int op); extern int query_operator_argument_type(int op, int argnum); + +// True if the string names a lens system from lens_flares.tbl, or one of the +// / values set-camera-lens accepts in place of one. +extern bool sexp_lens_name_is_valid(const char* lens_name); extern void update_sexp_references(const char *old_name, const char *new_name); extern void update_sexp_references(const char *old_name, const char *new_name, int format); extern std::pair query_referenced_in_sexp(sexp_ref_type type, const char *name, int &node); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index cf951b01700..b0c8feceffd 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -784,6 +784,7 @@ add_file_folder("Lab\\\\Dialogs" lab/dialogs/lab_ui.cpp lab/dialogs/lab_ui_helpers.h lab/dialogs/lab_ui_helpers.cpp + lab/dialogs/lab_ui_lens_flare.cpp ) add_file_folder("Lab\\\\Manager" diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 593c2f42441..878f4127e8c 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -19,6 +19,7 @@ #include "listitemchooser.h" #include "bmpman/bmpman.h" #include "graphics/light.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "math/bitarray.h" #include "mission/missionparse.h" @@ -82,6 +83,7 @@ bg_bitmap_dlg::bg_bitmap_dlg(CWnd* pParent) : CDialog(bg_bitmap_dlg::IDD, pParen m_sky_flag_5 = The_mission.skybox_flags & MR_NO_GLOWMAPS ? 1 : 0; m_sky_flag_6 = The_mission.skybox_flags & MR_FORCE_CLAMP ? 1 : 0; m_light_profile_index = 0; + m_camera_lens_index = 0; //}}AFX_DATA_INIT } @@ -150,6 +152,7 @@ void bg_bitmap_dlg::DoDataExchange(CDataExchange* pDX) DDX_Text(pDX, IDC_NEB2_FOG_SKYBOX_CLIP, m_neb_fog_skybox_clip); DDX_Text(pDX, IDC_NEB2_FOG_CLIP, m_neb_fog_clip); DDX_CBIndex(pDX, IDC_LIGHT_PROFILE, m_light_profile_index); + DDX_CBIndex(pDX, IDC_CAMERA_LENS, m_camera_lens_index); DDX_Text(pDX, IDC_NEB2_FOG_R, m_fog_r); DDV_MinMaxInt(pDX, m_fog_r, 0, 255); DDX_Text(pDX, IDC_NEB2_FOG_G, m_fog_g); @@ -425,6 +428,18 @@ void bg_bitmap_dlg::create() } box->SetCurSel(m_light_profile_index); + // The camera lens all sun flares are imaged through; entry 0 means none + box = (CComboBox *) GetDlgItem(IDC_CAMERA_LENS); + m_camera_lens_index = 0; + box->AddString("None"); + for (int idx = 0; idx < graphics::lens_flare_num_systems(); idx++) { + const SCP_string &lens_name = graphics::lens_flare_get_system(idx)->name; + box->AddString(lens_name.c_str()); + if (The_mission.camera_lens_name == lens_name) + m_camera_lens_index = idx + 1; + } + box->SetCurSel(m_camera_lens_index); + background_flags_init(); UpdateData(FALSE); @@ -567,6 +582,13 @@ void bg_bitmap_dlg::OnClose() Neb2_fog_clip_distance = Default_max_draw_distance; The_mission.lighting_profile_name = lighting_profiles::list_profiles()[m_light_profile_index]; + + if (m_camera_lens_index <= 0) { + The_mission.camera_lens_name.clear(); + } else { + The_mission.camera_lens_name = graphics::lens_flare_get_system(m_camera_lens_index - 1)->name; + } + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); // close sun data sun_data_close(); diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index d601bbd22d4..6528d6ef27a 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -96,6 +96,7 @@ class bg_bitmap_dlg : public CDialog CString m_neb_fog_skybox_clip; CString m_neb_fog_clip; int m_light_profile_index; + int m_camera_lens_index; //}}AFX_DATA // Overrides diff --git a/fred2/fred.rc b/fred2/fred.rc index bcb9b7ba576..388be9e455b 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1530,7 +1530,7 @@ BEGIN PUSHBUTTON "Bottom",IDC_MESSAGE_MOVE_TO_BOTTOM,367,55,16,16,BS_ICON,WS_EX_STATICEDGE END -IDD_BG_BITMAP DIALOGEX 0, 0, 431, 470 +IDD_BG_BITMAP DIALOGEX 0, 0, 431, 486 STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Background Editor" FONT 8, "MS Sans Serif", 0, 0, 0x1 @@ -1653,6 +1653,8 @@ BEGIN "Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,450,195,10 COMBOBOX IDC_LIGHT_PROFILE,319,448,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP LTEXT "Lighting Profile",IDC_STATIC,227,451,88,8 + COMBOBOX IDC_CAMERA_LENS,319,464,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Camera Lens",IDC_STATIC,227,467,88,8 END IDD_REINFORCEMENT_EDITOR DIALOGEX 0, 0, 183, 119 @@ -2824,7 +2826,7 @@ BEGIN VERTGUIDE, 215 VERTGUIDE, 220 VERTGUIDE, 426 - BOTTOMMARGIN, 413 + BOTTOMMARGIN, 479 HORZGUIDE, 126 HORZGUIDE, 132 END diff --git a/fred2/resource.h b/fred2/resource.h index 27c6268578d..45ba7f0f14f 100644 --- a/fred2/resource.h +++ b/fred2/resource.h @@ -570,6 +570,7 @@ #define IDC_YES_MESSAGE_LIST 1208 #define IDC_ALT_CLASS_LIST 1208 #define IDC_LIGHT_PROFILE 1208 +#define IDC_CAMERA_LENS 1747 #define IDC_OPEN_CUSTOM_STRINGS 1208 #define IDC_COMMAND_SENDER 1209 #define IDC_COMMAND_PERSONA 1210 @@ -1626,7 +1627,7 @@ #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_3D_CONTROLS 1 #define _APS_NEXT_RESOURCE_VALUE 340 -#define _APS_NEXT_CONTROL_VALUE 1747 +#define _APS_NEXT_CONTROL_VALUE 1748 #define _APS_NEXT_COMMAND_VALUE 33113 #define _APS_NEXT_SYMED_VALUE 105 #endif diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 5c1e47c175f..a2da3841c05 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -7,6 +7,7 @@ #include "nebula/neb.h" #include "nebula/neblightning.h" #include "starfield/nebula.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "missioneditor/common.h" @@ -1388,4 +1389,33 @@ void BackgroundEditorDialogModel::setLightingProfileName(const SCP_string& name) modify(The_mission.lighting_profile_name, name); } +// The camera lens every sun's flare is imaged through. "None" is a real choice +// rather than a missing value, so it heads the list. +SCP_vector BackgroundEditorDialogModel::getCameraLensOptions() +{ + SCP_vector out; + out.emplace_back(CAMERA_LENS_NONE); + for (int i = 0; i < graphics::lens_flare_num_systems(); i++) + out.emplace_back(graphics::lens_flare_get_system(i)->name); + return out; +} + +SCP_string BackgroundEditorDialogModel::getCameraLensName() +{ + return The_mission.camera_lens_name.empty() ? SCP_string(CAMERA_LENS_NONE) : The_mission.camera_lens_name; +} + +void BackgroundEditorDialogModel::setCameraLensName(const SCP_string& name) +{ + SCP_string lens_name = (name == CAMERA_LENS_NONE) ? SCP_string() : name; + if (lens_name == The_mission.camera_lens_name) + return; + + modify(The_mission.camera_lens_name, lens_name); + + // mount it right away so the editor's viewport shows what the mission will + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); + refreshBackgroundPreview(); +} + } // namespace fso::fred::dialogs \ No newline at end of file diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 36118b3aa8d..177f0e68d4c 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -175,6 +175,12 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { static SCP_string getLightingProfileName(); void setLightingProfileName(const SCP_string& name); + // combo entry standing in for "this mission has no camera lens" + static constexpr const char* CAMERA_LENS_NONE = "None"; + static SCP_vector getCameraLensOptions(); + static SCP_string getCameraLensName(); + void setCameraLensName(const SCP_string& name); + private: void initializeData(); void refreshBackgroundPreview(); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp index 075274477ed..72b2344a671 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp @@ -125,6 +125,10 @@ void BackgroundEditorDialog::initializeUi() ui->lightingProfileCombo->addItem(QString::fromStdString(s)); } + for (const auto& s : _model->getCameraLensOptions()) { + ui->cameraLensCombo->addItem(QString::fromStdString(s)); + } + updateMiscControls(); } @@ -406,6 +410,7 @@ void BackgroundEditorDialog::updateMiscControls() ui->subspaceCheckBox->setChecked(_model->getTakesPlaceInSubspace()); ui->envMapEdit->setText(QString::fromStdString(_model->getEnvironmentMapName())); ui->lightingProfileCombo->setCurrentIndex(ui->lightingProfileCombo->findText(QString::fromStdString(_model->getLightingProfileName()))); + ui->cameraLensCombo->setCurrentIndex(ui->cameraLensCombo->findText(QString::fromStdString(_model->getCameraLensName()))); } int BackgroundEditorDialog::pickBackgroundIndexDialog(QWidget* parent, int count, int defaultIndex) @@ -974,4 +979,13 @@ void BackgroundEditorDialog::on_lightingProfileCombo_currentIndexChanged(int ind _model->setLightingProfileName(text.toUtf8().constData()); } +void BackgroundEditorDialog::on_cameraLensCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + + const QString text = ui->cameraLensCombo->itemText(index); + _model->setCameraLensName(text.toUtf8().constData()); +} + } // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h index 42ea46fb77f..5cf913541c3 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h @@ -101,6 +101,7 @@ private slots: void on_envMapButton_clicked(); void on_envMapEdit_textChanged(const QString& arg1); void on_lightingProfileCombo_currentIndexChanged(int index); + void on_cameraLensCombo_currentIndexChanged(int index); protected: void closeEvent(QCloseEvent* e) override; diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui index 779d8ab0311..6a6361ae8e2 100644 --- a/qtfred/ui/BackgroundEditor.ui +++ b/qtfred/ui/BackgroundEditor.ui @@ -1103,6 +1103,16 @@ + + + + Camera Lens + + + + + + @@ -1188,6 +1198,7 @@ envMapButton envMapEdit lightingProfileCombo + cameraLensCombo oldNebulaPatternCombo oldNebulaColorCombo oldNebulaPitchSpinBox diff --git a/test/src/parse/test_sexp_lens.cpp b/test/src/parse/test_sexp_lens.cpp new file mode 100644 index 00000000000..5a4fad5e616 --- /dev/null +++ b/test/src/parse/test_sexp_lens.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include +#include + +#include + +// The five lens-flare operators are wired up by hand across five separate +// tables in sexp.cpp (the operator list, the argument-type switch, the category +// and subcategory switches, and the help text). Nothing makes those agree, and +// a desync is silent: an argument slot that returns the wrong OPF still parses, +// it just reads the designer's number into the wrong field. +namespace { + +struct lens_operator_expectation { + const char* name; + int op_const; + int min_args; + int max_args; + int first_arg_type; // OPF for argnum 0 +}; + +const lens_operator_expectation Lens_operators[] = { + // only set-camera-lens names a lens; the aperture operators restyle whatever + // lens the mission has mounted, so their first argument is already a value + {"set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, OPF_LENS_SYSTEM}, + {"set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, OPF_POSITIVE}, + {"set-lens-grating", OP_SET_LENS_GRATING, 1, 5, OPF_POSITIVE}, + {"set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, OPF_POSITIVE}, + {"set-lens-dust", OP_SET_LENS_DUST, 1, 4, OPF_POSITIVE}, +}; + +const sexp_oper* find_lens_operator(const char* name) +{ + auto it = std::find_if(Operators.begin(), Operators.end(), [name](const sexp_oper& op) { + return op.text == name; + }); + return (it == Operators.end()) ? nullptr : &(*it); +} + +} // namespace + +TEST(SexpLens, OperatorsAreRegisteredWithExpectedArity) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const sexp_oper* op = find_lens_operator(expected.name); + ASSERT_NE(op, nullptr) << "operator missing from the Operators table"; + EXPECT_EQ(op->value, expected.op_const); + EXPECT_EQ(op->min, expected.min_args); + EXPECT_EQ(op->max, expected.max_args); + } +} + +TEST(SexpLens, EveryArgumentSlotHasAnArgumentType) +{ + // note that query_operator_argument_type() wants the operator's index in the + // Operators table, not its OP_ constant + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const int op_index = find_operator_index(expected.op_const); + ASSERT_GE(op_index, 0); + + EXPECT_EQ(query_operator_argument_type(op_index, 0), expected.first_arg_type); + + // no slot may fall through to the switch default, which would hand the + // designer an argument the operator never reads + for (int argnum = 0; argnum < expected.max_args; argnum++) { + SCOPED_TRACE("argnum " + std::to_string(argnum)); + EXPECT_NE(query_operator_argument_type(op_index, argnum), OPF_NONE) + << "argument slot has no declared type"; + } + } + + // set-lens-aperture's curvature bows the blades inward at negative values, + // so that slot in particular must not be restricted to positive numbers + EXPECT_EQ(query_operator_argument_type(find_operator_index(OP_SET_LENS_APERTURE), 2), OPF_NUMBER); +} + +TEST(SexpLens, OperatorsAreCategorisedAndDocumented) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + + // an uncategorised operator doesn't appear in FRED's menus at all + EXPECT_EQ(get_category(expected.op_const), OP_CATEGORY_CHANGE); + EXPECT_EQ(get_subcategory(expected.op_const), CHANGE_SUBCATEGORY_BACKGROUND_AND_NEBULA); + + EXPECT_EQ(query_operator_return_type(expected.op_const), OPR_NULL); + + auto help = std::find_if(Sexp_help.begin(), Sexp_help.end(), [&expected](const sexp_help_struct& h) { + return h.id == expected.op_const; + }); + ASSERT_NE(help, Sexp_help.end()) << "operator has no help text"; + EXPECT_NE(help->help.find(expected.name), SCP_string::npos) << "help text doesn't name the operator"; + } +} + +// OPF_LENS_SYSTEM arguments are validated at mission load, where a rejection +// aborts the load outright. That is only safe because the built-in lens table +// is an engine default, so these names resolve whatever is installed. +class SexpLensTableTest : public test::FSTestFixture { + public: + SexpLensTableTest() : test::FSTestFixture(INIT_CFILE) {} + + void SetUp() override + { + test::FSTestFixture::SetUp(); + graphics::lens_flare_init(); + } + + void TearDown() override + { + graphics::lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(SexpLensTableTest, ValidatesLensNames) +{ + // every lens the default table ships must be nameable from a mission + const int count = graphics::lens_flare_num_systems(); + ASSERT_GT(count, 0); + for (int i = 0; i < count; i++) { + const char* name = graphics::lens_flare_get_system(i)->name.c_str(); + SCOPED_TRACE(name); + EXPECT_TRUE(sexp_lens_name_is_valid(name)); + } + + // the set-camera-lens sentinels, case-insensitively + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + + // and a name no table defines, which is what stops a typo from silently + // leaving the flare unchanged at runtime + EXPECT_FALSE(sexp_lens_name_is_valid("no_such_lens")); + EXPECT_FALSE(sexp_lens_name_is_valid("")); + EXPECT_FALSE(sexp_lens_name_is_valid(nullptr)); + + // the error code has a message, or FRED and the mission loader print nothing + EXPECT_STRNE(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM), nullptr); + EXPECT_GT(strlen(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM)), 0u); +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index 99c0af00b66..0e3ef78b6eb 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -53,6 +53,7 @@ add_file_folder("model" add_file_folder("Parse" parse/test_parselo.cpp parse/test_replace.cpp + parse/test_sexp_lens.cpp ) add_file_folder("Pilotfile" From f3ea7ba99d5d2e8c85c8294bffef0994cee9a49a Mon Sep 17 00:00:00 2001 From: the-e Date: Sun, 26 Jul 2026 10:44:08 +0200 Subject: [PATCH 3/6] Add support for anamorphic lens effects and streak artifacts Introduce "Anamorphic Squeeze" and "Anamorphic Streak" options for lens flares, enabling stretched ghost footprints and horizontal streak artifacts for cinematic anamorphic looks. Changes include: - Shader updates and new geometry for streak rendering. - Table parsing for customization of squeeze, streak length, thickness, and tint. - Per-lens configuration in the lab and live updates without texture rebuilds. - Default behavior preserves prior spherical lens content. --- code/def_files/data/effects/lensflare-f.sdr | 66 ++++++++++++++++--- code/def_files/data/effects/lensflare-v.sdr | 50 +++++++++++--- code/def_files/data/tables/lens_flares.tbl | 30 +++++++++ code/graphics/lens_flare.cpp | 43 +++++++++++- code/graphics/lens_flare.h | 32 +++++++++ code/graphics/lens_flare_table.cpp | 22 +++++++ code/graphics/util/uniform_structs.h | 24 +++++-- code/lab/dialogs/lab_ui_lens_flare.cpp | 14 ++++ test/src/graphics/test_lens_flare.cpp | 11 ++++ .../aperture_fields/data/tables/test-lens.tbm | 5 ++ 10 files changed, 272 insertions(+), 25 deletions(-) diff --git a/code/def_files/data/effects/lensflare-f.sdr b/code/def_files/data/effects/lensflare-f.sdr index 5bb1d86208b..a5cd2292870 100644 --- a/code/def_files/data/effects/lensflare-f.sdr +++ b/code/def_files/data/effects/lensflare-f.sdr @@ -18,6 +18,7 @@ layout(location = 2) flat in vec4 g_halfext; layout(location = 3) flat in vec4 g_apscale; layout(location = 4) flat in vec4 g_apoff; layout(location = 5) flat in vec4 g_color; +layout(location = 6) flat in float g_origin; layout(location = 0) out vec4 fragOut0; @@ -31,6 +32,7 @@ flat in vec4 g_halfext; flat in vec4 g_apscale; flat in vec4 g_apoff; flat in vec4 g_color; +flat in float g_origin; out vec4 fragOut0; @@ -48,13 +50,52 @@ uniform genericData { vec2 ndc_scale; vec4 tint; int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) lens_flare_instance instances[64]; }; -float ghost_channel(float center, float halfext, float apscale, float apoff, float intensity) +// Undo the anamorphic stretch the vertex shader applied to this quad, so +// everything below stays in the rotationally-symmetric frame the optics were +// solved in. Both shaders stretch about the instance's axial centre, which the +// vertex shader hands over in g_origin -- they have to agree on that origin, or +// off-axis ghosts shear instead of stretching. At squeeze == 1.0 this is exactly +// the identity, so a spherical lens renders bit-for-bit as it did before. +vec2 unsqueeze(vec2 p) { - vec2 q = (sensorPos - axis * center) / halfext; + vec2 d = p - axis * g_origin; + d.x /= squeeze; + return axis * g_origin + d; +} + +// The anamorphic streak, generated rather than sampled: it is a smooth 1D +// profile, so a texture would cost an upload and a sampler binding to store a +// curve that two lines of arithmetic describe exactly. +// +// `d` is the offset from the sun's image, `halfext` the half-length (x) and +// half-thickness (y) of the bar. The bar tapers toward the tips instead of +// keeping a constant width, because a streak of even thickness reads as a drawn +// line rather than a lens artifact; the taper is floored so the tip never +// narrows past the point where it would alias. +float streak_profile(vec2 d, vec2 halfext) +{ + float u = abs(d.x) / halfext.x; // 0 at the sun, 1 at the tip + if (u >= 1.0) { + return 0.0; + } + + float taper = 1.0 - u; + float v = d.y / (halfext.y * max(taper, 0.15)); + float across = exp(-v * v * 4.0); + // an even falloff down the length, plus a hot core so the streak visibly + // has a source rather than floating over the sun + float along = taper * taper + exp(-u * 12.0); + return across * along; +} + +float ghost_channel(vec2 sp, float center, float halfext, float apscale, float apoff, float intensity) +{ + vec2 q = (sp - axis * center) / halfext; // image of the circular entrance pupil clips the bundle float pupil = clamp((1.0 - length(q)) * 8.0, 0.0, 1.0); vec2 uv = q * apscale + axis * apoff; @@ -65,14 +106,21 @@ void main() { vec3 result; - if (g_center.w > 0.5) { - // starburst billboard centered on the sun - vec2 q = (sensorPos - axis * g_center.x) / g_halfext.x; - result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; + if (g_center.w > 1.5) { + // The streak is laid out in sensor space by the vertex shader, so unlike + // the ghosts it must not be un-squeezed on the way back + result = streak_profile(sensorPos - axis * g_center.x, g_halfext.xy) * g_color.rgb; } else { - result.r = ghost_channel(g_center.x, g_halfext.x, g_apscale.x, g_apoff.x, g_color.x); - result.g = ghost_channel(g_center.y, g_halfext.y, g_apscale.y, g_apoff.y, g_color.y); - result.b = ghost_channel(g_center.z, g_halfext.z, g_apscale.z, g_apoff.z, g_color.z); + vec2 sp = unsqueeze(sensorPos); + if (g_center.w > 0.5) { + // starburst billboard centered on the sun + vec2 q = (sp - axis * g_center.x) / g_halfext.x; + result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; + } else { + result.r = ghost_channel(sp, g_center.x, g_halfext.x, g_apscale.x, g_apoff.x, g_color.x); + result.g = ghost_channel(sp, g_center.y, g_halfext.y, g_apscale.y, g_apoff.y, g_color.y); + result.b = ghost_channel(sp, g_center.z, g_halfext.z, g_apscale.z, g_apoff.z, g_color.z); + } } fragOut0 = vec4(result * tint.rgb, 1.0); diff --git a/code/def_files/data/effects/lensflare-v.sdr b/code/def_files/data/effects/lensflare-v.sdr index d4e8ce01339..7fc001d4c67 100644 --- a/code/def_files/data/effects/lensflare-v.sdr +++ b/code/def_files/data/effects/lensflare-v.sdr @@ -18,6 +18,7 @@ layout(location = 2) flat out vec4 g_halfext; layout(location = 3) flat out vec4 g_apscale; layout(location = 4) flat out vec4 g_apoff; layout(location = 5) flat out vec4 g_color; +layout(location = 6) flat out float g_origin; #define INSTANCE_INDEX gl_InstanceIndex #else in vec4 vertPosition; @@ -27,6 +28,7 @@ flat out vec4 g_halfext; flat out vec4 g_apscale; flat out vec4 g_apoff; flat out vec4 g_color; +flat out float g_origin; #define INSTANCE_INDEX gl_InstanceID #endif @@ -40,6 +42,7 @@ uniform genericData { vec2 ndc_scale; // sensor mm -> NDC vec4 tint; // sun color * visibility * lens intensity int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) lens_flare_instance instances[64]; }; @@ -54,17 +57,48 @@ void main() lens_flare_instance inst = instances[INSTANCE_INDEX]; - // Bounds of the union of the three chromatic quads, along/around the axis - float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); - float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); - float caxis = 0.5 * (cmin + cmax); - float haxis = 0.5 * (cmax - cmin); - float hperp = max(inst.halfext.x, max(inst.halfext.y, inst.halfext.z)); + vec2 p; + float origin; - vec2 perp = vec2(-axis.y, axis.x); - vec2 p = axis * (caxis + corner.x * haxis) + perp * (corner.y * hperp); + if (inst.center.w > 1.5) { + // Anamorphic streak: a screen-horizontal bar centred on the sun's image, + // built straight in sensor space rather than in the axis/perp frame. The + // streak lies along the cylindrical element, not along the line to the + // frame centre, so it stays horizontal wherever the sun sits. It is also + // exempt from `squeeze` -- its length is an explicit control, and + // stretching it as well would count the anamorphic twice. + // The vertical bound is padded well past the half-thickness: the gaussian + // across the bar is still ~2% of peak at one half-thickness, so a quad + // cut exactly there would leave a hard horizontal line down the whole + // streak. At 3x the profile has decayed to exp(-36), i.e. nothing. This + // only pads the geometry -- halfext.y still means half-thickness, so + // +Thickness: keeps its meaning. + origin = inst.center.x; + p = axis * origin + vec2(corner.x * inst.halfext.x, corner.y * inst.halfext.y * 3.0); + } else { + // Bounds of the union of the three chromatic quads, along/around the axis + float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); + float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); + float caxis = 0.5 * (cmin + cmax); + float haxis = 0.5 * (cmax - cmin); + float hperp = max(inst.halfext.x, max(inst.halfext.y, inst.halfext.z)); + + // The anamorphic stretch is linear, so applying it to the corner offset + // takes the oriented rectangle to a parallelogram that still bounds the + // stretched footprint exactly -- no need to widen out to a screen-aligned + // box. The stretch is about the instance's axial centre, not the sensor + // origin, so ghosts stay put and only grow: on a desqueezed anamorphic + // frame the horizontal squeeze of capture cancels for positions but not + // for footprints. + vec2 perp = vec2(-axis.y, axis.x); + vec2 off = axis * (corner.x * haxis) + perp * (corner.y * hperp); + off.x *= squeeze; + origin = caxis; + p = axis * caxis + off; + } sensorPos = p; + g_origin = origin; g_center = inst.center; g_halfext = inst.halfext; g_apscale = inst.apscale; diff --git a/code/def_files/data/tables/lens_flares.tbl b/code/def_files/data/tables/lens_flares.tbl index 33c8b449650..03604121a3f 100644 --- a/code/def_files/data/tables/lens_flares.tbl +++ b/code/def_files/data/tables/lens_flares.tbl @@ -44,6 +44,36 @@ ; - $Intensity:, blade counts/rotation/curvature and the starburst settings are ; artistic; the shipped values were calibrated visually in the F3 lab ; (Render options -> Lens flare options) against retail suns. +; - $Anamorphic Squeeze: stretches every ghost and the starburst horizontally, +; for the oval-ghost anamorphic look; 1.0 (the default, and what every lens +; below uses) is a spherical lens and costs nothing. 2.0 matches a classic 2x +; anamorphic; below 1.0 compresses rather than stretches, which is not what +; the option is for. A front anamorphot is an afocal cylindrical telescope, so to +; first order it only magnifies one meridian -- which is why this is one +; number rather than a second set of surfaces, and why the iris behind it is +; unaffected. Note that FSO composes the frame directly, with no desqueeze +; stage, so this stretches footprints only: ghosts stay on the sun where they +; always were, exactly as a desqueezed anamorphic frame shows them. +; - $Anamorphic Streak: is the other half of that look: the long horizontal +; flare a cylindrical element throws across the frame. It is a separate quad +; rather than a stretched starburst because it is a separate artifact -- the +; starburst is the iris seen end-on and swings around with the sun, while the +; streak lies along the element and stays horizontal wherever the sun is. It +; is also what actually reads as "anamorphic": squeezing the starburst alone +; only ever gives a wider sun, since a real streak runs 20-50x longer than it +; is thick. Off by default, like the aperture imperfection layers. +; +; $Anamorphic Streak: 0 turns it off. +; +Length: half-length as a fraction of the sensor width; 1.0 spans the +; frame. +; +Thickness: as a fraction of the length, so 0.02 is a 50:1 bar. +; +Tint: ( r, g, b ), multiplied onto the sun's own colour rather than +; replacing it, so a red sun keeps a reddish streak. The +; default leans blue, which is where the classic look comes +; from -- real streaks take their colour from the coating on +; the cylindrical element. + + ; ; The iris ; -------- diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp index 1d9492ec4ca..0a9787c9b43 100644 --- a/code/graphics/lens_flare.cpp +++ b/code/graphics/lens_flare.cpp @@ -435,10 +435,12 @@ static int pack_sun_instances(const lens_system& lens, float theta, float sdist, const float pupil = lens.entrance_radius; const float min_halfext = lens.sensor_width * 0.004f; // sub-pixel guard for focused ghosts + // the starburst and the streak each need a slot of their own at the end + const int reserved = (lens.starburst ? 1 : 0) + (lens.streak.strength > 0.0f ? 1 : 0); + int count = 0; for (const auto& ghost : lens.ghosts) { - // one slot is kept in reserve for the starburst - if (count >= generic_data::MAX_LENS_FLARE_INSTANCES - 1) { + if (count >= generic_data::MAX_LENS_FLARE_INSTANCES - reserved) { break; } auto& inst = out->instances[count]; @@ -483,8 +485,43 @@ static int pack_sun_instances(const lens_system& lens, float theta, float sdist, count++; } + if (lens.streak.strength > 0.0f && count < generic_data::MAX_LENS_FLARE_INSTANCES) { + auto& inst = out->instances[count]; + + // Unlike the ghosts and the starburst this quad is screen-horizontal, so + // its extents are a length and a thickness rather than three chromatic + // half-widths: the shader reads x and y, not one value per channel. + const float half_len = MAX(lens.streak.length * lens.sensor_width * 0.5f, min_halfext); + const float half_thick = MAX(half_len * lens.streak.thickness, min_halfext * 0.25f); + + inst.center.xyzw.x = sdist; // the sun's image, same as the starburst + inst.center.xyzw.y = 0.0f; + inst.center.xyzw.z = 0.0f; + inst.center.xyzw.w = 2.0f; // streak marker + + inst.halfext.xyzw.x = half_len; + inst.halfext.xyzw.y = half_thick; + inst.halfext.xyzw.z = 0.0f; + inst.halfext.xyzw.w = 0.0f; + + inst.apscale.xyzw.x = inst.apscale.xyzw.y = inst.apscale.xyzw.z = inst.apscale.xyzw.w = 0.0f; + inst.apoff.xyzw.x = inst.apoff.xyzw.y = inst.apoff.xyzw.z = inst.apoff.xyzw.w = 0.0f; + + // The lens tint is a colour cast on top of the sun's own colour, which + // the shared `tint` already applies -- so a red sun keeps a reddish + // streak instead of the table's blue overriding it + for (int k = 0; k < 3; k++) { + inst.color.a1d[k] = lens.streak.tint[k] * lens.streak.strength; + } + inst.color.xyzw.w = 0.0f; + count++; + } + out->n_instances = count; - out->pad[0] = out->pad[1] = out->pad[2] = 0.0f; + // Single choke point for the squeeze, so a table typo, a lab slider and any + // future sexp all get the same guard against a divide by zero in the shader + out->squeeze = MAX(lens.anamorphic_squeeze, 0.01f); + out->pad[0] = out->pad[1] = 0.0f; return count; } diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h index 8da6943d53b..e162929d99f 100644 --- a/code/graphics/lens_flare.h +++ b/code/graphics/lens_flare.h @@ -129,6 +129,38 @@ struct lens_system { float entrance_radius = 10.0f; // entrance pupil (front element) radius, mm float aperture_radius = 5.0f; // iris half-opening, mm float sensor_width = 36.0f; // film-gate width, mm + + // Anamorphic squeeze: how much wider than tall the flare footprints are, + // 1.0 = spherical. A front anamorphot is an afocal cylindrical telescope, so + // to first order all it does is magnify one meridian -- which is why this is + // a single number and not a second set of ray-transfer matrices. The lens + // behind it stays rotationally symmetric, so the iris (and hence the mask and + // its transform) is unaffected; only the imaging of it is stretched. + // + // Note that FSO draws the flare into an already-composed frame with no + // desqueeze stage, so this is a look control rather than a 2x-squeeze + // capture pipeline: ghost positions follow the sun as always, and only their + // footprints are stretched, which is what a desqueezed anamorphic frame + // shows. + float anamorphic_squeeze = 1.0f; + + // The anamorphic streak: the long horizontal flare a cylindrical element + // throws across the frame, and the half of the look the squeeze alone does + // not buy -- stretching the starburst only ever reads as a wider sun, since + // a real streak runs 20-50 times longer than it is thick. + // + // It is its own quad rather than a reshaped starburst because it is a + // different artifact: the starburst is the iris seen end-on and rotates with + // the sun, while the streak lies along the cylindrical element and so stays + // horizontal wherever the sun is. Off by default, which keeps every lens + // written before it existed untouched. + struct { + float strength = 0.0f; // 0 = off + float length = 1.0f; // half-length as a fraction of the sensor width + float thickness = 0.02f; // as a fraction of the length, so 0.02 = 50:1 + float tint[3] = {0.35f, 0.55f, 1.0f}; // multiplies the sun's own colour + } streak; + float coating_wavelength = 540.0f; // default AR coating tuning, nm (0 = uncoated) lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst diff --git a/code/graphics/lens_flare_table.cpp b/code/graphics/lens_flare_table.cpp index e6fa0a25def..7764d999eb5 100644 --- a/code/graphics/lens_flare_table.cpp +++ b/code/graphics/lens_flare_table.cpp @@ -64,6 +64,28 @@ void parse_lens_table_core() if (optional_string("$Sensor Width:")) { stuff_float(&ls.sensor_width); } + if (optional_string("$Anamorphic Squeeze:")) { + stuff_float(&ls.anamorphic_squeeze); + } + if (optional_string("$Anamorphic Streak:")) { + stuff_float(&ls.streak.strength); + if (optional_string("+Length:")) { + stuff_float(&ls.streak.length); + } + if (optional_string("+Thickness:")) { + stuff_float(&ls.streak.thickness); + } + if (optional_string("+Tint:")) { + float rgb[3] = {1.0f, 1.0f, 1.0f}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Lens system '%s': +Tint: needs ( r, g, b )", ls.name.c_str()); + } + ls.streak.tint[0] = rgb[0]; + ls.streak.tint[1] = rgb[1]; + ls.streak.tint[2] = rgb[2]; + } + } if (optional_string("$Coating Wavelength:")) { stuff_float(&ls.coating_wavelength); } diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 087f7810640..9472a6071ad 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -292,11 +292,15 @@ struct fxaa_data { // Keep in sync with the literal array size in lensflare-v.sdr / lensflare-f.sdr! constexpr int MAX_LENS_FLARE_INSTANCES = 64; -// One ghost (or starburst) quad of the physically-based lens flare pass. -// Per-channel values are for red/green/blue in x/y/z; all positions/extents -// are in sensor-plane units (mm) along/around the flare axis. +// One quad of the physically-based lens flare pass. center.w picks which kind: +// 0 = ghost, 1 = starburst, 2 = anamorphic streak. +// +// Per-channel values are for red/green/blue in x/y/z; all positions/extents are +// in sensor-plane units (mm) along/around the flare axis. The streak is the +// exception -- it is screen-horizontal, so it reads center.x as the sun's +// position and halfext.xy as a length and a thickness. struct lens_flare_instance_data { - vec4 center; // xyz = per-channel quad center along the flare axis; w > 0.5 marks the starburst + vec4 center; // xyz = per-channel quad center along the flare axis; w = quad kind vec4 halfext; // xyz = per-channel quad half-extent vec4 apscale; // xyz = per-channel aperture-plane scale of the quad parametrization vec4 apoff; // xyz = per-channel aperture-plane offset along the flare axis @@ -310,11 +314,21 @@ struct lens_flare_data { vec4 tint; // rgb = sun color * visibility * lens intensity int n_instances; - float pad[3]; + float squeeze; // anamorphic horizontal stretch of every footprint, 1.0 = spherical + float pad[2]; lens_flare_instance_data instances[MAX_LENS_FLARE_INSTANCES]; }; +// This block is mirrored by hand in lensflare-v.sdr / lensflare-f.sdr, and the +// two must agree byte for byte. Nothing else can check that -- the GLSL side is +// only compiled at runtime -- so at least make a field added here (or a scalar +// silently promoted past its std140 slot) stop the build instead of quietly +// misaligning `instances` and corrupting every quad the pass draws. +static_assert(sizeof(lens_flare_data) == 48 + 80 * MAX_LENS_FLARE_INSTANCES, + "lens_flare_data no longer matches its std140 layout -- update the genericData block in " + "lensflare-v.sdr and lensflare-f.sdr to match, then fix this size"); + struct fog_data { vec3d fog_color; float fog_start; diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp index 5127ea149d6..01878241f88 100644 --- a/code/lab/dialogs/lab_ui_lens_flare.cpp +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -135,6 +135,20 @@ void LabUi::build_lens_flare_options() if (auto* lens = graphics::lens_flare_get_system_mutable(active_lens)) { SliderFloat("Lens intensity", &lens->intensity, 0.0f, 10.0f); + // Costs nothing to change: the squeeze is applied when the quads are + // drawn, so unlike the aperture sliders it needs no texture rebuild + SliderFloat("Anamorphic squeeze", &lens->anamorphic_squeeze, 1.0f, 3.0f, "%.2fx"); + + with_TreeNode("Anamorphic streak") + { + TextDisabled("Stays horizontal wherever the sun is"); + SliderFloat("Streak strength", &lens->streak.strength, 0.0f, 2.0f); + if (lens->streak.strength > 0.0f) { + SliderFloat("Streak length", &lens->streak.length, 0.0f, 4.0f); + SliderFloat("Streak thickness", &lens->streak.thickness, 0.001f, 0.2f, "%.3f"); + ColorEdit3("Streak tint", lens->streak.tint); + } + } Text("%d ghosts | EFL %.1f mm | f/%.1f | %s", static_cast(lens->ghosts.size()), lens->efl, diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp index 9a868e7a8e1..ca35ff0799a 100644 --- a/test/src/graphics/test_lens_flare.cpp +++ b/test/src/graphics/test_lens_flare.cpp @@ -202,6 +202,10 @@ TEST_F(LensFlareTableTest, ShippedLensesParseAndPrecompute) EXPECT_GT(ls->entrance_radius, 0.0f); EXPECT_GT(ls->aperture_radius, 0.0f); EXPECT_GT(ls->sensor_width, 0.0f); + // every shipped lens is spherical, so both anamorphic paths must be + // exactly off -- this is what keeps existing content identical + EXPECT_FLOAT_EQ(ls->anamorphic_squeeze, 1.0f); + EXPECT_FLOAT_EQ(ls->streak.strength, 0.0f); for (const auto& ghost : ls->ghosts) { for (int wl = 0; wl < 3; wl++) { @@ -433,6 +437,13 @@ TEST_F(LensFlareApertureTbmTest, ParsesEveryApertureField) EXPECT_EQ(ls->max_ghosts, 7); EXPECT_FLOAT_EQ(ls->entrance_radius, 20.0f); EXPECT_FLOAT_EQ(ls->aperture_radius, 14.0f); + EXPECT_FLOAT_EQ(ls->anamorphic_squeeze, 1.75f); + EXPECT_FLOAT_EQ(ls->streak.strength, 0.81f); + EXPECT_FLOAT_EQ(ls->streak.length, 0.82f); + EXPECT_FLOAT_EQ(ls->streak.thickness, 0.083f); + EXPECT_FLOAT_EQ(ls->streak.tint[0], 0.84f); + EXPECT_FLOAT_EQ(ls->streak.tint[1], 0.85f); + EXPECT_FLOAT_EQ(ls->streak.tint[2], 0.86f); // and the tbm must not have disturbed the built-in table EXPECT_GE(lens_flare_lookup("angenieux_100mm"), 0); diff --git a/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm index bf20f1a438b..7d28c17bf3c 100644 --- a/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm +++ b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm @@ -11,6 +11,11 @@ $Name: aperture_test_lens $Entrance Pupil Radius: 20.0 $Aperture Radius: 14.0 $Sensor Width: 36.0 +$Anamorphic Squeeze: 1.75 +$Anamorphic Streak: 0.81 ++Length: 0.82 ++Thickness: 0.083 ++Tint: ( 0.84, 0.85, 0.86 ) $Coating Wavelength: 500 $Aperture Blades: 11 +Blade Rotation: 21.0 From 317f3baabe2406323bb7bdb0e0004a2b595e9e95 Mon Sep 17 00:00:00 2001 From: the-e Date: Sun, 26 Jul 2026 13:06:25 +0200 Subject: [PATCH 4/6] Refactor lens flare system to clarify frame logic, prevent draw inconsistencies Decouple lens flare state setup from rendering by introducing `lens_flare_frame_update()` for reusable, deterministic frame data. Replace `build_frame_data()` usages, ensuring sun sprite and lens flare passes rely consistently on published results. Update shaders, backends, and logic for more precise artifact type handling and clarity. --- code/def_files/data/effects/lensflare-f.sdr | 18 +- code/def_files/data/effects/lensflare-v.sdr | 26 +- code/graphics/lens_flare.cpp | 253 +++++++++++------- code/graphics/lens_flare.h | 44 +-- .../opengl/gropenglpostprocessing.cpp | 12 +- code/graphics/util/uniform_structs.h | 38 ++- .../vulkan/VulkanPostProcessingLensFlare.cpp | 7 +- code/lab/dialogs/lab_ui_lens_flare.cpp | 2 +- code/starfield/starfield.cpp | 45 +++- 9 files changed, 288 insertions(+), 157 deletions(-) diff --git a/code/def_files/data/effects/lensflare-f.sdr b/code/def_files/data/effects/lensflare-f.sdr index a5cd2292870..d49ea017dd0 100644 --- a/code/def_files/data/effects/lensflare-f.sdr +++ b/code/def_files/data/effects/lensflare-f.sdr @@ -11,6 +11,17 @@ struct lens_flare_instance { vec4 color; }; +// Which artifact this quad draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + #ifdef VULKAN layout(location = 0) in vec2 sensorPos; layout(location = 1) flat in vec4 g_center; @@ -106,13 +117,14 @@ void main() { vec3 result; - if (g_center.w > 1.5) { + if (quad_is(g_center.w, LENS_QUAD_STREAK)) { // The streak is laid out in sensor space by the vertex shader, so unlike - // the ghosts it must not be un-squeezed on the way back + // the ghosts it must not be un-squeezed on the way back. halfext.xy is a + // half-length and a half-thickness here, not a chromatic triple. result = streak_profile(sensorPos - axis * g_center.x, g_halfext.xy) * g_color.rgb; } else { vec2 sp = unsqueeze(sensorPos); - if (g_center.w > 0.5) { + if (quad_is(g_center.w, LENS_QUAD_STARBURST)) { // starburst billboard centered on the sun vec2 q = (sp - axis * g_center.x) / g_halfext.x; result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; diff --git a/code/def_files/data/effects/lensflare-v.sdr b/code/def_files/data/effects/lensflare-v.sdr index 7fc001d4c67..cd915c55162 100644 --- a/code/def_files/data/effects/lensflare-v.sdr +++ b/code/def_files/data/effects/lensflare-v.sdr @@ -4,13 +4,24 @@ // per-instance data below; positions are in sensor-plane millimeters. struct lens_flare_instance { - vec4 center; // xyz = per-channel quad center along the flare axis; w > 0.5 = starburst - vec4 halfext; // xyz = per-channel quad half-extent - vec4 apscale; // xyz = per-channel aperture-plane scale - vec4 apoff; // xyz = per-channel aperture-plane offset along the axis - vec4 color; // rgb = per-channel intensity + vec4 center; // w = LENS_QUAD_*, the kind tag; xyz meaning depends on it + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; }; +// Which artifact this slot draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + #ifdef VULKAN layout(location = 0) out vec2 sensorPos; layout(location = 1) flat out vec4 g_center; @@ -60,7 +71,7 @@ void main() vec2 p; float origin; - if (inst.center.w > 1.5) { + if (quad_is(inst.center.w, LENS_QUAD_STREAK)) { // Anamorphic streak: a screen-horizontal bar centred on the sun's image, // built straight in sensor space rather than in the axis/perp frame. The // streak lies along the cylindrical element, not along the line to the @@ -76,6 +87,9 @@ void main() origin = inst.center.x; p = axis * origin + vec2(corner.x * inst.halfext.x, corner.y * inst.halfext.y * 3.0); } else { + // Ghosts and the starburst share this path: both are laid out per channel + // in the axis/perp frame, the starburst simply with all three channels + // equal. // Bounds of the union of the three chromatic quads, along/around the axis float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp index 0a9787c9b43..abec6528c8e 100644 --- a/code/graphics/lens_flare.cpp +++ b/code/graphics/lens_flare.cpp @@ -85,19 +85,34 @@ std::optional Lab_lens; SCP_vector Frame_data; SCP_vector Frame_draws; +// Indexed by sun: did this frame's draw for that sun include a starburst quad? +// Published by lens_flare_frame_update() alongside Frame_draws and read back by +// lens_flare_sun_starburst_drawn(), so the sun renderer and the flare pass can +// never disagree about which suns the starburst has taken over. +SCP_vector Sun_starburst_drawn; + // Lens the "flares are running through X" breadcrumb last reported. Logged from // the frame build rather than from lens_flare_switch_to(), because mission-info // scans (FRED opening a file dialog) mount lenses they never render with. int Logged_lens = -2; // True while the global conditions allow the flare pass to render at all -// (matching backends' pass reachability, independent of any particular sun) +// (independent of any particular sun) bool pass_globally_possible() { if (gr_screen.mode == GraphicsAPI::Stub || Lens_systems.empty()) { return false; } - if (!Gr_post_processing_enabled || PostProcessing_override) { + // The flare composites into the HDR scene buffer from the post-processing + // chain, so it can only draw when this scene render actually goes through + // that chain. Both backends raise this in their scene_texture_begin() + // precisely when post-processing is on and drop it again at + // scene_texture_end(), which makes it the one authoritative signal -- rather + // than a second copy of the backends' own conditions. It is also what keeps + // FRED and qtFRED, which draw the background without ever opening a scene + // texture, from having their sun sprites step aside for a pass that will + // never run. + if (!High_dynamic_range) { return false; } if (Game_subspace_effect) { @@ -238,6 +253,7 @@ void lens_flare_close() lens_flare_clear_lab_lens(); Frame_data.clear(); Frame_draws.clear(); + Sun_starburst_drawn.clear(); Logged_lens = -2; } @@ -344,20 +360,9 @@ const SCP_vector& lens_flare_get_frame_draws() return Frame_draws; } -bool lens_flare_sun_starburst_active(int sun_n) +bool lens_flare_sun_starburst_drawn(int sun_n) { - if (!pass_globally_possible()) { - return false; - } - - int lens_idx = lens_flare_active_lens(); - if (lens_idx < 0 || !Lens_systems[lens_idx].starburst) { - return false; - } - - // mirrors the glare requirement of the flare pass - int light_idx = light_find_for_sun(sun_n); - return light_idx < 0 || light_has_glare(light_idx); + return SCP_vector_inbounds(Sun_starburst_drawn, sun_n) && Sun_starburst_drawn[sun_n]; } const lens_flare_textures* lens_flare_get_textures(int lens_idx) @@ -382,6 +387,11 @@ void lens_flare_reset_for_level() lens_flare_clear_lab_lens(); Logged_lens = -2; + // The next mission's suns are not this one's; drop the published frame so + // nothing consumes it across the level change + Frame_draws.clear(); + Sun_starburst_drawn.clear(); + // drop any pending live edit first; it belongs to the mission being left Aperture_dirty = false; Aperture_dirty_lens = -1; @@ -425,97 +435,131 @@ void lens_flare_aperture_changed(int lens_idx) } } -// Pack one sun's ghost quads (plus the starburst, if the lens has one) into a -// uniform block and return how many instances were written. Depends only on the -// lens and where the sun's image lands on the sensor -- `sdist` is that image's -// distance from the sensor centre in mm, `theta` its paraxial field angle. -static int pack_sun_instances(const lens_system& lens, float theta, float sdist, - generic_data::lens_flare_data* out) +namespace { + +// The three quad kinds share one instance slot but read its fields differently +// (see lens_flare_instance_data in graphics/util/uniform_structs.h for the +// per-kind table). Each emit_* below is the sole writer of its kind, so the +// convention lives in exactly one place per artifact instead of being spread +// across one long packing function. + +// Blank a slot and tag its kind, so each emitter only writes the fields it +// actually means and never has to remember to zero the rest. +void instance_init(generic_data::lens_flare_instance_data& inst, float kind) { + inst = {}; + inst.center.xyzw.w = kind; +} + +// Sub-pixel guard: a nearly focused ghost would otherwise collapse to a point +// (and its energy concentration to infinity). +float ghost_min_halfext(const lens_system& lens) +{ + return lens.sensor_width * 0.004f; +} + +// A ghost: the aperture as imaged by one two-reflection path, evaluated at each +// of the three design wavelengths, so every xyz triple here is per-channel. +void emit_ghost(generic_data::lens_flare_instance_data& inst, const lens_system& lens, + const lens_flare_ghost& ghost, float theta) +{ + instance_init(inst, generic_data::LENS_QUAD_GHOST); + const float pupil = lens.entrance_radius; - const float min_halfext = lens.sensor_width * 0.004f; // sub-pixel guard for focused ghosts + const float min_halfext = ghost_min_halfext(lens); - // the starburst and the streak each need a slot of their own at the end - const int reserved = (lens.starburst ? 1 : 0) + (lens.streak.strength > 0.0f ? 1 : 0); + for (int k = 0; k < 3; k++) { + // Full path matrix F = Ms * Ma; only row 0 (heights) is needed + float f_a = ghost.ms[k][0] * ghost.ma[k][0] + ghost.ms[k][1] * ghost.ma[k][2]; + float f_b = ghost.ms[k][0] * ghost.ma[k][1] + ghost.ms[k][1] * ghost.ma[k][3]; - int count = 0; - for (const auto& ghost : lens.ghosts) { - if (count >= generic_data::MAX_LENS_FLARE_INSTANCES - reserved) { - break; - } - auto& inst = out->instances[count]; + float halfext = MAX(fabsf(f_a) * pupil, min_halfext); + float energy = MIN((pupil * pupil) / (halfext * halfext), GHOST_ENERGY_CAP); + + inst.center.a1d[k] = f_b * theta; + inst.halfext.a1d[k] = halfext; + inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; + inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; + inst.color.a1d[k] = ghost.reflectance[k] * energy * Ghost_brightness; + } +} - for (int k = 0; k < 3; k++) { - // Full path matrix F = Ms * Ma; only row 0 (heights) is needed - float f_a = ghost.ms[k][0] * ghost.ma[k][0] + ghost.ms[k][1] * ghost.ma[k][2]; - float f_b = ghost.ms[k][0] * ghost.ma[k][1] + ghost.ms[k][1] * ghost.ma[k][3]; +// The starburst: the Fraunhofer transform of the iris, sitting exactly on the +// sun's image. Achromatic here, because the texture carries its own per-channel +// diffraction scaling. `sdist` is the image's distance from the sensor centre. +void emit_starburst(generic_data::lens_flare_instance_data& inst, const lens_system& lens, float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STARBURST); - float halfext = MAX(fabsf(f_a) * pupil, min_halfext); - float energy = MIN((pupil * pupil) / (halfext * halfext), GHOST_ENERGY_CAP); + const float halfext = lens.starburst_scale * lens.sensor_width * 0.12f; + for (int k = 0; k < 3; k++) { + inst.center.a1d[k] = sdist; + inst.halfext.a1d[k] = halfext; + inst.color.a1d[k] = Starburst_brightness; + } +} - inst.center.a1d[k] = f_b * theta; - inst.halfext.a1d[k] = halfext; - inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; - inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; - inst.color.a1d[k] = ghost.reflectance[k] * energy * Ghost_brightness; - } - inst.center.xyzw.w = 0.0f; - inst.halfext.xyzw.w = 0.0f; - inst.apscale.xyzw.w = 0.0f; - inst.apoff.xyzw.w = 0.0f; - inst.color.xyzw.w = 0.0f; - count++; - } - - if (lens.starburst && count < generic_data::MAX_LENS_FLARE_INSTANCES) { - auto& inst = out->instances[count]; - float halfext = lens.starburst_scale * lens.sensor_width * 0.12f; - for (int k = 0; k < 3; k++) { - inst.center.a1d[k] = sdist; // exactly at the sun's image - inst.halfext.a1d[k] = halfext; - inst.apscale.a1d[k] = 0.0f; - inst.apoff.a1d[k] = 0.0f; - inst.color.a1d[k] = Starburst_brightness; - } - inst.center.xyzw.w = 1.0f; // starburst marker - inst.halfext.xyzw.w = 0.0f; - inst.apscale.xyzw.w = 0.0f; - inst.apoff.xyzw.w = 0.0f; - inst.color.xyzw.w = 0.0f; - count++; - } - - if (lens.streak.strength > 0.0f && count < generic_data::MAX_LENS_FLARE_INSTANCES) { - auto& inst = out->instances[count]; - - // Unlike the ghosts and the starburst this quad is screen-horizontal, so - // its extents are a length and a thickness rather than three chromatic - // half-widths: the shader reads x and y, not one value per channel. - const float half_len = MAX(lens.streak.length * lens.sensor_width * 0.5f, min_halfext); - const float half_thick = MAX(half_len * lens.streak.thickness, min_halfext * 0.25f); - - inst.center.xyzw.x = sdist; // the sun's image, same as the starburst - inst.center.xyzw.y = 0.0f; - inst.center.xyzw.z = 0.0f; - inst.center.xyzw.w = 2.0f; // streak marker - - inst.halfext.xyzw.x = half_len; - inst.halfext.xyzw.y = half_thick; - inst.halfext.xyzw.z = 0.0f; - inst.halfext.xyzw.w = 0.0f; - - inst.apscale.xyzw.x = inst.apscale.xyzw.y = inst.apscale.xyzw.z = inst.apscale.xyzw.w = 0.0f; - inst.apoff.xyzw.x = inst.apoff.xyzw.y = inst.apoff.xyzw.z = inst.apoff.xyzw.w = 0.0f; - - // The lens tint is a colour cast on top of the sun's own colour, which - // the shared `tint` already applies -- so a red sun keeps a reddish - // streak instead of the table's blue overriding it - for (int k = 0; k < 3; k++) { - inst.color.a1d[k] = lens.streak.tint[k] * lens.streak.strength; +// The anamorphic streak: screen-horizontal, so unlike the other two kinds it +// reads halfext as a half-length and a half-thickness rather than as three +// chromatic half-widths. +void emit_streak(generic_data::lens_flare_instance_data& inst, const lens_system& lens, float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STREAK); + + const float min_halfext = ghost_min_halfext(lens); + const float half_len = MAX(lens.streak.length * lens.sensor_width * 0.5f, min_halfext); + + inst.center.xyzw.x = sdist; // the sun's image, same as the starburst + inst.halfext.xyzw.x = half_len; + inst.halfext.xyzw.y = MAX(half_len * lens.streak.thickness, min_halfext * 0.25f); + + // The lens tint is a colour cast on top of the sun's own colour, which the + // shared `tint` already applies -- so a red sun keeps a reddish streak + // instead of the table's blue overriding it + for (int k = 0; k < 3; k++) { + inst.color.a1d[k] = lens.streak.tint[k] * lens.streak.strength; + } +} + +} // namespace + +// Pack one sun's quads into a uniform block and return how many instance slots +// were written. Depends only on the lens and where the sun's image lands on the +// sensor -- `sdist` is that image's distance from the sensor centre in mm, +// `theta` its paraxial field angle. +static int pack_sun_instances(const lens_system& lens, float theta, float sdist, + generic_data::lens_flare_data* out) +{ + // Each non-ghost artifact reserves its slot out of the budget up front, so the + // ghosts can never crowd it out and the emits below need no second bounds + // check. The predicates are named once and used for both the reservation and + // the emission, so a new artifact cannot be added to one without the other -- + // which is what lets lens_flare_frame_update() conclude that a + // starburst-enabled lens has certainly drawn its starburst, and hence what the + // sprite sun steps aside for. + const bool wants_starburst = lens.starburst; + const bool wants_streak = lens.streak.strength > 0.0f; + + const int reserved = (wants_starburst ? 1 : 0) + (wants_streak ? 1 : 0); + const int ghost_budget = generic_data::MAX_LENS_FLARE_INSTANCES - reserved; + + int count = 0; + for (const auto& ghost : lens.ghosts) { + if (count >= ghost_budget) { + break; } - inst.color.xyzw.w = 0.0f; - count++; + emit_ghost(out->instances[count++], lens, ghost, theta); } + if (wants_starburst) { + emit_starburst(out->instances[count++], lens, sdist); + } + if (wants_streak) { + emit_streak(out->instances[count++], lens, sdist); + } + Assertion(count <= generic_data::MAX_LENS_FLARE_INSTANCES, + "Lens flare packed %d instances into %d slots -- the ghost budget no longer reserves the " + "starburst/streak slots correctly", + count, generic_data::MAX_LENS_FLARE_INSTANCES); out->n_instances = count; // Single choke point for the squeeze, so a table typo, a lab slider and any @@ -525,16 +569,17 @@ static int pack_sun_instances(const lens_system& lens, float theta, float sdist, return count; } -const SCP_vector& lens_flare_build_frame_data() +void lens_flare_frame_update() { Frame_draws.clear(); + Sun_starburst_drawn.clear(); // live aperture edits from the lab land here, throttled flush_pending_aperture_edit(); const int lens_idx = lens_flare_active_lens(); if (lens_idx < 0 || !pass_globally_possible()) { - return Frame_draws; + return; } const lens_system& lens = Lens_systems[lens_idx]; @@ -552,12 +597,13 @@ const SCP_vector& lens_flare_build_frame_data() if (static_cast(Frame_data.size()) < num_suns) { Frame_data.resize(num_suns); } + Sun_starburst_drawn.resize(num_suns, false); film_gate gate; gate.clip_w = i2fl(gr_screen.clip_width); gate.clip_h = i2fl(gr_screen.clip_height); if (gate.clip_w <= 0.0f || gate.clip_h <= 0.0f) { - return Frame_draws; + return; } gate.half_w = lens.sensor_width * 0.5f; gate.half_h = gate.half_w * gate.clip_h / gate.clip_w; @@ -625,6 +671,11 @@ const SCP_vector& lens_flare_build_frame_data() draw.off_axis_deg = image.theta * (180.0f / PI); draw.output_scale = out_scale; Frame_draws.push_back(draw); + + // This sun is committed, and pack_sun_instances() reserves the starburst a + // slot up front, so a starburst-enabled lens has certainly drawn one. The + // sprite sun can now safely step aside for it. + Sun_starburst_drawn[sun_n] = lens.starburst; } if (!Frame_draws.empty() && lens_idx != Logged_lens) { @@ -632,8 +683,6 @@ const SCP_vector& lens_flare_build_frame_data() mprintf(("Lens flare: rendering through lens '%s' (%d ghosts + %s)\n", lens.name.c_str(), static_cast(lens.ghosts.size()), lens.starburst ? "starburst" : "no starburst")); } - - return Frame_draws; } diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h index e162929d99f..d75fc409994 100644 --- a/code/graphics/lens_flare.h +++ b/code/graphics/lens_flare.h @@ -279,11 +279,14 @@ void lens_flare_set_lab_lens(int lens_idx); void lens_flare_clear_lab_lens(); std::optional lens_flare_get_lab_lens(); -// True when this sun's flare will include the physically-based starburst (a lens -// is mounted, its starburst is enabled, the sun has glare, and the flare pass is -// reachable this frame). Used by the sun renderer to skip the bitmap sun glow so -// the two starbursts don't stack. -bool lens_flare_sun_starburst_active(int sun_n); +// True when the last lens_flare_frame_update() actually put a starburst quad in +// this sun's draw. Used by the sun renderer to skip the sprite sun and its glow +// so the two starbursts don't stack. +// +// This reports what the flare pass *is drawing*, read back out of the frame data +// below -- it does not re-derive it. Predicting it independently is how the +// sprite and the flare came to disagree about occluded and off-screen suns. +bool lens_flare_sun_starburst_drawn(int sun_n); // One sun's worth of flare quads. All suns share the mounted lens (hence one // aperture/starburst texture for the whole pass), but each has its own flare axis @@ -294,7 +297,7 @@ struct lens_flare_draw { int sun_index = -1; int instances = 0; // quads to draw (ghosts + optional starburst) // Uniform block for this draw, owned by lens_flare.cpp; valid until the - // next lens_flare_build_frame_data() call. + // next lens_flare_frame_update() call. const generic_data::lens_flare_data* data = nullptr; float visibility = 0.0f; // smoothed occlusion * off-axis fade @@ -302,15 +305,26 @@ struct lens_flare_draw { float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame }; -// Build the per-frame uniform data of every visible sun. Does all game-state -// access (sun projection, occlusion raycast, gates) so the render backends stay -// free of it. Returns one entry per sun that has something to draw, in sun order -// (empty = skip the pass entirely); every entry is drawn with the textures of -// lens_flare_active_lens(). -const SCP_vector& lens_flare_build_frame_data(); - -// The draws built by the last lens_flare_build_frame_data() call (lab -// instrumentation; empty when the pass was inactive). +// Decide what the flare pass will draw this frame and publish it, once per scene +// render. Does all the game-state access (sun projection, occlusion raycast, +// gates) and all the deferred work (flushing throttled aperture rebuilds), so +// that everything downstream is a pure read. +// +// Called from stars_draw(), which is the one place that both runs after the view +// and projection matrices are live and runs before the sun sprites and the +// post-processing pass consume the result. Deliberately *not* called while +// rendering an environment map: that path never reaches the flare pass, so +// publishing there would tell the sun renderer to step aside for a starburst +// that never gets drawn. +void lens_flare_frame_update(); + +// What the last lens_flare_frame_update() published: one entry per sun that has +// something to draw, in sun order (empty = skip the pass entirely). Every entry +// is drawn with the textures of lens_flare_active_lens(). +// +// The single source of truth for the pass -- the render backends draw exactly +// these, the sun renderer asks lens_flare_sun_starburst_drawn() about them, and +// the lab reports on them. const SCP_vector& lens_flare_get_frame_draws(); // ---- internals exposed for unit testing ---- diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index adcd3cb6d4b..6360c5fc006 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -583,12 +583,12 @@ static bool opengl_lens_flare_ensure_textures(int lens_idx) // camera lens (hence its textures), but each has its own flare axis and tint. static void opengl_post_pass_lens_flare() { - if (GL_rendering_to_texture) { - // no camera-lens artifacts in environment maps and other RTT passes - return; - } - - const auto& flare_draws = graphics::lens_flare_build_frame_data(); + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flare_draws = graphics::lens_flare_get_frame_draws(); if (flare_draws.empty()) { return; } diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 9472a6071ad..7ef6dafbed7 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -292,19 +292,35 @@ struct fxaa_data { // Keep in sync with the literal array size in lensflare-v.sdr / lensflare-f.sdr! constexpr int MAX_LENS_FLARE_INSTANCES = 64; -// One quad of the physically-based lens flare pass. center.w picks which kind: -// 0 = ghost, 1 = starburst, 2 = anamorphic streak. +// Which of the three artifacts an instance slot draws, tagged in center.w. +// Mirrored by the LENS_QUAD_* defines in lensflare-v.sdr / lensflare-f.sdr; the +// emit_* helpers in graphics/lens_flare.cpp are the only writers. +constexpr float LENS_QUAD_GHOST = 0.0f; +constexpr float LENS_QUAD_STARBURST = 1.0f; +constexpr float LENS_QUAD_STREAK = 2.0f; + +// One quad of the physically-based lens flare pass. The three kinds share this +// one slot layout but read it differently, so the field meanings are per-kind: // -// Per-channel values are for red/green/blue in x/y/z; all positions/extents are -// in sensor-plane units (mm) along/around the flare axis. The streak is the -// exception -- it is screen-horizontal, so it reads center.x as the sun's -// position and halfext.xy as a length and a thickness. +// center halfext apscale/apoff color +// GHOST xyz per-channel xyz per-channel xyz per-channel rgb per-channel +// centre along half-extent aperture-plane intensity +// the flare axis parametrization +// STARBURST x = the sun's x = half-extent unused rgb intensity +// image +// STREAK x = the sun's x = half-length unused rgb tint +// image y = half-thickness +// +// Per-channel means red/green/blue in x/y/z. All positions and extents are in +// sensor-plane millimeters, along and around the flare axis -- except the +// streak, which is screen-horizontal and so carries a length and a thickness +// instead of three chromatic values. struct lens_flare_instance_data { - vec4 center; // xyz = per-channel quad center along the flare axis; w = quad kind - vec4 halfext; // xyz = per-channel quad half-extent - vec4 apscale; // xyz = per-channel aperture-plane scale of the quad parametrization - vec4 apoff; // xyz = per-channel aperture-plane offset along the flare axis - vec4 color; // rgb = per-channel ghost intensity (reflectance * energy scale) + vec4 center; // w = LENS_QUAD_*, the kind tag; see the table above for xyz + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; }; struct lens_flare_data { diff --git a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp index 02f30d2a397..6fd76352bf6 100644 --- a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp @@ -205,7 +205,12 @@ void VulkanLensFlare::execute(vk::CommandBuffer cmd) return; } - const auto& flareDraws = graphics::lens_flare_build_frame_data(); + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flareDraws = graphics::lens_flare_get_frame_draws(); if (flareDraws.empty()) { return; } diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp index 01878241f88..b6018622320 100644 --- a/code/lab/dialogs/lab_ui_lens_flare.cpp +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -160,7 +160,7 @@ void LabUi::build_lens_flare_options() TextDisabled("No lens mounted: this background renders no physically-based flares"); } - // live pass state, refreshed every frame by lens_flare_build_frame_data(): + // live pass state, refreshed every frame by lens_flare_frame_update(): // one entry per sun that got a draw Separator(); const auto& draws = graphics::lens_flare_get_frame_draws(); diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index 4045ffe0394..833b2e18455 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -1270,6 +1270,20 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +// True when the post-processing flare pass is drawing this sun's starburst this +// frame, so the sprite sun and its glow must not stack a second one on top of it. +// +// Never true while rendering an environment map. That path goes straight to a +// render target without ever reaching the post-processing chain, so no flare pass +// runs there and the sprite is all there is -- and since the env faces are +// rendered *before* this frame's stars_draw() publishes, what is published at +// that moment is still the previous frame's. Both reasons say the same thing: +// only the scene render that published may act on the result. +static bool sun_starburst_replaces_sprite(int sun_n) +{ + return !Rendering_to_env && graphics::lens_flare_sun_starburst_drawn(sun_n); +} + // The sun's tabled light, or nothing if the sun instance itself is invalid. std::optional stars_get_sun_rgbi(int sun_n) { @@ -1367,16 +1381,15 @@ void stars_draw_sun(int show_sun) continue; } - // when a physically-based lens with a starburst is active for this sun, the - // post-processing flare pass draws the starburst; skip this sun's bitmap so - // the two don't stack (the remaining suns are unaffected) - if (graphics::lens_flare_sun_starburst_active(idx)) { - continue; + // When the flare pass is drawing this sun's starburst, skip the sprite so + // the two don't stack (the remaining suns are unaffected). Sun_drew is + // still counted: it means "a sun was on screen this frame", which drives + // the sunspot glare downstream and stays true either way. + if (!sun_starburst_replaces_sprite(idx)) { + material mat_params; + material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); + g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); } - - material mat_params; - material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); - g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); Sun_drew++; // if ( !g3_draw_bitmap(&sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, TMAP_FLAG_TEXTURED) ) @@ -1467,10 +1480,9 @@ void stars_draw_sun_glow(int sun_n) if (bm->glow_bitmap < 0) return; - // when a physically-based lens with a starburst is active for this sun, the - // post-processing flare pass draws the starburst; skip the bitmap glow so + // when the flare pass is drawing this sun's starburst, skip the bitmap glow so // the two don't stack - if (graphics::lens_flare_sun_starburst_active(sun_n)) { + if (sun_starburst_replaces_sprite(sun_n)) { return; } @@ -1984,6 +1996,15 @@ void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_s Rendering_to_env = env; + // Decide what the camera lens will flare this frame, before anything consults + // the answer: the sun sprites below step aside for a starburst the flare pass + // is drawing, and the post-processing pass draws exactly what is published + // here. Skipped for environment maps, which never reach that pass -- see + // sun_starburst_replaces_sprite(). + if (!env) { + graphics::lens_flare_frame_update(); + } + if (show_subspace) subspace_render(); From dbf4aa6cc436df7b13e8b1c372173ab818dfa22a Mon Sep 17 00:00:00 2001 From: the-e Date: Sun, 26 Jul 2026 13:25:49 +0200 Subject: [PATCH 5/6] Distinguish "" and "" from unnamed camera lenses Ensure missions requesting no flares or default lens retain their behavior even with a declared $Default Lens:. Update lens selection logic, mission parsing, and editor handling to preserve intentional "" values. Add tests and refactor lens APIs for consistency and clarity. --- code/graphics/lens_flare.cpp | 37 ++++- code/graphics/lens_flare.h | 144 ++++++++++++------ code/lab/renderer/lab_renderer.cpp | 20 ++- code/mission/missionparse.cpp | 17 +-- code/mission/missionparse.h | 8 +- code/missioneditor/missionsave.cpp | 9 +- code/missioneditor/sexp_tree_opf.cpp | 8 +- code/parse/sexp.cpp | 36 ++--- code/starfield/starfield.cpp | 4 + fred2/bgbitmapdlg.cpp | 26 +++- fred2/bgbitmapdlg.h | 9 ++ .../dialogs/BackgroundEditorDialogModel.cpp | 26 +++- .../dialogs/BackgroundEditorDialogModel.h | 5 +- test/src/graphics/test_lens_flare.cpp | 74 ++++++++- .../default_lens/data/tables/test-lens.tbm | 23 +++ 15 files changed, 339 insertions(+), 107 deletions(-) create mode 100644 test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp index abec6528c8e..c4e50ef94ee 100644 --- a/code/graphics/lens_flare.cpp +++ b/code/graphics/lens_flare.cpp @@ -314,7 +314,15 @@ const char* lens_flare_default_name() void lens_flare_switch_to(const char* lens_name) { - if (lens_name == nullptr || *lens_name == '\0') { + // No opinion (a mission with no "$Camera Lens:" at all), or the default asked + // for by name -- see the vocabulary in lens_flare.h + if (lens_name == nullptr || *lens_name == '\0' || !stricmp(lens_name, LENS_NAME_DEFAULT)) { + Mission_lens = Default_lens; + return; + } + + // The one way to say "no flares even though a default exists" + if (!stricmp(lens_name, LENS_NAME_NONE)) { Mission_lens = -1; return; } @@ -322,7 +330,7 @@ void lens_flare_switch_to(const char* lens_name) Mission_lens = lens_flare_lookup(lens_name); if (Mission_lens < 0) { // An unknown lens falls back to the table default rather than to no - // flares: a typo shouldn't silently look like "the mod wanted none" + // flares: a typo shouldn't silently look like LENS_NAME_NONE Warning(LOCATION, "No lens system named '%s' is defined in lens_flares.tbl; using the default lens.", lens_name); Mission_lens = Default_lens; @@ -379,6 +387,17 @@ const lens_flare_textures* lens_flare_get_textures(int lens_idx) return lens.textures.get(); } +void lens_flare_prime_textures() +{ + // The editors draw the background without ever opening a scene texture, so the + // flare pass never runs there and generating the pair would be pure waste on + // every mission load + if (Fred_running) { + return; + } + lens_flare_get_textures(lens_flare_active_lens()); +} + void lens_flare_reset_for_level() { // Unmount: the mission being loaded sets its own $Camera Lens: right after @@ -392,9 +411,12 @@ void lens_flare_reset_for_level() Frame_draws.clear(); Sun_starburst_drawn.clear(); - // drop any pending live edit first; it belongs to the mission being left + // Drop any pending live edit first; it belongs to the mission being left. The + // throttle stamp goes too, so the next mission's first edit applies at once + // instead of waiting out an interval started by the previous one. Aperture_dirty = false; Aperture_dirty_lens = -1; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); for (int i = 0; i < static_cast(Lens_systems.size()); i++) { if (Lens_systems[i].aperture != Lens_systems[i].tabled_aperture) { @@ -429,10 +451,11 @@ void lens_flare_aperture_changed(int lens_idx) Aperture_dirty = true; Aperture_dirty_lens = lens_idx; - // Apply straight away unless we just did; the flush below picks up the rest - if (!Aperture_dirty_stamp.isValid() || ui_timestamp_elapsed(Aperture_dirty_stamp)) { - flush_pending_aperture_edit(); - } + // Apply straight away if the interval has already elapsed; if it hasn't, this + // is a no-op and the per-frame flush picks the edit up when it does. (The + // interval check lives in flush_pending_aperture_edit() alone -- repeating it + // here as a guard would just be the same condition written twice.) + flush_pending_aperture_edit(); } namespace { diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h index d75fc409994..1af8047c0c2 100644 --- a/code/graphics/lens_flare.h +++ b/code/graphics/lens_flare.h @@ -41,6 +41,63 @@ struct lens_surface { // // Every layer below the shape defaults to strength 0 (off), which reproduces // the plain-iris look of tables written before they existed. +// Every imperfection layer below is a named type with its own operator==, rather +// than an anonymous struct compared field by field from the aperture. The +// comparison is load-bearing -- lens_flare_reset_for_level() uses it to decide +// whether a lens needs restoring, which is what keeps one mission's iris out of +// the next -- and a field added without extending it would break that silently. +// Keeping each layer's comparison next to its own fields is what makes that hard +// to get wrong. (C++20 would make all four `= default`.) + +// Diffraction grating around the rim: fine radial ridges that throw extra spikes +// into the starburst. +struct lens_aperture_grating { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 360 possible ridges + float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius + float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges + float softness = 0.0f; + + bool operator==(const lens_aperture_grating& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + softness == o.softness; + } + bool operator!=(const lens_aperture_grating& o) const { return !(*this == o); } +}; + +// Scratches on the glass: randomly placed and oriented slivers. +struct lens_aperture_scratches { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible scratches + float length = 0.5f; + float width = 0.25f; + float rotation = 0.0f; // degrees + float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random + float softness = 0.0f; + + bool operator==(const lens_aperture_scratches& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + rotation == o.rotation && rotation_variation == o.rotation_variation && softness == o.softness; + } + bool operator!=(const lens_aperture_scratches& o) const { return !(*this == o); } +}; + +// Dust on the glass: randomly placed specks. +struct lens_aperture_dust { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible specks + float radius = 0.5f; + float softness = 0.0f; + + bool operator==(const lens_aperture_dust& o) const + { + return strength == o.strength && density == o.density && radius == o.radius && softness == o.softness; + } + bool operator!=(const lens_aperture_dust& o) const { return !(*this == o); } +}; + struct lens_aperture { // Iris opening. Blade count/rotation/curvature are the original fields; // curvature 0 = straight blades, 1 = circular, and negative values bow the @@ -54,50 +111,18 @@ struct lens_aperture { // visibly weakens the starburst spikes, so it is not a free parameter. float softness = 0.0039f; - // Diffraction grating around the rim: fine radial ridges that throw extra - // spikes into the starburst. - struct { - float strength = 0.0f; // 0 = off - float density = 0.5f; // fraction of the 360 possible ridges - float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius - float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges - float softness = 0.0f; - } grating; - - // Scratches on the glass: randomly placed and oriented slivers. - struct { - float strength = 0.0f; // 0 = off - float density = 0.5f; // fraction of the 1000 possible scratches - float length = 0.5f; - float width = 0.25f; - float rotation = 0.0f; // degrees - float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random - float softness = 0.0f; - } scratches; - - // Dust on the glass: randomly placed specks. - struct { - float strength = 0.0f; // 0 = off - float density = 0.5f; // fraction of the 1000 possible specks - float radius = 0.5f; - float softness = 0.0f; - } dust; - - // Compared field by field so a caller that reassigns the whole aperture can - // tell whether anything actually moved -- regenerating costs a 512^2 mask - // plus an FFT, which a repeating mission event must not pay every frame. + lens_aperture_grating grating; + lens_aperture_scratches scratches; + lens_aperture_dust dust; + + // Lets a caller that reassigns the whole aperture tell whether anything + // actually moved -- regenerating costs a 512^2 mask plus an FFT, which a + // repeating mission event must not pay every frame. Each layer compares + // itself, so this only has to cover the iris fields and the three layers. bool operator==(const lens_aperture& o) const { return blades == o.blades && rotation == o.rotation && curvature == o.curvature && - softness == o.softness && grating.strength == o.grating.strength && - grating.density == o.grating.density && grating.length == o.grating.length && - grating.width == o.grating.width && grating.softness == o.grating.softness && - scratches.strength == o.scratches.strength && scratches.density == o.scratches.density && - scratches.length == o.scratches.length && scratches.width == o.scratches.width && - scratches.rotation == o.scratches.rotation && - scratches.rotation_variation == o.scratches.rotation_variation && - scratches.softness == o.scratches.softness && dust.strength == o.dust.strength && - dust.density == o.dust.density && dust.radius == o.dust.radius && dust.softness == o.dust.softness; + softness == o.softness && grating == o.grating && scratches == o.scratches && dust == o.dust; } bool operator!=(const lens_aperture& o) const { return !(*this == o); } }; @@ -204,6 +229,20 @@ const lens_system* lens_flare_get_system(int lens_idx); // Returns nullptr for an invalid index. const lens_flare_textures* lens_flare_get_textures(int lens_idx); +// Generate the mounted lens's textures now, so the render backends find them +// already cached instead of paying for them mid-frame. +// +// Building them is a 512^2 iris mask plus a 2D FFT of it -- a visible hitch if it +// lands on the first frame a sun flares. Call it from wherever a lens has just +// been mounted for a scene that is about to be rendered and a moment's work is +// already expected: stars_post_level_init() for a mission, and the lab's +// useBackground(). Not from lens_flare_switch_to() itself, which also runs during +// mission-info scans (FRED's file dialog) that mount lenses they never render. +// +// No-op in FRED/qtFRED, which draw the background without a scene texture and so +// never reach the flare pass. +void lens_flare_prime_textures(); + // Drop a lens's cached textures so the next lens_flare_get_textures() rebuilds // them (after its lens_aperture was edited). void lens_flare_invalidate_textures(int lens_idx); @@ -260,9 +299,26 @@ void lens_flare_set_hdr_headroom(float value); // which case missions without a "$Camera Lens:" render no flares at all). const char* lens_flare_default_name(); -// Mount a lens by name: "" / for no flares, or an unknown name -// for the table default (unknown names warn). Called from mission parse and by -// the set-camera-lens sexp. +// The two names that stand in for a lens instead of naming one. The mission's +// "$Camera Lens:", the set-camera-lens sexp and both editors all speak this same +// vocabulary, so it lives here with the code that resolves it rather than being +// re-spelled at each of those. +#define LENS_NAME_NONE "" +#define LENS_NAME_DEFAULT "" + +// Mount a lens, resolving the whole vocabulary above in one place: +// +// ""/nullptr the caller has no opinion -> the table default. This is what +// a mission without a "$Camera Lens:" gets, which is why it +// means "default" and not "none". +// no lens, hence no flares, even when a default exists. The +// only way to say that, and the reason it is a token rather +// than an empty string. +// the table default, said explicitly. +// a lens name that lens; an unknown name warns and falls back to the +// default, since a typo shouldn't silently look like . +// +// Called from mission parse, the set-camera-lens sexp, the lab and both editors. void lens_flare_switch_to(const char* lens_name); // The lens actually in use (a lab override beats the mission's), -1 = none. diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index 490e2359cfc..cf5ba45070c 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -446,17 +446,23 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { ltp::switch_to(ltp_name); } - // the camera lens all sun flares are imaged through, same fall-back to - // the tabled default as parse_mission_info() + // The camera lens all sun flares are imaged through. Same as + // parse_mission_info(): hand the token over as written and let + // lens_flare_switch_to() resolve it, empty (no "$Camera Lens:" at all) + // included. skip_to_start_of_string_either("$Camera Lens:", "#Background bitmaps"); - SCP_string lens_name = graphics::lens_flare_default_name(); - if (optional_string("$Camera Lens:")) { + SCP_string lens_name; + if (optional_string("$Camera Lens:")) stuff_string(lens_name, F_NAME); - if (lens_name.empty()) - lens_name = graphics::lens_flare_default_name(); - } graphics::lens_flare_switch_to(lens_name.c_str()); + // Loading a background is the lab's level load, and stars_pre_level_init() + // above has just dropped the cached textures -- so build them here, as + // stars_post_level_init() does in the game. The lab is where the aperture + // sliders live, which makes it the worst place to leave the rebuild to the + // first flaring frame. + graphics::lens_flare_prime_textures(); + // Mission headers include additional fields between lighting profile and the // background section. If we stopped at the lighting profile, we need to seek again. skip_to_start_of_string("#Background bitmaps"); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index 97ae051ea5d..975ff743ee3 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -1120,16 +1120,14 @@ void parse_mission_info(mission *pm, bool basic = false) lighting_profiles::switch_to(The_mission.lighting_profile_name); // The camera lens every sun's flare is imaged through (graphics/lens_flare.h). - // Empty means no physically-based flares, which is what the shipped table - // defaults to, so existing missions are unaffected. + // Stored as the token the mission actually wrote, so that "this mission says + // nothing" (empty, taking the tabled default) stays distinct from an explicit + // -- otherwise a mod adding a $Default Lens: would silently override + // missions that had deliberately asked for no flares. lens_flare_switch_to() + // resolves all of it, including the empty case. + The_mission.camera_lens_name.clear(); if (optional_string("$Camera Lens:")) - { stuff_string(The_mission.camera_lens_name, F_NAME); - if (The_mission.camera_lens_name.empty()) - The_mission.camera_lens_name = graphics::lens_flare_default_name(); - } - else - The_mission.camera_lens_name = graphics::lens_flare_default_name(); graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); if (optional_string("$Sound Environment:")) { @@ -7409,7 +7407,8 @@ void mission::Reset() ai_profile = &Ai_profiles[Default_ai_profile]; lighting_profile_name = lighting_profiles::default_name(); - camera_lens_name = graphics::lens_flare_default_name(); + // empty = this mission names no lens, so the tabled default applies + camera_lens_name.clear(); cutscenes.clear( ); diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 4a904719f09..85a974690ab 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -236,8 +236,12 @@ typedef struct mission { SCP_string lighting_profile_name; - // the camera lens all sun flares are imaged through (graphics/lens_flare.h); - // empty = no physically-based flares + // The camera lens all sun flares are imaged through: the literal + // "$Camera Lens:" token, resolved by lens_flare_switch_to() (see the + // vocabulary in graphics/lens_flare.h). Empty means the mission names no lens + // and so takes the tabled default; LENS_NAME_NONE is how it asks for no + // flares at all. Keeping those two apart is what lets the field round-trip + // through FRED unchanged. SCP_string camera_lens_name; SCP_vector cutscenes; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 20cc83e26d0..32c73f80dcb 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -22,7 +22,6 @@ #include "iff_defs/iff_defs.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" -#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/fhash.h" #include "localization/localize.h" @@ -3129,9 +3128,11 @@ int Fred_mission_save::save_mission_info() bypass_comment(";;FSO 23.1.0;; $Lighting Profile:"); } - // the-e's camera lens for physically-based flares - if (!The_mission.camera_lens_name.empty() && - The_mission.camera_lens_name != graphics::lens_flare_default_name()) { + // the-e's camera lens for physically-based flares. The token is written back + // verbatim: an empty one means the mission named no lens, and anything else -- + // a lens name or -- is a deliberate choice that has to survive the + // round trip even if it happens to match the current $Default Lens:. + if (!The_mission.camera_lens_name.empty()) { fso_comment_push(";;FSO 26.1.0;;"); if (optional_string_fred("$Camera Lens:")) { parse_comments(2); diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index 2d2357f70f8..ba849078834 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -1159,9 +1159,9 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_lens_system() { sexp_list_item head; - // the two magic values set-sun-lens accepts in place of a real lens - head.add_data(""); - head.add_data(""); + // the two magic values set-camera-lens accepts in place of a real lens + head.add_data(LENS_NAME_NONE); + head.add_data(LENS_NAME_DEFAULT); for (int i = 0; i < graphics::lens_flare_num_systems(); i++) { head.add_data(graphics::lens_flare_get_system(i)->name.c_str()); @@ -3231,7 +3231,7 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const break; case OPF_LENS_SYSTEM: - str = ""; + str = LENS_NAME_DEFAULT; break; case OPF_CUSTOM_HUD_GAUGE: diff --git a/code/parse/sexp.cpp b/code/parse/sexp.cpp index 68838868b29..771d85f498b 100644 --- a/code/parse/sexp.cpp +++ b/code/parse/sexp.cpp @@ -1157,11 +1157,12 @@ int check_dynamic_value_node_type(int node, bool is_string, bool is_number); // hud-display-gauge magic values #define SEXP_HUD_GAUGE_WARPOUT "warpout" -// Magic values accepted by set-camera-lens in place of a lens name. Checked -// before the table is searched, so a lens that somehow carried one of these -// names could not shadow them. -#define SEXP_LENS_NONE "" -#define SEXP_LENS_DEFAULT "" +// The set-lens-* operators warn when no lens is mounted, but a mission event +// without a guard re-evaluates every frame and Warning() is modal in debug +// builds. Shared by all four operators rather than one flag each: the cause is +// the same in every case, so one complaint per mission is enough even though the +// message names whichever operator hit it first. Reset by init_sexp(). +static bool Sexp_lens_aperture_warned = false; // Whether an OPF_LENS_SYSTEM argument names something the engine can resolve. // @@ -1177,8 +1178,9 @@ bool sexp_lens_name_is_valid(const char* lens_name) return false; } // set-camera-lens is the only operator taking a lens name, so the sentinels + // (shared with the mission field and the editors, see graphics/lens_flare.h) // are always meaningful here - if (!stricmp(lens_name, SEXP_LENS_NONE) || !stricmp(lens_name, SEXP_LENS_DEFAULT)) { + if (!stricmp(lens_name, LENS_NAME_NONE) || !stricmp(lens_name, LENS_NAME_DEFAULT)) { return true; } return graphics::lens_flare_lookup(lens_name) >= 0; @@ -1412,6 +1414,7 @@ void init_sexp() // init data structures used by certain operators // (note, Sexp_music_handles are not cleared here because sexp_music_close() handled that at the end of the previous mission) Sexp_is_true_for_duration_times.clear(); + Sexp_lens_aperture_warned = false; } // done at the beginning of the game @@ -16796,16 +16799,10 @@ void sexp_remove_background_bitmap(int n, bool is_sun) void sexp_set_camera_lens(int n) { - const char* lens_name = CTEXT(n); - - if (!stricmp(lens_name, SEXP_LENS_NONE)) { - graphics::lens_flare_switch_to(""); - } else if (!stricmp(lens_name, SEXP_LENS_DEFAULT)) { - graphics::lens_flare_switch_to(graphics::lens_flare_default_name()); - } else { - // unknown names warn and fall back to the default there - graphics::lens_flare_switch_to(lens_name); - } + // , , and the unknown-name warning are all resolved by + // lens_flare_switch_to() -- the same vocabulary the mission's "$Camera Lens:" + // and both editors use, so there is nothing to translate here + graphics::lens_flare_switch_to(CTEXT(n)); } // Shared front end of the four aperture operators: hand the mounted lens's @@ -16817,7 +16814,12 @@ void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit) { int lens_idx = graphics::lens_flare_active_lens(); if (lens_idx < 0) { - Warning(LOCATION, "%s: this mission has no camera lens mounted; use set-camera-lens first.", op_name); + // Once per mission: an unguarded event lands here every frame, and this + // warning is modal in debug builds (see Sexp_lens_aperture_warned) + if (!Sexp_lens_aperture_warned) { + Sexp_lens_aperture_warned = true; + Warning(LOCATION, "%s: this mission has no camera lens mounted; use set-camera-lens first.", op_name); + } return; } diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index 833b2e18455..f9f2c7da8ce 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -981,6 +981,10 @@ void stars_post_level_init() stars_preload_background(idx); } + // The mission's $Camera Lens: is mounted by now, so build its iris mask and + // starburst here rather than letting the first flaring frame pay for them + graphics::lens_flare_prime_textures(); + stars_set_background_model(The_mission.skybox_model, NULL, The_mission.skybox_flags); stars_set_background_orientation(&The_mission.skybox_orientation); diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 878f4127e8c..ea3317e9c61 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -428,15 +428,24 @@ void bg_bitmap_dlg::create() } box->SetCurSel(m_light_profile_index); - // The camera lens all sun flares are imaged through; entry 0 means none + // The camera lens all sun flares are imaged through. "Default" and "None" are + // genuinely different answers -- the first leaves the mission silent so it + // follows lens_flares.tbl's $Default Lens:, the second says no flares even if + // one is declared -- so both get an entry ahead of the lenses themselves. box = (CComboBox *) GetDlgItem(IDC_CAMERA_LENS); - m_camera_lens_index = 0; + box->AddString("Default"); box->AddString("None"); + + // An unset (or explicitly ) mission lands on "Default" + m_camera_lens_index = CAMERA_LENS_IDX_DEFAULT; + if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) + m_camera_lens_index = CAMERA_LENS_IDX_NONE; + for (int idx = 0; idx < graphics::lens_flare_num_systems(); idx++) { const SCP_string &lens_name = graphics::lens_flare_get_system(idx)->name; box->AddString(lens_name.c_str()); if (The_mission.camera_lens_name == lens_name) - m_camera_lens_index = idx + 1; + m_camera_lens_index = idx + CAMERA_LENS_IDX_FIRST_LENS; } box->SetCurSel(m_camera_lens_index); @@ -583,10 +592,15 @@ void bg_bitmap_dlg::OnClose() The_mission.lighting_profile_name = lighting_profiles::list_profiles()[m_light_profile_index]; - if (m_camera_lens_index <= 0) { - The_mission.camera_lens_name.clear(); + // Mirrors the combo built in create(): empty for "Default" so the mission stays + // silent, the token for "None" so the choice survives being saved + if (m_camera_lens_index == CAMERA_LENS_IDX_NONE) { + The_mission.camera_lens_name = LENS_NAME_NONE; + } else if (m_camera_lens_index >= CAMERA_LENS_IDX_FIRST_LENS) { + The_mission.camera_lens_name = + graphics::lens_flare_get_system(m_camera_lens_index - CAMERA_LENS_IDX_FIRST_LENS)->name; } else { - The_mission.camera_lens_name = graphics::lens_flare_get_system(m_camera_lens_index - 1)->name; + The_mission.camera_lens_name.clear(); } graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); // close sun data diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index 6528d6ef27a..4bf2224bd60 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -99,6 +99,15 @@ class bg_bitmap_dlg : public CDialog int m_camera_lens_index; //}}AFX_DATA + // Fixed head of the camera-lens combo: "Default" leaves the mission silent so + // it follows lens_flares.tbl, "None" is the explicit , and the tabled + // lenses follow. See create()/OnClose() in bgbitmapdlg.cpp. + enum { + CAMERA_LENS_IDX_DEFAULT = 0, + CAMERA_LENS_IDX_NONE = 1, + CAMERA_LENS_IDX_FIRST_LENS = 2, + }; + // Overrides // ClassWizard generated virtual function overrides //{{AFX_VIRTUAL(bg_bitmap_dlg) diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index a2da3841c05..f230316b841 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -1389,11 +1389,14 @@ void BackgroundEditorDialogModel::setLightingProfileName(const SCP_string& name) modify(The_mission.lighting_profile_name, name); } -// The camera lens every sun's flare is imaged through. "None" is a real choice -// rather than a missing value, so it heads the list. +// The camera lens every sun's flare is imaged through. "Default" and "None" are +// genuinely different answers -- the first leaves the mission silent so it follows +// lens_flares.tbl's $Default Lens:, the second says no flares even when one is +// declared -- so both head the list, ahead of the lenses themselves. SCP_vector BackgroundEditorDialogModel::getCameraLensOptions() { SCP_vector out; + out.emplace_back(CAMERA_LENS_DEFAULT); out.emplace_back(CAMERA_LENS_NONE); for (int i = 0; i < graphics::lens_flare_num_systems(); i++) out.emplace_back(graphics::lens_flare_get_system(i)->name); @@ -1402,12 +1405,27 @@ SCP_vector BackgroundEditorDialogModel::getCameraLensOptions() SCP_string BackgroundEditorDialogModel::getCameraLensName() { - return The_mission.camera_lens_name.empty() ? SCP_string(CAMERA_LENS_NONE) : The_mission.camera_lens_name; + // An unset mission (and one that spelled by hand) shows as "Default" + if (The_mission.camera_lens_name.empty() || + !stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_DEFAULT)) + return SCP_string(CAMERA_LENS_DEFAULT); + + if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) + return SCP_string(CAMERA_LENS_NONE); + + return The_mission.camera_lens_name; } void BackgroundEditorDialogModel::setCameraLensName(const SCP_string& name) { - SCP_string lens_name = (name == CAMERA_LENS_NONE) ? SCP_string() : name; + // Empty for "Default" so the mission stays silent, the token for "None" + // so the choice survives being saved + SCP_string lens_name; + if (name == CAMERA_LENS_NONE) + lens_name = LENS_NAME_NONE; + else if (name != CAMERA_LENS_DEFAULT) + lens_name = name; + if (lens_name == The_mission.camera_lens_name) return; diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 177f0e68d4c..6d30c94df4c 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -175,7 +175,10 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { static SCP_string getLightingProfileName(); void setLightingProfileName(const SCP_string& name); - // combo entry standing in for "this mission has no camera lens" + // Combo entries standing in for the two answers that aren't a lens name: + // "Default" leaves the mission silent so it follows lens_flares.tbl, + // "None" is the explicit token (see graphics/lens_flare.h). + static constexpr const char* CAMERA_LENS_DEFAULT = "Default"; static constexpr const char* CAMERA_LENS_NONE = "None"; static SCP_vector getCameraLensOptions(); static SCP_string getCameraLensName(); diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp index ca35ff0799a..27f24cf0b2f 100644 --- a/test/src/graphics/test_lens_flare.cpp +++ b/test/src/graphics/test_lens_flare.cpp @@ -375,6 +375,73 @@ TEST_F(LensFlareTableTest, TextureInvalidationBumpsGeneration) // path rather than by setting the struct directly. Every aperture field is // wired up by hand, so only running a table through it proves each one lands in // the member it names. +// Uses a table that declares a $Default Lens:, so that "take the default" and +// "no flares" are distinguishable -- see the tbm's own comment. +class LensFlareDefaultLensTest : public test::FSTestFixture { + public: + LensFlareDefaultLensTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("default_lens"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +// lens_flare_switch_to() is the one place that resolves a camera-lens name, for +// the mission field, the set-camera-lens sexp and both editors alike. The two +// cases that matter are the ones a declared default separates: an empty name is +// "this mission says nothing" and must take the default, while LENS_NAME_NONE is +// "no flares" and must not. Collapsing those two is what silently overrode a +// mission that had deliberately asked for no flares. +TEST_F(LensFlareDefaultLensTest, NameVocabularyDistinguishesDefaultFromNone) +{ + const int declared = lens_flare_lookup("default_test_lens"); + ASSERT_GE(declared, 0) << "the modular *-lens.tbm was not picked up at all"; + ASSERT_STREQ(lens_flare_default_name(), "default_test_lens"); + + // no opinion -> the declared default + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), declared); + lens_flare_switch_to(nullptr); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // ...and the default asked for by name is the same thing + lens_flare_switch_to(LENS_NAME_DEFAULT); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // is the one answer that survives a declared default + lens_flare_switch_to(LENS_NAME_NONE); + EXPECT_EQ(lens_flare_active_lens(), -1) << " must not fall back to $Default Lens:"; + + // the sentinels are case-insensitive, like every other name here + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), -1); + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // (An unknown name also falls back to the default, but it warns on the way and + // the test harness turns Warning() into a thrown exception, so that path can't + // be exercised from here.) + + // leaving the mission re-arms the default, not "no lens" + lens_flare_switch_to(LENS_NAME_NONE); + ASSERT_EQ(lens_flare_active_lens(), -1); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_active_lens(), declared); +} + class LensFlareApertureTbmTest : public test::FSTestFixture { public: LensFlareApertureTbmTest() : test::FSTestFixture(INIT_CFILE) @@ -535,11 +602,14 @@ TEST_F(LensFlareTableTest, MountingAndOverridingTheCameraLens) ASSERT_GE(tessar, 0); ASSERT_GE(angenieux, 0); - // the shipped table declares no default, so a mission that asks for nothing - // renders no flares at all + // The shipped table declares no default, so here "take the default" and "no + // flares" happen to coincide -- LensFlareDefaultLensTest pulls them apart with + // a table that does declare one. EXPECT_STREQ(lens_flare_default_name(), ""); lens_flare_switch_to(""); EXPECT_EQ(lens_flare_active_lens(), -1); + lens_flare_switch_to(LENS_NAME_NONE); + EXPECT_EQ(lens_flare_active_lens(), -1); lens_flare_switch_to("tessar_50mm"); EXPECT_EQ(lens_flare_active_lens(), tessar); diff --git a/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm new file mode 100644 index 00000000000..eb88c40236b --- /dev/null +++ b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm @@ -0,0 +1,23 @@ +; A table that actually declares a $Default Lens:, which the shipped +; lens_flares.tbl deliberately does not. +; +; Without a declared default, "take the tabled default" and "no flares at all" +; both come out as "no lens mounted", so the two cannot be told apart. That is +; exactly the case where a mission's explicit used to be lost: it was +; stored as an empty string, an empty string was not written out, and on reload an +; absent $Camera Lens: became the default. This table is what lets a test see the +; difference. + +#Lens Systems + +$Default Lens: default_test_lens + +$Name: default_test_lens +$Entrance Pupil Radius: 12.0 +$Aperture Radius: 6.0 +$Sensor Width: 36.0 +$Surface: ( 50.0, 4.0, 1.62 ) +$Stop: ( 3.0 ) +$Surface: ( -50.0, 40.0, 1.0 ) + +#End From 7e2125285f31dc040e007542877438bd23fed049 Mon Sep 17 00:00:00 2001 From: the-e Date: Sun, 26 Jul 2026 13:50:10 +0200 Subject: [PATCH 6/6] Add "+Camera Lens Flare:" opt-in for sun flares and associated tests Introduce a "+Camera Lens Flare:" option in *stars.tbl* to explicitly enable or disable physically-based camera lens flares for individual suns. This allows fine-grained control over flare behavior, complementing or replacing legacy $Flare: blocks. Update parsing, rendering logic, and add comprehensive tests to verify correct behavior across all combinations. --- code/graphics/lens_flare.cpp | 44 ++++++---- code/graphics/lens_flare.h | 61 ++++++++----- code/graphics/util/uniform_structs.h | 9 ++ code/lab/dialogs/lab_ui_lens_flare.cpp | 23 ++--- code/starfield/starfield.cpp | 86 ++++++++++++++----- code/starfield/starfield.h | 23 +++++ .../dialogs/BackgroundEditorDialogModel.cpp | 4 +- test/src/graphics/test_lens_flare.cpp | 50 +++++++++++ .../sun_flare_opt_in/data/tables/stars.tbl | 58 +++++++++++++ 9 files changed, 279 insertions(+), 79 deletions(-) create mode 100644 test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp index c4e50ef94ee..d1bd9c7742f 100644 --- a/code/graphics/lens_flare.cpp +++ b/code/graphics/lens_flare.cpp @@ -23,11 +23,10 @@ extern int Game_subspace_effect; namespace graphics { namespace { -// Overall brightness calibration of the ghost/starburst energy model relative -// to the HDR scene (defaults; runtime-tunable from the lab, and scaled per -// lens via $Intensity:) -float Ghost_brightness = 64.0f; -float Starburst_brightness = 1.6f; +// Overall brightness calibration of the ghost/starburst energy model relative to +// the HDR scene, plus the HDR headroom below. Runtime-tunable from the lab (and +// scaled per lens via $Intensity:); the field defaults are in lens_flare.h. +lens_flare_tuning Tuning; // SDR/HDR consistency (see lens_flare_output_scale()). The flare composites // additively into the pre-tonemap HDR scene buffer, so the tonemapper is the @@ -44,7 +43,6 @@ float Starburst_brightness = 1.6f; // W constant. HDR forces its own tonemapper, so this is a fixed calibration // reference, not the live SDR curve. constexpr float LENS_FLARE_SDR_REFERENCE_WHITE = 11.2f; -float Hdr_flare_headroom = 2.5f; // flare may reach ~this multiple of paper white in HDR // Cap on the (entrance pupil / ghost size)^2 energy concentration so nearly // focused ghosts can't blow out to infinity @@ -288,12 +286,7 @@ lens_system* lens_flare_get_system_mutable(int lens_idx) return &Lens_systems[lens_idx]; } -float lens_flare_get_ghost_brightness() { return Ghost_brightness; } -void lens_flare_set_ghost_brightness(float value) { Ghost_brightness = MAX(value, 0.0f); } -float lens_flare_get_starburst_brightness() { return Starburst_brightness; } -void lens_flare_set_starburst_brightness(float value) { Starburst_brightness = MAX(value, 0.0f); } -float lens_flare_get_hdr_headroom() { return Hdr_flare_headroom; } -void lens_flare_set_hdr_headroom(float value) { Hdr_flare_headroom = MAX(value, 0.0f); } +lens_flare_tuning& lens_flare_get_tuning() { return Tuning; } // Extra multiplier applied to the whole flare so its brightness reads // consistently in SDR and HDR output without per-lens re-tuning. SDR is the @@ -302,7 +295,7 @@ void lens_flare_set_hdr_headroom(float value) { Hdr_flare_headroom = MAX(value, static float lens_flare_output_scale() { if (Gr_hdr_output_active) { - return Hdr_flare_headroom / LENS_FLARE_SDR_REFERENCE_WHITE; + return MAX(Tuning.hdr_headroom, 0.0f) / LENS_FLARE_SDR_REFERENCE_WHITE; } return 1.0f; } @@ -408,8 +401,7 @@ void lens_flare_reset_for_level() // The next mission's suns are not this one's; drop the published frame so // nothing consumes it across the level change - Frame_draws.clear(); - Sun_starburst_drawn.clear(); + lens_flare_clear_frame(); // Drop any pending live edit first; it belongs to the mission being left. The // throttle stamp goes too, so the next mission's first edit applies at once @@ -503,7 +495,9 @@ void emit_ghost(generic_data::lens_flare_instance_data& inst, const lens_system& inst.halfext.a1d[k] = halfext; inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; - inst.color.a1d[k] = ghost.reflectance[k] * energy * Ghost_brightness; + // clamped here rather than at the setter: the lab hands out the tuning + // struct for direct editing, so this is the boundary that has to hold + inst.color.a1d[k] = ghost.reflectance[k] * energy * MAX(Tuning.ghost_brightness, 0.0f); } } @@ -518,7 +512,7 @@ void emit_starburst(generic_data::lens_flare_instance_data& inst, const lens_sys for (int k = 0; k < 3; k++) { inst.center.a1d[k] = sdist; inst.halfext.a1d[k] = halfext; - inst.color.a1d[k] = Starburst_brightness; + inst.color.a1d[k] = MAX(Tuning.starburst_brightness, 0.0f); // see emit_ghost } } @@ -592,10 +586,15 @@ static int pack_sun_instances(const lens_system& lens, float theta, float sdist, return count; } -void lens_flare_frame_update() +void lens_flare_clear_frame() { Frame_draws.clear(); Sun_starburst_drawn.clear(); +} + +void lens_flare_frame_update() +{ + lens_flare_clear_frame(); // live aperture edits from the lab land here, throttled flush_pending_aperture_edit(); @@ -640,6 +639,15 @@ void lens_flare_frame_update() continue; } + // A sun the content never asked to flare gets nothing from the camera lens. + // stars.tbl decides whether a sun flares ("+Camera Lens Flare:", or a legacy + // $Flare: block for tables predating it); the mounted lens only decides how + // that flare is drawn. Mounting a lens must not invent flares on suns + // deliberately tabled without one. + if (!stars_sun_has_camera_lens_flare(sun_n)) { + continue; + } + vec3d sun_pos = vmd_zero_vector; sun_pos.xyz.y = 1.0f; stars_get_sun_pos(sun_n, &sun_pos); diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h index 1af8047c0c2..f720cd9d793 100644 --- a/code/graphics/lens_flare.h +++ b/code/graphics/lens_flare.h @@ -270,19 +270,27 @@ void lens_flare_reset_for_level(); // Changes take effect the next frame; they are lost on table reload. lens_system* lens_flare_get_system_mutable(int lens_idx); -// Global brightness calibration multipliers applied to every ghost/starburst -// (defaults match the shipped calibration; tunable live from the lab) -float lens_flare_get_ghost_brightness(); -void lens_flare_set_ghost_brightness(float value); -float lens_flare_get_starburst_brightness(); -void lens_flare_set_starburst_brightness(float value); - -// HDR consistency headroom: how many multiples of paper white the flare is -// allowed to reach in HDR output. SDR is the calibration reference and is -// unaffected; this only rescales the HDR path so an SDR-tuned flare doesn't -// blow out. Defaults to a value that keeps a little HDR "pop". -float lens_flare_get_hdr_headroom(); -void lens_flare_set_hdr_headroom(float value); +// The calibration that isn't per-lens: overall brightness of the energy model +// against the HDR scene, plus the SDR/HDR consistency headroom. Defaults match +// the shipped calibration. +// +// Handed out mutably, the same way lens_flare_get_system_mutable() hands out a +// lens for the lab to edit in place -- one idiom for live tuning rather than +// clamping accessors for the globals and direct access for everything else. +// Values are sanitized where they are consumed, so a caller cannot break the +// renderer by writing a silly number here. +struct lens_flare_tuning { + // multiplies every ghost / the starburst respectively + float ghost_brightness = 64.0f; + float starburst_brightness = 1.6f; + + // How many multiples of paper white the flare may reach in HDR output. SDR is + // the calibration reference and is unaffected; this only rescales the HDR path + // so an SDR-tuned flare doesn't blow out. The default keeps a little HDR "pop". + float hdr_headroom = 2.5f; +}; + +lens_flare_tuning& lens_flare_get_tuning(); // ---- the camera lens ---- // @@ -295,10 +303,6 @@ void lens_flare_set_hdr_headroom(float value); // "$Default Lens:" in lens_flares.tbl), can be changed at runtime by the // set-camera-lens sexp, and can be overridden live in the lab. -// The name in "$Default Lens:", or "" when the table declares no default (in -// which case missions without a "$Camera Lens:" render no flares at all). -const char* lens_flare_default_name(); - // The two names that stand in for a lens instead of naming one. The mission's // "$Camera Lens:", the set-camera-lens sexp and both editors all speak this same // vocabulary, so it lives here with the code that resolves it rather than being @@ -368,12 +372,19 @@ struct lens_flare_draw { // // Called from stars_draw(), which is the one place that both runs after the view // and projection matrices are live and runs before the sun sprites and the -// post-processing pass consume the result. Deliberately *not* called while -// rendering an environment map: that path never reaches the flare pass, so -// publishing there would tell the sun renderer to step aside for a starburst -// that never gets drawn. +// post-processing pass consume the result. void lens_flare_frame_update(); +// Publish an empty frame: nothing flares, so every consumer reads "no". +// +// For a scene render that cannot reach the flare pass at all -- an environment map +// goes straight to a render target, outside the post-processing chain. Publishing +// nothing rather than skipping the publish is deliberate: it keeps the answer in +// one place, so no consumer has to know where it is being called from, and it +// stops the previous frame's published draws from being read by a render that +// isn't going to draw them. +void lens_flare_clear_frame(); + // What the last lens_flare_frame_update() published: one entry per sun that has // something to draw, in sun order (empty = skip the pass entirely). Every entry // is drawn with the textures of lens_flare_active_lens(). @@ -385,6 +396,14 @@ const SCP_vector& lens_flare_get_frame_draws(); // ---- internals exposed for unit testing ---- +// The name in "$Default Lens:", or "" when the table declares no default. +// +// Diagnostic only: nothing needs it to *resolve* a default any more, because +// lens_flare_switch_to() does that for every caller (an empty or name +// lands on it). Kept for the load-time log line and so a test can assert what a +// table declared. +const char* lens_flare_default_name(); + // Run the ghost/matrix precompute on a hand-built lens_system. // Returns false (with ghosts cleared) if the prescription is unusable. bool lens_flare_precompute(lens_system& lens); diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 7ef6dafbed7..4287b2b65e0 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -329,10 +329,19 @@ struct lens_flare_data { vec4 tint; // rgb = sun color * visibility * lens intensity + // Neither shader reads this -- the instance count comes from the draw call's + // instance parameter. Kept because it occupies a std140 slot the rest of the + // block is laid out around, and because it makes a captured frame readable. int n_instances; float squeeze; // anamorphic horizontal stretch of every footprint, 1.0 = spherical float pad[2]; + // The fragment shader declares this array too, and reads none of it: the + // per-instance values reach it as flat varyings. It is declared there purely so + // both stages agree on the block layout byte for byte. Splitting the per-draw + // constants above into their own block would let the fragment stage stop + // carrying ~5 KB it never touches, but it needs a second descriptor binding in + // the Vulkan set template, so it is not the free change it looks like. lens_flare_instance_data instances[MAX_LENS_FLARE_INSTANCES]; }; diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp index b6018622320..c065f654d62 100644 --- a/code/lab/dialogs/lab_ui_lens_flare.cpp +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -76,23 +76,14 @@ void LabUi::build_lens_aperture_options(int lens_idx, graphics::lens_aperture& a void LabUi::build_lens_flare_options() { - // global calibration multipliers (see graphics/lens_flare.cpp) - float ghost_brightness = graphics::lens_flare_get_ghost_brightness(); - if (SliderFloat("Ghost brightness", &ghost_brightness, 0.0f, 500.0f, "%.1f", ImGuiSliderFlags_Logarithmic)) { - graphics::lens_flare_set_ghost_brightness(ghost_brightness); - } - - float starburst_brightness = graphics::lens_flare_get_starburst_brightness(); - if (SliderFloat("Starburst brightness", &starburst_brightness, 0.0f, 10.0f)) { - graphics::lens_flare_set_starburst_brightness(starburst_brightness); - } - - float hdr_headroom = graphics::lens_flare_get_hdr_headroom(); - if (SliderFloat("HDR headroom (x paper white)", &hdr_headroom, 0.0f, 8.0f)) { - graphics::lens_flare_set_hdr_headroom(hdr_headroom); - } + // Global calibration, edited in place -- same as the per-lens sliders below + // (see graphics/lens_flare.h). The sliders' own bounds keep the values sane. + auto& tuning = graphics::lens_flare_get_tuning(); + SliderFloat("Ghost brightness", &tuning.ghost_brightness, 0.0f, 500.0f, "%.1f", ImGuiSliderFlags_Logarithmic); + SliderFloat("Starburst brightness", &tuning.starburst_brightness, 0.0f, 10.0f); + SliderFloat("HDR headroom (x paper white)", &tuning.hdr_headroom, 0.0f, 8.0f); if (Gr_hdr_output_active) { - TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", hdr_headroom); + TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", tuning.hdr_headroom); } else { TextDisabled("SDR output active: HDR headroom has no effect right now"); } diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index f9f2c7da8ce..a6aba8dad55 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -80,6 +80,15 @@ typedef struct flare_bitmap { } flare_bitmap; +// Values of starfield_bitmap::camera_lens_flare, i.e. of "+Camera Lens Flare:". +// The unset default deliberately defers to $Flare:, which was the only way to say +// "this sun flares" before this option existed. +enum { + SUN_LENS_FLARE_FROM_FLARE = -1, // no "+Camera Lens Flare:"; follow $Flare: + SUN_LENS_FLARE_OFF = 0, + SUN_LENS_FLARE_ON = 1, +}; + // global info (not individual instances) typedef struct starfield_bitmap { char filename[MAX_FILENAME_LEN]; // bitmap filename @@ -94,6 +103,10 @@ typedef struct starfield_bitmap { float r, g, b, i; // only for suns int glare; // only for suns int flare; // Is there a lens-flare for this sun? + // Does this sun flare through the physically-based camera lens (graphics/lens_flare.h)? + // Tristate, from "+Camera Lens Flare:": SUN_LENS_FLARE_FROM_FLARE follows $Flare:, + // so a table written before this option existed behaves exactly as it did. + int camera_lens_flare; flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale flare_bitmap flare_bitmaps[MAX_FLARE_BMP]; // bitmaps for different lens flares (can be re-used) int n_flares; // number of flares actually used @@ -400,6 +413,8 @@ static void starfield_bitmap_entry_init(starfield_bitmap *sbm) sbm->bitmap_id = -1; sbm->glow_bitmap = -1; sbm->glow_n_frames = 1; + // the memset above would otherwise read as SUN_LENS_FLARE_OFF + sbm->camera_lens_flare = SUN_LENS_FLARE_FROM_FLARE; for (i = 0; i < MAX_FLARE_BMP; i++) { sbm->flare_bitmaps[i].bitmap_id = -1; @@ -554,6 +569,18 @@ void parse_startbl(const char *filename) } } + // Opt this sun into (or out of) the physically-based camera-lens + // flare independently of the legacy sprite $Flare: block above, + // which otherwise doubles as the opt-in. Lets a sun flare through + // the camera lens without having to carry a full set of sprite + // flare fields it will never draw, and lets one that does carry + // them keep the sprites while sitting out the lens. + if (optional_string("+Camera Lens Flare:")) { + bool enabled = false; + stuff_boolean(&enabled); + sbm.camera_lens_flare = enabled ? SUN_LENS_FLARE_ON : SUN_LENS_FLARE_OFF; + } + sbm.glare = !optional_string("$NoGlare:"); sbm.xparent = 1; @@ -1274,20 +1301,6 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } -// True when the post-processing flare pass is drawing this sun's starburst this -// frame, so the sprite sun and its glow must not stack a second one on top of it. -// -// Never true while rendering an environment map. That path goes straight to a -// render target without ever reaching the post-processing chain, so no flare pass -// runs there and the sprite is all there is -- and since the env faces are -// rendered *before* this frame's stars_draw() publishes, what is published at -// that moment is still the previous frame's. Both reasons say the same thing: -// only the scene render that published may act on the result. -static bool sun_starburst_replaces_sprite(int sun_n) -{ - return !Rendering_to_env && graphics::lens_flare_sun_starburst_drawn(sun_n); -} - // The sun's tabled light, or nothing if the sun instance itself is invalid. std::optional stars_get_sun_rgbi(int sun_n) { @@ -1305,6 +1318,29 @@ std::optional stars_get_sun_rgbi(int sun_n) return rgbi; } +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx) +{ + if (!SCP_vector_inbounds(Sun_bitmaps, bitmap_idx)) { + return false; + } + const starfield_bitmap* bm = &Sun_bitmaps[bitmap_idx]; + + // "+Camera Lens Flare:" wins where it is given; otherwise $Flare: stands in for + // it, since that was the only way to say "this sun flares" before it existed + if (bm->camera_lens_flare != SUN_LENS_FLARE_FROM_FLARE) { + return bm->camera_lens_flare == SUN_LENS_FLARE_ON; + } + return bm->flare != 0; +} + +bool stars_sun_has_camera_lens_flare(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n)) { + return false; + } + return stars_sun_bitmap_has_camera_lens_flare(Suns[sun_n].star_bitmap_index); +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1389,7 +1425,7 @@ void stars_draw_sun(int show_sun) // the two don't stack (the remaining suns are unaffected). Sun_drew is // still counted: it means "a sun was on screen this frame", which drives // the sunspot glare downstream and stays true either way. - if (!sun_starburst_replaces_sprite(idx)) { + if (!graphics::lens_flare_sun_starburst_drawn(idx)) { material mat_params; material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); @@ -1486,7 +1522,7 @@ void stars_draw_sun_glow(int sun_n) // when the flare pass is drawing this sun's starburst, skip the bitmap glow so // the two don't stack - if (sun_starburst_replaces_sprite(sun_n)) { + if (graphics::lens_flare_sun_starburst_drawn(sun_n)) { return; } @@ -2000,12 +2036,18 @@ void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_s Rendering_to_env = env; - // Decide what the camera lens will flare this frame, before anything consults - // the answer: the sun sprites below step aside for a starburst the flare pass - // is drawing, and the post-processing pass draws exactly what is published - // here. Skipped for environment maps, which never reach that pass -- see - // sun_starburst_replaces_sprite(). - if (!env) { + // Decide what the camera lens will flare for *this* render, before anything + // consults the answer: the sun sprites below step aside for a starburst the + // flare pass is drawing, and the post-processing pass draws exactly what is + // published here. + // + // Environment maps publish nothing, because they go straight to a render target + // without ever reaching that pass. Saying so here -- rather than having each + // consumer check where it is -- is what keeps "does this sun flare" a single + // answer that everything below can just read. + if (env) { + graphics::lens_flare_clear_frame(); + } else { graphics::lens_flare_frame_update(); } diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h index 40ed7ec04dc..ccbf02a2d01 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -164,6 +164,12 @@ int stars_find_bitmap(const char *name); // lookup a sun by bitmap filename, return index or -1 on fail int stars_find_sun(const char *name); +// Parse a stars.tbl (or a *-str.tbm) into the bitmap/sun tables. Normally reached +// only through stars_init(), which also loads the bitmaps; declared here because +// parsing alone is meaningful on its own -- a sun's tabled properties are readable +// straight afterwards, before any bitmap exists. +void parse_startbl(const char *filename); + // get the world coords of the sun pos on the unit sphere. void stars_get_sun_pos(int sun_n, vec3d *pos); @@ -176,6 +182,23 @@ struct sun_rgbi { // The sun's tabled light, or nothing if the sun instance itself is invalid. std::optional stars_get_sun_rgbi(int sun_n); +// True when this sun's stars.tbl entry asks to flare through the physically-based +// camera lens (graphics/lens_flare.h), when one is mounted. +// +// The content decides *whether* a sun flares; the mounted lens only decides *how* +// it is drawn, so mounting a lens never invents flares on suns tabled without one. +// A sun says so either with "+Camera Lens Flare:" or, for tables written before +// that existed, by carrying a legacy sprite "$Flare:" block -- the explicit option +// wins where both are present, and is the only way to have one without the other. +bool stars_sun_has_camera_lens_flare(int sun_n); + +// The same question keyed on a sun *bitmap* index (what stars_find_sun() returns) +// rather than on a placed sun instance. This is where the rule above actually +// lives; the instance form just looks up the bitmap. Separate because a sun's +// tabled answer is knowable straight after parsing, before any instance -- and so +// before any bitmap has to load, which is what lets it be tested. +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx); + // for SEXP stuff so that we can mark a bitmap as being used regardless of whether // or not there is an instance for it yet void stars_preload_background(const char *token); diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index f230316b841..4cf76ae3213 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -1408,10 +1408,10 @@ SCP_string BackgroundEditorDialogModel::getCameraLensName() // An unset mission (and one that spelled by hand) shows as "Default" if (The_mission.camera_lens_name.empty() || !stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_DEFAULT)) - return SCP_string(CAMERA_LENS_DEFAULT); + return { CAMERA_LENS_DEFAULT }; if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) - return SCP_string(CAMERA_LENS_NONE); + return { CAMERA_LENS_NONE }; return The_mission.camera_lens_name; } diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp index 27f24cf0b2f..1d5f295cd3a 100644 --- a/test/src/graphics/test_lens_flare.cpp +++ b/test/src/graphics/test_lens_flare.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -442,6 +443,55 @@ TEST_F(LensFlareDefaultLensTest, NameVocabularyDistinguishesDefaultFromNone) EXPECT_EQ(lens_flare_active_lens(), declared); } +// stars.tbl decides *whether* a sun flares; the camera lens only decides *how*. +// Parses a table covering both ways a sun can say so, and the case where they +// disagree. Only parses -- stars_init() would also load bitmaps this test has +// none of. +class SunCameraLensFlareTest : public test::FSTestFixture { + public: + SunCameraLensFlareTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("sun_flare_opt_in"); + } + + // The tabled answer, by sun name. stars_find_sun() gives the bitmap index the + // option was parsed into, which is knowable without placing an instance. + static bool sun_flares(const char* name) + { + const int idx = stars_find_sun(name); + EXPECT_GE(idx, 0) << "sun '" << name << "' did not parse out of the test stars.tbl"; + return stars_sun_bitmap_has_camera_lens_flare(idx); + } +}; + +TEST_F(SunCameraLensFlareTest, CameraLensFlareOptInOverridesTheFlareFallback) +{ + parse_startbl("stars.tbl"); + + // A sun that never asked to flare gets nothing from the camera lens. This is + // the whole point: mounting a lens must not invent flares on existing content. + EXPECT_FALSE(sun_flares("SunNeither")); + + // A legacy sprite $Flare: block still stands in for the opt-in, so tables + // written before "+Camera Lens Flare:" existed keep working unchanged + EXPECT_TRUE(sun_flares("SunLegacyFlareOnly")); + + // ...and the new option opts in on its own, with no sprite flare fields, which + // is the reason it exists + EXPECT_TRUE(sun_flares("SunLensOnly")); + + // Where the two disagree the explicit option wins -- otherwise a sun could not + // keep its sprite flare while sitting out the physically-based one + EXPECT_FALSE(sun_flares("SunFlareButNoLens")); + + // The option is parsed between the $Flare: block and $NoGlare:; if that + // ordering were wrong the option would silently never match, so check a sun + // that uses it alongside the option that follows it + EXPECT_TRUE(sun_flares("SunLensAndNoGlare")); +} + class LensFlareApertureTbmTest : public test::FSTestFixture { public: LensFlareApertureTbmTest() : test::FSTestFixture(INIT_CFILE) diff --git a/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl b/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl new file mode 100644 index 00000000000..0ef8eddf234 --- /dev/null +++ b/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl @@ -0,0 +1,58 @@ +; Sun entries covering every combination of the two things that can declare a sun +; flares: the legacy sprite "$Flare:" block and the explicit +; "+Camera Lens Flare:" option. +; +; The option is parsed after the $Flare: block and before $NoGlare:, and a +; mis-ordered optional_string() is silent -- it would simply never match, and the +; sun would quietly keep the $Flare: fallback. That is what the accompanying test +; is really guarding. +; +; No bitmaps here are ever loaded: the test only parses. + +#Background Bitmaps + +$Sun: SunNeither +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 + +; the pre-existing way to opt in: a legacy sprite flare block, no explicit option +$Sun: SunLegacyFlareOnly +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 +$Flare: ++FlareCount: 1 +$FlareTexture1: nonexistent_flare +$FlareGlow1: ++FlareTexture: 0 ++FlarePos: 0.5 ++FlareScale: 0.5 + +; the new lightweight opt-in: camera lens flare with no sprite flare fields at all +$Sun: SunLensOnly +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 ++Camera Lens Flare: YES + +; explicitly opted out while still carrying a sprite flare block -- the option has +; to win over the $Flare: fallback, or there is no way to keep sprites without the +; physically-based flare +$Sun: SunFlareButNoLens +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 +$Flare: ++FlareCount: 1 +$FlareTexture1: nonexistent_flare +$FlareGlow1: ++FlareTexture: 0 ++FlarePos: 0.5 ++FlareScale: 0.5 ++Camera Lens Flare: NO + +; and the option must not swallow the $NoGlare: that follows it +$Sun: SunLensAndNoGlare +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 ++Camera Lens Flare: YES +$NoGlare: + +#End