diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr
index 22a9053f2a1..1b4e4d255c5 100644
--- a/code/def_files/data/effects/fxaa-v.sdr
+++ b/code/def_files/data/effects/fxaa-v.sdr
@@ -8,6 +8,7 @@ void main() {
}
#else
in vec4 vertPosition;
+in vec4 vertTexCoord;
out vec2 v_rcpFrame;
noperspective out vec2 v_pos;
@@ -20,6 +21,8 @@ layout (std140) uniform genericData {
void main() {
gl_Position = vertPosition;
v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h);
- v_pos = vertPosition.xy*0.5 + 0.5;
+ // Use the real texcoord rather than deriving it from vertPosition: the draw call may ask for a
+ // sub-rectangle of the source texture, which the clip-space formula ignored. Matches post-v.sdr.
+ v_pos = vertTexCoord.xy;
}
#endif
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..d49ea017dd0
--- /dev/null
+++ b/code/def_files/data/effects/lensflare-f.sdr
@@ -0,0 +1,139 @@
+// 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;
+};
+
+// 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;
+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;
+
+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;
+flat in float g_origin;
+
+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;
+ 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];
+};
+
+// 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 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;
+ return texture(apertureMap, uv * 0.5 + 0.5).r * pupil * intensity;
+}
+
+void main()
+{
+ vec3 result;
+
+ 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. 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 (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;
+ } 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
new file mode 100644
index 00000000000..cd915c55162
--- /dev/null
+++ b/code/def_files/data/effects/lensflare-v.sdr
@@ -0,0 +1,123 @@
+// 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; // 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;
+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;
+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;
+flat out float g_origin;
+#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;
+ 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];
+};
+
+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];
+
+ vec2 p;
+ float origin;
+
+ 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
+ // 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 {
+ // 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));
+ 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;
+ 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..cf7ed367745
--- /dev/null
+++ b/code/def_files/data/tables/lens_flares.tbl
@@ -0,0 +1,569 @@
+; 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: the prescription of each lens sits between $Lens Stack Start: and
+; $Lens Stack End (note: no colon on the closing token). Inside it, 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.
+;
+; A xxx-lens.tbm can edit any lens here instead of restating it, by putting
+; +override directly after the $Name: of an existing lens. Every option the
+; override entry gives is applied; everything it leaves out keeps the value
+; below. The one exception is the prescription: opening a $Lens Stack Start:
+; replaces the whole stack, because a stack is an ordered run whose focal
+; length, ghost set and iris position all follow from the run as a whole, so
+; there is nothing a partial edit of it could mean. A xxx-lens.tbm entry with
+; no +override is a complete definition, and replaces a lens of the same name
+; outright.
+;
+; 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.
+; - $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
+; --------
+;
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+; 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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+#End
diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp
index 2b0e6a556d2..1056434a463 100644
--- a/code/graphics/2d.cpp
+++ b/code/graphics/2d.cpp
@@ -856,6 +856,29 @@ bool gr_is_smaa_mode(AntiAliasMode mode) {
return mode == AntiAliasMode::SMAA_Low || mode == AntiAliasMode::SMAA_Medium || mode == AntiAliasMode::SMAA_High || mode == AntiAliasMode::SMAA_Ultra;
}
+SCP_vector gr_get_supported_anisotropy_levels()
+{
+ float max;
+ if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) {
+ return {};
+ }
+
+ if (max <= 2.0f) {
+ return {};
+ }
+
+ SCP_vector out;
+
+ // We assume here that the anisotropy levels are powers of two...
+ float current = 1.0f;
+ while (current <= max) {
+ out.push_back(current);
+ current *= 2.0f;
+ }
+
+ return out;
+}
+
static void parse_post_processing_func()
{
bool value;
@@ -1644,6 +1667,13 @@ void gr_screen_resize(int width, int height)
gr_screen.save_max_h_unscaled_zoomed = gr_screen.max_h_unscaled_zoomed;
gr_setup_viewport();
+
+ // The offscreen targets that back the scene/post-processing pipeline were sized for the old
+ // gr_screen; give the backend a chance to grow them before anything renders at the new size.
+ // Backends that already handle this elsewhere (Vulkan, via recreateSwapChain()) leave it unset.
+ if (gr_screen.gf_resize_render_targets) {
+ gr_screen.gf_resize_render_targets();
+ }
}
void gr_window_to_render_pos(float& x, float& y)
diff --git a/code/graphics/2d.h b/code/graphics/2d.h
index 0f66ba3539b..1460ebd3dbb 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
};
@@ -861,6 +863,12 @@ typedef struct screen {
std::function gf_scene_texture_end;
std::function gf_copy_effect_texture;
+ // Grow the offscreen render targets that back the scene/post-processing pipeline to cover the
+ // current gr_screen, if they don't already. Called from gr_screen_resize(). Optional: the
+ // Vulkan backend leaves this unset because VulkanRenderer::recreateSwapChain() already owns
+ // resizing its extent-sized targets.
+ std::function gf_resize_render_targets;
+
std::function gf_zbias;
std::function gf_set_fill_mode;
@@ -1403,6 +1411,12 @@ inline bool gr_get_property(gr_property property, void* destination)
return gr_screen.gf_get_property(property, destination);
}
+// Anisotropic filtering levels the current hardware supports: 1.0 (off), then powers of two up to
+// the reported maximum. Empty if anisotropy is unavailable or the hardware caps out below 4x, in
+// which case there is nothing meaningful to offer. Backs both the in-game option's enumerator and
+// qtFRED's Preferences combo, so the two can't drift.
+SCP_vector gr_get_supported_anisotropy_levels();
+
inline void gr_push_debug_group(const char* name)
{
gr_screen.gf_push_debug_group(name);
diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp
new file mode 100644
index 00000000000..b1c55e6b37b
--- /dev/null
+++ b/code/graphics/lens_flare.cpp
@@ -0,0 +1,974 @@
+
+#include "lens_flare.h"
+#include "lens_flare_internal.h"
+
+#include "globalincs/systemvars.h"
+
+#include "ai/ai.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 {
+
+// How the flare is fitted to HDR output, and whether nozzles draw ghosts.
+// Runtime-tunable from the lab; the field defaults are in lens_flare.h. The
+// brightness calibration that used to live here is per-camera and therefore
+// overridable, so it moved into lens_overrides.
+lens_flare_tuning Tuning;
+
+// What a mission, the lab, the set-lens-* sexps or an editor has restyled about
+// the camera. One set, because there is one camera.
+lens_overrides Overrides;
+
+// 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;
+
+// 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;
+
+// The ceiling lens_flare.h publishes has to be the one the uniform block can
+// actually hold, less the starburst and streak slots pack_source_instances()
+// reserves.
+static_assert(MAX_LENS_FLARE_GHOSTS == generic_data::MAX_LENS_FLARE_INSTANCES - 2,
+ "MAX_LENS_FLARE_GHOSTS no longer matches the uniform block's instance budget");
+
+SCP_vector Lens_systems;
+
+// The iris/starburst textures currently generated, and the exact (lens, iris)
+// pair they were generated from. One cache, because there is one camera: no
+// other pair is ever drawn with, so keying on the pair rather than owning a set
+// per lens is both smaller and impossible to get out of step.
+//
+// Keying on the aperture *value* is also what makes a no-op edit free: a slider
+// dragged out and back lands on an equal aperture, matches here, and neither
+// rebuilds nor bumps the generation the backends watch.
+struct texture_cache {
+ int lens_idx = -1;
+ lens_aperture aperture;
+ std::unique_ptr textures;
+};
+texture_cache Tex_cache;
+
+// Bumped whenever those textures are rebuilt, so the render backends can tell a
+// re-generated iris from the one they already uploaded
+unsigned int Texture_generation = 1;
+
+// Live iris editing: coalesce a slider drag into one regeneration every
+// APERTURE_REGEN_INTERVAL ms
+constexpr int APERTURE_REGEN_INTERVAL = 100;
+bool Aperture_dirty = false;
+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;
+
+// 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
+// (independent of any particular sun)
+bool pass_globally_possible()
+{
+ if (gr_screen.mode == GraphicsAPI::Stub || Lens_systems.empty()) {
+ return false;
+ }
+ // 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
+ // plain FRED, and qtFred unless its View menu's "Enable Post Processing"
+ // toggle is on, from having their sun sprites step aside for a pass that
+ // will never run -- neither draws the background through a scene texture
+ // otherwise.
+ if (!High_dynamic_range) {
+ 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 light source's image lands on the film.
+struct film_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 flare source onto the film gate, each kind the same way the thing it
+// stands for is drawn: a sun through the faraway path stars_draw_sun() uses for
+// its sprite, an engine as the ordinary finite point its glow is drawn at. False
+// when the source isn't imaged onto the sensor at all.
+//
+// The field angle below is derived from where the image lands, which treats the
+// source as collimated -- true for a sun, an approximation for an engine a few
+// hundred metres away. Getting it exactly right would mean re-tracing the ghost
+// matrices per source and per frame for an object whose flare is a few pixels
+// wide, so the ghosts of a near source are placed a little as if it were far.
+bool project_source(const flare_source& src, const film_gate& gate, float efl, film_image* out)
+{
+ vertex vex;
+ memset(&vex, 0, sizeof(vertex));
+ if (src.at_infinity) {
+ g3_rotate_faraway_vertex(&vex, &src.pos);
+ } else {
+ g3_rotate_vertex(&vex, &src.pos);
+ }
+
+ if (vex.codes & CC_BEHIND) {
+ return false;
+ }
+ if (!(vex.flags & PF_PROJECTED)) {
+ g3_project_vertex(&vex);
+ }
+ if (vex.flags & PF_OVERFLOW) {
+ return false;
+ }
+
+ float sx = vex.screen.xyw.x / gate.clip_w * 2.0f - 1.0f;
+ float sy = 1.0f - 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;
+}
+
+} // namespace
+
+lens_overrides& lens_flare_overrides() { return Overrides; }
+
+lens_settings lens_flare_effective_settings(int lens_idx)
+{
+ lens_settings s;
+ if (const lens_system* lens = lens_flare_get_system(lens_idx)) {
+ s.aperture = lens->aperture;
+ s.anamorphic = lens->anamorphic;
+ s.intensity = lens->intensity;
+ s.starburst = lens->starburst;
+ s.starburst_scale = lens->starburst_scale;
+ s.max_ghosts = lens->max_ghosts;
+ }
+
+ // Whatever the camera has been restyled with wins over what the glass tables.
+ // The two brightness figures have no per-lens baseline to fall back to -- they
+ // calibrate the energy model itself -- so lens_settings' own defaults stand in.
+ if (Overrides.aperture)
+ s.aperture = *Overrides.aperture;
+ if (Overrides.anamorphic)
+ s.anamorphic = *Overrides.anamorphic;
+ if (Overrides.intensity)
+ s.intensity = *Overrides.intensity;
+ if (Overrides.starburst)
+ s.starburst = *Overrides.starburst;
+ if (Overrides.starburst_scale)
+ s.starburst_scale = *Overrides.starburst_scale;
+ if (Overrides.max_ghosts)
+ s.max_ghosts = *Overrides.max_ghosts;
+ if (Overrides.ghost_brightness)
+ s.ghost_brightness = *Overrides.ghost_brightness;
+ if (Overrides.starburst_brightness)
+ s.starburst_brightness = *Overrides.starburst_brightness;
+
+ return s;
+}
+
+namespace {
+
+// The iris the camera is actually looking through, which is what the textures
+// have to be generated from.
+const lens_aperture& effective_aperture(int lens_idx)
+{
+ if (Overrides.aperture) {
+ return *Overrides.aperture;
+ }
+ static const lens_aperture Fallback;
+ const lens_system* lens = lens_flare_get_system(lens_idx);
+ return (lens != nullptr) ? lens->aperture : Fallback;
+}
+
+// Drop the cache so the next lens_flare_get_textures() rebuilds it, and tell the
+// render backends their uploaded copy is stale.
+void drop_texture_cache()
+{
+ Tex_cache.textures.reset();
+ Tex_cache.lens_idx = -1;
+ Texture_generation++;
+}
+
+// Act on a scheduled iris 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 sliders fire every frame a drag is held, so a
+// drag has to be coalesced into a few rebuilds rather than sixty.
+//
+// This is the *only* place a changed iris invalidates the cache. The lazy
+// generate in lens_flare_get_textures() deliberately does not, or a drag would
+// pull the FFT into the render path once a frame -- it fills an empty cache,
+// never replaces a merely outdated one.
+void flush_pending_aperture_edit()
+{
+ if (!Aperture_dirty) {
+ return;
+ }
+ if (Aperture_dirty_stamp.isValid() && !ui_timestamp_elapsed(Aperture_dirty_stamp)) {
+ return;
+ }
+ Aperture_dirty = false;
+ Aperture_dirty_stamp = ui_timestamp(APERTURE_REGEN_INTERVAL);
+
+ // Only a genuinely different iris is worth the rebuild. An edit that landed
+ // back where it started, or one that touched a field the textures don't
+ // depend on, stops here.
+ if (Tex_cache.textures != nullptr && Tex_cache.aperture == effective_aperture(Tex_cache.lens_idx)) {
+ return;
+ }
+ drop_texture_cache();
+}
+
+} // namespace
+
+void lens_flare_overrides_changed()
+{
+ Aperture_dirty = true;
+
+ // Act 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 would
+ // just be the same condition written twice.)
+ flush_pending_aperture_edit();
+}
+
+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;
+ Overrides.clear();
+ drop_texture_cache();
+ lens_flare_clear_lab_lens();
+ lens_flare_lab_thruster_flare().reset();
+ Frame_data.clear();
+ Frame_draws.clear();
+ Sun_starburst_drawn.clear();
+ Logged_lens = -2;
+
+ // As in lens_flare_reset_for_level(), and for the same reason: a scheduled
+ // rebuild belongs to the table being torn down, and the throttle stamp has
+ // to go with it, since a deadline that outlives the clock it was taken
+ // against sits in that clock's future and swallows every edit until it
+ // passes.
+ Aperture_dirty = false;
+ Aperture_dirty_stamp = UI_TIMESTAMP::invalid();
+}
+
+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_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
+// 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 MAX(Tuning.hdr_headroom, 0.0f) / 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)
+{
+ // 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;
+ }
+
+ 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 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;
+ }
+}
+
+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_drawn(int sun_n)
+{
+ 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)
+{
+ if (!SCP_vector_inbounds(Lens_systems, lens_idx)) {
+ return nullptr;
+ }
+
+ // Fill an empty cache, or one holding a different lens -- but never merely a
+ // different iris. Rebuilding for a changed iris is flush_pending_aperture_edit()'s
+ // job precisely so that it stays throttled; doing it here would put the FFT in
+ // whatever called us, which mid-frame is a render backend.
+ if (Tex_cache.textures == nullptr || Tex_cache.lens_idx != lens_idx) {
+ Tex_cache.aperture = effective_aperture(lens_idx);
+ auto tex = std::make_unique();
+ lens_flare_generate_textures(Tex_cache.aperture, tex.get());
+ Tex_cache.textures = std::move(tex);
+ Tex_cache.lens_idx = lens_idx;
+ Texture_generation++;
+ }
+ return Tex_cache.textures.get();
+}
+
+const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens,
+ unsigned int& cached_generation)
+{
+ const unsigned int generation = Texture_generation;
+ if (lens_idx == cached_lens && generation == cached_generation) {
+ return nullptr;
+ }
+
+ const lens_flare_textures* tex = lens_flare_get_textures(lens_idx);
+ if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) {
+ return nullptr;
+ }
+
+ // Read back rather than reused: generating above may have bumped it.
+ cached_lens = lens_idx;
+ cached_generation = Texture_generation;
+ return tex;
+}
+
+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
+ // this (see parse_mission_info), and the lab sets its override on demand
+ Mission_lens = Default_lens;
+ lens_flare_clear_lab_lens();
+ lens_flare_lab_thruster_flare().reset();
+ Logged_lens = -2;
+
+ // Every lens is left exactly as its table declared it, so this one line is the
+ // whole of "one mission's camera cannot carry into the next" -- there is
+ // nothing stamped into a lens to put back.
+ Overrides.clear();
+ drop_texture_cache();
+
+ // The next mission's suns are not this one's; drop the published frame so
+ // nothing consumes it across the level change
+ lens_flare_clear_frame();
+
+ // Any scheduled rebuild 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_stamp = UI_TIMESTAMP::invalid();
+}
+
+unsigned int lens_flare_get_texture_generation() { return Texture_generation; }
+
+bool lens_flare_aperture_edit_pending() { return Aperture_dirty; }
+
+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.
+//
+// All three take the lens for its prescription -- pupil, iris radius, sensor
+// width, none of which is overridable -- and lens_settings for everything about
+// the look. Anything a mission can restyle must be read from the settings; the
+// split in the signature is what keeps that hard to get wrong.
+
+// 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_settings& set,
+ 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 = ghost_min_halfext(lens);
+
+ 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;
+ // clamped here rather than at the setter: the lab and the sexps write the
+ // overrides directly, so this is the boundary that has to hold
+ inst.color.a1d[k] = ghost.reflectance[k] * energy * MAX(set.ghost_brightness, 0.0f);
+ }
+}
+
+// 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, const lens_settings& set,
+ float sdist)
+{
+ instance_init(inst, generic_data::LENS_QUAD_STARBURST);
+
+ const float halfext = MAX(set.starburst_scale, 0.0f) * 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] = MAX(set.starburst_brightness, 0.0f); // see emit_ghost
+ }
+}
+
+// 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, const lens_settings& set,
+ float sdist)
+{
+ instance_init(inst, generic_data::LENS_QUAD_STREAK);
+
+ const lens_streak& streak = set.anamorphic.streak;
+ const float min_halfext = ghost_min_halfext(lens);
+ const float half_len = MAX(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 * 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] = streak.tint[k] * streak.strength;
+ }
+}
+
+} // namespace
+
+// Pack one source's quads into a uniform block and return how many instance
+// slots were written. Depends only on the camera and where the source's image
+// lands on the sensor -- `sdist` is that image's distance from the sensor centre
+// in mm, `theta` its paraxial field angle -- so a sun and an engine that happen
+// to land in the same place get the same quads, which is what "one camera, one
+// lens" means.
+static int pack_source_instances(const lens_system& lens, const lens_settings& set, float theta, float sdist,
+ bool with_ghosts, 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 = set.starburst;
+ const bool wants_streak = set.anamorphic.streak.strength > 0.0f;
+
+ const int reserved = (wants_starburst ? 1 : 0) + (wants_streak ? 1 : 0);
+ // lens.ghosts is enumerated brightest first, so taking a prefix of it is
+ // exactly what asking for fewer ghosts means. Applying it here rather than at
+ // enumeration is what lets $Max Ghosts: be overridden at all: the alternative
+ // would be re-running the whole paraxial precompute on every edit.
+ const int ghost_budget =
+ MIN(generic_data::MAX_LENS_FLARE_INSTANCES - reserved, MAX(set.max_ghosts, 0));
+
+ int count = 0;
+ if (with_ghosts) {
+ for (const auto& ghost : lens.ghosts) {
+ if (count >= ghost_budget) {
+ break;
+ }
+ emit_ghost(out->instances[count++], lens, set, ghost, theta);
+ }
+ }
+ if (wants_starburst) {
+ emit_starburst(out->instances[count++], lens, set, sdist);
+ }
+ if (wants_streak) {
+ emit_streak(out->instances[count++], lens, set, 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 a
+ // mission override all get the same guard against a divide by zero in the shader
+ out->squeeze = MAX(set.anamorphic.squeeze, 0.01f);
+ out->pad[0] = out->pad[1] = 0.0f;
+ return count;
+}
+
+void lens_flare_clear_frame()
+{
+ Frame_draws.clear();
+ Sun_starburst_drawn.clear();
+}
+
+bool lens_flare_point_visible(const vec3d& world_pos)
+{
+ vec3d to_eye;
+ vm_vec_sub(&to_eye, &Eye_position, &world_pos);
+ const float dist = vm_vec_normalize_safe(&to_eye, true);
+ if (dist <= 0.2f) {
+ // point-blank range: nothing can fit between the eye and the source
+ return true;
+ }
+
+ // The point sits exactly on the emitting ship's own hull, and a segment
+ // ending precisely on a surface is the one case a poly test can register as
+ // a spurious self-hit. Pulling the far end back toward the eye by a flat,
+ // small offset dodges that without excluding the emitting ship, which is
+ // deliberate: a nozzle or muzzle on the far side of its own hull should
+ // occlude exactly like it would behind anything else. The dist <= 0.2f bail
+ // above guarantees this offset never reaches back past the eye.
+ vec3d test_point;
+ vm_vec_scale_add(&test_point, &world_pos, &to_eye, 0.1f);
+
+ // A zero threshold is deliberate: test_line_of_sight()'s default (10.0f)
+ // exists to let AI weapon fire ignore stray debris, but it would just as
+ // happily skip a fighter-sized ship as an occluder -- including the
+ // emitting ship itself, which is the self-occlusion case this test exists
+ // for in the first place.
+ return test_line_of_sight(&Eye_position, &test_point, {}, 0.0f);
+}
+
+void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget)
+{
+ if (static_cast(candidates.size()) > budget) {
+ std::partial_sort(candidates.begin(), candidates.begin() + budget, candidates.end(),
+ [](const flare_source& a, const flare_source& b) { return a.intensity > b.intensity; });
+ candidates.resize(budget);
+ }
+
+ candidates.erase(std::remove_if(candidates.begin(), candidates.end(),
+ [](const flare_source& src) { return !lens_flare_point_visible(src.pos); }),
+ candidates.end());
+
+ out.insert(out.end(), candidates.begin(), candidates.end());
+}
+
+namespace {
+
+// Every sun the content asked to flare, with its occlusion and off-axis fades
+// already folded into the source's brightness.
+void gather_sun_sources(SCP_vector& out, float dt, bool snap)
+{
+ const int num_suns = stars_get_num_suns();
+
+ 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;
+ }
+
+ // 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);
+ 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;
+ }
+
+ flare_source src;
+ src.pos = sun_pos;
+ src.at_infinity = true;
+ src.color = sun_light->color;
+ src.intensity = sun_light->intensity * total_vis;
+ src.visibility = total_vis;
+ src.kind = flare_source_kind::sun;
+ src.index = sun_n;
+ out.push_back(src);
+ }
+}
+
+// How many nozzles the pass will image at most, brightest first. Every lit
+// nozzle is its own source (a capital ship's engines are too far apart to
+// average), so this is what stands between a fleet engagement and several
+// hundred draws: each source costs a multi-kilobyte uniform block and its own
+// instanced draw.
+//
+// It is generous rather than small because the thruster flares that motivate it
+// are the ones on a big ship, where a dozen nozzles are visible at once. What
+// makes that affordable is lens_flare_tuning::thruster_ghosts being off, which
+// leaves each of them a single starburst quad instead of a full ghost train.
+//
+// Must stay within what the Vulkan backend's per-frame UBO ring can hold across
+// however many times the scene is rendered in a frame -- see
+// LENS_FLARE_UBO_SLOTS in VulkanPostProcessingLensFlare.cpp.
+constexpr int MAX_THRUSTER_SOURCES = 32;
+
+// Beams are scarce even in a capital-ship engagement, so this is a backstop
+// rather than something a normal frame is expected to reach -- which is also
+// why they keep their ghost train where nozzles drop theirs: a handful of
+// ghost trains reads as an optical effect rather than noise, and ghosts cost
+// nothing extra once a source's uniform block is uploaded.
+//
+// The two budgets plus the suns are what LENS_FLARE_UBO_SLOTS in the Vulkan
+// backend has to stay above.
+constexpr int MAX_BEAM_SOURCES = 8;
+
+} // namespace
+
+void lens_flare_frame_update()
+{
+ lens_flare_clear_frame();
+
+ // scheduled iris rebuilds land here, throttled
+ flush_pending_aperture_edit();
+
+ const int lens_idx = lens_flare_active_lens();
+ if (lens_idx < 0 || !pass_globally_possible()) {
+ return;
+ }
+ const lens_system& lens = Lens_systems[lens_idx];
+
+ // Resolved once: the camera is the camera for every source in the frame, and
+ // re-resolving it per source would copy an aperture forty times over.
+ const lens_settings settings = lens_flare_effective_settings(lens_idx);
+
+ 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;
+ }
+ gate.half_w = lens.sensor_width * 0.5f;
+ gate.half_h = gate.half_w * gate.clip_h / gate.clip_w;
+
+ // 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);
+
+ // Everything the camera images this frame, gathered before anything is packed
+ // so that the two kinds of light -- one lens, one film gate -- go through the
+ // identical projection and packing below
+ SCP_vector sources;
+ gather_sun_sources(sources, dt, snap);
+ lens_flare_gather_thruster_sources(sources, MAX_THRUSTER_SOURCES);
+ lens_flare_gather_beam_sources(sources, MAX_BEAM_SOURCES);
+ if (sources.empty()) {
+ return;
+ }
+
+ // Sized once, now that the source list is final: the loop hands out pointers
+ // into this and must never grow it afterwards
+ if (Frame_data.size() < sources.size()) {
+ Frame_data.resize(sources.size());
+ }
+ Sun_starburst_drawn.resize(stars_get_num_suns(), false);
+
+ // The camera's, not any source's, so it is a frame constant too
+ const float out_scale = lens_flare_output_scale();
+
+ for (const auto& src : sources) {
+ film_image image;
+ if (!project_source(src, gate, lens.efl, &image)) {
+ continue;
+ }
+
+ // The slot is only committed by the push_back below, so a source 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 = src.intensity * settings.intensity * out_scale;
+ out->tint.xyzw.x = src.color.xyz.x * tint_scale;
+ out->tint.xyzw.y = src.color.xyz.y * tint_scale;
+ out->tint.xyzw.z = src.color.xyz.z * tint_scale;
+ out->tint.xyzw.w = 0.0f;
+
+ int count = pack_source_instances(lens, settings, image.theta, image.dist_mm, src.draw_ghosts, out);
+ if (count == 0) {
+ continue;
+ }
+
+ lens_flare_draw draw;
+ draw.kind = src.kind;
+ draw.source_index = src.index;
+ draw.instances = count;
+ draw.data = out;
+ draw.visibility = src.visibility;
+ 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_source_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. Thrusters are not in this
+ // bookkeeping on purpose: an engine's glow is the light the flare is *of*,
+ // not a second drawing of the same artifact, so it keeps rendering.
+ if (src.kind == flare_source_kind::sun) {
+ Sun_starburst_drawn[src.index] = settings.starburst;
+ }
+ }
+
+ 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(),
+ MIN(static_cast(lens.ghosts.size()), MAX(settings.max_ghosts, 0)),
+ settings.starburst ? "starburst" : "no starburst"));
+ }
+}
+
+
+} // namespace graphics
diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h
new file mode 100644
index 00000000000..8e0f7cc8d4b
--- /dev/null
+++ b/code/graphics/lens_flare.h
@@ -0,0 +1,600 @@
+#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.
+//
+// Each layer carries its own operator== because the iris is the one part of the
+// camera whose textures cost real time to build (a 512^2 mask plus a 2D FFT), so
+// the texture cache keys on it to tell a genuine edit from a slider that landed
+// back where it started. Keeping each layer's comparison next to its own fields
+// is what stops a newly added field from being silently left out of that check.
+// (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
+ // 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;
+
+ lens_aperture_grating grating;
+ lens_aperture_scratches scratches;
+ lens_aperture_dust dust;
+
+ // 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 == o.grating && scratches == o.scratches && dust == o.dust;
+ }
+ 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
+};
+
+// 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 lens_streak {
+ 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
+
+ bool operator==(const lens_streak& o) const
+ {
+ return strength == o.strength && length == o.length && thickness == o.thickness &&
+ tint[0] == o.tint[0] && tint[1] == o.tint[1] && tint[2] == o.tint[2];
+ }
+ bool operator!=(const lens_streak& o) const { return !(*this == o); }
+};
+
+// The anamorphic look -- squeeze plus streak -- bundled the same way
+// lens_aperture bundles the iris and its layers, so that it can be tabled,
+// overridden and compared against its own neutral defaults as one thing.
+//
+// Anamorphic squeeze is 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.
+struct lens_anamorphic {
+ float squeeze = 1.0f;
+ lens_streak streak;
+
+ bool operator==(const lens_anamorphic& o) const { return squeeze == o.squeeze && streak == o.streak; }
+ bool operator!=(const lens_anamorphic& o) const { return !(*this == o); }
+};
+
+// A lens as the tables declare it. Everything here is either the lens's
+// prescription -- the part that makes it *this* lens, and that nothing may
+// override -- or the tabled baseline of a knob that lens_overrides can restyle.
+struct lens_system {
+ SCP_string name;
+
+ // ---- the prescription: a lens's identity, never overridden ----
+ 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)
+
+ // ---- the tabled look: the baseline lens_overrides lays over ----
+ lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst
+ lens_anamorphic anamorphic; // squeeze + streak
+ float intensity = 0.2f;
+ bool starburst = true;
+ float starburst_scale = 1.0f;
+ int max_ghosts = 40;
+
+ // --- filled by lens_flare_precompute() ---
+ // Brightest first, so max_ghosts can be applied at draw time by simply
+ // taking a prefix of this.
+ SCP_vector ghosts;
+ float efl = 50.0f; // effective focal length (green), mm
+ float bfd = 40.0f; // back focal distance last surface -> sensor (green), mm
+};
+
+// ---- restyling the camera ----
+//
+// A mission, the lab, the set-lens-* sexps and both editors all restyle the same
+// one camera, so they all write to the same one set of overrides: an unset field
+// means "whatever the mounted lens tables", exactly the way an unset Lab_lens
+// means "whatever lens the mission mounted" (see lens_flare_active_lens()).
+//
+// Overriding rather than stamping the values into the lens is what keeps one
+// mission's camera out of the next: lens_flare_reset_for_level() clears these
+// and every lens is untouched, with nothing to restore and no backup copy that
+// could go stale when a field is added.
+// The most ghosts a single source's uniform block can ever hold, once the
+// starburst and streak have taken their slots. Restated here rather than reached
+// through graphics/util/uniform_structs.h so that the editors and the sexps can
+// bound "$Max Ghosts:" without pulling the whole uniform layout in; lens_flare.cpp
+// static_asserts the two against each other.
+constexpr int MAX_LENS_FLARE_GHOSTS = 62;
+
+struct lens_overrides {
+ std::optional aperture;
+ std::optional anamorphic;
+ std::optional intensity;
+ std::optional starburst;
+ std::optional starburst_scale;
+ std::optional max_ghosts;
+ // The energy-model calibration. Per-camera rather than per-lens, since it
+ // scales the model itself rather than describing any particular glass.
+ std::optional ghost_brightness;
+ std::optional starburst_brightness;
+
+ bool any() const
+ {
+ return aperture || anamorphic || intensity || starburst || starburst_scale || max_ghosts ||
+ ghost_brightness || starburst_brightness;
+ }
+ void clear() { *this = lens_overrides(); }
+
+ // Compared as a whole because that is how it is edited: FRED stores one of
+ // these per mission and needs to know whether a dialog actually changed
+ // anything. std::optional compares both the "is it set" and the value, which
+ // is exactly the distinction that matters here -- unset is not the same
+ // answer as set-to-the-default.
+ bool operator==(const lens_overrides& o) const
+ {
+ return aperture == o.aperture && anamorphic == o.anamorphic && intensity == o.intensity &&
+ starburst == o.starburst && starburst_scale == o.starburst_scale && max_ghosts == o.max_ghosts &&
+ ghost_brightness == o.ghost_brightness && starburst_brightness == o.starburst_brightness;
+ }
+ bool operator!=(const lens_overrides& o) const { return !(*this == o); }
+};
+
+// The camera as it actually is: a lens's tabled look with the overrides above
+// laid over it. Resolved in one place by lens_flare_effective_settings(), so no
+// caller re-implements the precedence, and passed down by value so that a
+// consumer physically cannot read the un-overridden value off the lens instead.
+struct lens_settings {
+ lens_aperture aperture;
+ lens_anamorphic anamorphic;
+ float intensity = 0.2f;
+ bool starburst = true;
+ float starburst_scale = 1.0f;
+ int max_ghosts = 40;
+ float ghost_brightness = 64.0f;
+ float starburst_brightness = 1.6f;
+};
+
+// 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);
+
+// The overrides in force, for reading and for editing in place. One set, because
+// there is one camera: mission load, the set-lens-* sexps, the lab and both
+// editors are all restyling the same glass.
+//
+// Anything that edits these must follow up with lens_flare_overrides_changed().
+lens_overrides& lens_flare_overrides();
+
+// Note that the overrides were edited. Only the iris costs anything to change --
+// a 512^2 mask plus its 2D FFT -- so this schedules a texture rebuild, which the
+// per-frame flush then performs at most once every few frames and only if the
+// effective aperture really did move. Everything else takes effect next frame at
+// no cost, so calling this after any edit is always correct and never wasteful.
+void lens_flare_overrides_changed();
+
+// Whether a scheduled iris rebuild is still outstanding. For the lab, which
+// shows it while a slider drag is being coalesced. Call once per frame.
+bool lens_flare_aperture_edit_pending();
+
+// The mounted lens's tabled look with the overrides above laid over it -- the
+// single resolver, so no caller re-implements the precedence. Returns the plain
+// defaults for an invalid index.
+lens_settings lens_flare_effective_settings(int lens_idx);
+
+// Lazily generate (and cache) the iris/starburst textures of the effective
+// aperture of a lens. Returns nullptr for an invalid index.
+//
+// There is one cache, because there is one camera: whichever lens is mounted,
+// with whatever iris is in force, is the only pair anything ever draws with.
+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, the lab's
+// useBackground(), and qtFred's Background Editor when it switches the mission's
+// lens interactively. Not from lens_flare_switch_to() itself, which is also
+// reached from the editors while nothing is being rendered.
+//
+// A no-op everywhere the flare pass never runs regardless: plain FRED, and
+// qtFred unless its View menu's "Enable Post Processing" toggle is on (qtFred
+// otherwise draws the background without a scene texture, so High_dynamic_range
+// never goes true -- see lens_flare.cpp's pass_globally_possible()).
+void lens_flare_prime_textures();
+
+// Bumped whenever the cached textures are rebuilt. Render backends cache the
+// uploaded copy, so they must key that cache on this to notice a rebuild --
+// which lens_flare_textures_if_changed() below does for them.
+unsigned int lens_flare_get_texture_generation();
+
+// The whole staleness protocol a render backend needs, in one call: returns the
+// textures to upload, or nullptr when the ones the caller already holds are
+// still current. `cached_lens` / `cached_generation` are the backend's own record
+// of what it last uploaded, and are updated on a non-null return.
+//
+// Backends own their GPU handles; they do not each need to re-derive when those
+// handles went stale, which is a rule about this module and belongs here.
+const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens,
+ unsigned int& cached_generation);
+
+// Undo everything a mission or the lab did to the camera: unmount whatever lens
+// was mounted (back to $Default Lens:) and drop every override, 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();
+
+// The calibration that is neither per-lens nor per-mission: how the flare is fitted
+// to HDR output, and whether nozzles draw ghosts. Handed out mutably for the lab
+// to edit in place; values are sanitized where they are consumed, so a caller
+// cannot break the renderer by writing a silly number here.
+struct lens_flare_tuning {
+ // 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".
+ // Not overridable per mission: it describes the display, not the camera.
+ float hdr_headroom = 2.5f;
+
+ // Whether a thruster flare draws the ghost train as well as its starburst.
+ // Off, because unlike a sun an engine is one of dozens of small sources in
+ // frame: a ghost train each is both the expensive part of the pass and, at
+ // that count, visual noise rather than an optical effect you can read.
+ // Exposed so the lab can turn them on and show what they cost and look like.
+ // Suns are unaffected and always draw theirs.
+ bool thruster_ghosts = false;
+};
+
+lens_flare_tuning& lens_flare_get_tuning();
+
+// ---- 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 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.
+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 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 rather than re-derived, which is what keeps the sprite and the flare
+// from disagreeing about occluded and off-screen suns.
+//
+// Suns only: an engine's glow is the light source the flare is *of*, not a
+// competing sprite of the same artifact, so a thruster flare never makes the
+// thruster glow step aside.
+bool lens_flare_sun_starburst_drawn(int sun_n);
+
+// What a draw images. Diagnostics for the lab -- the pass draws every kind the
+// same way, through the same lens.
+enum class flare_source_kind {
+ sun,
+ thruster,
+ beam,
+};
+
+// One light source's worth of flare quads. Every source shares 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 {
+ flare_source_kind kind = flare_source_kind::sun;
+ // Which sun (a stars.tbl instance index), which ship (an objnum, for a
+ // thruster) or which beam (an objnum, for a beam) this draw images.
+ int source_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_frame_update() call.
+ const generic_data::lens_flare_data* data = nullptr;
+
+ // The 0..1 fade already folded into this draw's tint that isn't the source's
+ // own tabled brightness: for a sun, smoothed occlusion times the off-axis
+ // fade; for a thruster, the throttle; for a beam, its warmup/warmdown ramp.
+ float visibility = 0.0f;
+ float off_axis_deg = 0.0f; // paraxial field angle of the source
+ float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame
+};
+
+// 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.
+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 light source
+// that has something to draw -- suns first, in sun order, then thrusters
+// (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();
+
+// ---- thruster flares ----
+//
+// Engines are the other intensely bright thing in a FreeSpace scene, so they
+// flare through the same camera lens the suns do. What differs is the source: a
+// sun is a point at infinity with a tabled colour and a shadow test, while a
+// nozzle is a finite source whose brightness follows the throttle, the
+// afterburner, how squarely it faces the camera, and how far away it is.
+//
+// Every lit nozzle is its own source, because a capital ship's engines are set
+// far enough apart to read as separate points in frame -- one flare at their
+// centroid would sit where no engine is.
+//
+// Declared per species in species_defs.tbl ("$Thruster Flare:"), and off unless
+// a species asks for it -- so no existing mod gains flares it never tabled, and
+// a mission with no camera lens mounted still gets none either way.
+
+struct thruster_flare_info {
+ // False until a species_defs.tbl entry declares "$Thruster Flare:". Tables
+ // written before this existed have no such block, so their engines keep
+ // flaring exactly as much as they used to: not at all.
+ bool enabled = false;
+
+ // Brightness at the reference apparent size -- one nozzle of radius r seen
+ // from 32r away (see lens_flare_thrusters.cpp) -- scaling linearly from there.
+ //
+ // The defaults are starting points for tuning in the lab, not derived values.
+ // They are this large because at any real combat range a nozzle subtends a
+ // small fraction of the reference, so a value near 1.0 puts the whole effect
+ // below the level a pixel can show.
+ //
+ // The afterburner figure *replaces* the normal one while the burner or a
+ // booster is lit rather than multiplying it, so a species can make the two
+ // states independently bright without doing division in the table.
+ float intensity = 6.0f;
+ float afterburner_intensity = 15.0f;
+
+ // Linear rgb the flare is tinted with, multiplying the lens's own tint the
+ // same way a sun's colour does.
+ vec3d color = {{{1.0f, 1.0f, 1.0f}}};
+};
+
+// The lab's live override of every species' thruster-flare settings: unset means
+// each species' own table entry stands.
+//
+// One override for all species rather than one per species, because the lab
+// shows one ship at a time -- and, more usefully, because it leaves the tabled
+// values untouched, so nothing has to be backed up and restored between missions
+// the way a lens's edited aperture does.
+//
+// Handed out mutably, like lens_flare_get_tuning(). Cleared by
+// lens_flare_reset_for_level().
+std::optional& lens_flare_lab_thruster_flare();
+
+// The settings that actually apply to a species this frame: its own, unless the
+// lab is overriding. The single resolver, so no caller re-implements the
+// precedence (compare lens_flare_active_lens()). An unknown species gets the
+// defaults, which are "off".
+thruster_flare_info lens_flare_thruster_settings(int species_idx);
+
+// ---- 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);
+
+// 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_beams.cpp b/code/graphics/lens_flare_beams.cpp
new file mode 100644
index 00000000000..a33e6113100
--- /dev/null
+++ b/code/graphics/lens_flare_beams.cpp
@@ -0,0 +1,78 @@
+
+#include "lens_flare.h"
+#include "lens_flare_internal.h"
+
+#include "globalincs/linklist.h"
+#include "object/object.h"
+#include "render/3d.h"
+#include "weapon/beam.h"
+
+#include
+
+// Firing beams as lens-flare sources: one flare at each beam's muzzle, for the
+// whole time the beam exists.
+//
+// Brightness is not invented here. beam_get_muzzle_glow() reports what the
+// beam's own muzzle light is emitting, which already ramps up over the warmup,
+// holds while the beam fires and ramps back down over the warmdown -- so the
+// flare grows and fades with the glow it belongs to instead of following a
+// second curve that could disagree with it.
+//
+// Unlike thruster flares there is no table opt-in, because there is nothing for
+// content to opt into that it has not already said: a beam that throws no muzzle
+// light throws no flare, and no flare of any kind is drawn unless the mission
+// mounts a camera lens in the first place.
+//
+// beam_get_muzzle_glow() deliberately does not check the Detail.lighting setting
+// that gates the dynamic muzzle light itself (beam_light_sanity_and_setup()): a
+// lens flare is an artifact of the camera, not a scene light, so lowering the
+// lighting detail slider shouldn't make it vanish.
+
+namespace graphics {
+
+void lens_flare_gather_beam_sources(SCP_vector& out, int budget)
+{
+ if (budget <= 0) {
+ return;
+ }
+
+ SCP_vector candidates;
+
+ for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) {
+ if (objp->type != OBJ_BEAM || objp->instance < 0 || objp->instance >= MAX_BEAMS) {
+ continue;
+ }
+
+ beam_muzzle_glow glow;
+ if (!beam_get_muzzle_glow(&Beams[objp->instance], &glow)) {
+ continue;
+ }
+
+ // The muzzle sits somewhere out in front of the camera, so how large it
+ // looks matters as much as how hard it is burning -- the same reasoning,
+ // and the same calibration, as an engine nozzle
+ const float dist_sq = vm_vec_dist_squared(&glow.pos, &Eye_position);
+ if (dist_sq <= 0.0f) {
+ continue;
+ }
+ const float ratio = lens_flare_apparent_ratio(PI * glow.radius * glow.radius / dist_sq);
+
+ flare_source src;
+ src.pos = glow.pos;
+ src.at_infinity = false;
+ src.color = glow.color;
+ src.intensity = glow.intensity * ratio;
+ src.visibility = MIN(glow.intensity, 1.0f); // the warmup/warmdown ramp, for the lab
+ src.kind = flare_source_kind::beam;
+ src.index = OBJ_INDEX(objp);
+
+ if (src.intensity <= 0.0f) {
+ continue;
+ }
+ candidates.push_back(src);
+ }
+
+ lens_flare_commit_candidates(out, candidates, budget);
+}
+
+} // namespace graphics
diff --git a/code/graphics/lens_flare_internal.h b/code/graphics/lens_flare_internal.h
new file mode 100644
index 00000000000..33a3b66771b
--- /dev/null
+++ b/code/graphics/lens_flare_internal.h
@@ -0,0 +1,151 @@
+#pragma once
+
+#include "graphics/lens_flare.h"
+
+// Private interface between the six 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
+// lens_flare_thrusters.cpp finding and ranking the nozzles bright enough to
+// flare
+// lens_flare_beams.cpp the same for firing beam weapons
+//
+// The optics and image-synthesis halves touch no engine state at all; they are
+// pure functions of a lens_system / lens_aperture.
+
+struct glow_point; // model/model.h
+
+namespace graphics {
+
+// One light the camera images this frame, already reduced to a world position, a
+// colour and a brightness. The frame build in lens_flare.cpp projects and packs
+// these without caring where they came from, which is what lets a sun, an engine
+// nozzle and a firing beam share every step below the gather.
+struct flare_source {
+ // World position -- or, when at_infinity, a direction from the eye. Suns are
+ // at infinity and engines are not, and it changes which g3 projection applies,
+ // so the two cannot simply be the same vector.
+ vec3d pos = {{{0.0f, 0.0f, 1.0f}}};
+ bool at_infinity = false;
+
+ vec3d color = {{{1.0f, 1.0f, 1.0f}}}; // linear rgb, 0..1
+ float intensity = 0.0f; // multiplies the colour; every fade is already folded in
+
+ // Whether this source draws the lens's ghost train as well as its starburst.
+ // Decided by the gather -- suns and beams always do, thrusters follow the
+ // lab's lens_flare_tuning::thruster_ghosts, since there are dozens of them
+ // and a ghost train each is noise -- so the packing below stays a plain
+ // function of the lens, the geometry and this flag.
+ bool draw_ghosts = true;
+
+ // Diagnostics for the lab, reported straight through to lens_flare_draw.
+ // `visibility` is the fade that `intensity` above already accounts for, kept
+ // separately only so the lab can show it.
+ float visibility = 0.0f;
+ flare_source_kind kind = flare_source_kind::sun;
+ int index = -1; // sun index, or objnum for a thruster or beam source
+};
+
+// Append the nozzles bright enough to be worth a flare, brightest first and at
+// most `budget` of them. A no-op when no species tables one.
+//
+// Budgeted rather than unbounded because every source costs a multi-kilobyte
+// uniform block and its own instanced draw, and a fleet engagement has hundreds
+// of lit nozzles on screen. The budget is a hard cap on the pass, not a hint.
+void lens_flare_gather_thruster_sources(SCP_vector& out, int budget);
+
+// Where one nozzle images and how large it appears from `eye`: the solid angle it
+// subtends (pi*r^2 over the square of its distance), scaled by how squarely it
+// faces the camera. Nothing here is normalized against the calibration reference
+// -- the caller does that -- so this stays a plain statement of geometry.
+//
+// `orient`/`pos` place the model in the world, and nothing else about the ship
+// matters, which is what lets this be tested without a scene.
+//
+// Returns false when the nozzle faces away from the camera, or when the eye sits
+// exactly on it and it therefore has no direction to face.
+bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye,
+ vec3d* world_pnt, float* apparent);
+
+// Append the muzzles of every firing beam, brightest first and at most `budget`
+// of them. Their brightness follows the beam's own muzzle light, so a beam ramps
+// its flare up over its warmup and back down over its warmdown exactly as it
+// ramps that light.
+void lens_flare_gather_beam_sources(SCP_vector& out, int budget);
+
+// The tail every finite-source gather shares: rank `candidates` by brightness,
+// cut to `budget`, drop whatever the eye cannot see, and append the rest to
+// `out`. `candidates` is left in an unspecified state.
+//
+// The order matters and is the reason this is one function rather than a
+// convention each gather follows. Ranking, not any brightness threshold, is what
+// bounds the pass -- a flare that would have been drawn faintly is the one worth
+// losing, and ranking by the same number the tint is built from means the pass
+// degrades by dropping what was least visible anyway. Visibility comes last
+// because it costs a scene-wide raycast per source, so testing before the cut
+// would scale the cost with every candidate in the mission instead of with the
+// budget. A source the raycast drops does not free its slot for the next
+// brightest: one lost flare is cheaper than a second pass to refill it.
+void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget);
+
+// Whether the eye has an unobstructed line of sight to a finite world point --
+// the same segment/model test AI targeting uses to decide whether a shot has a
+// clear path to its target (test_line_of_sight(), ai/aicode.cpp). A nozzle or
+// beam muzzle is bright and squarely faced just as often tucked behind its own
+// ship's hull, a wing, or another ship entirely, so both gathers call this on
+// the sources that survive their budget cut -- after the cut, not before, since
+// this costs a scene-wide raycast and the budget is what bounds how many of
+// those a frame can afford. Deliberately does not exclude the emitting ship:
+// a nozzle or muzzle on the far side of its own hull should occlude exactly
+// like it would behind anything else.
+bool lens_flare_point_visible(const vec3d& world_pos);
+
+// 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};
+
+// Every finite source -- an engine nozzle, a beam muzzle -- is calibrated
+// against one reference: a disc of radius r seen from thirty-two of its own
+// radii away. Keeping it here rather than per source kind is what makes an
+// intensity of 1.0 mean the same brightness whatever it was stated on, and means
+// re-tuning the calibration moves one constant.
+constexpr float Reference_radius = 1.0f;
+constexpr float Reference_distance = 32.0f;
+constexpr float Reference_apparent = PI * Reference_radius * Reference_radius /
+ (Reference_distance * Reference_distance);
+
+// Ceiling on how far past that reference a source may be driven. Flying down a
+// destroyer's exhaust, or standing next to a firing beam, would otherwise put an
+// unbounded number into the tint and white out the frame; a flare that has
+// already saturated cannot usefully get brighter anyway.
+constexpr float Max_apparent_ratio = 3.0f;
+
+// The solid angle a finite source subtends (pi*r^2 over the square of its
+// distance), as the multiple of the reference above that an intensity of 1.0 is
+// stated against.
+inline float lens_flare_apparent_ratio(float solid_angle)
+{
+ return MIN(solid_angle / Reference_apparent, Max_apparent_ratio);
+}
+
+// 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..66cce237485
--- /dev/null
+++ b/code/graphics/lens_flare_optics.cpp
@@ -0,0 +1,297 @@
+#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; });
+
+ // Enumerate every ghost the instance budget can hold, brightest first, and
+ // leave $Max Ghosts: to pack_source_instances() -- which just takes a prefix of
+ // this. Capping here instead would bake the tabled number into the precompute
+ // and so put a full paraxial re-trace behind every edit of it.
+ //
+ // The ceiling is what remains once the starburst and streak have reserved their
+ // slots, so enumerating more could never be drawn anyway.
+ const int cap = MAX_LENS_FLARE_GHOSTS;
+ 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..2d13e1bd776
--- /dev/null
+++ b/code/graphics/lens_flare_table.cpp
@@ -0,0 +1,300 @@
+#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:")) {
+ SCP_string name;
+ stuff_string(name, F_NAME);
+
+ // "+override" edits the lens of this name that an earlier table defined,
+ // leaving everything the entry does not mention exactly as it was --
+ // which is how a mod restyles one shipped lens without transcribing its
+ // whole prescription. Without it the entry is a complete definition, and
+ // a name already in the table is replaced outright.
+ const bool overriding = optional_string("+override") != 0;
+
+ const int existing = find_system(systems, name.c_str());
+
+ lens_system ls;
+ if (overriding) {
+ if (existing >= 0) {
+ ls = systems[existing];
+ } else {
+ error_display(0,
+ "Lens system '%s': +override names a lens no earlier table defines; reading this entry as a "
+ "new lens system instead",
+ name.c_str());
+ }
+ }
+ ls.name = 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("$Anamorphic Squeeze:")) {
+ stuff_float(&ls.anamorphic.squeeze);
+ }
+ if (optional_string("$Anamorphic Streak:")) {
+ stuff_float(&ls.anamorphic.streak.strength);
+ if (optional_string("+Length:")) {
+ stuff_float(&ls.anamorphic.streak.length);
+ }
+ if (optional_string("+Thickness:")) {
+ stuff_float(&ls.anamorphic.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.anamorphic.streak.tint[0] = rgb[0];
+ ls.anamorphic.streak.tint[1] = rgb[1];
+ ls.anamorphic.streak.tint[2] = rgb[2];
+ }
+ }
+ 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);
+ }
+
+ // The prescription, wrapped in a start/end pair so that a stack of twenty
+ // surfaces reads as one block rather than as twenty loose options.
+ //
+ // An entry that opens a stack replaces the whole of it. A prescription is
+ // an ordered run of surfaces whose every property (focal length, ghost
+ // enumeration, where the iris falls) comes from the run as a whole, so
+ // there is nothing a partial edit could mean -- which is why the clear()
+ // below is unconditional rather than something "+override" opts out of.
+ if (optional_string("$Lens Stack Start:")) {
+ ls.surfaces.clear();
+
+ 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;
+ }
+ }
+
+ // The closing token has no colon, but optional_string matches on a
+ // prefix, so a table that writes one anyway would otherwise leave it in
+ // the stream and abort the parse several lines later with an error
+ // naming the wrong thing. Checked longest-first for the same reason.
+ if (!optional_string("$Lens Stack End:") && !optional_string("$Lens Stack End")) {
+ error_display(1, "Lens system '%s': $Lens Stack Start: is never closed by $Lens Stack End",
+ ls.name.c_str());
+ }
+ } else if (check_for_string("$Surface:") || check_for_string("$Stop:")) {
+ // Diagnosed rather than accepted: left to the loop above, a bare
+ // surface list ends the entry and then fails against "$Name:"/"#End"
+ // with a message that says nothing about surfaces
+ error_display(1,
+ "Lens system '%s': surfaces must be wrapped in $Lens Stack Start: ... $Lens Stack End",
+ ls.name.c_str());
+ }
+
+ if (!lens_flare_precompute(ls)) {
+ error_display(0, "Lens system '%s' has an unusable prescription and will be ignored", ls.name.c_str());
+ continue;
+ }
+
+ // a later table may redefine (or, with "+override", edit) a lens the
+ // engine or an earlier tbm shipped. Committed only now, so an entry whose
+ // prescription turned out to be unusable leaves the earlier one standing.
+ 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/lens_flare_thrusters.cpp b/code/graphics/lens_flare_thrusters.cpp
new file mode 100644
index 00000000000..f9a69c1cb88
--- /dev/null
+++ b/code/graphics/lens_flare_thrusters.cpp
@@ -0,0 +1,225 @@
+
+#include "lens_flare.h"
+#include "lens_flare_internal.h"
+
+#include "globalincs/systemvars.h"
+
+#include "model/model.h"
+#include "object/object.h"
+#include "render/3d.h"
+#include "ship/ship.h"
+#include "species_defs/species_defs.h"
+
+#include
+#include
+
+// Engines as lens-flare sources. Everything here answers one question -- which
+// ships' engines are bright enough this frame to be worth imaging, and how
+// bright -- and hands the answer to lens_flare.cpp as plain flare_sources. It
+// knows nothing about lenses, ghosts or quads.
+//
+// Every lit nozzle is its own source: a capital ship's engine banks are set far
+// enough apart to read as separate points in frame, so one flare at their
+// centroid would sit where no engine is. The cost of that is a uniform block and
+// a draw call per nozzle, which is what the budget in
+// lens_flare_gather_thruster_sources() bounds, and what
+// lens_flare_tuning::thruster_ghosts keeps affordable by drawing only the
+// starburst of each.
+//
+// None of modelrender.cpp's thruster geometry is duplicated here: submodel
+// rotation, warp-plane clipping and the per-frame glow noise all move a nozzle by
+// less than its own apparent size, so a plain rigid transform of the glow points
+// is enough.
+
+namespace graphics {
+namespace {
+
+// The lab's override of every species' settings; see lens_flare.h
+std::optional Lab_thruster_flare;
+
+// The apparent-size calibration a nozzle is stated against, and the ceiling on
+// how far past it one may be driven, are shared with beam muzzles --
+// lens_flare_apparent_ratio() in lens_flare_internal.h. Keeping one copy is what
+// makes an intensity of 1.0 mean the same brightness whichever kind of source it
+// was tabled on.
+
+// Floor below which a source cannot change a pixel: by the time it reaches the
+// frame it has also been multiplied by the lens's own intensity, so this is
+// already a small fraction of one display level.
+//
+// Deliberately far below anything you could see, because the budget's ranking --
+// not a threshold -- is what decides which flares are worth drawing. A threshold
+// set where it could plausibly cull something visible is how normal-throttle
+// engines came to look like they did not flare at all.
+constexpr float MIN_SOURCE_INTENSITY = 0.002f;
+
+// Is anything at all asking for thruster flares? Almost always no, and that case
+// has to cost nothing more than this loop over the (three, usually) species.
+bool any_thruster_flares_enabled()
+{
+ if (Lab_thruster_flare) {
+ return Lab_thruster_flare->enabled;
+ }
+ return std::any_of(Species_info.begin(), Species_info.end(),
+ [](const species_info& species) { return species.thruster_flare.enabled; });
+}
+
+// Append one source per lit, camera-facing nozzle of this ship. Nothing at all
+// when its engines are off, destroyed, or the ship isn't drawn.
+void gather_ship_nozzles(const object* objp, SCP_vector& out)
+{
+ const ship* shipp = &Ships[objp->instance];
+ const ship_info* sip = &Ship_info[shipp->ship_info_index];
+
+ const thruster_flare_info flare = lens_flare_thruster_settings(sip->species);
+ if (!flare.enabled) {
+ return;
+ }
+
+ // The gates ship_render() applies before it ever asks for thruster geometry:
+ // a ship that isn't drawn has no glow for the camera to image, and engines
+ // that are dead or disrupted are exactly the ones the glow is suppressed for.
+ if (!(objp->flags[Object::Object_Flags::Renders]) || shipp->flags[Ship::Ship_Flags::Cloaked] ||
+ shipp->flags[Ship::Ship_Flags::Disabled] || ship_subsys_disrupted(shipp, SUBSYSTEM_ENGINE)) {
+ return;
+ }
+ // The player's own ship isn't drawn from inside its own cockpit, so its
+ // engines -- a couple of metres behind the camera -- must not flare either
+ if (objp == Viewer_obj && !(Viewer_mode & VM_TOPDOWN)) {
+ return;
+ }
+
+ // How hard the engines are running, which is the same quantity the thruster
+ // geometry is stretched by -- so a nozzle flares exactly when its glow is
+ // drawn, at every throttle setting and not only under afterburner.
+ const float throttle = std::clamp(vm_vec_mag(&objp->phys_info.linear_thrust), 0.0f, 1.0f);
+ if (throttle <= 0.0f) {
+ return;
+ }
+ const bool use_ab = (objp->phys_info.flags & (PF_AFTERBURNER_ON | PF_BOOSTER_ON)) != 0;
+
+ if (sip->model_num < 0) {
+ return;
+ }
+ const polymodel* pm = model_get(sip->model_num);
+ if (pm == nullptr || pm->n_thrusters <= 0) {
+ return;
+ }
+
+ // Ghosts are a property of the whole class of thruster flares, resolved once
+ // here rather than per nozzle
+ const bool draw_ghosts = lens_flare_get_tuning().thruster_ghosts;
+ const float brightness = use_ab ? flare.afterburner_intensity : flare.intensity;
+
+ for (int i = 0; i < pm->n_thrusters; i++) {
+ const thruster_bank& bank = pm->thrusters[i];
+ if (!bank.points || bank.num_points <= 0) {
+ continue;
+ }
+ if (!model_should_render_engine_glow(OBJ_INDEX(objp), bank.obj_num)) {
+ continue;
+ }
+
+ for (int j = 0; j < bank.num_points; j++) {
+ vec3d world_pnt;
+ float apparent;
+ if (!lens_flare_nozzle_apparent(bank.points[j], objp->orient, objp->pos, Eye_position, &world_pnt,
+ &apparent)) {
+ continue;
+ }
+
+ // Irradiance at the entrance pupil, as a multiple of the reference
+ // source. This is the term "brightness follows the throttle and the
+ // afterburner" leaves out, and the one that keeps a battle's worth of
+ // distant fighters from each throwing a full-strength flare.
+ const float ratio = lens_flare_apparent_ratio(apparent);
+
+ flare_source src;
+ src.pos = world_pnt;
+ src.at_infinity = false;
+ src.color = flare.color;
+ src.intensity = brightness * throttle * ratio;
+ src.draw_ghosts = draw_ghosts;
+ src.visibility = throttle;
+ src.kind = flare_source_kind::thruster;
+ src.index = OBJ_INDEX(objp);
+
+ if (src.intensity < MIN_SOURCE_INTENSITY) {
+ continue;
+ }
+ out.push_back(src);
+ }
+ }
+}
+
+} // namespace
+
+std::optional& lens_flare_lab_thruster_flare() { return Lab_thruster_flare; }
+
+thruster_flare_info lens_flare_thruster_settings(int species_idx)
+{
+ if (Lab_thruster_flare) {
+ return *Lab_thruster_flare;
+ }
+ if (SCP_vector_inbounds(Species_info, species_idx)) {
+ return Species_info[species_idx].thruster_flare;
+ }
+ return {};
+}
+
+bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye,
+ vec3d* world_pnt, float* apparent)
+{
+ vm_vec_unrotate(world_pnt, &gpt.pnt, &orient);
+ vm_vec_add2(world_pnt, &pos);
+
+ vec3d to_eye;
+ vm_vec_sub(&to_eye, &eye, world_pnt);
+ const float dist = vm_vec_normalize_safe(&to_eye, true);
+ if (dist <= 0.0f) {
+ // the eye is exactly on the nozzle; it has no direction to face
+ return false;
+ }
+
+ // A null normal is a legal glowpoint, and means the nozzle shines every way --
+ // the same reading the thruster renderer gives it.
+ float facing = 1.0f;
+ if (!IS_VEC_NULL_SQ_SAFE(&gpt.norm)) {
+ vec3d world_norm;
+ vm_vec_unrotate(&world_norm, &gpt.norm, &orient);
+ // model normals are not guaranteed unit-length
+ if (vm_vec_normalize_safe(&world_norm, true) <= 0.0f) {
+ return false;
+ }
+ // The glow itself fades in over the first third of the hemisphere (the
+ // `d *= 3` in model_queue_render_thrusters), so the flare follows the same
+ // curve rather than inventing a second one.
+ facing = std::clamp(vm_vec_dot(&to_eye, &world_norm) * 3.0f, 0.0f, 1.0f);
+ }
+ if (facing <= 0.0f) {
+ return false;
+ }
+
+ *apparent = facing * PI * gpt.radius * gpt.radius / (dist * dist);
+ return true;
+}
+
+void lens_flare_gather_thruster_sources(SCP_vector& out, int budget)
+{
+ if (budget <= 0 || !any_thruster_flares_enabled()) {
+ return;
+ }
+
+ SCP_vector candidates;
+
+ for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) {
+ if (objp->type != OBJ_SHIP || objp->instance < 0) {
+ continue;
+ }
+ gather_ship_nozzles(objp, candidates);
+ }
+
+ lens_flare_commit_candidates(out, candidates, budget);
+}
+
+} // namespace graphics
diff --git a/code/graphics/opengl/gropengl.cpp b/code/graphics/opengl/gropengl.cpp
index 444515b797a..66938f670fa 100644
--- a/code/graphics/opengl/gropengl.cpp
+++ b/code/graphics/opengl/gropengl.cpp
@@ -1126,6 +1126,7 @@ void gr_opengl_init_function_pointers()
gr_screen.gf_scene_texture_begin = gr_opengl_scene_texture_begin;
gr_screen.gf_scene_texture_end = gr_opengl_scene_texture_end;
gr_screen.gf_copy_effect_texture = gr_opengl_copy_effect_texture;
+ gr_screen.gf_resize_render_targets = gr_opengl_resize_render_targets;
gr_screen.gf_deferred_lighting_begin = gr_opengl_deferred_lighting_begin;
gr_screen.gf_deferred_lighting_msaa = gr_opengl_deferred_lighting_msaa;
@@ -1513,7 +1514,7 @@ bool gr_opengl_init(std::unique_ptr&& graphicsOps)
opengl_shader_init();
// post processing effects, after shaders are initialized
- opengl_setup_scene_textures();
+ opengl_setup_scene_textures(gr_screen.max_w, gr_screen.max_h);
opengl_post_process_init();
// must be called after extensions are setup
diff --git a/code/graphics/opengl/gropengldeferred.cpp b/code/graphics/opengl/gropengldeferred.cpp
index 35d67419977..2b80207daa1 100644
--- a/code/graphics/opengl/gropengldeferred.cpp
+++ b/code/graphics/opengl/gropengldeferred.cpp
@@ -86,7 +86,7 @@ void gr_opengl_deferred_lighting_begin(bool clearNonColorBufs)
Current_shader->program->Uniforms.setTextureUniform("tex", 0);
GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE);
GL_state.SetZbufferType(ZBUFFER_TYPE_NONE);
- opengl_draw_full_screen_textured(0, 0, 1, 1);
+ opengl_draw_full_screen_scene_texture();
} else {
// Copy the existing color data into the emissive part of the G-buffer since everything that already existed is
// treated as emissive
@@ -159,7 +159,9 @@ void gr_opengl_deferred_lighting_msaa()
});
GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE);
GL_state.SetZbufferType(ZBUFFER_TYPE_WRITE);
- opengl_draw_full_screen_textured(0, 0, 1, 1);
+ // msaa-f.sdr resolves via ivec2(textureSize(texColor) * fragTexCoord), so the texcoords have to
+ // stay inside the rendered sub-rectangle of the multisampled G-buffer.
+ opengl_draw_full_screen_scene_texture();
}
void gr_opengl_deferred_lighting_end()
@@ -321,8 +323,12 @@ void gr_opengl_deferred_lighting_finish()
shadow_cascade_params_bind(offset, count);
}
- header->invScreenWidth = 1.0f / gr_screen.max_w;
- header->invScreenHeight = 1.0f / gr_screen.max_h;
+ // deferred-f.sdr turns gl_FragCoord into a G-buffer texture coordinate with these, so they
+ // have to normalize against the G-buffer's own dimensions. Those only equal gr_screen while
+ // the viewport exactly fills the scene textures -- not after a shrink, and not when the
+ // allocation was clamped by GL_max_renderbuffer_size.
+ header->invScreenWidth = 1.0f / Scene_texture_width;
+ header->invScreenHeight = 1.0f / Scene_texture_height;
header->nearPlane = gr_near_plane;
{
@@ -557,7 +563,8 @@ void gr_opengl_deferred_lighting_finish()
data->clip_dist = Neb2_fog_clip_distance;
});
- opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f);
+ // fog-f.sdr samples the composite and depth targets straight off fragTexCoord.
+ opengl_draw_full_screen_scene_texture();
if (bDrawNebVolumetrics) {
glReadBuffer(GL_COLOR_ATTACHMENT0);
@@ -653,6 +660,12 @@ void gr_opengl_deferred_lighting_finish()
{
GR_DEBUG_SCOPE("Volumetric Nebulae Draw");
+ // Deliberately unscaled. volumetric-f.sdr uses fragTexCoord for two incompatible
+ // things: reconstructing an eye-space ray direction, which needs the full 0..1 range
+ // across the viewport, and sampling composite/depth/emissive, which needs the
+ // rendered sub-rectangle. Scaling here would fix the sampling and skew every ray.
+ // Separating the two needs a second varying (or a scale uniform) in the shader; until
+ // then volumetrics are only correct while the targets exactly match the viewport.
opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f);
}
GL_state.Texture.Enable(Scene_emissive_texture);
diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp
index 63ac62ffe6b..ea32c2950b7 100644
--- a/code/graphics/opengl/gropengldraw.cpp
+++ b/code/graphics/opengl/gropengldraw.cpp
@@ -70,6 +70,33 @@ int Scene_texture_height;
GLfloat Scene_texture_u_scale = 1.0f;
GLfloat Scene_texture_v_scale = 1.0f;
+// Render targets are torn down and rebuilt mid-session by gr_opengl_resize_render_targets(), not
+// just at shutdown, so deletion has to go through the state cache: the driver is free to hand a
+// freed name straight back out, and a cache entry still holding that name would make a later
+// Enable() of the recycled texture a no-op.
+void opengl_delete_render_texture(GLuint& tex)
+{
+ if ( !tex ) {
+ return;
+ }
+
+ GL_state.Texture.Delete(tex);
+ glDeleteTextures(1, &tex);
+ tex = 0;
+}
+
+// Callers must have bound something else first (the resize path binds 0); the framebuffer cache
+// has no equivalent of Texture.Delete() to unbind through.
+void opengl_delete_render_framebuffer(GLuint& fbo)
+{
+ if ( !fbo ) {
+ return;
+ }
+
+ glDeleteFramebuffers(1, &fbo);
+ fbo = 0;
+}
+
inline GLenum opengl_primitive_type(primitive_type prim_type)
{
switch ( prim_type ) {
@@ -98,7 +125,7 @@ void gr_opengl_sphere(material* material_def, float /*rad*/)
}
extern int opengl_check_framebuffer();
-void opengl_setup_scene_textures()
+void opengl_setup_scene_textures(int width, int height)
{
Scene_texture_initialized = 0;
@@ -113,10 +140,10 @@ void opengl_setup_scene_textures()
return;
}
- // clamp size, if needed
- Scene_texture_width = gr_screen.max_w;
- Scene_texture_height = gr_screen.max_h;
+ Scene_texture_width = width;
+ Scene_texture_height = height;
+ // clamp size, if needed
if ( Scene_texture_width > GL_max_renderbuffer_size ) {
Scene_texture_width = GL_max_renderbuffer_size;
}
@@ -125,6 +152,13 @@ void opengl_setup_scene_textures()
Scene_texture_height = GL_max_renderbuffer_size;
}
+ mprintf((" Scene textures: %dx%d (screen %dx%d, max renderbuffer %d)\n",
+ Scene_texture_width,
+ Scene_texture_height,
+ gr_screen.max_w,
+ gr_screen.max_h,
+ GL_max_renderbuffer_size));
+
// create framebuffer
glGenFramebuffers(1, &Scene_framebuffer);
GL_state.BindFrameBuffer(Scene_framebuffer);
@@ -333,32 +367,15 @@ void opengl_setup_scene_textures()
if ( opengl_check_framebuffer() ) {
GL_state.BindFrameBuffer(0);
- glDeleteFramebuffers(1, &Scene_framebuffer);
- Scene_framebuffer = 0;
+ opengl_delete_render_framebuffer(Scene_framebuffer);
- glDeleteTextures(1, &Scene_color_texture);
- Scene_color_texture = 0;
-
- glDeleteTextures(1, &Scene_position_texture);
- Scene_position_texture = 0;
-
- glDeleteTextures(1, &Scene_normal_texture);
- Scene_normal_texture = 0;
-
- glDeleteTextures(1, &Scene_specular_texture);
- Scene_specular_texture = 0;
-
- glDeleteTextures(1, &Scene_emissive_texture);
- Scene_emissive_texture = 0;
-
- glDeleteTextures(1, &Scene_depth_texture);
- Scene_depth_texture = 0;
-
- glDeleteTextures(1, &Scene_luminance_texture);
- Scene_luminance_texture = 0;
-
- //glDeleteTextures(1, &Scene_fxaa_output_texture);
- //Scene_fxaa_output_texture = 0;
+ opengl_delete_render_texture(Scene_color_texture);
+ opengl_delete_render_texture(Scene_position_texture);
+ opengl_delete_render_texture(Scene_normal_texture);
+ opengl_delete_render_texture(Scene_specular_texture);
+ opengl_delete_render_texture(Scene_emissive_texture);
+ opengl_delete_render_texture(Scene_depth_texture);
+ opengl_delete_render_texture(Scene_luminance_texture);
Gr_post_processing_enabled = false;
Gr_enable_soft_particles = false;
@@ -687,77 +704,103 @@ void opengl_scene_texture_shutdown()
return;
}
- if ( Scene_color_texture ) {
- glDeleteTextures(1, &Scene_color_texture);
- Scene_color_texture = 0;
- }
-
- if ( Scene_position_texture ) {
- glDeleteTextures(1, &Scene_position_texture);
- Scene_position_texture = 0;
- }
-
- if ( Scene_normal_texture ) {
- glDeleteTextures(1, &Scene_normal_texture);
- Scene_normal_texture = 0;
- }
-
- if ( Scene_specular_texture ) {
- glDeleteTextures(1, &Scene_specular_texture);
- Scene_specular_texture = 0;
- }
+ // Everything opengl_setup_scene_textures() generated, in the same order. Note that
+ // GammaBlit_texture is 0 when the gamma pass is aliasing Scene_ldr_texture, so the shared
+ // texture is only released once.
+ opengl_delete_render_texture(Scene_color_texture);
+ opengl_delete_render_texture(Scene_ldr_texture);
+ opengl_delete_render_texture(Scene_position_texture);
+ opengl_delete_render_texture(Scene_normal_texture);
+ opengl_delete_render_texture(Scene_specular_texture);
+ opengl_delete_render_texture(Scene_emissive_texture);
+ opengl_delete_render_texture(Scene_composite_texture);
+ opengl_delete_render_texture(Scene_luminance_texture);
+ opengl_delete_render_texture(Cockpit_depth_texture);
+ opengl_delete_render_texture(Scene_depth_texture);
+ opengl_delete_render_framebuffer(Scene_framebuffer);
+
+ opengl_delete_render_texture(Scene_color_texture_ms);
+ opengl_delete_render_texture(Scene_position_texture_ms);
+ opengl_delete_render_texture(Scene_normal_texture_ms);
+ opengl_delete_render_texture(Scene_specular_texture_ms);
+ opengl_delete_render_texture(Scene_emissive_texture_ms);
+ opengl_delete_render_texture(Scene_depth_texture_ms);
+ opengl_delete_render_framebuffer(Scene_framebuffer_ms);
+
+ opengl_delete_render_texture(Back_texture);
+ opengl_delete_render_texture(Back_depth_texture);
+ opengl_delete_render_framebuffer(Back_framebuffer);
+
+ opengl_delete_render_texture(GammaBlit_texture);
+ opengl_delete_render_framebuffer(GammaBlit_framebuffer);
+
+ opengl_delete_render_texture(Distortion_texture[0]);
+ opengl_delete_render_texture(Distortion_texture[1]);
+ opengl_delete_render_framebuffer(Distortion_framebuffer);
- if (Scene_emissive_texture) {
- glDeleteTextures(1, &Scene_emissive_texture);
- Scene_emissive_texture = 0;
- }
-
- if ( Scene_depth_texture ) {
- glDeleteTextures(1, &Scene_depth_texture);
- Scene_depth_texture = 0;
- }
-
- if ( Scene_framebuffer ) {
- glDeleteFramebuffers(1, &Scene_framebuffer);
- Scene_framebuffer = 0;
- }
-
- if (Back_texture) {
- glDeleteTextures(1, &Back_texture);
- Back_texture = 0;
- }
-
- if (Back_depth_texture) {
- glDeleteTextures(1, &Back_depth_texture);
- Back_depth_texture = 0;
- }
+ Scene_texture_initialized = 0;
+ Scene_framebuffer_in_frame = false;
+}
- if (Back_framebuffer) {
- glDeleteFramebuffers(1, &Back_framebuffer);
- Back_framebuffer = 0;
+void gr_opengl_resize_render_targets()
+{
+ // Nothing allocated yet (still inside gr_init()), or FBOs are unavailable entirely.
+ if ( !Scene_texture_initialized ) {
+ return;
}
- if (GammaBlit_texture) {
- glDeleteTextures(1, &GammaBlit_texture);
- GammaBlit_texture = 0;
+ // Grow only. Shrinking back would mean reallocating every G-buffer again the moment the window
+ // grew back, and the shrunk state is already handled correctly: Scene_texture_u_scale and
+ // _v_scale confine rendering to the sub-rectangle actually in use. The hardware limit is
+ // applied here rather than left to opengl_setup_scene_textures(), so that a viewport larger
+ // than anything the GPU can allocate compares equal below and stops asking.
+ const int new_width = MIN(MAX(gr_screen.max_w, Scene_texture_width), GL_max_renderbuffer_size);
+ const int new_height = MIN(MAX(gr_screen.max_h, Scene_texture_height), GL_max_renderbuffer_size);
+
+ // The overwhelmingly common case: qtFred calls gr_screen_resize() every frame and the game
+ // calls it on every SDL resize event, almost always at a size the current targets already
+ // cover -- or, past the hardware limit, at one they never will.
+ if ( new_width == Scene_texture_width && new_height == Scene_texture_height ) {
+ return;
}
- if (GammaBlit_framebuffer) {
- glDeleteFramebuffers(1, &GammaBlit_framebuffer);
- GammaBlit_framebuffer = 0;
+ // Tearing down the framebuffer we are currently rendering into would corrupt the frame rather
+ // than fail cleanly, so refuse rather than trying to recover. Callers resize between frames.
+ // Scene_framebuffer_in_frame covers the post-processing passes too: they only ever run inside
+ // gr_scene_texture_begin()/end(), so it is set for the whole of Post_in_frame as well.
+ if ( Scene_framebuffer_in_frame ) {
+ Assertion(false, "Tried to resize the render targets to %dx%d while a scene was being "
+ "rendered into them! The resize has been skipped; the frame will be stretched.",
+ new_width, new_height);
+ return;
}
- glDeleteTextures(2, Distortion_texture);
- Distortion_texture[0] = 0;
- Distortion_texture[1] = 0;
-
- if ( Distortion_framebuffer ) {
- glDeleteFramebuffers(1, &Distortion_framebuffer);
- Distortion_framebuffer = 0;
+ mprintf(("Growing render targets from %dx%d to %dx%d to cover the new %dx%d viewport.\n",
+ Scene_texture_width, Scene_texture_height, new_width, new_height,
+ gr_screen.max_w, gr_screen.max_h));
+
+ // Leave the framebuffer cache pointing at a name that cannot be deleted out from under it.
+ GL_state.BindFrameBufferBoth(0, 0);
+
+ // Only the size-dependent resources are touched. The post-processing table, the compiled
+ // shaders and the SMAA lookup textures are all resolution-independent and stay alive, which is
+ // what keeps this cheap enough to run off a window drag. The post-processing targets are
+ // rebuilt after the scene textures because they are sized to match them.
+ opengl_scene_texture_shutdown();
+ opengl_setup_scene_textures(new_width, new_height);
+
+ // Reallocating larger is exactly when running out of video memory is most likely, and
+ // opengl_setup_scene_textures() reports that by leaving the scene uninitialized (having
+ // already turned post-processing and soft particles off). Rebuilding the post-processing
+ // targets on top of scene textures that don't exist would only make it worse, so stop here;
+ // the renderer keeps drawing without the offscreen pipeline.
+ if ( !Scene_texture_initialized ) {
+ mprintf(("Failed to allocate %dx%d render targets! The offscreen rendering pipeline has "
+ "been disabled for the rest of this session.\n", new_width, new_height));
+ return;
}
- Scene_texture_initialized = 0;
- Scene_framebuffer_in_frame = false;
+ opengl_post_resize_render_targets();
}
void gr_opengl_scene_texture_begin()
@@ -776,20 +819,35 @@ void gr_opengl_scene_texture_begin()
GL_state.PushFramebufferState();
GL_state.BindFrameBuffer(Scene_framebuffer);
- if (GL_rendering_to_texture)
- {
- Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width);
- Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height);
-
- CLAMP(Scene_texture_u_scale, 0.0f, 1.0f);
- CLAMP(Scene_texture_v_scale, 0.0f, 1.0f);
- }
- else
- {
- Scene_texture_u_scale = 1.0f;
- Scene_texture_v_scale = 1.0f;
+ // The fraction of the scene textures this frame actually renders into. Normally 1.0 -- the
+ // targets are grown to cover gr_screen (gr_opengl_resize_render_targets()) -- but they are
+ // never shrunk back, so a viewport that got smaller leaves the rest of the allocation stale.
+ // Every pass that samples these textures has to stay inside this sub-rectangle; use
+ // opengl_draw_full_screen_scene_texture() rather than open-coding the extents.
+ Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width);
+ Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height);
+
+ // Above 1.0 means the viewport outgrew the allocation and the resize could not keep up -- only
+ // reachable when GL_max_renderbuffer_size capped the targets. Render what fits and let the
+ // blit stretch it; say so once rather than every frame.
+ if (Scene_texture_u_scale > 1.0f || Scene_texture_v_scale > 1.0f) {
+ static bool reported_undersized_scene_texture = false;
+
+ if (!reported_undersized_scene_texture) {
+ reported_undersized_scene_texture = true;
+ nprintf(("OpenGL",
+ "Viewport (%dx%d) is larger than the scene texture backing it (%dx%d); "
+ "the post-processed image will be stretched to fit.\n",
+ gr_screen.max_w,
+ gr_screen.max_h,
+ Scene_texture_width,
+ Scene_texture_height));
+ }
}
+ CLAMP(Scene_texture_u_scale, 0.0f, 1.0f);
+ CLAMP(Scene_texture_v_scale, 0.0f, 1.0f);
+
if (!light_deferred_enabled()) {
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
@@ -1236,6 +1294,11 @@ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloa
opengl_render_primitives_immediate(PRIM_TYPE_TRIS, &vert_def, 3, glVertices, sizeof(glVertices));
}
+void opengl_draw_full_screen_scene_texture()
+{
+ opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale);
+}
+
void gr_opengl_render_decals(decal_material* material_info,
primitive_type prim_type,
vertex_layout* layout,
diff --git a/code/graphics/opengl/gropengldraw.h b/code/graphics/opengl/gropengldraw.h
index 3cfd3183c80..5bfa170b341 100644
--- a/code/graphics/opengl/gropengldraw.h
+++ b/code/graphics/opengl/gropengldraw.h
@@ -55,8 +55,14 @@ void gr_opengl_render_shield_impact(shield_material* material_info,
gr_buffer_handle buffer_handle,
int n_verts);
-void opengl_setup_scene_textures();
+void opengl_setup_scene_textures(int width, int height);
void opengl_scene_texture_shutdown();
+void gr_opengl_resize_render_targets();
+
+// Release a render target, keeping the GL state cache in sync. See the definitions for why the
+// cache matters now that these are rebuilt mid-session.
+void opengl_delete_render_texture(GLuint& tex);
+void opengl_delete_render_framebuffer(GLuint& fbo);
void gr_opengl_scene_texture_begin();
void gr_opengl_scene_texture_end();
void gr_opengl_copy_effect_texture();
@@ -147,6 +153,12 @@ void opengl_draw_textured_quad(GLfloat x1,
*/
void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloat v2);
+// Fullscreen pass over a source that is one of the scene/post-processing textures. Those are only
+// filled out to Scene_texture_u_scale/v_scale of their allocation, so sampling them over the full
+// [0,1] range would pull in whatever is beyond the rendered region. Prefer this over passing
+// literal 1.0f extents whenever the bound texture came from that pipeline.
+void opengl_draw_full_screen_scene_texture();
+
inline GLenum opengl_primitive_type(primitive_type prim_type);
void gr_opengl_start_decal_pass();
diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp
index ed9e14a995c..cafb228d533 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"
@@ -28,6 +31,9 @@
#include "es_compatibility.h"
#endif
+static void opengl_post_setup_render_targets();
+static void opengl_post_shutdown_render_targets();
+
extern bool PostProcessing_override;
extern int opengl_check_framebuffer();
// Needed to track where the FXAA shaders are
@@ -63,6 +69,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;
@@ -99,7 +113,7 @@ void opengl_post_pass_tonemap()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
}
void opengl_post_pass_bloom()
@@ -134,7 +148,10 @@ void opengl_post_pass_bloom()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture);
- opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f);
+ // Reads the scene texture directly rather than an already-cropped intermediate, so it is
+ // the scaled variant. The blur/composite passes below read Bloom_textures, which this pass
+ // fills edge to edge, so those stay unscaled.
+ opengl_draw_full_screen_scene_texture();
}
// ------ end bright pass ------
@@ -293,7 +310,7 @@ void opengl_post_pass_fxaa()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
// set and configure post shader ..
opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0));
@@ -310,7 +327,7 @@ void opengl_post_pass_fxaa()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_luminance_texture);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
opengl_shader_set_current();
}
@@ -333,7 +350,7 @@ static void smaa_detect_edges()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
}
static void smaa_calculate_blending_weights()
@@ -358,7 +375,7 @@ static void smaa_calculate_blending_weights()
GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_area_tex);
GL_state.Texture.Enable(2, GL_TEXTURE_2D, Smaa_search_tex);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
}
static void smaa_neighborhood_blending()
@@ -381,7 +398,7 @@ static void smaa_neighborhood_blending()
GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture);
GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_blend_tex);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
}
void smaa_resolve()
@@ -491,7 +508,7 @@ void opengl_post_lightshafts()
GL_state.Blend(GL_TRUE);
GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE);
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
GL_state.Blend(GL_FALSE);
break;
@@ -500,6 +517,141 @@ void opengl_post_lightshafts()
}
}
+// Delete the uploaded aperture/starburst handles, leaving the cache keys alone --
+// the re-upload path below has already set them to what it is about to upload.
+static void opengl_lens_flare_delete_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;
+ }
+}
+
+// drop them and forget what was uploaded, so the next frame uploads afresh
+static void opengl_lens_flare_release_textures()
+{
+ opengl_lens_flare_delete_textures();
+ Lens_flare_tex_lens_idx = -1;
+ Lens_flare_tex_generation = 0;
+}
+
+// Upload the CPU-generated aperture/starburst textures of the mounted lens, or
+// keep the ones already uploaded for it. When the pair is still current
+// lens_flare_textures_if_changed() says so and there is nothing to do -- deciding
+// *that* is a rule about the lens module, so it lives there rather than being
+// re-derived identically in each backend.
+static bool opengl_lens_flare_ensure_textures(int lens_idx)
+{
+ const auto* tex =
+ graphics::lens_flare_textures_if_changed(lens_idx, Lens_flare_tex_lens_idx, Lens_flare_tex_generation);
+ if (tex == nullptr) {
+ return Lens_flare_aperture_tex != 0;
+ }
+
+ opengl_lens_flare_delete_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");
+
+ 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()
+{
+ // 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;
+ }
+
+ 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 +667,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();
@@ -625,7 +780,7 @@ void gr_opengl_post_process_end()
// now render it to the screen ...
GL_state.PopFramebufferState();
- opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+ opengl_draw_full_screen_scene_texture();
//Shadow Map debug window
//#define SHADOW_DEBUG
@@ -1020,77 +1175,93 @@ static GLuint load_smaa_texture(GLsizei width, GLsizei height, GLenum format, co
return tex;
}
-static void setup_smaa_resources()
+// The SMAA area and search textures are fixed-size lookup tables baked into the binary, so unlike
+// everything else here they survive a resolution change untouched.
+static void setup_smaa_lookup_textures()
{
- GL_state.PushFramebufferState();
-
Smaa_area_tex = load_smaa_texture(AREATEX_WIDTH, AREATEX_HEIGHT, GL_RG8, areaTexBytes, "SMAA Area Texture");
Smaa_search_tex =
load_smaa_texture(SEARCHTEX_WIDTH, SEARCHTEX_HEIGHT, GL_R8, searchTexBytes, "SMAA Search Texture");
+}
+static void setup_smaa_render_targets()
+{
setup_smaa_edges_resources();
setup_smaa_blending_weight_resources();
setup_smaa_neighborhood_blending_resources();
-
- GL_state.PopFramebufferState();
}
-// generate and test the framebuffer and textures that we are going to use
-static bool opengl_post_init_framebuffer()
+static void shutdown_smaa_render_targets()
{
- bool rval = false;
+ opengl_delete_render_texture(Smaa_edges_tex);
+ opengl_delete_render_framebuffer(Smaa_edge_detection_fb);
- // clamp size, if needed
- Post_texture_width = gr_screen.max_w;
- Post_texture_height = gr_screen.max_h;
+ opengl_delete_render_texture(Smaa_blend_tex);
+ opengl_delete_render_framebuffer(Smaa_blending_weight_fb);
- if (Post_texture_width > GL_max_renderbuffer_size) {
- Post_texture_width = GL_max_renderbuffer_size;
- }
+ opengl_delete_render_texture(Smaa_output_tex);
+ opengl_delete_render_framebuffer(Smaa_neighborhood_blending_fb);
+}
- if (Post_texture_height > GL_max_renderbuffer_size) {
- Post_texture_height = GL_max_renderbuffer_size;
- }
+// Allocate every post-processing resource whose size follows the scene textures. Split out from
+// opengl_post_process_init() so gr_opengl_resize_render_targets() can rebuild just these without
+// re-parsing post_processing.tbl or recompiling shaders.
+static void opengl_post_setup_render_targets()
+{
+ // These consume the scene textures pass by pass, so they have to match them exactly rather
+ // than being sized from gr_screen independently -- see gr_opengl_scene_texture_begin() for
+ // what the two sizes diverging would mean.
+ Post_texture_width = Scene_texture_width;
+ Post_texture_height = Scene_texture_height;
+
+ GL_state.PushFramebufferState();
opengl_setup_bloom_textures();
// Always set up SMAA resources so the user can switch to an SMAA preset
// at runtime even when starting with a non-SMAA AA mode, such as None.
- //if (Gr_aa_mode != AntiAliasMode::None) {
- setup_smaa_resources();
- //}
+ setup_smaa_render_targets();
- GL_state.BindFrameBuffer(0);
+ GL_state.PopFramebufferState();
- rval = true;
+ GL_state.BindFrameBuffer(0);
+}
- if ( opengl_check_for_errors("post_init_framebuffer()") ) {
- rval = false;
- }
+void opengl_post_process_shutdown_bloom()
+{
+ opengl_delete_render_texture(Bloom_textures[0]);
+ opengl_delete_render_texture(Bloom_textures[1]);
+ opengl_delete_render_framebuffer(Bloom_framebuffer);
+}
- return rval;
+static void opengl_post_shutdown_render_targets()
+{
+ opengl_post_process_shutdown_bloom();
+ shutdown_smaa_render_targets();
}
+void opengl_post_resize_render_targets()
+{
+ // Post-processing may have been disabled outright (no FBOs, missing shaders, or turned off in
+ // the table), in which case none of these resources exist and none should start existing now.
+ if ( !Post_initialized ) {
+ return;
+ }
+ opengl_post_shutdown_render_targets();
+ opengl_post_setup_render_targets();
+}
-void opengl_post_process_shutdown_bloom()
+// generate and test the framebuffer and textures that we are going to use
+static bool opengl_post_init_framebuffer()
{
- if ( Bloom_textures[0] ) {
- glDeleteTextures(1, &Bloom_textures[0]);
- Bloom_textures[0] = 0;
- }
+ setup_smaa_lookup_textures();
- if ( Bloom_textures[1] ) {
- glDeleteTextures(1, &Bloom_textures[1]);
- Bloom_textures[1] = 0;
- }
+ opengl_post_setup_render_targets();
- if ( Bloom_framebuffer > 0 ) {
- glDeleteFramebuffers(1, &Bloom_framebuffer);
- Bloom_framebuffer = 0;
- }
+ return !opengl_check_for_errors("post_init_framebuffer()");
}
void opengl_post_process_init()
@@ -1141,20 +1312,22 @@ void opengl_post_process_shutdown()
return;
}
- if (Post_framebuffer_id[0]) {
- glDeleteFramebuffers(1, &Post_framebuffer_id[0]);
- Post_framebuffer_id[0] = 0;
-
- if (Post_framebuffer_id[1]) {
- glDeleteFramebuffers(1, &Post_framebuffer_id[1]);
- Post_framebuffer_id[1] = 0;
- }
- }
+ opengl_delete_render_framebuffer(Post_framebuffer_id[0]);
+ opengl_delete_render_framebuffer(Post_framebuffer_id[1]);
graphics::Post_processing_manager->clear();
graphics::Post_processing_manager = nullptr;
- opengl_post_process_shutdown_bloom();
+ opengl_post_shutdown_render_targets();
+
+ opengl_delete_render_texture(Smaa_area_tex);
+ opengl_delete_render_texture(Smaa_search_tex);
+
+ 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/opengl/gropenglpostprocessing.h b/code/graphics/opengl/gropenglpostprocessing.h
index d820222bb89..cdba2579d88 100644
--- a/code/graphics/opengl/gropenglpostprocessing.h
+++ b/code/graphics/opengl/gropenglpostprocessing.h
@@ -8,6 +8,10 @@
void opengl_post_process_init();
void opengl_post_process_shutdown();
+// Rebuild the resolution-dependent subset of the above for the current scene texture size, without
+// re-parsing post_processing.tbl or recompiling shaders. No-op if post-processing isn't active.
+void opengl_post_resize_render_targets();
+
void gr_opengl_post_process_set_effect(const char *name, int x, const vec3d *rgb);
void gr_opengl_post_process_set_defaults();
void gr_opengl_post_process_save_zbuffer();
diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp
index 9375b353468..54c3718999d 100644
--- a/code/graphics/opengl/gropengltexture.cpp
+++ b/code/graphics/opengl/gropengltexture.cpp
@@ -89,28 +89,6 @@ static auto TextureFilteringOption __UNUSED = options::OptionBuilder("Graph
.parser(parse_texture_filtering_func)
.finish();
-static SCP_vector anisotropic_value_enumerator()
-{
- float max;
- if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) {
- return SCP_vector();
- }
-
- if (max <= 2.0f) {
- return SCP_vector();
- }
-
- SCP_vector out;
-
- // We assume here that the anisotropy levels are powers of two...
- float current = 1.0f;
- while (current <= max) {
- out.push_back(current);
- current *= 2.0f;
- }
-
- return out;
-}
static SCP_string anisotropic_display(float val)
{
if (val < 2.0f) {
@@ -134,7 +112,7 @@ static float anisotropic_default()
static auto AnisotropyOption = options::OptionBuilder("Graphics.Anisotropy",
std::pair{"Anistropic filtering", 1736},
std::pair{"Controls the amount of anistropic filtering of the textures", 1737})
- .enumerator(anisotropic_value_enumerator)
+ .enumerator(gr_get_supported_anisotropy_levels)
.category(std::make_pair("Graphics", 1825))
.display(anisotropic_display)
.default_func(anisotropic_default)
@@ -189,8 +167,15 @@ void opengl_tcache_init()
// check what mipmap filter we should be using
// 0 == Bilinear
// 1 == Trilinear
+ // Seed from the legacy config key first: TextureFilteringOption's default_func returns
+ // GL_mipmap_filter, so this read is what supplies that default. Only then let the option
+ // override it, the same order the anisotropy setting below uses.
GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1);
+ if (Using_in_game_options) {
+ GL_mipmap_filter = TextureFilteringOption->getValue();
+ }
+
if (GL_mipmap_filter > 1) {
GL_mipmap_filter = 1;
}
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..4287b2b65e0 100644
--- a/code/graphics/util/uniform_structs.h
+++ b/code/graphics/util/uniform_structs.h
@@ -289,6 +289,71 @@ 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;
+
+// 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:
+//
+// 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; // w = LENS_QUAD_*, the kind tag; see the table above for xyz
+ vec4 halfext;
+ vec4 apscale;
+ vec4 apoff;
+ vec4 color;
+};
+
+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
+
+ // 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];
+};
+
+// 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/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..3064e0d9b1e 100644
--- a/code/graphics/vulkan/VulkanPostProcessing.h
+++ b/code/graphics/vulkan/VulkanPostProcessing.h
@@ -273,6 +273,91 @@ 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);
+ void forgetTextures();
+
+ 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 +869,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 +973,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 +1265,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..50d6a3ea791
--- /dev/null
+++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp
@@ -0,0 +1,389 @@
+#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 flare source per scene render. Sun counts are single-digit,
+// but every lit nozzle is also a source, capped at MAX_THRUSTER_SOURCES (32) in
+// lens_flare.cpp -- so this has to hold that plus the suns, several times over
+// for a frame that renders the scene more than once. At ~5 KB a slot that is
+// still under a megabyte per frame in flight. The draw loop bails out rather
+// than overflowing the ring if a frame ever exceeds it anyway.
+constexpr uint32_t LENS_FLARE_UBO_SLOTS = 128;
+} // 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);
+}
+
+// Drop them and forget what was uploaded, so the next frame uploads afresh.
+// Distinct from releaseTextures(), which the re-upload path uses to retire the
+// outgoing pair *after* the cache keys have been set to the incoming one.
+void VulkanLensFlare::forgetTextures()
+{
+ releaseTextures(true);
+ m_texLensIdx = -1;
+ m_texGeneration = 0;
+}
+
+bool VulkanLensFlare::ensureTextures(int lensIdx)
+{
+ // Whether the pair we already hold is still current is a rule about the lens
+ // module, so it answers it -- rather than each backend re-deriving the same
+ // (lens, generation) comparison. A null return means nothing changed.
+ const auto* tex = graphics::lens_flare_textures_if_changed(lensIdx, m_texLensIdx, m_texGeneration);
+ if (tex == nullptr) {
+ return m_apertureView.operator bool();
+ }
+
+ auto* texMgr = getTextureManager();
+ if (texMgr == nullptr) {
+ forgetTextures();
+ 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)) {
+ forgetTextures();
+ 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)) {
+ forgetTextures();
+ return false;
+ }
+
+ return true;
+}
+
+void VulkanLensFlare::execute(vk::CommandBuffer cmd)
+{
+ if (!m_initialized) {
+ return;
+ }
+
+ // 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;
+ }
+
+ 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), so this
+ // destroys immediately rather than queueing, and forgets what was uploaded so
+ // a re-init starts from nothing.
+ releaseTextures(false);
+ m_texLensIdx = -1;
+ m_texGeneration = 0;
+
+ 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/VulkanPostProcessingLighting.cpp b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp
index 2985c0b6509..e353bf73e93 100644
--- a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp
+++ b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp
@@ -489,8 +489,12 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd)
{
auto* header = reinterpret_cast(uboMapped);
memset(header, 0, sizeof(graphics::deferred_global_data));
- header->invScreenWidth = 1.0f / gr_screen.max_w;
- header->invScreenHeight = 1.0f / gr_screen.max_h;
+ // Same as the OpenGL backend: deferred-f.sdr normalizes gl_FragCoord against these to
+ // sample the G-buffer, so they must describe the G-buffer, not gr_screen. resize() keeps
+ // sceneExtent equal to gr_screen today, which is why deriving it from the extent is a
+ // no-op here -- but it states the actual requirement instead of relying on that.
+ header->invScreenWidth = 1.0f / static_cast(m_ctx->sceneExtent.width);
+ header->invScreenHeight = 1.0f / static_cast(m_ctx->sceneExtent.height);
header->nearPlane = gr_near_plane;
if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) {
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/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..fe62319229f 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,10 @@ 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(graphics::lens_aperture& ap);
+ static void build_thruster_flare_options();
+ static void build_lens_flare_pass_report();
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..f8929e3d24e
--- /dev/null
+++ b/code/lab/dialogs/lab_ui_lens_flare.cpp
@@ -0,0 +1,344 @@
+#include "lab_ui.h"
+
+#include "graphics/2d.h"
+#include "graphics/lens_flare.h"
+#include "lab/labv2_internal.h"
+#include "object/object.h"
+#include "ship/ship.h"
+#include "starfield/starfield.h"
+#include "weapon/beam.h"
+
+using namespace ImGui;
+
+namespace {
+
+// A thruster draw names the ship by objnum, and the pass that produced it ran a
+// frame ago -- so the object may already be gone by the time the panel reads it.
+const char* flare_source_ship_name(int objnum)
+{
+ if (objnum < 0 || objnum >= MAX_OBJECTS || Objects[objnum].type != OBJ_SHIP) {
+ return "";
+ }
+ return Ships[Objects[objnum].instance].ship_name;
+}
+
+// A beam draw names the beam object itself, which isn't a ship -- what the
+// panel actually wants to show is who is firing it.
+const char* flare_source_beam_shooter_name(int beam_objnum)
+{
+ if (beam_objnum < 0 || beam_objnum >= MAX_OBJECTS || Objects[beam_objnum].type != OBJ_BEAM) {
+ return "";
+ }
+ const int bm_idx = Objects[beam_objnum].instance;
+ if (bm_idx < 0 || bm_idx >= MAX_BEAMS) {
+ return "";
+ }
+ const beam& bm = Beams[bm_idx];
+ if (bm.objp == nullptr || bm.objp->type != OBJ_SHIP) {
+ return "";
+ }
+ return Ships[bm.objp->instance].ship_name;
+}
+
+} // namespace
+
+// 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(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_overrides().aperture = ap;
+ graphics::lens_flare_overrides_changed();
+ }
+}
+
+void LabUi::build_lens_flare_options()
+{
+ // The camera's own settings, resolved once: the controls below start from
+ // whatever is currently in force -- a lens's tabled values, or whatever this
+ // panel or a mission has already overridden them with -- so nothing here has
+ // to know which of the two it is looking at.
+ const int active_lens = graphics::lens_flare_active_lens();
+ graphics::lens_settings settings = graphics::lens_flare_effective_settings(active_lens);
+ auto& overrides = graphics::lens_flare_overrides();
+ bool settings_changed = false;
+
+ // A control writes back *only its own* override, and only when it actually
+ // moved. Writing the whole set on any change would freeze the mounted lens's
+ // entire tabled look into the overrides the moment one slider was nudged --
+ // after which switching lenses in the combo below would keep showing the old
+ // lens's intensity, starburst and squeeze, since an override quite correctly
+ // beats whatever the new lens tables.
+ auto edited = [&settings_changed](bool moved, auto& slot, const auto& value) {
+ if (moved) {
+ slot = value;
+ settings_changed = true;
+ }
+ return moved;
+ };
+
+ // Not per-camera and so not overridable: this one describes the display.
+ auto& tuning = graphics::lens_flare_get_tuning();
+ edited(SliderFloat("Ghost brightness", &settings.ghost_brightness, 0.0f, 500.0f, "%.1f",
+ ImGuiSliderFlags_Logarithmic),
+ overrides.ghost_brightness, settings.ghost_brightness);
+ edited(SliderFloat("Starburst brightness", &settings.starburst_brightness, 0.0f, 10.0f),
+ overrides.starburst_brightness, settings.starburst_brightness);
+ 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", tuning.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 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 (const auto* lens = graphics::lens_flare_get_system(active_lens)) {
+ edited(SliderFloat("Lens intensity", &settings.intensity, 0.0f, 10.0f), overrides.intensity,
+ settings.intensity);
+ edited(Checkbox("Starburst", &settings.starburst), overrides.starburst, settings.starburst);
+ if (settings.starburst) {
+ edited(SliderFloat("Starburst scale", &settings.starburst_scale, 0.0f, 4.0f, "%.2fx"),
+ overrides.starburst_scale, settings.starburst_scale);
+ }
+ edited(SliderInt("Max ghosts", &settings.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS),
+ overrides.max_ghosts, settings.max_ghosts);
+
+ // The squeeze and the streak are one artifact and are overridden together,
+ // so unlike the knobs above they share a slot -- any of the five moving
+ // writes the whole lens_anamorphic. All of them cost nothing to change:
+ // they are applied when the quads are drawn, with no texture to rebuild.
+ graphics::lens_streak& streak = settings.anamorphic.streak;
+ bool anamorphic_moved = SliderFloat("Anamorphic squeeze", &settings.anamorphic.squeeze, 1.0f, 3.0f, "%.2fx");
+ with_TreeNode("Anamorphic streak")
+ {
+ TextDisabled("Stays horizontal wherever the sun is");
+ anamorphic_moved |= SliderFloat("Streak strength", &streak.strength, 0.0f, 2.0f);
+ if (streak.strength > 0.0f) {
+ anamorphic_moved |= SliderFloat("Streak length", &streak.length, 0.0f, 4.0f);
+ anamorphic_moved |= SliderFloat("Streak thickness", &streak.thickness, 0.001f, 0.2f, "%.3f");
+ anamorphic_moved |= ColorEdit3("Streak tint", streak.tint);
+ }
+ }
+ edited(anamorphic_moved, overrides.anamorphic, settings.anamorphic);
+
+ Text("%d of %d ghosts | EFL %.1f mm | f/%.1f | %s",
+ MIN(static_cast(lens->ghosts.size()), MAX(settings.max_ghosts, 0)),
+ static_cast(lens->ghosts.size()),
+ lens->efl,
+ lens->efl / (2.0f * lens->entrance_radius),
+ settings.starburst ? "starburst" : "no starburst");
+
+ build_lens_aperture_options(settings.aperture);
+ } else {
+ TextDisabled("No lens mounted: this background renders no physically-based flares");
+ }
+
+ // The overrides themselves were written above, by whichever control moved.
+ // This only publishes the fact that something did -- the iris sliders do it
+ // for themselves, since theirs is the edit that costs a texture rebuild.
+ if (settings_changed) {
+ graphics::lens_flare_overrides_changed();
+ }
+
+ Separator();
+ build_thruster_flare_options();
+
+ // live pass state, refreshed every frame by lens_flare_frame_update():
+ // one entry per light source that got a draw
+ Separator();
+ build_lens_flare_pass_report();
+}
+
+// What the last pass drew. Suns and beams are listed one by one -- neither is
+// ever more than a handful -- while nozzles are summarised, because at full
+// budget there are dozens of them and a line each would bury everything else
+// in this window.
+void LabUi::build_lens_flare_pass_report()
+{
+ const auto& draws = graphics::lens_flare_get_frame_draws();
+ if (draws.empty()) {
+ TextUnformatted("Last pass: inactive (no visible source, or no lens mounted)");
+ return;
+ }
+
+ int thruster_draws = 0;
+ int thruster_instances = 0;
+ float max_off_axis = -1.0f;
+ int max_off_axis_obj = -1;
+
+ Text("Last pass: %d source(s) drawn", static_cast(draws.size()));
+ for (const auto& draw : draws) {
+ if (draw.kind == graphics::flare_source_kind::sun) {
+ Text(" Sun (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f",
+ stars_get_sun_name(draw.source_index),
+ draw.instances,
+ draw.visibility,
+ draw.off_axis_deg,
+ draw.output_scale);
+ continue;
+ }
+
+ if (draw.kind == graphics::flare_source_kind::beam) {
+ Text(" Beam (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f",
+ flare_source_beam_shooter_name(draw.source_index),
+ draw.instances,
+ draw.visibility,
+ draw.off_axis_deg,
+ draw.output_scale);
+ continue;
+ }
+
+ thruster_draws++;
+ thruster_instances += draw.instances;
+ if (draw.off_axis_deg > max_off_axis) {
+ max_off_axis = draw.off_axis_deg;
+ max_off_axis_obj = draw.source_index;
+ }
+ }
+
+ if (thruster_draws > 0) {
+ Text(" Thrusters: %d nozzle(s), %d instances total, furthest off-axis %.1f deg on %s",
+ thruster_draws,
+ thruster_instances,
+ max_off_axis,
+ flare_source_ship_name(max_off_axis_obj));
+ }
+}
+
+// Thruster flares are tabled per species, but the lab overrides all species at
+// once -- it shows one ship at a time, and a single override leaves every tabled
+// value untouched, so nothing has to be restored on the way out (see
+// lens_flare.h).
+void LabUi::build_thruster_flare_options()
+{
+ auto& lab_override = graphics::lens_flare_lab_thruster_flare();
+
+ bool overriding = lab_override.has_value();
+ if (Checkbox("Override thruster flares", &overriding)) {
+ if (overriding) {
+ // Start from what the displayed ship's own species tables, so switching
+ // the override on changes nothing until a slider is touched
+ const int species_idx =
+ getLabManager()->isSafeForShips() ? Ship_info[getLabManager()->CurrentClass].species : -1;
+ auto tabled = graphics::lens_flare_thruster_settings(species_idx);
+ tabled.enabled = true;
+ lab_override = tabled;
+ } else {
+ lab_override.reset();
+ }
+ }
+
+ // Not part of the override: this is a render policy, not content, so it
+ // applies whether or not the tabled values are being overridden
+ auto& tuning = graphics::lens_flare_get_tuning();
+ Checkbox("Draw ghosts for thruster flares", &tuning.thruster_ghosts);
+ TextDisabled("Off by default: every lit nozzle is its own source, so a ghost");
+ TextDisabled("train each is both the cost of the pass and, at that count,");
+ TextDisabled("noise. Turn it on to see what it buys and what it costs.");
+
+ if (!lab_override) {
+ TextDisabled("Using each species' own species_defs.tbl settings");
+ return;
+ }
+
+ Checkbox("Thruster flares enabled", &lab_override->enabled);
+ SliderFloat("Thruster intensity", &lab_override->intensity, 0.0f, 50.0f);
+ SliderFloat("Afterburner intensity", &lab_override->afterburner_intensity, 0.0f, 50.0f);
+ ColorEdit3("Thruster flare tint", lab_override->color.a1d);
+ TextDisabled("1.0 = one nozzle of radius r seen from 32r away. At combat");
+ TextDisabled("range a nozzle is a small fraction of that, which is why the");
+ TextDisabled("useful values are large. Brightness also follows throttle,");
+ TextDisabled("nozzle facing and distance, so it is never constant per ship.");
+}
diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp
index eec2c2d1813..cf5ba45070c 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,23 @@ void LabRenderer::useBackground(const SCP_string& mission_name) {
ltp::switch_to(ltp_name);
}
+ // 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;
+ if (optional_string("$Camera Lens:"))
+ stuff_string(lens_name, F_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 b5090b26fd0..5c15d5bb3dc 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"
@@ -774,6 +775,127 @@ void parse_custom_bitmap(const char *expected_string_640, const char *expected_s
}
}
+// Read a mission-file option into an override slot, leaving it unset when the
+// mission doesn't mention it. Unset is what makes the mounted lens's own tabled
+// value stand, so it has to stay distinct from a value that happens to equal it.
+static void stuff_lens_override(const char *token, std::optional &dest)
+{
+ if (optional_string(token))
+ stuff_float(&dest.emplace());
+}
+
+static void stuff_lens_override(const char *token, std::optional &dest)
+{
+ if (optional_string(token))
+ stuff_int(&dest.emplace());
+}
+
+static void stuff_lens_override(const char *token, std::optional &dest)
+{
+ if (optional_string(token))
+ stuff_boolean(&dest.emplace());
+}
+
+// How this mission restyles the camera lens (see graphics/lens_flare.h). Three
+// blocks, each independently optional:
+//
+// "$Lens Aperture:" the iris, replaced as a whole
+// "$Lens Anamorphic:" the squeeze and streak, replaced as a whole
+// "$Lens Flare Strength:" the brightness knobs, each on its own
+//
+// The first two are whole-struct replacements because they are single artifacts
+// -- one iris drives both the ghosts and the starburst, and one squeeze governs
+// the streak that goes with it -- so a partial "$Lens Aperture:" that only names
+// "+Dust Strength:" also takes the *default* blades and curvature rather than the
+// mounted lens's. FRED writes every field it doesn't leave at default, so this
+// only bites a hand-edited mission file. The strength knobs have no such coupling
+// and so are overridden one at a time.
+static void parse_camera_lens_overrides(graphics::lens_overrides &overrides)
+{
+ if (optional_string("$Lens Aperture:")) {
+ graphics::lens_aperture &ap = overrides.aperture.emplace();
+
+ if (optional_string("+Blades:"))
+ stuff_int(&ap.blades);
+ if (optional_string("+Rotation:"))
+ stuff_float(&ap.rotation);
+ if (optional_string("+Curvature:"))
+ stuff_float(&ap.curvature);
+ if (optional_string("+Softness:"))
+ stuff_float(&ap.softness);
+
+ if (optional_string("+Grating Strength:"))
+ stuff_float(&ap.grating.strength);
+ if (optional_string("+Grating Density:"))
+ stuff_float(&ap.grating.density);
+ if (optional_string("+Grating Length:"))
+ stuff_float(&ap.grating.length);
+ if (optional_string("+Grating Width:"))
+ stuff_float(&ap.grating.width);
+ if (optional_string("+Grating Softness:"))
+ stuff_float(&ap.grating.softness);
+
+ if (optional_string("+Scratches Strength:"))
+ stuff_float(&ap.scratches.strength);
+ if (optional_string("+Scratches Density:"))
+ stuff_float(&ap.scratches.density);
+ if (optional_string("+Scratches Length:"))
+ stuff_float(&ap.scratches.length);
+ if (optional_string("+Scratches Width:"))
+ stuff_float(&ap.scratches.width);
+ if (optional_string("+Scratches Rotation:"))
+ stuff_float(&ap.scratches.rotation);
+ if (optional_string("+Scratches Rotation Variation:"))
+ stuff_float(&ap.scratches.rotation_variation);
+ if (optional_string("+Scratches Softness:"))
+ stuff_float(&ap.scratches.softness);
+
+ if (optional_string("+Dust Strength:"))
+ stuff_float(&ap.dust.strength);
+ if (optional_string("+Dust Density:"))
+ stuff_float(&ap.dust.density);
+ if (optional_string("+Dust Radius:"))
+ stuff_float(&ap.dust.radius);
+ if (optional_string("+Dust Softness:"))
+ stuff_float(&ap.dust.softness);
+ }
+
+ if (optional_string("$Lens Anamorphic:")) {
+ graphics::lens_anamorphic &an = overrides.anamorphic.emplace();
+
+ if (optional_string("+Squeeze:"))
+ stuff_float(&an.squeeze);
+
+ if (optional_string("+Streak Strength:"))
+ stuff_float(&an.streak.strength);
+ if (optional_string("+Streak Length:"))
+ stuff_float(&an.streak.length);
+ if (optional_string("+Streak Thickness:"))
+ stuff_float(&an.streak.thickness);
+ if (optional_string("+Streak Tint:")) {
+ float rgb[3] = {an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]};
+ size_t count = stuff_float_list(rgb, 3);
+ if (count != 3) {
+ error_display(0, "Mission '%s': $Lens Anamorphic:'s +Streak Tint: needs ( r, g, b )",
+ The_mission.name.c_str());
+ } else {
+ an.streak.tint[0] = rgb[0];
+ an.streak.tint[1] = rgb[1];
+ an.streak.tint[2] = rgb[2];
+ }
+ }
+ }
+
+ if (optional_string("$Lens Flare Strength:")) {
+ stuff_lens_override("+Intensity:", overrides.intensity);
+ stuff_lens_override("+Ghost Brightness:", overrides.ghost_brightness);
+ stuff_lens_override("+Starburst Brightness:", overrides.starburst_brightness);
+ stuff_lens_override("+Starburst:", overrides.starburst);
+ stuff_lens_override("+Starburst Scale:", overrides.starburst_scale);
+ stuff_lens_override("+Max Ghosts:", overrides.max_ghosts);
+ }
+}
+
void parse_mission_info(mission *pm, bool basic = false)
{
char game_string[NAME_LENGTH];
@@ -1118,6 +1240,26 @@ 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).
+ // 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);
+ graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str());
+
+ parse_camera_lens_overrides(The_mission.camera_lens_overrides);
+
+ // One camera, so the mission's overrides simply *are* the camera's until
+ // something else (a set-lens-* sexp, the lab) restyles it again. Nothing is
+ // stamped into the mounted lens, which is why nothing has to be restored when
+ // this mission ends -- lens_flare_reset_for_level() just drops these.
+ graphics::lens_flare_overrides() = The_mission.camera_lens_overrides;
+ graphics::lens_flare_overrides_changed();
+
if (optional_string("$Sound Environment:")) {
char preset[65] = { '\0' };
stuff_string(preset, F_NAME, sizeof(preset)-1);
@@ -7395,6 +7537,10 @@ void mission::Reset()
ai_profile = &Ai_profiles[Default_ai_profile];
lighting_profile_name = lighting_profiles::default_name();
+ // empty = this mission names no lens, so the tabled default applies
+ camera_lens_name.clear();
+ // all unset = this mission restyles nothing, so the mounted lens stands as tabled
+ camera_lens_overrides.clear();
cutscenes.clear( );
diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h
index 8e917787108..0223c20e191 100644
--- a/code/mission/missionparse.h
+++ b/code/mission/missionparse.h
@@ -17,6 +17,7 @@
#include "ai/ai_profiles.h"
#include "globalincs/version.h"
#include "graphics/2d.h"
+#include "graphics/lens_flare.h"
#include "io/keycontrol.h"
#include "model/model.h"
#include "model/animation/modelanimation.h"
@@ -236,6 +237,26 @@ typedef struct mission {
SCP_string lighting_profile_name;
+ // 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;
+
+ // How this mission restyles the camera: iris shape, anamorphic look, and the
+ // flare's strength. Settable in FRED via the Background Editor's
+ // "Lens Aperture..." dialog, and the same thing the set-lens-* sexps write --
+ // except applied at mission load rather than by an event.
+ //
+ // Every field is independently optional, so "this mission says nothing about
+ // the iris" stays distinct from "this mission wants the default iris".
+ // Independent of which lens is mounted, since there is only ever one camera
+ // (graphics/lens_flare.h) -- if $Camera Lens: changes, these still apply to
+ // whichever lens ends up mounted.
+ graphics::lens_overrides camera_lens_overrides;
+
SCP_vector cutscenes;
SCP_map custom_data;
diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp
index 72201544116..b7680b6ced6 100644
--- a/code/missioneditor/missionsave.cpp
+++ b/code/missioneditor/missionsave.cpp
@@ -393,6 +393,57 @@ int Fred_mission_save::fout_version(const char* format, ...)
return 0;
}
+void Fred_mission_save::fout_lens_field(const char* token, float val)
+{
+ if (optional_string_fred(token)) {
+ parse_comments(1);
+ fout(" %f", val);
+ } else {
+ fout_version("\n%s %f", token, val);
+ }
+}
+
+void Fred_mission_save::fout_lens_field(const char* token, int val)
+{
+ if (optional_string_fred(token)) {
+ parse_comments(1);
+ fout(" %d", val);
+ } else {
+ fout_version("\n%s %d", token, val);
+ }
+}
+
+void Fred_mission_save::fout_lens_field(const char* token, bool val)
+{
+ if (optional_string_fred(token)) {
+ parse_comments(1);
+ fout(" %s", val ? "YES" : "NO");
+ } else {
+ fout_version("\n%s %s", token, val ? "YES" : "NO");
+ }
+}
+
+void Fred_mission_save::fout_lens_field(const char* token, float val, float def)
+{
+ if (val != def) {
+ fout_lens_field(token, val);
+ }
+}
+
+void Fred_mission_save::fout_lens_field(const char* token, int val, int def)
+{
+ if (val != def) {
+ fout_lens_field(token, val);
+ }
+}
+
+void Fred_mission_save::fout_lens_field(const char* token, bool val, bool def)
+{
+ if (val != def) {
+ fout_lens_field(token, val);
+ }
+}
+
void Fred_mission_save::fout_raw_comment(const char* comment_start)
{
Assertion(comment_start <= raw_ptr, "This function assumes the beginning of the comment precedes the current raw pointer!");
@@ -3128,6 +3179,139 @@ int Fred_mission_save::save_mission_info()
bypass_comment(";;FSO 23.1.0;; $Lighting Profile:");
}
+ // 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);
+ 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:");
+ }
+
+ // the-e's per-mission camera-lens overrides -- the iris, the anamorphic look
+ // and the flare's strength, the same things the set-lens-* sexps control but
+ // applied at mission load instead of by an event (see the field comment in
+ // missionparse.h). Each block is written only when the mission actually
+ // overrides that part, and within it each field only when it differs from its
+ // own default, so leaving (say) grating alone doesn't bloat every mission file
+ // with zeroes.
+ const graphics::lens_overrides& lens = The_mission.camera_lens_overrides;
+
+ if (lens.aperture) {
+ const graphics::lens_aperture& ap = *lens.aperture;
+ const graphics::lens_aperture def;
+
+ fso_comment_push(";;FSO 26.1.0;;");
+ if (optional_string_fred("$Lens Aperture:")) {
+ parse_comments(2);
+ } else {
+ fout_version("\n\n$Lens Aperture:");
+ }
+
+ fout_lens_field("+Blades:", ap.blades, def.blades);
+ fout_lens_field("+Rotation:", ap.rotation, def.rotation);
+ fout_lens_field("+Curvature:", ap.curvature, def.curvature);
+ fout_lens_field("+Softness:", ap.softness, def.softness);
+
+ fout_lens_field("+Grating Strength:", ap.grating.strength, def.grating.strength);
+ fout_lens_field("+Grating Density:", ap.grating.density, def.grating.density);
+ fout_lens_field("+Grating Length:", ap.grating.length, def.grating.length);
+ fout_lens_field("+Grating Width:", ap.grating.width, def.grating.width);
+ fout_lens_field("+Grating Softness:", ap.grating.softness, def.grating.softness);
+
+ fout_lens_field("+Scratches Strength:", ap.scratches.strength, def.scratches.strength);
+ fout_lens_field("+Scratches Density:", ap.scratches.density, def.scratches.density);
+ fout_lens_field("+Scratches Length:", ap.scratches.length, def.scratches.length);
+ fout_lens_field("+Scratches Width:", ap.scratches.width, def.scratches.width);
+ fout_lens_field("+Scratches Rotation:", ap.scratches.rotation, def.scratches.rotation);
+ fout_lens_field("+Scratches Rotation Variation:", ap.scratches.rotation_variation,
+ def.scratches.rotation_variation);
+ fout_lens_field("+Scratches Softness:", ap.scratches.softness, def.scratches.softness);
+
+ fout_lens_field("+Dust Strength:", ap.dust.strength, def.dust.strength);
+ fout_lens_field("+Dust Density:", ap.dust.density, def.dust.density);
+ fout_lens_field("+Dust Radius:", ap.dust.radius, def.dust.radius);
+ fout_lens_field("+Dust Softness:", ap.dust.softness, def.dust.softness);
+
+ fso_comment_pop();
+ } else {
+ bypass_comment(";;FSO 26.1.0;; $Lens Aperture:");
+ }
+
+ if (lens.anamorphic) {
+ const graphics::lens_anamorphic& an = *lens.anamorphic;
+ const graphics::lens_anamorphic def;
+
+ fso_comment_push(";;FSO 26.1.0;;");
+ if (optional_string_fred("$Lens Anamorphic:")) {
+ parse_comments(2);
+ } else {
+ fout_version("\n\n$Lens Anamorphic:");
+ }
+
+ fout_lens_field("+Squeeze:", an.squeeze, def.squeeze);
+ fout_lens_field("+Streak Strength:", an.streak.strength, def.streak.strength);
+ fout_lens_field("+Streak Length:", an.streak.length, def.streak.length);
+ fout_lens_field("+Streak Thickness:", an.streak.thickness, def.streak.thickness);
+
+ // The one field that isn't a single number, so it can't go through the
+ // helper: a tint is only meaningful as a whole triple.
+ if (an.streak.tint[0] != def.streak.tint[0] || an.streak.tint[1] != def.streak.tint[1] ||
+ an.streak.tint[2] != def.streak.tint[2]) {
+ if (optional_string_fred("+Streak Tint:")) {
+ parse_comments(1);
+ fout(" ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]);
+ } else {
+ fout_version("\n+Streak Tint: ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1],
+ an.streak.tint[2]);
+ }
+ }
+
+ fso_comment_pop();
+ } else {
+ bypass_comment(";;FSO 26.1.0;; $Lens Anamorphic:");
+ }
+
+ // Unlike the two blocks above -- an iris and an anamorphic look are each one
+ // artifact, overridden whole -- these knobs are independent of each other, so
+ // each is written only if this mission overrode that one.
+ if (lens.intensity || lens.ghost_brightness || lens.starburst_brightness || lens.starburst ||
+ lens.starburst_scale || lens.max_ghosts) {
+ fso_comment_push(";;FSO 26.1.0;;");
+ if (optional_string_fred("$Lens Flare Strength:")) {
+ parse_comments(2);
+ } else {
+ fout_version("\n\n$Lens Flare Strength:");
+ }
+
+ // Written whenever the mission set it, default value or not: here the fact
+ // that it was overridden at all is the content.
+ if (lens.intensity)
+ fout_lens_field("+Intensity:", *lens.intensity);
+ if (lens.ghost_brightness)
+ fout_lens_field("+Ghost Brightness:", *lens.ghost_brightness);
+ if (lens.starburst_brightness)
+ fout_lens_field("+Starburst Brightness:", *lens.starburst_brightness);
+ if (lens.starburst)
+ fout_lens_field("+Starburst:", *lens.starburst);
+ if (lens.starburst_scale)
+ fout_lens_field("+Starburst Scale:", *lens.starburst_scale);
+ if (lens.max_ghosts)
+ fout_lens_field("+Max Ghosts:", *lens.max_ghosts);
+
+ fso_comment_pop();
+ } else {
+ bypass_comment(";;FSO 26.1.0;; $Lens Flare Strength:");
+ }
+
// 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/missionsave.h b/code/missioneditor/missionsave.h
index 7d8f1558344..1da9d87e5b6 100644
--- a/code/missioneditor/missionsave.h
+++ b/code/missioneditor/missionsave.h
@@ -192,6 +192,29 @@ class Fred_mission_save {
*/
int fout_version(const char* format, ...);
+ /**
+ * @brief Writes one "+Token: value" line of a camera-lens override block
+ *
+ * Follows the usual round-trip convention: reuse the token's existing position
+ * and comments if the file already had one, otherwise append.
+ *
+ * @param[in] token The "+Something:" token, including the colon
+ * @param[in] val The value to write
+ */
+ void fout_lens_field(const char* token, float val);
+ void fout_lens_field(const char* token, int val);
+ void fout_lens_field(const char* token, bool val);
+
+ /**
+ * @brief As above, but emits nothing when the value still equals @a def
+ *
+ * For blocks that are overridden as a whole, where a field left at its default
+ * carries no information and would only bloat the file.
+ */
+ void fout_lens_field(const char* token, float val, float def);
+ void fout_lens_field(const char* token, int val, int def);
+ void fout_lens_field(const char* token, bool val, bool def);
+
private:
/**
diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp
index dc29f09fa04..ba849078834 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-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());
+ }
+
+ 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 = LENS_NAME_DEFAULT;
+ 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 48b59f5a53c..21495429049 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,12 @@ 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
+ { "set-lens-flare-strength", OP_SET_LENS_FLARE_STRENGTH, 1, 5, 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 +1158,35 @@ 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"
+// 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.
+//
+// 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
+ // (shared with the mission field and the editors, see graphics/lens_flare.h)
+ // are always meaningful here
+ if (!stricmp(lens_name, LENS_NAME_NONE) || !stricmp(lens_name, LENS_NAME_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;
@@ -1379,6 +1415,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
@@ -3930,6 +3967,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;
@@ -16744,11 +16789,204 @@ 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)
+{
+ // , , 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 lens operators: check that there is a camera to
+// restyle, hand the caller the settings currently in force so it can edit from
+// there rather than from the tabled values, and publish the result.
+//
+// The edit starts from lens_flare_effective_settings() so that these operators
+// compose: set-lens-grating after set-lens-dust keeps the dust, and either after
+// a mission's own "$Lens Aperture:" keeps the rest of that block.
+template
+void sexp_edit_lens(const char* op_name, EditFunc&& edit)
+{
+ int lens_idx = graphics::lens_flare_active_lens();
+ if (lens_idx < 0) {
+ // 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;
+ }
+
+ graphics::lens_settings settings = graphics::lens_flare_effective_settings(lens_idx);
+ edit(settings, graphics::lens_flare_overrides());
+
+ // Cheap to call even when nothing moved: only a genuinely changed iris costs
+ // anything, and the module works that out for itself.
+ graphics::lens_flare_overrides_changed();
+}
+
+// The four iris operators all edit the aperture and nothing else, so they share
+// this wrapper on top of the above.
+template
+void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit)
+{
+ sexp_edit_lens(op_name, [&edit](graphics::lens_settings& settings, graphics::lens_overrides& overrides) {
+ edit(settings.aperture);
+ overrides.aperture = settings.aperture;
+ });
+}
+
+// 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);
+ });
+}
+
+// Same as sexp_lens_next_pct(), but scaling a percentage against a reference
+// value rather than into 0..1 -- so 100 means "the engine's calibrated default"
+// and a designer states these as a proportion of it instead of having to know
+// that a ghost brightness of 64 is normal.
+void sexp_lens_next_scaled_pct(int& n, float& dest, float reference, 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 * reference, 0.0f, max_val);
+
+ n = CDR(n);
+}
+
+// The cheap counterpart to the four iris operators: nothing here touches the
+// aperture, so nothing here rebuilds a texture. Safe to drive from a repeating
+// event, which is exactly what makes it the right operator for a flare that
+// brightens or fades over time.
+void sexp_set_lens_flare_strength(int node)
+{
+ sexp_edit_lens("set-lens-flare-strength",
+ [node](graphics::lens_settings& set, graphics::lens_overrides& overrides) {
+ int n = node;
+
+ // Stated against the value in force rather than a fixed constant, so
+ // "50" halves whatever the mounted lens tables instead of jumping to
+ // half of some other lens's number.
+ sexp_lens_next_scaled_pct(n, set.intensity, set.intensity, 100.0f);
+ sexp_lens_next_scaled_pct(n, set.ghost_brightness, set.ghost_brightness, 10000.0f);
+ sexp_lens_next_scaled_pct(n, set.starburst_brightness, set.starburst_brightness, 1000.0f);
+ sexp_lens_next_scaled_pct(n, set.starburst_scale, set.starburst_scale, 100.0f);
+ sexp_lens_next_int(n, set.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS);
+
+ overrides.intensity = set.intensity;
+ overrides.ghost_brightness = set.ghost_brightness;
+ overrides.starburst_brightness = set.starburst_brightness;
+ overrides.starburst_scale = set.starburst_scale;
+ overrides.max_ghosts = set.max_ghosts;
+ });
+}
+
void sexp_nebula_change_storm(int n)
{
if (!(The_mission.flags[Mission::Mission_Flags::Fullneb]))
return;
-
+
nebl_set_storm(CTEXT(n));
}
@@ -30286,8 +30524,38 @@ 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;
+
+ case OP_SET_LENS_FLARE_STRENGTH:
+ sexp_set_lens_flare_strength(node);
sexp_val = SEXP_TRUE;
break;
@@ -31989,6 +32257,12 @@ 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_LENS_FLARE_STRENGTH:
case OP_SET_POST_EFFECT:
case OP_RESET_POST_EFFECTS:
case OP_CHANGE_IFF_COLOR:
@@ -34301,7 +34575,24 @@ 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:
+ case OP_SET_LENS_FLARE_STRENGTH:
+ return OPF_POSITIVE;
+
case OP_SET_POST_EFFECT:
if (argnum == 0)
return OPF_POST_EFFECT;
@@ -35581,6 +35872,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";
@@ -37116,6 +37410,12 @@ 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_SET_LENS_FLARE_STRENGTH:
case OP_CHANGE_IFF_COLOR:
case OP_TURRET_SUBSYS_TARGET_DISABLE:
case OP_TURRET_SUBSYS_TARGET_ENABLE:
@@ -37731,6 +38031,12 @@ 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_SET_LENS_FLARE_STRENGTH:
case OP_TOGGLE_ASTEROID_FIELD:
case OP_SET_ASTEROID_FIELD:
case OP_SET_DEBRIS_FIELD:
@@ -41874,6 +42180,114 @@ 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"
+ "\tCOST: changing the iris is expensive. The engine has to re-render the iris\r\n"
+ "\tmask and then take a 512x512 Fourier transform of it to get the new starburst,\r\n"
+ "\twhich takes long enough to be seen as a stutter. Use these operators for\r\n"
+ "\toccasional, deliberate changes -- a lens getting dirty over the course of a\r\n"
+ "\tmission, say. Do NOT drive them from a repeating event or an every-frame\r\n"
+ "\tcondition: the rebuild is coalesced so it cannot happen more than a few times\r\n"
+ "\ta second, but a value that keeps changing will keep paying for it. To vary the\r\n"
+ "\tflare continuously, use set-lens-flare-strength, which costs nothing.\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"
+ "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n"
+ "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n"
+ "\tevents.\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"
+ "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n"
+ "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n"
+ "\tevents.\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"
+ "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n"
+ "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n"
+ "\tevents.\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_LENS_FLARE_STRENGTH, "set-lens-flare-strength\r\n"
+ "\tChanges how strongly the mounted camera lens flares, without changing the\r\n"
+ "\tshape of anything. Every value is a percentage of what is currently in force,\r\n"
+ "\tso 50 halves whatever the mounted lens tables and 100 leaves it alone --\r\n"
+ "\twhich means the same event does the same thing whichever lens is mounted.\r\n\r\n"
+ "\tUnlike set-lens-aperture and its relatives, this is cheap: it changes no\r\n"
+ "\ttexture, so there is nothing to rebuild and nothing to stutter. This is the\r\n"
+ "\toperator to use when the flare should brighten or fade over time -- drive it\r\n"
+ "\tfrom a repeating event as often as you like.\r\n\r\n"
+ "\tDoes nothing (with a warning) if no lens is mounted. Everything goes back to\r\n"
+ "\tits tabled values when the mission ends. Not sent over the network.\r\n\r\n"
+ "\tTakes 1 to 5 arguments...\r\n"
+ "\t1:\tOverall flare intensity, as a percentage of the current value.\r\n"
+ "\t2:\t(optional) Ghost brightness, as a percentage. Scales the ghost train --\r\n"
+ "\t\tthe row of iris images strung along the flare axis -- on its own.\r\n"
+ "\t3:\t(optional) Starburst brightness, as a percentage. Scales the spikes on\r\n"
+ "\t\tthe sun itself on its own.\r\n"
+ "\t4:\t(optional) Starburst size, as a percentage.\r\n"
+ "\t5:\t(optional) How many ghosts to draw at most. They are drawn brightest\r\n"
+ "\t\tfirst, so lowering this drops the faintest ones; 0 leaves only the\r\n"
+ "\t\tstarburst. Cheap either way -- fewer ghosts is also less to draw."
+ },
+
{ 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 456bc8b767f..68066d03f7a 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,13 @@ 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_SET_LENS_FLARE_STRENGTH, // the-e
+
// OP_CATEGORY_AI
// defined for AI goals
@@ -1304,6 +1311,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 +1496,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 70dd601d39a..43c036b3992 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,14 @@ 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_beams.cpp
+ graphics/lens_flare_internal.h
+ graphics/lens_flare_optics.cpp
+ graphics/lens_flare_table.cpp
+ graphics/lens_flare_thrusters.cpp
graphics/light.cpp
graphics/light.h
graphics/line_draw_list.cpp
@@ -617,6 +628,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
@@ -774,6 +786,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/code/species_defs/species_defs.cpp b/code/species_defs/species_defs.cpp
index 90ea9e33a88..3c4298245a5 100644
--- a/code/species_defs/species_defs.cpp
+++ b/code/species_defs/species_defs.cpp
@@ -151,6 +151,41 @@ void parse_thrust_glows(species_info *species, bool no_create)
generic_anim_init(&species->thruster_info.glow.afterburn, NULL);
}
+// How this species' engines flare through the camera lens (graphics/lens_flare.h).
+// Wholly optional -- a table without the block leaves thruster_flare disabled, so
+// nothing that predates this feature gains a flare -- which is also why there is
+// no "!no_create" branch here: there are no defaults to warn about, only the
+// struct's own. (Hence no `no_create` parameter, unlike its two neighbours.)
+void parse_thrust_flare(species_info *species)
+{
+ if (!optional_string("$Thruster Flare:"))
+ return;
+
+ auto &flare = species->thruster_flare;
+ flare.enabled = true;
+
+ if (optional_string("+Intensity:"))
+ stuff_float(&flare.intensity);
+
+ if (optional_string("+Afterburner Intensity:"))
+ stuff_float(&flare.afterburner_intensity);
+
+ if (optional_string("+Color:") || optional_string("+Colour:"))
+ {
+ int rgb[3];
+ stuff_int_list(rgb, 3, ParseLookupType::RAW_INTEGER_TYPE);
+ for (int i = 0; i < 3; i++)
+ flare.color.a1d[i] = i2fl(rgb[i]) / 255.0f;
+ }
+
+ // A negative brightness would invert the flare rather than dim it, and a
+ // negative tint would subtract light from the frame
+ flare.intensity = MAX(flare.intensity, 0.0f);
+ flare.afterburner_intensity = MAX(flare.afterburner_intensity, 0.0f);
+ for (float & i : flare.color.a1d)
+ i = MAX(i, 0.0f);
+}
+
void parse_species_tbl(const char *filename)
{
char species_name[NAME_LENGTH];
@@ -305,6 +340,9 @@ void parse_species_tbl(const char *filename)
// Thruster Glow Anims
parse_thrust_glows(species, no_create);
+ // Thruster lens flares
+ parse_thrust_flare(species);
+
// Goober5000 - AWACS multiplier
if (optional_string("$AwacsMultiplier:"))
diff --git a/code/species_defs/species_defs.h b/code/species_defs/species_defs.h
index 24475923803..fe4e46dd617 100644
--- a/code/species_defs/species_defs.h
+++ b/code/species_defs/species_defs.h
@@ -16,6 +16,7 @@
#include "globalincs/globals.h"
#include "globalincs/pstypes.h"
#include "graphics/generic.h"
+#include "graphics/lens_flare.h"
#include "hud/hudparse.h"
#include "mission/missionbriefcommon.h"
@@ -59,6 +60,10 @@ class species_info
generic_anim shield_anim;
thrust_info thruster_info;
+ // How this species' engines flare through the camera lens; disabled unless
+ // species_defs.tbl says otherwise (graphics/lens_flare.h)
+ graphics::thruster_flare_info thruster_flare;
+
// Bobboau's thruster stuff
thrust_pair_bitmap thruster_secondary_glow_info;
thrust_pair_bitmap thruster_tertiary_glow_info;
diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp
index a90ede83880..a6aba8dad55 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"
@@ -79,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
@@ -93,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
@@ -399,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;
@@ -553,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;
@@ -793,6 +821,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 +846,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 +860,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();
@@ -972,6 +1008,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);
@@ -1261,6 +1301,46 @@ 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;
+}
+
+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)
{
@@ -1341,9 +1421,15 @@ void stars_draw_sun(int show_sun)
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);
+ // 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 (!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);
+ }
Sun_drew++;
// if ( !g3_draw_bitmap(&sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, TMAP_FLAG_TEXTURED) )
@@ -1434,6 +1520,12 @@ void stars_draw_sun_glow(int sun_n)
if (bm->glow_bitmap < 0)
return;
+ // 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_drawn(sun_n)) {
+ return;
+ }
+
memset( &sun_vex, 0, sizeof(vertex) );
// get sun pos
@@ -1466,7 +1558,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);
@@ -1942,6 +2036,21 @@ 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 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();
+ }
+
if (show_subspace)
subspace_render();
diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h
index 0e443a00b72..ccbf02a2d01 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
@@ -162,9 +164,41 @@ 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);
+// 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);
+
+// 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/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/code/weapon/beam.cpp b/code/weapon/beam.cpp
index 0466130d365..cc9214eef1c 100644
--- a/code/weapon/beam.cpp
+++ b/code/weapon/beam.cpp
@@ -1911,7 +1911,7 @@ DCF(blight, "Sets the beam light scale factor (Default is 25.5f)")
dc_stuff_float(&blight);
}
namespace ltp = lighting_profiles;
-float beam_current_light_radius(beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise)
+float beam_current_light_radius(const beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise)
{
auto lp = ltp::current();
float width = lp->beam_light_radius.handle(wip->light_radius);
@@ -1972,6 +1972,61 @@ void beam_light_color(weapon_info *wip,hdr_color *to_fill )
to_fill->set_vecf(colors);
}
+// How strongly a beam's muzzle is emitting right now, 0..1: ramping up over the
+// warmup, full while firing, and back down over the warmdown.
+//
+// Halved during both ramps, which is what the muzzle light has always done --
+// anything that wants to follow a beam's brightness follows this rather than
+// writing a second curve that would drift out of step with it.
+static float beam_muzzle_ramp(const beam *bm)
+{
+ if (bm->warmup_stamp != -1) {
+ return BEAM_WARMUP_PCT(bm) * 0.5f;
+ }
+ if (bm->warmdown_stamp != -1) {
+ return MAX(1.0f - BEAM_WARMDOWN_PCT(bm) * 1.3f, 0.0f) * 0.5f;
+ }
+ // otherwise the beam is really firing
+ return 1.0f;
+}
+
+bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out)
+{
+ if (bm == nullptr || bm->weapon_info_index < 0) {
+ return false;
+ }
+ weapon_info *wip = &Weapon_info[bm->weapon_info_index];
+ beam_weapon_info *bwi = &wip->b_info;
+
+ const float pct = beam_muzzle_ramp(bm);
+ if (pct <= 0.0f) {
+ return false;
+ }
+
+ // Deliberately without the flicker noise the muzzle light applies: this is
+ // read once per frame by the renderer rather than by the light code, and a
+ // fresh frand() per frame would make the flare jitter independently of the
+ // light it is supposed to be following.
+ const float radius = beam_current_light_radius(bm, wip, bwi, 1.0f);
+ if (radius <= 0.0f) {
+ return false;
+ }
+
+ hdr_color light_color;
+ beam_light_color(wip, &light_color);
+ if (light_color.i() <= 0.0f) {
+ return false;
+ }
+
+ out->pos = bm->last_start;
+ out->color.xyz.x = light_color.r();
+ out->color.xyz.y = light_color.g();
+ out->color.xyz.z = light_color.b();
+ out->intensity = light_color.i() * pct;
+ out->radius = radius;
+ return true;
+}
+
// call to add a light source to a small object
void beam_add_light_small(beam *bm, object *objp, vec3d *pt)
{
@@ -1988,19 +2043,7 @@ void beam_add_light_small(beam *bm, object *objp, vec3d *pt)
// get the width of the beam
float light_rad = beam_current_light_radius(bm, wip, bwi, noise);
- float pct = 0.0f;
-
- if (bm->warmup_stamp != -1) { // calculate muzzle light intensity
- // get warmup pct
- pct = BEAM_WARMUP_PCT(bm)*0.5f;
- } else if (bm->warmdown_stamp != -1) { // if the beam is warming down
- // get warmup pct
- pct = MAX(1.0f - BEAM_WARMDOWN_PCT(bm)*1.3f,0.0f)*0.5f;
- }
- // otherwise the beam is really firing
- else {
- pct = 1.0f;
- }
+ float pct = beam_muzzle_ramp(bm);
// Color is a copy so that we can modify it the brigthness without side-effect
hdr_color light_color;
diff --git a/code/weapon/beam.h b/code/weapon/beam.h
index c34181eaa0f..29c53e44e68 100644
--- a/code/weapon/beam.h
+++ b/code/weapon/beam.h
@@ -226,6 +226,27 @@ typedef struct beam {
extern std::array Beams; // all beams
extern int Beam_count;
+// A beam's muzzle as a light source: where it is, the colour and strength of the
+// light it throws, and how large that glow is. Filled by beam_get_muzzle_glow().
+struct beam_muzzle_glow {
+ vec3d pos;
+ vec3d color; // linear rgb, 0..1
+ float intensity; // the light's own intensity, scaled by the warmup/warmdown ramp
+ float radius; // world radius of the glow
+};
+
+// What a beam's muzzle is emitting this frame, or false when it is emitting
+// nothing. The intensity ramps up over the warmup, holds while firing, and ramps
+// back down over the warmdown.
+//
+// Answered here rather than by the caller because it is the same question the
+// muzzle light already asks -- beam_add_light_small() scales by the same ramp and
+// uses the same radius -- and a second copy of it in another module would drift
+// out of step with the light it is meant to be following. Unlike that light it is
+// not gated on the lighting detail setting: a camera-lens flare is an artifact of
+// the camera, not a light in the scene.
+bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out);
+
#define BEAM_INDEX(beam) (int)((beam) - Beams.data())
// ------------------------------------------------------------------------------------------------
diff --git a/documentation/qtfred-post-processing-viewport-resize.md b/documentation/qtfred-post-processing-viewport-resize.md
new file mode 100644
index 00000000000..24e05f4ea36
--- /dev/null
+++ b/documentation/qtfred-post-processing-viewport-resize.md
@@ -0,0 +1,248 @@
+# qtFred post-processing viewport resize issues
+
+Notes on two bugs hit while adding the qtFred "Enable Post Processing" View-menu
+toggle. Both are fixed; kept here so the next person who reopens this doesn't
+have to re-derive the diagnosis from scratch, and so the dead ends are on record.
+
+## Background
+
+qtFred's `FredRenderer::render_frame()` calls `gr_screen_resize()` every frame
+to match whatever size its dockable/resizable 3D viewport widget currently is.
+The game does this too, but only on an SDL window-resize event
+(`osapi.cpp`) — for most of its life `gr_screen` is fixed after `gr_init()`.
+That difference in *frequency* is the root of everything below; the underlying
+bug was reachable from the game's resizable window as well.
+
+## Bug 1: `u_scale`/`v_scale` copy-paste in the post-processing passes
+
+**Symptom:** with post-processing enabled, sun sprites and lens flares landed
+at different screen positions depending on where in the viewport the sun was
+— vertical-only offset top-left, both-axes top-right, horizontal-only
+bottom-right, near-perfect bottom-left. Bloom was also misaligned the same way.
+
+**Cause:** `code/graphics/opengl/gropenglpostprocessing.cpp` had eight call
+sites of the form:
+
+```cpp
+opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale);
+```
+
+`u_scale` was passed for *both* the horizontal and vertical scale argument —
+a copy-paste error. In the game this was a no-op (`u_scale == v_scale` there,
+since the scene texture is always exactly screen-sized). In qtFred, where the
+scene texture can be a different aspect ratio than the current viewport, this
+silently applied the horizontal crop fraction to the vertical axis (or vice
+versa) at each pass (tonemap, FXAA prepass, both SMAA passes, cockpit
+lightshafts, and the final composite-to-screen blit), compounding into a
+direction- and position-dependent drift.
+
+**Fix:** those sites now call `opengl_draw_full_screen_scene_texture()`
+(`gropengldraw.cpp`), which supplies both scales itself. Open-coding the extents
+is what allowed one axis to be wrong, so the helper exists to make the whole
+class of bug unreachable — prefer it over literal extents in any new pass that
+samples a scene or post-processing texture.
+
+Fixing bug 1 alone was **not** sufficient — the user confirmed misalignment
+persisted afterwards, which is what led to bug 2.
+
+(Three further `u_scale`-twice sites survive at the bottom of
+`gr_opengl_post_process_end()`; they are inside a `/* */` block of dead debug
+code and were deliberately left alone.)
+
+### Related sites found later
+
+The same "sample the full [0,1] range of a partially-filled target" bug existed
+outside the post-processing module and was fixed alongside the resize work:
+
+- `gropengldeferred.cpp` — the MSAA scene-colour copy, the MSAA resolve, and
+ the fog pass all passed literal `1.0f` extents while sampling scene targets.
+- `deferred-f.sdr` reconstructs a G-buffer texture coordinate from
+ `gl_FragCoord` and `invScreenWidth`/`invScreenHeight`, which described
+ `gr_screen` rather than the G-buffer. Fixed in both the OpenGL
+ (`gropengldeferred.cpp`) and Vulkan (`VulkanPostProcessingLighting.cpp`)
+ backends. It is currently a no-op under Vulkan, whose `resize()` keeps the
+ scene extent equal to `gr_screen`, but it states the requirement rather than
+ depending on that staying true.
+- `fxaa-v.sdr` derived its texcoord from `vertPosition` instead of the
+ `vertTexCoord` attribute, ignoring whatever sub-rectangle the draw call asked
+ for. Every other post-process vertex shader already used the attribute.
+
+**Deliberately left alone:** the volumetric nebula pass. `volumetric-f.sdr` uses
+`fragTexCoord` for two incompatible purposes — reconstructing an eye-space ray
+direction, which needs the full 0..1 range across the viewport, and sampling
+composite/depth/emissive, which needs the rendered sub-rectangle. Scaling the
+draw would fix the sampling and skew every ray. Separating them needs a second
+varying (or a scale uniform) in the shader. Until then volumetrics are only
+correct while the targets exactly match the viewport, which is the normal case.
+
+## Bug 2: stale scene-texture allocation when the viewport grows
+
+**Symptom:** after the bug-1 fix, misalignment (and bloom stretching) still
+appeared, but only after the qtFred window had been resized/maximized/
+fullscreened *larger* than it was earlier in the session. A manual resize
+cycle would often fix it; going fullscreen would reintroduce it.
+
+**Root cause:** `Scene_texture_width`/`Scene_texture_height`
+(`opengl_setup_scene_textures()`, `gropengldraw.cpp`) and the post-processing
+surfaces sized off `gr_screen.max_w`/`max_h`
+(`opengl_post_init_framebuffer()`, `gropenglpostprocessing.cpp`) were allocated
+exactly once, at `gr_init()`, and never revisited. When the viewport grows past
+that original allocation the render still only writes into the old (smaller)
+texture: everything past its edge is silently clipped, and the final blit —
+unaware anything was clipped — stretches that smaller, cropped result back over
+the new, larger viewport. That non-uniform stretch is what reads as a position-
+and axis-dependent drift, magnified further at fullscreen. Shrinking the
+viewport back below the allocation is unaffected, since
+`Scene_texture_u_scale`/`v_scale` already crop correctly for a viewport smaller
+than the allocation.
+
+Diagnosed empirically (no way to run the qtFred GUI directly) via two
+throttled `mprintf` diagnostics temporarily added to `project_source()`
+(`lens_flare.cpp`) and `gr_opengl_scene_texture_begin()` (`gropengldraw.cpp`),
+comparing `Scene_texture_width/height` against `gr_screen.max_w/h` and
+`Canvas_width/height`. Log evidence
+(`Scene_texture=3072x1728 gr_screen.max=3512x1910`) confirmed the texture was
+smaller than the live viewport, and the user's own manual testing (resize
+fixes it, fullscreen re-breaks it) confirmed the allocate-once behavior.
+Both diagnostics were removed once the root cause was confirmed; they are not
+in the tree.
+
+### Fix: grow the render targets when the viewport outgrows them
+
+`gr_screen.gf_resize_render_targets` (`2d.h`) is a backend hook called from
+`gr_screen_resize()` (`2d.cpp`) after `gr_setup_viewport()`. OpenGL implements
+it as `gr_opengl_resize_render_targets()` (`gropengldraw.cpp`); Vulkan leaves it
+unset, because `VulkanRenderer::recreateSwapChain()` already owns resizing its
+extent-sized targets and a second entry point would risk double-resizing.
+
+The OpenGL implementation rebuilds only the resolution-dependent resources:
+
+- `opengl_scene_texture_shutdown()` + `opengl_setup_scene_textures(w, h)` for
+ the scene textures. The latter now takes explicit dimensions rather than
+ reading `gr_screen` itself, so the sizing policy lives in one named place.
+- `opengl_post_resize_render_targets()` (`gropenglpostprocessing.cpp`) for the
+ bloom mip chain and the SMAA surfaces. It sizes `Post_texture_*` to match
+ `Scene_texture_*` — they consume those textures pass by pass, so the two
+ sizes diverging is what bug 1 looked like.
+
+The post-processing table, the compiled shaders and the SMAA area/search lookup
+textures are all resolution-independent and stay alive. That is what keeps this
+cheap enough to run off a window drag, and it mirrors what
+`VulkanPostProcessor::resize()` has always done — the OpenGL backend was the
+odd one out.
+
+Three properties the implementation depends on:
+
+- **Grow only.** `gr_screen_resize()` runs every frame in qtFred, and
+ `BriefingMapWidget` resizes down and back repeatedly. Tracking the high-water
+ mark avoids thrashing, and the shrunk state is already correct via
+ `Scene_texture_u_scale`/`v_scale`.
+- **Clamp before comparing.** `GL_max_renderbuffer_size` is applied to the
+ requested size *inside* `gr_opengl_resize_render_targets()`, before it decides
+ whether anything changed. Clamping inside the allocator instead would leave a
+ viewport larger than the hardware limit requesting a resize that can never be
+ satisfied — a full teardown and rebuild every single frame.
+- **Never resize mid-frame.** The function refuses (with an `Assertion`) while
+ `Scene_framebuffer_in_frame` is set. That flag covers the post-processing
+ passes too: they only run inside `gr_scene_texture_begin()`/`end()`, and it is
+ cleared at the very end of `gr_opengl_scene_texture_end()`.
+
+If the larger allocation fails outright — most likely precisely when growing —
+`opengl_setup_scene_textures()` reports it by leaving `Scene_texture_initialized`
+at 0, having already turned post-processing and soft particles off. The resize
+stops there rather than rebuilding the post-processing targets on top of scene
+textures that no longer exist.
+
+This replaced an earlier `Gr_min_render_target_w`/`_h` floor, which sized the
+targets up front for the largest attached display. That worked, but every
+qtFred user paid for it at launch whether or not they ever enabled
+post-processing: on a 4K display at 2x scaling the floor was 7680x4320, which
+across the nine scene textures (most of them `RGBA16F`) plus the post-processing
+surfaces is on the order of a gigabyte of VRAM — and `-msaa 8` multiplied the
+six multisample targets on top of that. AGENTS.md is explicit that FSO must run
+across the whole hardware range, so an unconditional worst-case allocation for
+an off-by-default feature was the wrong trade.
+
+### Verifying it from a log
+
+`opengl_setup_scene_textures()` reports each allocation:
+
+```
+ Scene textures: 3840x2160 (screen 3840x2160, max renderbuffer 16384)
+```
+
+and `gr_opengl_resize_render_targets()` reports each growth:
+
+```
+Growing render targets from 1024x768 to 3840x2160 to cover the new 3840x2160 viewport.
+```
+
+Launch qtFred, enable post-processing, and drag the viewport dock larger. The
+growth line should appear **once per growth step and never per frame** — a
+per-frame stream means the clamp/early-out logic is wrong. The one-shot
+`nprintf(("OpenGL", "Viewport (...) is larger than the scene texture backing
+it ..."))` in `gr_opengl_scene_texture_begin()` should not appear at all; if it
+does, the targets could not grow (`GL_max_renderbuffer_size` on low-end
+hardware) and the old stretching is back. It must stay `nprintf` and stay
+one-shot: that function runs every frame.
+
+**Not yet checked in-editor.** None of this has been exercised at runtime. Note
+that the CLion `qtfred` run configuration passes `-vulkan`, which exercises
+`VulkanPostProcessor::resize()` rather than any of the OpenGL code above — drop
+that flag to test this path. Worth eyeballing once someone does: bloom radius
+and SMAA quality, since the bright pass renders into the full
+`Post_texture_width >> 1` viewport while sampling only the cropped
+sub-rectangle, and SMAA's RT-metrics are likewise derived from `Post_texture_*`.
+Worst case there is a cosmetic difference, not misalignment.
+
+### History: why reallocation was rejected twice before
+
+Two earlier attempts at exactly the approach now implemented both regressed to a
+black viewport and were reverted:
+
+1. A size check inside `gr_opengl_scene_texture_begin()` that tore down and
+ rebuilt in place, every frame.
+2. A cross-backend `gr_scene_texture_grow()` entry point wired through the
+ function-pointer table and called from `FredRenderer::render_frame()` after
+ `gr_screen_resize()` — structurally the same as the current hook.
+
+The second failed on *every* post-processing frame, not just grown ones, which
+is consistent: qtFred's first post-processing frame is almost always already
+larger than the `gr_init()` allocation.
+
+**Why it works now.** The previous version of this document identified the
+prerequisite correctly, and it turned out to be the whole problem:
+`opengl_scene_texture_shutdown()` did not delete or zero `Scene_ldr_texture`,
+`Scene_composite_texture`, `Scene_luminance_texture`, `Cockpit_depth_texture`,
+or any of the six `_ms` objects and `Scene_framebuffer_ms`, while
+`opengl_setup_scene_textures()` re-`glGenTextures`'d over those handles. Any FBO
+left attached to a stale or deleted texture is incomplete, and draws to an
+incomplete FBO go nowhere — black viewport. (At shutdown this was merely a leak,
+which is why it went unnoticed.)
+
+The teardown now releases everything setup allocates, and post-processing
+shutdown releases the SMAA lookup textures it was also leaking. Two further
+prerequisites had to be met:
+
+- **Deletion goes through the state cache.** `GL_state.Texture.Delete()` unbinds
+ a texture from every unit before `glDeleteTextures()`. Without it the cache
+ can still hold a freed name, and since the driver is free to hand that name
+ straight back out, a later `Enable()` of the recycled texture is silently
+ skipped. Use `opengl_delete_render_texture()` /
+ `opengl_delete_render_framebuffer()` (`gropengldraw.cpp`) rather than calling
+ the GL entry points directly. The framebuffer cache has no equivalent unbind,
+ so the resize path binds 0 before deleting anything.
+- **Only the size-dependent work is redone.** `opengl_post_process_init()`
+ re-parses `post_processing.tbl` and rebuilds
+ `graphics::Post_processing_manager` from scratch, which is not something to do
+ mid-session; the resolution-dependent half was split out into
+ `opengl_post_resize_render_targets()` precisely so the resize does not touch
+ it.
+
+The earlier document also floated `GL_state`'s framebuffer-binding cache as the
+cause of the black viewport and proposed an explicit
+`GL_state.BindFrameBufferBoth(0, 0)`. That diagnosis did not hold up on its own —
+both setup functions already end with `GL_state.BindFrameBuffer(0)` from a
+non-zero cache, so the bind does get issued. The call is nonetheless present in
+the resize path, for the different and real reason given above: to keep the
+cache off a framebuffer name that is about to be deleted.
diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp
index 593c2f42441..ea3317e9c61 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,27 @@ void bg_bitmap_dlg::create()
}
box->SetCurSel(m_light_profile_index);
+ // 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);
+ 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 + CAMERA_LENS_IDX_FIRST_LENS;
+ }
+ box->SetCurSel(m_camera_lens_index);
+
background_flags_init();
UpdateData(FALSE);
@@ -567,6 +591,18 @@ 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];
+
+ // 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.clear();
+ }
+ 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..4bf2224bd60 100644
--- a/fred2/bgbitmapdlg.h
+++ b/fred2/bgbitmapdlg.h
@@ -96,8 +96,18 @@ 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
+ // 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/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/AGENTS.md b/qtfred/AGENTS.md
new file mode 100644
index 00000000000..c54ef11c87a
--- /dev/null
+++ b/qtfred/AGENTS.md
@@ -0,0 +1,10 @@
+# Module: qtfred
+
+Entry-point guide for this module lives at:
+**`documentation/modules/qtfred.md`** (from repo root).
+
+It covers the editor's purpose, build requirements, layout, and how it relates to
+the shared engine code in `code/`.
+
+For the engine-wide architecture overview see `documentation/ARCHITECTURE.md`.
+For build/test/style conventions see the root `AGENTS.md`.
diff --git a/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html b/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html
index 3b79720ceec..53886dc907f 100644
--- a/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html
+++ b/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html
@@ -124,7 +124,45 @@ Misc
and lighting on ship surfaces.
| Lighting profile | Selects the lighting profile from the tables to
apply to the mission. |
+ | Camera Lens | The physically-based camera lens every sun's flare is
+ imaged through. Default follows the tables' declared default lens;
+ None disables lens flares entirely even when a default exists. |
+The Lens Aperture... button opens a dialog for overriding
+everything about the camera except which lens it is – the same parameters the
+set-lens-* sexps control, but applied for the whole mission instead of
+by an event:
+
+ | Group | What it overrides |
+ | Iris | Blade count, rotation, curvature and edge softness. One
+ iris drives both the ghosts and the starburst, so these restyle both. |
+ | Rim grating, Scratches, Dust | Imperfections layered onto the
+ iris, each off at strength 0. |
+ | Anamorphic | The squeeze (how much wider than tall flare
+ footprints are) and the horizontal streak (strength, length, thickness and
+ colour tint). |
+ | Strength | Overall lens intensity, ghost and starburst
+ brightness, starburst on/off and size, and how many ghosts to draw at
+ most. |
+
+Every control applies live as you move it; use Reset to Lens
+Default to drop every override and go back to what the lens's own table
+entry declares. A control left at its default is not saved into the mission, so a
+mission only carries what it actually restyles.
+The Iris, Rim grating,
+Scratches and Dust groups are the expensive ones:
+changing any of them makes the engine re-render the iris mask and take a Fourier
+transform of it to get the new starburst. That is fine for an editor and for
+occasional mission events, but it is why set-lens-aperture and its
+relatives should not be driven from a repeating event. The Anamorphic
+and Strength groups cost nothing, and
+set-lens-flare-strength is the operator to use for a flare that
+brightens or fades over time.
+Lens flares (and bloom/tonemapping generally) are only visible in
+the 3D viewport when View › Enable Post Processing is
+checked. With it off the viewport still renders normally, just without that
+pipeline, so a lens or aperture change made here won't be visible until it's
+turned on.
Old Nebula
A legacy FS1-style nebula system. These settings are read and written for
diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html
index 8a8ca2e3e84..cb4bfca04ba 100644
--- a/qtfred/help-src/doc/general/PreferencesDialog.html
+++ b/qtfred/help-src/doc/general/PreferencesDialog.html
@@ -10,7 +10,9 @@
Preferences
Opens via File › Preferences.
Controls QtFRED editor settings. Changes take effect immediately; there is no
-Apply button.
+Apply button. The exception is a handful of graphics settings that the renderer
+can only read while it is starting up - those are marked below, and QtFRED
+applies them the next time it is launched.
General
Miscellaneous editor options. Hover over each setting for a tooltip
@@ -30,6 +32,50 @@
Autosave
being asked.
+Graphics
+Controls how the 3D viewport is rendered. These settings affect the editor
+only - they are stored separately from the game's own graphics options and
+never change them.
+
+Post-processing
+Enable post-processing in the viewport routes the viewport
+through the same HDR rendering pipeline the game uses, adding bloom,
+tonemapping, and lightshafts. It is off by default, so missions look the way
+they always have until you opt in. The same switch is available as
+View › Enable Post Processing.
+Every other setting in this section only changes what you see while
+post-processing is on, because they are all consumed by that pipeline.
+
+Shadows & anti-aliasing
+
+ - Shadow quality - detail level of the shadows cast by the
+ mission's sun. Higher settings cost more performance. Shadows are off
+ until you raise this above Disabled, so enabling post-processing
+ alone will not produce any. Requires a restart - including the
+ first time you turn it on.
+ - Anti-aliasing - post-process anti-aliasing mode (FXAA or
+ SMAA, in increasing quality). Applies immediately.
+ - MSAA - multisample anti-aliasing for the 3D scene, off
+ or 4x/8x. More expensive than the post-process modes above but higher
+ quality. Requires a restart.
+
+
+Textures
+
+ - Texture filtering - bilinear or trilinear mipmap
+ filtering. Left alone, QtFRED uses the same default the game does.
+ Requires a restart.
+ - Anisotropic filtering - sharpens textures viewed at a
+ steep angle. The list is limited to the levels your GPU reports, and the
+ control is greyed out if it reports nothing worth offering. Left alone,
+ QtFRED uses your hardware's maximum. Requires a restart.
+
+
+Gamma
+Brightness of the viewport, from 0.10 to 5.00. QtFRED defaults this to 3.00,
+higher than the game, because the viewport is not tonemapped unless
+post-processing is enabled. Applies immediately.
+
Grid
Configures the reference grid shown in the main viewport. You can set which
world-space plane the grid lies on (XZ, XY, or YZ) and adjust the grid's center
diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake
index 6718b68a21a..d432dfc5f8a 100644
--- a/qtfred/source_groups.cmake
+++ b/qtfred/source_groups.cmake
@@ -28,6 +28,8 @@ add_file_folder("Source/Mission"
src/mission/EditorViewport.h
src/mission/FredRenderer.cpp
src/mission/FredRenderer.h
+ src/mission/GraphicsSettings.cpp
+ src/mission/GraphicsSettings.h
src/mission/IDialogProvider.h
src/mission/management.cpp
src/mission/management.h
@@ -167,6 +169,8 @@ add_file_folder("Source/UI/Dialogs"
src/ui/dialogs/AsteroidEditorDialog.h
src/ui/dialogs/BackgroundEditorDialog.h
src/ui/dialogs/BackgroundEditorDialog.cpp
+ src/ui/dialogs/LensApertureDialog.h
+ src/ui/dialogs/LensApertureDialog.cpp
src/ui/dialogs/BriefingEditorDialog.cpp
src/ui/dialogs/BriefingEditorDialog.h
src/ui/dialogs/CampaignEditorDialog.h
@@ -347,6 +351,7 @@ add_file_folder("UI"
ui/AboutDialog.ui
ui/AsteroidEditorDialog.ui
ui/BackgroundEditor.ui
+ ui/LensApertureDialog.ui
ui/BriefingEditorDialog.ui
ui/CampaignEditorDialog.ui
ui/CheckBoxListDialog.ui
diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp
index e91fcb7d67f..bc289f83833 100644
--- a/qtfred/src/mission/EditorViewport.cpp
+++ b/qtfred/src/mission/EditorViewport.cpp
@@ -122,6 +122,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr&
syncMissionLayerNames();
loadSettings();
+ view.Graphics.applyLive();
fredApp->runAfterInit([this]() { initialSetup(); });
}
@@ -180,6 +181,8 @@ void EditorViewport::loadSettings() {
camera.setInvertOrbitX(settings.value("camera_invert_orbit_x", camera.getInvertOrbitX()).toBool());
camera.setInvertOrbitY(settings.value("camera_invert_orbit_y", camera.getInvertOrbitY()).toBool());
settings.endGroup();
+
+ view.Graphics = GraphicsSettings::load();
}
void EditorViewport::saveSettings() const {
@@ -228,7 +231,10 @@ void EditorViewport::saveSettings() const {
settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX());
settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY());
settings.endGroup();
+
+ view.Graphics.save();
}
+
void EditorViewport::needsUpdate() {
_renderer->scheduleUpdate();
}
diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h
index 26962561525..4004f97f6b7 100644
--- a/qtfred/src/mission/EditorViewport.h
+++ b/qtfred/src/mission/EditorViewport.h
@@ -4,6 +4,7 @@
#include "CameraController.h"
#include "FredRenderer.h"
#include "Editor.h"
+#include "GraphicsSettings.h"
#include "IDialogProvider.h"
#include "ui/ThemeMode.h"
@@ -63,6 +64,9 @@ struct ViewSettings {
bool Highlight_selectable_subsys = false;
int Outline_lod = 1;
+ //! Preferences > Graphics. Owns its own persistence and apply rules; see GraphicsSettings.
+ GraphicsSettings Graphics;
+
ViewSettings();
};
diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp
index 5bd82493288..9ee0517f3a2 100644
--- a/qtfred/src/mission/FredRenderer.cpp
+++ b/qtfred/src/mission/FredRenderer.cpp
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -28,6 +29,8 @@
#include
#include
+#include
+
#include "mission/object.h"
#include "prop/prop.h"
#include "weapon/weapon.h"
@@ -56,6 +59,38 @@ void disable_htl() {
gr_end_view_matrix();
}
+//! Scoped gr_scene_texture_begin()/end(), so the two can't drift apart as render_frame() grows.
+struct ScenePostProcessing {
+ ScenePostProcessing() { gr_scene_texture_begin(); }
+ ~ScenePostProcessing() { gr_scene_texture_end(); }
+
+ ScenePostProcessing(const ScenePostProcessing&) = delete;
+ ScenePostProcessing& operator=(const ScenePostProcessing&) = delete;
+};
+
+/**
+ * @brief Render the shadow pass for the current camera.
+ *
+ * Only meaningful inside a ScenePostProcessing scope: the shadow pass writes into deferred
+ * G-buffer surfaces that only exist while the scene texture is bound. Mirrors game_render_frame(),
+ * which calls this right after its own gr_scene_texture_begin() + stars_draw().
+ *
+ * shadows_render_all() works on the HTL proj/view matrix stack -- the same one enable_htl() and
+ * disable_htl() push and pop for the starfield -- and expects it to be open: it ends whatever is
+ * active (gr_end_view_matrix() asserts on modelview_matrix_depth) and restores it when done. The
+ * starfield's disable_htl() just popped that stack, hence the push here, and the matching pop
+ * afterwards so the next frame's enable_htl() doesn't assert on a stack left open.
+ */
+void render_shadows() {
+ gr_set_proj_matrix(Proj_fov, gr_screen.clip_aspect, Min_draw_distance, Max_draw_distance);
+ gr_set_view_matrix(&Eye_position, &Eye_matrix);
+
+ shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, nullptr, nullptr, nullptr);
+
+ gr_end_proj_matrix();
+ gr_end_view_matrix();
+}
+
bool fred_colors_inited = false;
color colour_white;
color colour_green;
@@ -994,6 +1029,15 @@ void FredRenderer::render_frame(int cur_object_index,
g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f);
+ // Optionally run the 3D world through the game's HDR post-processing pipeline (bloom,
+ // tonemapping, lightshafts, shadows) instead of drawing straight to the default framebuffer.
+ // Brackets only the 3D content, the same way game_render_frame() does; the 2D overlays further
+ // down (distances, ship info, tooltips) stay outside it.
+ std::optional postProcessing;
+ if (view().Graphics.enablePostProcessing) {
+ postProcessing.emplace();
+ }
+
// Force max star detail so the editor always shows the full Num_stars count
// regardless of the player's graphics quality setting (Detail.num_stars can be 0).
int saved_detail_stars = Detail.num_stars;
@@ -1003,6 +1047,10 @@ void FredRenderer::render_frame(int cur_object_index,
disable_htl();
Detail.num_stars = saved_detail_stars;
+ if (postProcessing) {
+ render_shadows();
+ }
+
if (view().Show_horizon) {
gr_set_color(128, 128, 64);
g3_draw_horizon_line();
@@ -1019,6 +1067,8 @@ void FredRenderer::render_frame(int cur_object_index,
render_models(cur_object_index);
render_volumetric_overlay();
+ postProcessing.reset();
+
if (view().Show_distances) {
display_distances();
}
diff --git a/qtfred/src/mission/GraphicsSettings.cpp b/qtfred/src/mission/GraphicsSettings.cpp
new file mode 100644
index 00000000000..560c8f3b6dc
--- /dev/null
+++ b/qtfred/src/mission/GraphicsSettings.cpp
@@ -0,0 +1,132 @@
+#include "mission/GraphicsSettings.h"
+
+#include
+#include
+
+#include
+
+namespace fso::fred {
+
+namespace {
+
+const char* SETTINGS_GROUP = "Preferences";
+
+const char* KEY_POST_PROCESSING = "view_enable_post_processing";
+const char* KEY_SHADOW_QUALITY = "view_graphics_shadow_quality";
+const char* KEY_AA_MODE = "view_graphics_aa_mode";
+const char* KEY_MSAA_SAMPLES = "view_graphics_msaa_samples";
+const char* KEY_TEXTURE_FILTER = "view_graphics_texture_filter";
+const char* KEY_ANISOTROPY = "view_graphics_anisotropy";
+const char* KEY_GAMMA = "view_graphics_gamma";
+
+// A settings file can be hand-edited or left over from a build with a different set of values, so
+// anything that ends up in an enum or a fixed value list gets checked rather than cast blindly.
+template
+T readEnum(const QSettings& settings, const char* key, T fallback, T highest)
+{
+ const int raw = settings.value(key, static_cast(fallback)).toInt();
+
+ if (raw < 0 || raw > static_cast(highest)) {
+ return fallback;
+ }
+
+ return static_cast(raw);
+}
+
+} // namespace
+
+bool GraphicsSettings::operator==(const GraphicsSettings& rhs) const
+{
+ return enablePostProcessing == rhs.enablePostProcessing && shadowQuality == rhs.shadowQuality &&
+ aaMode == rhs.aaMode && msaaSamples == rhs.msaaSamples && gamma == rhs.gamma &&
+ textureFilter == rhs.textureFilter && anisotropy == rhs.anisotropy;
+}
+
+SCP_vector GraphicsSettings::validMsaaSampleCounts()
+{
+ return {0, 4, 8};
+}
+
+GraphicsSettings GraphicsSettings::load()
+{
+ GraphicsSettings out;
+
+ QSettings settings;
+ settings.beginGroup(SETTINGS_GROUP);
+
+ out.enablePostProcessing = settings.value(KEY_POST_PROCESSING, out.enablePostProcessing).toBool();
+ out.shadowQuality = readEnum(settings, KEY_SHADOW_QUALITY, out.shadowQuality, ShadowQuality::Ultra);
+ out.aaMode = readEnum(settings, KEY_AA_MODE, out.aaMode, AntiAliasMode::SMAA_Ultra);
+ out.gamma = settings.value(KEY_GAMMA, out.gamma).toFloat();
+
+ if (settings.contains(KEY_TEXTURE_FILTER)) {
+ out.textureFilter = settings.value(KEY_TEXTURE_FILTER).toInt() != 0 ? 1 : 0;
+ }
+
+ if (settings.contains(KEY_ANISOTROPY)) {
+ out.anisotropy = settings.value(KEY_ANISOTROPY).toFloat();
+ }
+
+ const int samples = settings.value(KEY_MSAA_SAMPLES, out.msaaSamples).toInt();
+ const auto validSamples = validMsaaSampleCounts();
+ if (std::find(validSamples.begin(), validSamples.end(), samples) != validSamples.end()) {
+ out.msaaSamples = samples;
+ }
+
+ settings.endGroup();
+
+ return out;
+}
+
+void GraphicsSettings::save() const
+{
+ QSettings settings;
+ settings.beginGroup(SETTINGS_GROUP);
+
+ settings.setValue(KEY_POST_PROCESSING, enablePostProcessing);
+ settings.setValue(KEY_SHADOW_QUALITY, static_cast(shadowQuality));
+ settings.setValue(KEY_AA_MODE, static_cast(aaMode));
+ settings.setValue(KEY_MSAA_SAMPLES, msaaSamples);
+ settings.setValue(KEY_GAMMA, gamma);
+
+ // Don't write the sentinels back out -- an absent key is what keeps the engine default in play.
+ if (textureFilter != NO_TEXTURE_FILTER_CHOICE) {
+ settings.setValue(KEY_TEXTURE_FILTER, textureFilter);
+ }
+
+ if (anisotropy != NO_ANISOTROPY_CHOICE) {
+ settings.setValue(KEY_ANISOTROPY, anisotropy);
+ }
+
+ settings.endGroup();
+}
+
+void GraphicsSettings::applyLive() const
+{
+ Gr_aa_mode = aaMode;
+ gr_set_gamma(gamma);
+}
+
+GraphicsSettings GraphicsSettings::applyBeforeGrInit()
+{
+ const GraphicsSettings settings = load();
+
+ Cmdline_msaa_enabled = settings.msaaSamples;
+ Shadow_quality = settings.shadowQuality;
+
+ auto* options = options::OptionsManager::instance();
+
+ // Overrides are in-memory only, so they steer this session's gr_init() without touching the
+ // config file the game reads its own graphics settings from.
+ if (settings.textureFilter != NO_TEXTURE_FILTER_CHOICE) {
+ options->setOverride("Graphics.TextureFilter", std::to_string(settings.textureFilter));
+ }
+
+ if (settings.anisotropy != NO_ANISOTROPY_CHOICE) {
+ options->setOverride("Graphics.Anisotropy", std::to_string(settings.anisotropy));
+ }
+
+ return settings;
+}
+
+} // namespace fso::fred
diff --git a/qtfred/src/mission/GraphicsSettings.h b/qtfred/src/mission/GraphicsSettings.h
new file mode 100644
index 00000000000..8e85a368360
--- /dev/null
+++ b/qtfred/src/mission/GraphicsSettings.h
@@ -0,0 +1,81 @@
+#pragma once
+
+#include
+#include
+
+namespace fso::fred {
+
+/**
+ * @brief qtFRED's Preferences > Graphics settings.
+ *
+ * Single owner of these values: the QSettings keys, their defaults, and the rules for when each
+ * one can be applied all live here. Two very different callers need them -- management.cpp before
+ * gr_init(), and EditorViewport once the editor is up -- and previously each read QSettings with
+ * its own copy of the key strings and default values.
+ *
+ * The settings split by *when* they can take effect, which is what the two apply functions below
+ * encode:
+ *
+ * - applyLive() settings can be changed at any time and are re-applied whenever the user hits
+ * Apply in Preferences.
+ * - applyBeforeGrInit() settings are baked into GPU resources during gr_init() and cannot be
+ * changed afterwards, so they are read straight out of QSettings before the editor exists.
+ * Shadow quality is the sharpest example: shadow_cascade_params_init() only runs during
+ * gr_init(), and only if Shadow_quality is already non-Disabled, so setting it later leaves the
+ * cascade buffers unsized and crashes the next frame that renders shadows.
+ */
+struct GraphicsSettings {
+ // Texture filtering and anisotropy reach the engine through the options system, whose defaults
+ // are better informed than anything qtFRED could hardcode -- the user's existing config for
+ // one, the hardware maximum for the other. Both therefore carry a sentinel meaning "the user
+ // has not chosen one", and applyBeforeGrInit() overrides only once there is a real choice.
+ static constexpr int NO_TEXTURE_FILTER_CHOICE = -1;
+ static constexpr float NO_ANISOTROPY_CHOICE = 0.0f;
+
+ /**
+ * @brief Run the viewport through the game's HDR scene-texture + post-processing pipeline.
+ *
+ * Off by default so missions keep looking exactly as they do today unless a user opts in.
+ * Everything else on this struct is only visible in the viewport while this is on.
+ */
+ bool enablePostProcessing = false;
+
+ ShadowQuality shadowQuality = ShadowQuality::Disabled;
+ AntiAliasMode aaMode = AntiAliasMode::None;
+
+ int msaaSamples = 0; //!< 0 (off), 4, or 8; see validMsaaSampleCounts()
+ float gamma = 3.0f; //!< matches the brightness qtFRED has always launched with
+
+ int textureFilter = NO_TEXTURE_FILTER_CHOICE; //!< 0 = bilinear, 1 = trilinear
+ float anisotropy = NO_ANISOTROPY_CHOICE; //!< 1.0 = off, otherwise a power of two
+
+ bool operator==(const GraphicsSettings& rhs) const;
+ bool operator!=(const GraphicsSettings& rhs) const { return !(*this == rhs); }
+
+ /**
+ * @brief The MSAA sample counts the Preferences combo offers, in combo order.
+ */
+ static SCP_vector validMsaaSampleCounts();
+
+ static GraphicsSettings load();
+ void save() const;
+
+ /**
+ * @brief Push the settings that can be changed without a restart into the engine.
+ */
+ void applyLive() const;
+
+ /**
+ * @brief Load and apply the settings that must be in place before gr_init() runs.
+ *
+ * The engine reads texture filtering and anisotropy through the options system, so those are
+ * pushed as in-memory-only overrides rather than written out -- qtFRED must never rewrite the
+ * config file the game reads its own graphics settings from.
+ *
+ * @return the settings that were loaded, so the caller can applyLive() them once gr_init() has
+ * returned and there is a renderer to apply them to.
+ */
+ static GraphicsSettings applyBeforeGrInit();
+};
+
+} // namespace fso::fred
diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp
index 5c1e47c175f..f6b48f3b11d 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,138 @@ void BackgroundEditorDialogModel::setLightingProfileName(const SCP_string& name)
modify(The_mission.lighting_profile_name, name);
}
+// 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);
+ return out;
+}
+
+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 { CAMERA_LENS_DEFAULT };
+
+ if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE))
+ return { CAMERA_LENS_NONE };
+
+ return The_mission.camera_lens_name;
+}
+
+void BackgroundEditorDialogModel::setCameraLensName(const 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;
+
+ 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());
+ // Build this lens's textures now rather than paying for a 512^2 mask + FFT
+ // mid-frame the moment a sun next flares through it (see lens_flare.h).
+ graphics::lens_flare_prime_textures();
+ refreshBackgroundPreview();
+}
+
+graphics::lens_settings BackgroundEditorDialogModel::getLensSettings()
+{
+ // The neutral baseline, with whatever this mission overrides laid over it --
+ // the same resolution the engine does, against defaults rather than against a
+ // lens (see the header for why).
+ const graphics::lens_overrides& ov = The_mission.camera_lens_overrides;
+ graphics::lens_settings settings;
+
+ if (ov.aperture)
+ settings.aperture = *ov.aperture;
+ if (ov.anamorphic)
+ settings.anamorphic = *ov.anamorphic;
+ if (ov.intensity)
+ settings.intensity = *ov.intensity;
+ if (ov.starburst)
+ settings.starburst = *ov.starburst;
+ if (ov.starburst_scale)
+ settings.starburst_scale = *ov.starburst_scale;
+ if (ov.max_ghosts)
+ settings.max_ghosts = *ov.max_ghosts;
+ if (ov.ghost_brightness)
+ settings.ghost_brightness = *ov.ghost_brightness;
+ if (ov.starburst_brightness)
+ settings.starburst_brightness = *ov.starburst_brightness;
+
+ return settings;
+}
+
+void BackgroundEditorDialogModel::setLensSettings(const graphics::lens_settings& settings)
+{
+ // A value that landed back on the neutral baseline -- every slider dragged
+ // down again without using Reset -- is indistinguishable from no override at
+ // all, and must be treated as one, or the mission keeps saving an empty block
+ // forever.
+ const graphics::lens_settings neutral;
+ graphics::lens_overrides ov;
+
+ if (settings.aperture != neutral.aperture)
+ ov.aperture = settings.aperture;
+ if (settings.anamorphic != neutral.anamorphic)
+ ov.anamorphic = settings.anamorphic;
+ if (settings.intensity != neutral.intensity)
+ ov.intensity = settings.intensity;
+ if (settings.starburst != neutral.starburst)
+ ov.starburst = settings.starburst;
+ if (settings.starburst_scale != neutral.starburst_scale)
+ ov.starburst_scale = settings.starburst_scale;
+ if (settings.max_ghosts != neutral.max_ghosts)
+ ov.max_ghosts = settings.max_ghosts;
+ if (settings.ghost_brightness != neutral.ghost_brightness)
+ ov.ghost_brightness = settings.ghost_brightness;
+ if (settings.starburst_brightness != neutral.starburst_brightness)
+ ov.starburst_brightness = settings.starburst_brightness;
+
+ modify(The_mission.camera_lens_overrides, ov);
+ applyLensOverridesToViewport();
+}
+
+void BackgroundEditorDialogModel::resetLensSettings()
+{
+ modify(The_mission.camera_lens_overrides, graphics::lens_overrides());
+ applyLensOverridesToViewport();
+}
+
+bool BackgroundEditorDialogModel::getLensMounted()
+{
+ return graphics::lens_flare_active_lens() >= 0;
+}
+
+// Push the mission's overrides at the running engine so the viewport shows what
+// the mission will. Cheap to call on every slider tick: only a changed iris costs
+// anything, and lens_flare_overrides_changed() coalesces that rebuild.
+void BackgroundEditorDialogModel::applyLensOverridesToViewport()
+{
+ graphics::lens_flare_overrides() = The_mission.camera_lens_overrides;
+ graphics::lens_flare_overrides_changed();
+
+ // qtFred's viewport repaints on demand, not continuously (unlike the lab) --
+ // without this, the edit above is real but invisible until some unrelated
+ // event (mouse move, resize) happens to trigger the next repaint.
+ if (_viewport)
+ _viewport->needsUpdate();
+}
+
} // 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..02c9673236c 100644
--- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h
+++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h
@@ -4,6 +4,7 @@
#include "AbstractDialogModel.h"
+#include "graphics/lens_flare.h"
#include "starfield/starfield.h"
#include
@@ -175,7 +176,39 @@ class BackgroundEditorDialogModel : public AbstractDialogModel {
static SCP_string getLightingProfileName();
void setLightingProfileName(const SCP_string& name);
+ // 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();
+ void setCameraLensName(const SCP_string& name);
+
+ // How this mission restyles the camera lens -- iris, anamorphic look and flare
+ // strength -- as one value, which is what lets LensApertureDialog stay a plain
+ // form instead of tracking which parts the mission had already overridden.
+ // See The_mission.camera_lens_overrides in missionparse.h.
+ //
+ // A field the mission does not override reads back as its neutral default, and
+ // a field set back to that default stops being an override. Deliberately *not*
+ // the mounted lens's own tabled values: a mission-level override replaces the
+ // whole group it belongs to, so starting the sliders at (say) this lens's blade
+ // curvature would mean the first field a user touches quietly saves that too,
+ // frozen at one lens's numbers.
+ static graphics::lens_settings getLensSettings();
+ void setLensSettings(const graphics::lens_settings& settings);
+ void resetLensSettings();
+
+ // Whether a lens is currently mounted at all ("Default" with no
+ // $Default Lens: in the tables, or "None", both leave nothing mounted).
+ // Edits still update the mission's stored override either way, but there is
+ // nothing for them to visibly change without a mounted lens -- the dialog
+ // uses this to warn instead of leaving the user wondering why nothing moved.
+ static bool getLensMounted();
+
private:
+ void applyLensOverridesToViewport();
void initializeData();
void refreshBackgroundPreview();
static background_t& getActiveBackground();
diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp
index c674f26744e..57681573958 100644
--- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp
+++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp
@@ -26,6 +26,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport*
, _dataMenuStyle(viewport->Data_menu_style)
, _toolbarIconSize(viewport->toolbar_icon_size)
, _outlineLod(viewport->view.Outline_lod)
+ , _graphics(viewport->view.Graphics)
, _invertOrbitX(viewport->camera.getInvertOrbitX())
, _invertOrbitY(viewport->camera.getInvertOrbitY())
, _gridCenterX(static_cast(viewport->The_grid->center.xyz.x))
@@ -68,6 +69,9 @@ bool PreferencesDialogModel::apply() {
_viewport->Data_menu_style = _dataMenuStyle;
_viewport->toolbar_icon_size = _toolbarIconSize;
_viewport->view.Outline_lod = _outlineLod;
+ _viewport->view.Graphics = _graphics;
+ // Only some of these can take effect now; the rest need a restart (see GraphicsSettings).
+ _viewport->view.Graphics.applyLive();
_viewport->camera.setInvertOrbitX(_invertOrbitX);
_viewport->camera.setInvertOrbitY(_invertOrbitY);
@@ -180,6 +184,9 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS
int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; }
void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); }
+const GraphicsSettings& PreferencesDialogModel::getGraphics() const { return _graphics; }
+void PreferencesDialogModel::setGraphics(const GraphicsSettings& value) { modify(_graphics, value); }
+
QKeySequence PreferencesDialogModel::getControlKey(ControlAction action) const {
auto it = _controlKeys.find(action);
Assertion(it != _controlKeys.end(), "Unknown control action!");
diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h
index 9fcc5b50884..0d12bb0dcfe 100644
--- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h
+++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h
@@ -1,5 +1,6 @@
#pragma once
+#include "mission/GraphicsSettings.h"
#include "mission/dialogs/AbstractDialogModel.h"
#include "ui/ControlBindings.h"
#include "ui/ThemeMode.h"
@@ -67,6 +68,11 @@ class PreferencesDialogModel : public AbstractDialogModel {
int getOutlineLod() const;
void setOutlineLod(int value);
+ // Graphics. The dialog edits a whole GraphicsSettings rather than a field at a time; the
+ // setter exists so edits still route through modify() and mark the model dirty.
+ const GraphicsSettings& getGraphics() const;
+ void setGraphics(const GraphicsSettings& value);
+
// Controls
QKeySequence getControlKey(ControlAction action) const;
void setControlKey(ControlAction action, const QKeySequence& sequence);
@@ -109,6 +115,8 @@ class PreferencesDialogModel : public AbstractDialogModel {
int _toolbarIconSize;
int _outlineLod;
+ GraphicsSettings _graphics;
+
// Controls
std::map _controlKeys;
bool _invertOrbitX;
diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp
index cc8cf8efb8d..db32e78246d 100644
--- a/qtfred/src/mission/management.cpp
+++ b/qtfred/src/mission/management.cpp
@@ -3,6 +3,8 @@
#include "object.h"
+#include "mission/GraphicsSettings.h"
+
#include "cmdline/cmdline.h"
#include
@@ -125,9 +127,16 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor,
// Cmdline_noglow = 1;
Cmdline_window = 1;
+ // These have to be in place before gr_init() bakes them into GPU resources, which is well
+ // before EditorViewport (and its copy of the settings) exists.
+ const GraphicsSettings graphicsSettings = GraphicsSettings::applyBeforeGrInit();
+
std::unique_ptr graphicsOps(new QtGraphicsOperations(editor));
gr_init(std::move(graphicsOps));
- gr_set_gamma(3.0f);
+
+ // The rest needs a live renderer. EditorViewport re-applies these once it exists, but the
+ // startup screens render before that.
+ graphicsSettings.applyLive();
io::mouse::CursorManager::get()->showCursor(false);
diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp
index 7c6aae8f482..4bbe97d6e41 100644
--- a/qtfred/src/ui/FredView.cpp
+++ b/qtfred/src/ui/FredView.cpp
@@ -942,6 +942,7 @@ void FredView::syncViewOptions() {
connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on);
connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail);
+ connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing);
connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances);
diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp
index 075274477ed..3df1d886596 100644
--- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp
+++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp
@@ -3,6 +3,7 @@
#include "ui/util/default_dir.h"
#include "ui/util/SignalBlockers.h"
#include "ui/dialogs/General/ImagePickerDialog.h"
+#include "ui/dialogs/LensApertureDialog.h"
#include "ui_BackgroundEditor.h"
#include
@@ -125,6 +126,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 +411,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 +980,19 @@ 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());
+}
+
+void BackgroundEditorDialog::on_lensApertureButton_clicked()
+{
+ LensApertureDialog dlg(this, _model.get());
+ dlg.exec();
+}
+
} // namespace fso::fred::dialogs
diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h
index 42ea46fb77f..e6d9d20aefd 100644
--- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h
+++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h
@@ -101,6 +101,8 @@ 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);
+ void on_lensApertureButton_clicked();
protected:
void closeEvent(QCloseEvent* e) override;
diff --git a/qtfred/src/ui/dialogs/LensApertureDialog.cpp b/qtfred/src/ui/dialogs/LensApertureDialog.cpp
new file mode 100644
index 00000000000..ec22b03d3a4
--- /dev/null
+++ b/qtfred/src/ui/dialogs/LensApertureDialog.cpp
@@ -0,0 +1,295 @@
+#include "LensApertureDialog.h"
+#include "ui/util/SignalBlockers.h"
+#include "ui_LensApertureDialog.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace fso::fred::dialogs {
+
+namespace {
+
+using graphics::lens_settings;
+
+// One row of the dialog: what it is called, how it is presented, and where its
+// value lives in a lens_settings.
+//
+// This table is the dialog. The controls are built from it, read from it and
+// written back through it, so a knob added to lens_settings reaches the editor by
+// adding one line here rather than by being spelled out in a .ui file, a build
+// list, a load function and a save function that can all drift apart.
+//
+// The accessors are captureless lambdas (hence plain function pointers) rather
+// than member pointers because most of these live inside a nested struct, and a
+// pointer-to-member chain through lens_aperture::grating::density is far less
+// readable than just naming it.
+struct lens_field {
+ // Non-null starts a new group box, with this as its title.
+ const char* section;
+ const char* label;
+
+ enum class kind {
+ Real, // a slider carrying a float, quantized to `decimals` places
+ Integer, // a slider carrying a whole number
+ Boolean, // a checkbox
+ };
+ kind type;
+
+ float min;
+ float max;
+ int decimals; // Real only; also fixes the slider's step size
+
+ float (*get)(const lens_settings&);
+ void (*set)(lens_settings&, float);
+};
+
+// QSlider is integer-only, so a Real field rides a slider scaled by 10^decimals.
+// Deriving the scale from the displayed precision rather than picking one per
+// field is what keeps a value read back out of a slider equal to the value put
+// in: anything the dialog can show, it can represent exactly.
+float field_scale(const lens_field& f)
+{
+ return (f.type == lens_field::kind::Real) ? std::pow(10.0f, static_cast(f.decimals)) : 1.0f;
+}
+
+// clang-format off
+const lens_field Lens_fields[] = {
+ {"Iris", "Blades", lens_field::kind::Integer, 0.0f, 64.0f, 0,
+ [](const lens_settings& s) { return static_cast(s.aperture.blades); },
+ [](lens_settings& s, float v) { s.aperture.blades = static_cast(std::lround(v)); }},
+ {nullptr, "Blade rotation (deg)", lens_field::kind::Real, 0.0f, 180.0f, 1,
+ [](const lens_settings& s) { return s.aperture.rotation; },
+ [](lens_settings& s, float v) { s.aperture.rotation = v; }},
+ {nullptr, "Blade curvature", lens_field::kind::Real, -1.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.curvature; },
+ [](lens_settings& s, float v) { s.aperture.curvature = v; }},
+ {nullptr, "Edge softness", lens_field::kind::Real, 0.0f, 0.5f, 4,
+ [](const lens_settings& s) { return s.aperture.softness; },
+ [](lens_settings& s, float v) { s.aperture.softness = v; }},
+
+ {"Rim grating", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.grating.strength; },
+ [](lens_settings& s, float v) { s.aperture.grating.strength = v; }},
+ {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.grating.density; },
+ [](lens_settings& s, float v) { s.aperture.grating.density = v; }},
+ {nullptr, "Length", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.grating.length; },
+ [](lens_settings& s, float v) { s.aperture.grating.length = v; }},
+ {nullptr, "Width", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.grating.width; },
+ [](lens_settings& s, float v) { s.aperture.grating.width = v; }},
+ {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4,
+ [](const lens_settings& s) { return s.aperture.grating.softness; },
+ [](lens_settings& s, float v) { s.aperture.grating.softness = v; }},
+
+ {"Scratches", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.scratches.strength; },
+ [](lens_settings& s, float v) { s.aperture.scratches.strength = v; }},
+ {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.scratches.density; },
+ [](lens_settings& s, float v) { s.aperture.scratches.density = v; }},
+ {nullptr, "Length", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.scratches.length; },
+ [](lens_settings& s, float v) { s.aperture.scratches.length = v; }},
+ {nullptr, "Width", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.scratches.width; },
+ [](lens_settings& s, float v) { s.aperture.scratches.width = v; }},
+ {nullptr, "Rotation (deg)", lens_field::kind::Real, 0.0f, 180.0f, 1,
+ [](const lens_settings& s) { return s.aperture.scratches.rotation; },
+ [](lens_settings& s, float v) { s.aperture.scratches.rotation = v; }},
+ {nullptr, "Rotation variation", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.scratches.rotation_variation; },
+ [](lens_settings& s, float v) { s.aperture.scratches.rotation_variation = v; }},
+ {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4,
+ [](const lens_settings& s) { return s.aperture.scratches.softness; },
+ [](lens_settings& s, float v) { s.aperture.scratches.softness = v; }},
+
+ {"Dust", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.dust.strength; },
+ [](lens_settings& s, float v) { s.aperture.dust.strength = v; }},
+ {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.dust.density; },
+ [](lens_settings& s, float v) { s.aperture.dust.density = v; }},
+ {nullptr, "Radius", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.aperture.dust.radius; },
+ [](lens_settings& s, float v) { s.aperture.dust.radius = v; }},
+ {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4,
+ [](const lens_settings& s) { return s.aperture.dust.softness; },
+ [](lens_settings& s, float v) { s.aperture.dust.softness = v; }},
+
+ {"Anamorphic", "Squeeze", lens_field::kind::Real, 1.0f, 3.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.squeeze; },
+ [](lens_settings& s, float v) { s.anamorphic.squeeze = v; }},
+ {nullptr, "Streak strength", lens_field::kind::Real, 0.0f, 2.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.streak.strength; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.strength = v; }},
+ {nullptr, "Streak length", lens_field::kind::Real, 0.0f, 4.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.streak.length; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.length = v; }},
+ {nullptr, "Streak thickness", lens_field::kind::Real, 0.0f, 0.2f, 4,
+ [](const lens_settings& s) { return s.anamorphic.streak.thickness; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.thickness = v; }},
+ {nullptr, "Streak tint (red)", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.streak.tint[0]; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.tint[0] = v; }},
+ {nullptr, "Streak tint (green)", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.streak.tint[1]; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.tint[1] = v; }},
+ {nullptr, "Streak tint (blue)", lens_field::kind::Real, 0.0f, 1.0f, 2,
+ [](const lens_settings& s) { return s.anamorphic.streak.tint[2]; },
+ [](lens_settings& s, float v) { s.anamorphic.streak.tint[2] = v; }},
+
+ // The knobs below cost nothing to change, unlike everything above the
+ // anamorphic section: none of them touches the iris mask or its transform.
+ {"Strength", "Lens intensity", lens_field::kind::Real, 0.0f, 10.0f, 3,
+ [](const lens_settings& s) { return s.intensity; },
+ [](lens_settings& s, float v) { s.intensity = v; }},
+ {nullptr, "Ghost brightness", lens_field::kind::Real, 0.0f, 500.0f, 1,
+ [](const lens_settings& s) { return s.ghost_brightness; },
+ [](lens_settings& s, float v) { s.ghost_brightness = v; }},
+ {nullptr, "Starburst brightness", lens_field::kind::Real, 0.0f, 10.0f, 2,
+ [](const lens_settings& s) { return s.starburst_brightness; },
+ [](lens_settings& s, float v) { s.starburst_brightness = v; }},
+ {nullptr, "Draw starburst", lens_field::kind::Boolean, 0.0f, 1.0f, 0,
+ [](const lens_settings& s) { return s.starburst ? 1.0f : 0.0f; },
+ [](lens_settings& s, float v) { s.starburst = (v != 0.0f); }},
+ {nullptr, "Starburst scale", lens_field::kind::Real, 0.0f, 4.0f, 2,
+ [](const lens_settings& s) { return s.starburst_scale; },
+ [](lens_settings& s, float v) { s.starburst_scale = v; }},
+ {nullptr, "Max ghosts", lens_field::kind::Integer, 0.0f,
+ static_cast(graphics::MAX_LENS_FLARE_GHOSTS), 0,
+ [](const lens_settings& s) { return static_cast(s.max_ghosts); },
+ [](lens_settings& s, float v) { s.max_ghosts = static_cast(std::lround(v)); }},
+};
+// clang-format on
+
+constexpr int Num_lens_fields = static_cast(sizeof(Lens_fields) / sizeof(Lens_fields[0]));
+
+} // namespace
+
+LensApertureDialog::LensApertureDialog(QWidget* parent, BackgroundEditorDialogModel* model)
+ : QDialog(parent), ui(new Ui::LensApertureDialog()), _model(model)
+{
+ ui->setupUi(this);
+
+ buildFields();
+
+ connect(ui->resetButton, &QPushButton::clicked, this, [this]() {
+ _model->resetLensSettings();
+ updateUi();
+ });
+
+ updateUi();
+}
+
+LensApertureDialog::~LensApertureDialog() = default;
+
+// Build one control per table row, grouping consecutive rows under whichever
+// section header last named one.
+void LensApertureDialog::buildFields()
+{
+ auto* outer = ui->scrollAreaContents->layout();
+ QFormLayout* form = nullptr;
+
+ _rows.resize(Num_lens_fields);
+
+ for (int i = 0; i < Num_lens_fields; i++) {
+ const lens_field& f = Lens_fields[i];
+ row& r = _rows[i];
+
+ if (f.section != nullptr) {
+ auto* group = new QGroupBox(QString::fromUtf8(f.section), ui->scrollAreaContents);
+ form = new QFormLayout(group);
+ outer->addWidget(group);
+ }
+ Assertion(form != nullptr, "The first lens field must open a section!");
+
+ if (f.type == lens_field::kind::Boolean) {
+ r.check = new QCheckBox();
+ connect(r.check, &QCheckBox::toggled, this, [this]() { applyFromControls(); });
+ form->addRow(QString::fromUtf8(f.label), r.check);
+ continue;
+ }
+
+ const float scale = field_scale(f);
+ r.slider = new QSlider(Qt::Horizontal);
+ r.slider->setMinimum(static_cast(std::lround(f.min * scale)));
+ r.slider->setMaximum(static_cast(std::lround(f.max * scale)));
+ r.value = new QLabel();
+ // Wide enough that the row doesn't jump about as digits come and go
+ r.value->setMinimumWidth(60);
+ r.value->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+
+ connect(r.slider, &QSlider::valueChanged, this, [this]() { applyFromControls(); });
+
+ auto* rowLayout = new QHBoxLayout();
+ rowLayout->addWidget(r.slider);
+ rowLayout->addWidget(r.value);
+ form->addRow(QString::fromUtf8(f.label), rowLayout);
+ }
+
+ outer->addItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Expanding));
+}
+
+// The value label for a row, formatted at that row's own precision.
+void LensApertureDialog::showValue(int index, float value)
+{
+ const lens_field& f = Lens_fields[index];
+ if (_rows[index].value == nullptr) {
+ return;
+ }
+ _rows[index].value->setText(QString::number(value, 'f', f.decimals));
+}
+
+void LensApertureDialog::updateUi()
+{
+ util::SignalBlockers blockers(this);
+
+ ui->noLensWarningLabel->setVisible(!BackgroundEditorDialogModel::getLensMounted());
+
+ const graphics::lens_settings settings = fso::fred::dialogs::BackgroundEditorDialogModel::getLensSettings();
+
+ for (int i = 0; i < Num_lens_fields; i++) {
+ const lens_field& f = Lens_fields[i];
+ const float value = f.get(settings);
+
+ if (f.type == lens_field::kind::Boolean) {
+ _rows[i].check->setChecked(value != 0.0f);
+ continue;
+ }
+
+ _rows[i].slider->setValue(static_cast(std::lround(value * field_scale(f))));
+ showValue(i, value);
+ }
+}
+
+void LensApertureDialog::applyFromControls()
+{
+ graphics::lens_settings settings;
+
+ for (int i = 0; i < Num_lens_fields; i++) {
+ const lens_field& f = Lens_fields[i];
+
+ if (f.type == lens_field::kind::Boolean) {
+ f.set(settings, _rows[i].check->isChecked() ? 1.0f : 0.0f);
+ continue;
+ }
+
+ const float value = static_cast(_rows[i].slider->value()) / field_scale(f);
+ f.set(settings, value);
+ showValue(i, value);
+ }
+
+ _model->setLensSettings(settings);
+}
+
+} // namespace fso::fred::dialogs
diff --git a/qtfred/src/ui/dialogs/LensApertureDialog.h b/qtfred/src/ui/dialogs/LensApertureDialog.h
new file mode 100644
index 00000000000..6cd64a55d40
--- /dev/null
+++ b/qtfred/src/ui/dialogs/LensApertureDialog.h
@@ -0,0 +1,58 @@
+#pragma once
+
+#include
+
+#include "mission/dialogs/BackgroundEditorDialogModel.h"
+
+class QCheckBox;
+class QLabel;
+class QSlider;
+
+namespace fso::fred::dialogs {
+
+namespace Ui {
+class LensApertureDialog;
+}
+
+// Live editor for how the mission restyles the camera lens: the iris (shape plus
+// grating/scratches/dust), the anamorphic look, and how strongly the flare draws
+// -- everything about the camera except the lens prescription itself, which is
+// what naming a lens in the Background Editor picks. See graphics/lens_flare.h's
+// lens_settings for the whole set and the set-lens-* sexps this mirrors.
+//
+// Opened from the Background Editor's "Lens Aperture..." button; every control
+// applies straight to BackgroundEditorDialogModel as it is dragged, the same way
+// that dialog's own ambient light sliders do, so there is no separate Apply/OK
+// step.
+//
+// The controls are not laid out in the .ui file. They are built from the field
+// table at the top of LensApertureDialog.cpp, which is also what reads and writes
+// them, so a knob added to lens_settings needs one line there rather than a
+// widget, a build entry, a load line and a save line that can all drift apart.
+class LensApertureDialog : public QDialog {
+ Q_OBJECT
+
+ public:
+ explicit LensApertureDialog(QWidget* parent, BackgroundEditorDialogModel* model);
+ ~LensApertureDialog() override;
+
+ private:
+ // The widgets built for one field: a slider plus its value readout, or a
+ // checkbox. Owned by the layout, like everything else Qt parents.
+ struct row {
+ QSlider* slider = nullptr;
+ QLabel* value = nullptr;
+ QCheckBox* check = nullptr;
+ };
+
+ void buildFields();
+ void showValue(int index, float value);
+ void updateUi();
+ void applyFromControls();
+
+ std::unique_ptr ui;
+ BackgroundEditorDialogModel* _model;
+ SCP_vector _rows; // parallel to the field table
+};
+
+} // namespace fso::fred::dialogs
diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp
index 07db9e06d92..0067bb2b90c 100644
--- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp
+++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp
@@ -8,9 +8,54 @@
#include "ui/util/SignalBlockers.h"
#include "ui/widgets/sexp_tree_view.h"
+#include
+#include
+
namespace fso::fred::dialogs {
namespace {
+// The engine already describes each of these settings once, with its own value list and display
+// names (Graphics.Shadows in shadows.cpp, Graphics.AAMode and Graphics.TextureFilter in 2d.cpp and
+// gropengltexture.cpp). Reading the labels back out of those definitions keeps this dialog from
+// carrying a second, silently-diverging copy of them.
+//
+// The values are enumerated in declaration order, which for these three is enum order, so a combo
+// index is the enum value. Returns empty if the option isn't registered, in which case the caller
+// leaves the combo alone rather than showing a half-populated list.
+SCP_vector engineOptionValues(const char* configKey)
+{
+ for (const auto& option : options::OptionsManager::instance()->getOptions()) {
+ if (option->getConfigKey() == configKey && option->getType() == options::OptionType::Selection) {
+ return option->getValidValues();
+ }
+ }
+
+ return {};
+}
+
+void populateFromEngineOption(QComboBox* combo, const char* configKey)
+{
+ const auto values = engineOptionValues(configKey);
+
+ for (const auto& value : values) {
+ combo->addItem(QString::fromStdString(value.display));
+ }
+
+ // Nothing to choose from means the option isn't registered in this build (a renderer compiled
+ // out, say). Grey the control out rather than leaving an empty combo that looks broken.
+ combo->setEnabled(!values.empty());
+}
+
+// The model stores graphics settings as one struct, so an edit is read-modify-write. Doing it this
+// way keeps every control's slot to the one line that actually differs.
+template
+void editGraphics(PreferencesDialogModel* model, F&& mutate)
+{
+ GraphicsSettings graphics = model->getGraphics();
+ mutate(graphics);
+ model->setGraphics(graphics);
+}
+
int themeModeToIndex(ThemeMode mode)
{
switch (mode) {
@@ -94,6 +139,27 @@ void PreferencesDialog::applyChanges() {
}
void PreferencesDialog::initializeUi() {
+ // setupUi() has already run connectSlotsByName(), so filling a combo here would fire its
+ // currentIndexChanged slot and mark the model modified before the user has touched anything.
+ // (Items declared in the .ui file were added before the connections existed and so were safe.)
+ util::SignalBlockers blockers(this);
+
+ populateFromEngineOption(ui->shadowQualityCombo, "Graphics.Shadows");
+ populateFromEngineOption(ui->aaModeCombo, "Graphics.AAMode");
+ populateFromEngineOption(ui->textureFilterCombo, "Graphics.TextureFilter");
+
+ // Anisotropy levels are hardware-dependent, so ask the engine for the same list its own
+ // options screen offers. Empty means the hardware can't do it; leave the combo disabled.
+ _anisotropyLevels = gr_get_supported_anisotropy_levels();
+ for (float level : _anisotropyLevels) {
+ ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0));
+ }
+ ui->anisotropyCombo->setEnabled(!_anisotropyLevels.empty());
+
+ for (int samples : GraphicsSettings::validMsaaSampleCounts()) {
+ ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples));
+ }
+
// Build the controls key-binding form dynamically from the registered bindings
auto* form = new QFormLayout(ui->controlsFormWidget);
auto& bindings = ControlBindings::instance();
@@ -125,6 +191,35 @@ void PreferencesDialog::updateUi() {
const int iconSize = _model->getToolbarIconSize();
ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1);
ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod());
+
+ // Graphics
+ const GraphicsSettings& graphics = _model->getGraphics();
+
+ ui->enablePostProcessing->setChecked(graphics.enablePostProcessing);
+ ui->shadowQualityCombo->setCurrentIndex(static_cast(graphics.shadowQuality));
+ ui->aaModeCombo->setCurrentIndex(static_cast(graphics.aaMode));
+ ui->gammaSpin->setValue(graphics.gamma);
+
+ const auto msaaCounts = GraphicsSettings::validMsaaSampleCounts();
+ ui->msaaCombo->setCurrentIndex(
+ static_cast(std::find(msaaCounts.begin(), msaaCounts.end(), graphics.msaaSamples) - msaaCounts.begin()));
+
+ // Both of these can be "not chosen", in which case the engine picks: trilinear, and the
+ // hardware's maximum anisotropy. Show what will actually be used rather than a blank.
+ ui->textureFilterCombo->setCurrentIndex(
+ graphics.textureFilter == GraphicsSettings::NO_TEXTURE_FILTER_CHOICE ? 1 : graphics.textureFilter);
+
+ if (!_anisotropyLevels.empty()) {
+ int index = static_cast(_anisotropyLevels.size()) - 1;
+ for (size_t i = 0; i < _anisotropyLevels.size(); ++i) {
+ if (_anisotropyLevels[i] == graphics.anisotropy) {
+ index = static_cast(i);
+ break;
+ }
+ }
+ ui->anisotropyCombo->setCurrentIndex(index);
+ }
+
ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents());
ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals());
ui->showSexpHelpMissionCutscenes->setChecked(_model->getShowSexpHelpMissionCutscenes());
@@ -202,6 +297,43 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) {
_model->setThemeMode(themeModeFromIndex(index));
}
+void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) {
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; });
+}
+
+void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) {
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast(index); });
+}
+
+void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) {
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast(index); });
+}
+
+void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) {
+ const auto counts = GraphicsSettings::validMsaaSampleCounts();
+ if (index < 0 || static_cast(index) >= counts.size()) {
+ return;
+ }
+
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = counts[index]; });
+}
+
+void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) {
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; });
+}
+
+void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) {
+ if (index < 0 || static_cast(index) >= _anisotropyLevels.size()) {
+ return;
+ }
+
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = _anisotropyLevels[index]; });
+}
+
+void PreferencesDialog::on_gammaSpin_valueChanged(double value) {
+ editGraphics(_model.get(), [=](GraphicsSettings& g) { g.gamma = static_cast(value); });
+}
+
void PreferencesDialog::on_dataMenuStyleCombo_currentIndexChanged(int index) {
_model->setDataMenuStyle(static_cast(index));
}
diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h
index e4e8266866e..a4ed07e2614 100644
--- a/qtfred/src/ui/dialogs/PreferencesDialog.h
+++ b/qtfred/src/ui/dialogs/PreferencesDialog.h
@@ -34,6 +34,14 @@ private slots:
void on_toolbarIconSizeCombo_currentIndexChanged(int index);
void on_outlineLodCombo_currentIndexChanged(int index);
void on_themeCombo_currentIndexChanged(int index);
+ // Graphics
+ void on_enablePostProcessing_toggled(bool checked);
+ void on_shadowQualityCombo_currentIndexChanged(int index);
+ void on_aaModeCombo_currentIndexChanged(int index);
+ void on_msaaCombo_currentIndexChanged(int index);
+ void on_textureFilterCombo_currentIndexChanged(int index);
+ void on_anisotropyCombo_currentIndexChanged(int index);
+ void on_gammaSpin_valueChanged(double value);
void on_dataMenuStyleCombo_currentIndexChanged(int index);
void on_showSexpHelpMissionEvents_toggled(bool checked);
void on_showSexpHelpMissionGoals_toggled(bool checked);
@@ -63,6 +71,8 @@ private slots:
std::unique_ptr ui;
std::unique_ptr _model;
+ //! Anisotropy levels backing the combo, in combo order. Queried once; hardware-dependent.
+ SCP_vector _anisotropyLevels;
std::map _controlEditors;
FredView* _fredView = nullptr;
EditorViewport* _viewport = nullptr;
diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui
index 779d8ab0311..df4aba447e5 100644
--- a/qtfred/ui/BackgroundEditor.ui
+++ b/qtfred/ui/BackgroundEditor.ui
@@ -1103,6 +1103,23 @@
-
+ -
+
+
+ Camera Lens
+
+
+
+ -
+
+
+ -
+
+
+ Lens Aperture...
+
+
+
@@ -1188,6 +1205,8 @@
envMapButton
envMapEdit
lightingProfileCombo
+ cameraLensCombo
+ lensApertureButton
oldNebulaPatternCombo
oldNebulaColorCombo
oldNebulaPitchSpinBox
diff --git a/qtfred/ui/FredView.ui b/qtfred/ui/FredView.ui
index 7d8cdc3e035..9cb356264bc 100644
--- a/qtfred/ui/FredView.ui
+++ b/qtfred/ui/FredView.ui
@@ -120,6 +120,8 @@
+
+
+
+
+ Graphics
+
+
+ -
+
+
+ Route the 3D viewport through the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead of drawing straight to the screen. The other settings on this tab only have a visible effect while this is on.
+
+
+ Enable post-processing in the viewport
+
+
+
+ -
+
+
+ Shadows && Anti-Aliasing
+
+
+
-
+
+
+ Shadow quality:
+
+
+
+ -
+
+
+ Quality of the shadows cast by the mission's sun in the viewport. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect.
+
+
+
+ -
+
+
+ Anti-aliasing:
+
+
+
+ -
+
+
+ Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled.
+
+
+
+ -
+
+
+ MSAA:
+
+
+
+ -
+
+
+ Multisample anti-aliasing used for the viewport's 3D scene. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect.
+
+
+
+
+
+
+ -
+
+
+ Textures
+
+
+
-
+
+
+ Texture filtering:
+
+
+
+ -
+
+
+ Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect.
+
+
+
+ -
+
+
+ Anisotropic filtering:
+
+
+
+ -
+
+
+ Anisotropic texture filtering level. Restarting qtFRED is required for a change to take effect.
+
+
+
+
+
+
+ -
+
+
+ Brightness
+
+
+
-
+
+
+ Gamma:
+
+
+
+ -
+
+
+ Brightness of the viewport. qtFRED defaults this higher than the game since the viewport isn't normally tonemapped.
+
+
+ 2
+
+
+ 0.10
+
+
+ 5.00
+
+
+ 0.05
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+
+
Grid
diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp
new file mode 100644
index 00000000000..da2514e0065
--- /dev/null
+++ b/test/src/graphics/test_lens_flare.cpp
@@ -0,0 +1,1130 @@
+#include
+
+#include
+
+#include
+#include
+#include
+#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());
+ // Every ghost the uniform block can hold is enumerated; $Max Ghosts: is
+ // applied when the quads are packed, so that it can be overridden without
+ // re-running this precompute.
+ EXPECT_LE(static_cast(ls->ghosts.size()), MAX_LENS_FLARE_GHOSTS);
+ EXPECT_GT(ls->max_ghosts, 0);
+ 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->anamorphic.streak.strength, 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);
+ const lens_system* lens = lens_flare_get_system(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 edited = lens->aperture;
+ edited.blades = 3;
+ edited.rotation = 40.0f;
+ lens_flare_overrides().aperture = edited;
+ lens_flare_overrides_changed();
+
+ 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";
+
+ // and the lens itself was never touched, which is the whole point of an
+ // override: there is nothing to put back
+ EXPECT_NE(lens->aperture.blades, 3);
+}
+
+// The backends cache their uploaded copy, so an edit has to change the
+// generation counter or the new mask never reaches the GPU -- and, just as
+// importantly, an edit that changes nothing must *not* bump it, or every slider
+// tick would re-upload.
+TEST_F(LensFlareTableTest, TextureInvalidationBumpsGeneration)
+{
+ const int idx = lens_flare_lookup("angenieux_100mm");
+ ASSERT_GE(idx, 0);
+ const lens_system* lens = lens_flare_get_system(idx);
+ ASSERT_NE(lens, nullptr);
+
+ ASSERT_NE(lens_flare_get_textures(idx), nullptr);
+ const unsigned int before = lens_flare_get_texture_generation();
+
+ lens_aperture edited = lens->aperture;
+ edited.blades = 3;
+ lens_flare_overrides().aperture = edited;
+ lens_flare_overrides_changed();
+ EXPECT_NE(lens_flare_get_texture_generation(), before);
+
+ // An override that resolves to the same iris is not an edit. This is what
+ // keeps a repeating mission event from rebuilding the mask forever.
+ ASSERT_NE(lens_flare_get_textures(idx), nullptr);
+ const unsigned int settled = lens_flare_get_texture_generation();
+ lens_flare_overrides().aperture = edited;
+ lens_flare_overrides_changed();
+ EXPECT_EQ(lens_flare_get_texture_generation(), settled);
+}
+
+// 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.
+// 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);
+}
+
+// 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)
+ {
+ 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);
+ EXPECT_FLOAT_EQ(ls->anamorphic.squeeze, 1.75f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.strength, 0.81f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.length, 0.82f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.thickness, 0.083f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[0], 0.84f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[1], 0.85f);
+ EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[2], 0.86f);
+
+ // and the tbm must not have disturbed the built-in table
+ EXPECT_GE(lens_flare_lookup("angenieux_100mm"), 0);
+}
+
+// Overriding is what a mission, the lab and the set-lens-* sexps all do, and
+// dropping those overrides is the whole of "one mission's camera cannot carry
+// into the next". The lens itself is never written to, so there is nothing to
+// restore and nothing that can be left half-restored.
+TEST_F(LensFlareTableTest, ResetForLevelDropsOverrides)
+{
+ const int idx = lens_flare_lookup("angenieux_100mm");
+ ASSERT_GE(idx, 0);
+ const lens_system* lens = lens_flare_get_system(idx);
+ ASSERT_NE(lens, nullptr);
+ lens_flare_switch_to("angenieux_100mm");
+ ASSERT_EQ(lens_flare_active_lens(), idx);
+
+ const lens_aperture tabled = lens->aperture;
+ ASSERT_EQ(lens_flare_effective_settings(idx).aperture, tabled)
+ << "with nothing overridden the camera must be exactly what the table declared";
+
+ // make sure the textures exist, so the reset has something to drop
+ 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 edited = tabled;
+ edited.blades = 3;
+ edited.dust.strength = 0.8f;
+ edited.scratches.strength = 0.4f;
+ ASSERT_NE(edited, tabled);
+ lens_flare_overrides().aperture = edited;
+ lens_flare_overrides_changed();
+
+ EXPECT_EQ(lens_flare_effective_settings(idx).aperture, edited);
+ EXPECT_EQ(lens->aperture, tabled) << "an override must never write through to the lens";
+
+ lens_flare_reset_for_level();
+
+ EXPECT_FALSE(lens_flare_overrides().any());
+ EXPECT_EQ(lens_flare_effective_settings(idx).aperture, tabled);
+ EXPECT_NE(lens_flare_get_texture_generation(), generation) << "backends would keep the edited textures";
+}
+
+// Every knob resolves the same way -- the mounted lens's tabled value unless
+// overridden -- so one test covers the whole set rather than one per field. What
+// it actually guards is that lens_flare_effective_settings() reads each override
+// into the member it names: a copy-paste slip there would silently make a knob
+// unoverridable.
+TEST_F(LensFlareTableTest, EveryOverrideReachesTheEffectiveSettings)
+{
+ const int idx = lens_flare_lookup("tessar_50mm");
+ ASSERT_GE(idx, 0);
+ lens_flare_switch_to("tessar_50mm");
+ ASSERT_EQ(lens_flare_active_lens(), idx);
+
+ const lens_system* lens = lens_flare_get_system(idx);
+ ASSERT_NE(lens, nullptr);
+ const lens_settings tabled = lens_flare_effective_settings(idx);
+
+ lens_overrides& ov = lens_flare_overrides();
+ lens_aperture ap = tabled.aperture;
+ ap.blades = 3;
+ ap.rotation = 25.0f;
+ ap.dust.strength = 0.6f;
+ ov.aperture = ap;
+
+ lens_anamorphic an;
+ an.squeeze = 2.5f;
+ an.streak.strength = 0.8f;
+ an.streak.length = 3.0f;
+ ov.anamorphic = an;
+
+ ov.intensity = tabled.intensity + 1.0f;
+ ov.starburst = !tabled.starburst;
+ ov.starburst_scale = tabled.starburst_scale + 1.5f;
+ ov.max_ghosts = 5;
+ ov.ghost_brightness = 123.0f;
+ ov.starburst_brightness = 4.5f;
+ lens_flare_overrides_changed();
+
+ const lens_settings eff = lens_flare_effective_settings(idx);
+ EXPECT_EQ(eff.aperture, ap);
+ EXPECT_EQ(eff.anamorphic, an);
+ EXPECT_FLOAT_EQ(eff.intensity, tabled.intensity + 1.0f);
+ EXPECT_EQ(eff.starburst, !tabled.starburst);
+ EXPECT_FLOAT_EQ(eff.starburst_scale, tabled.starburst_scale + 1.5f);
+ EXPECT_EQ(eff.max_ghosts, 5);
+ EXPECT_FLOAT_EQ(eff.ghost_brightness, 123.0f);
+ EXPECT_FLOAT_EQ(eff.starburst_brightness, 4.5f);
+
+ // None of it reached the lens, so all of it goes away at once
+ EXPECT_EQ(lens->aperture, tabled.aperture);
+ EXPECT_EQ(lens->anamorphic, tabled.anamorphic);
+
+ lens_flare_reset_for_level();
+
+ const lens_settings after = lens_flare_effective_settings(idx);
+ EXPECT_EQ(after.aperture, tabled.aperture);
+ EXPECT_EQ(after.anamorphic, tabled.anamorphic);
+ EXPECT_FLOAT_EQ(after.intensity, tabled.intensity);
+ EXPECT_EQ(after.starburst, tabled.starburst);
+ EXPECT_FLOAT_EQ(after.starburst_scale, tabled.starburst_scale);
+ EXPECT_EQ(after.max_ghosts, tabled.max_ghosts);
+ EXPECT_FLOAT_EQ(after.ghost_brightness, tabled.ghost_brightness);
+ EXPECT_FLOAT_EQ(after.starburst_brightness, tabled.starburst_brightness);
+}
+
+// An unmounted camera resolves to the plain defaults rather than to whatever
+// lens happened to be looked up last -- the case a mission with "" hits,
+// and the one where an out-of-range index would otherwise index the vector.
+TEST_F(LensFlareTableTest, EffectiveSettingsWithoutALensAreTheDefaults)
+{
+ const lens_settings none = lens_flare_effective_settings(-1);
+ const lens_settings expected;
+
+ EXPECT_EQ(none.aperture, expected.aperture);
+ EXPECT_EQ(none.anamorphic, expected.anamorphic);
+ EXPECT_FLOAT_EQ(none.intensity, expected.intensity);
+ EXPECT_EQ(none.max_ghosts, expected.max_ghosts);
+ EXPECT_FLOAT_EQ(none.ghost_brightness, expected.ghost_brightness);
+
+ EXPECT_EQ(lens_flare_effective_settings(lens_flare_num_systems() + 5).max_ghosts, expected.max_ghosts);
+}
+
+// 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 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);
+ 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(), "");
+}
+
+// ---- thruster flares ----
+
+namespace {
+
+// One nozzle, in model space. `norm` is the direction it fires (and so shines)
+// in; a zero normal is legal and means "every way".
+glow_point make_nozzle(vec3d pnt, vec3d norm, float radius)
+{
+ glow_point gpt;
+ gpt.pnt = pnt;
+ gpt.norm = norm;
+ gpt.radius = radius;
+ return gpt;
+}
+
+// The scene every test below shares: a ship at the origin, unrotated, seen from
+// far down -z -- so the eye looks along +z at the back of the ship, and a nozzle
+// whose normal is (0, 0, -1) faces the camera squarely.
+const matrix Unrotated = vmd_identity_matrix;
+const vec3d Ship_pos = vmd_zero_vector;
+const vec3d Eye = {{{0.0f, 0.0f, -100.0f}}};
+
+// The apparent size of one nozzle in the shared scene, or -1 when it produces no
+// source at all.
+float nozzle_apparent(const glow_point& gpt, vec3d* world_pnt = nullptr)
+{
+ vec3d scratch;
+ float apparent = 0.0f;
+ if (!lens_flare_nozzle_apparent(gpt, Unrotated, Ship_pos, Eye, world_pnt ? world_pnt : &scratch, &apparent)) {
+ return -1.0f;
+ }
+ return apparent;
+}
+
+const vec3d Toward_eye = {{{0.0f, 0.0f, -1.0f}}};
+const vec3d Away = {{{0.0f, 0.0f, 1.0f}}};
+
+} // namespace
+
+// A nozzle pointed away from the camera produces no source, which is what keeps
+// the far side of every ship out of the pass.
+TEST(LensFlareThrusters, FacingAwayProducesNoSource)
+{
+ EXPECT_LT(nozzle_apparent(make_nozzle(vmd_zero_vector, Away, 1.0f)), 0.0f);
+ EXPECT_GT(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f)), 0.0f);
+}
+
+// Apparent size is the solid angle the nozzle subtends: it grows with the square
+// of its radius and falls with the square of its distance. The distance term is
+// what keeps a battle's worth of distant engines from each drawing at full
+// strength, so it is worth pinning exactly.
+TEST(LensFlareThrusters, ApparentSizeIsSolidAngle)
+{
+ // squarely facing, 100 units away, radius 1 -> pi * 1^2 / 100^2
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f)), PI / 10000.0f, 1e-9f);
+
+ // twice the radius is four times the apparent size
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 2.0f)), 4.0f * PI / 10000.0f, 1e-9f);
+
+ // half the distance is four times the apparent size
+ const vec3d halfway = {{{0.0f, 0.0f, -50.0f}}};
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(halfway, Toward_eye, 1.0f)), PI / 2500.0f, 1e-9f);
+}
+
+// Each nozzle images where it actually is. A ship's engines are set far enough
+// apart to read as separate points, which is the whole reason they are no longer
+// averaged into one source at their centroid.
+TEST(LensFlareThrusters, EachNozzleImagesAtItsOwnPosition)
+{
+ const vec3d port = {{{-40.0f, 0.0f, 0.0f}}};
+ const vec3d starboard = {{{40.0f, 0.0f, 0.0f}}};
+
+ vec3d where;
+ nozzle_apparent(make_nozzle(port, Toward_eye, 1.0f), &where);
+ EXPECT_NEAR(where.xyz.x, -40.0f, 1e-3f);
+ nozzle_apparent(make_nozzle(starboard, Toward_eye, 1.0f), &where);
+ EXPECT_NEAR(where.xyz.x, 40.0f, 1e-3f);
+}
+
+// The flare fades in over the same first third of the hemisphere the thruster
+// glow itself does (modelrender.cpp's `d *= 3`), so a flare can never appear on
+// an engine whose glow is still invisible.
+TEST(LensFlareThrusters, FacingFalloffMatchesTheGlow)
+{
+ const float square = nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f));
+
+ // a normal tipped so that its cosine against the view is exactly 0.2
+ const float cos_view = 0.2f;
+ const vec3d tipped = {{{sqrtf(1.0f - (cos_view * cos_view)), 0.0f, -cos_view}}};
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, tipped, 1.0f)), 0.6f * square, 1e-6f);
+
+ // and anything past a third of the hemisphere is simply full brightness
+ const float past_third = 0.5f;
+ const vec3d wide = {{{sqrtf(1.0f - (past_third * past_third)), 0.0f, -past_third}}};
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, wide, 1.0f)), square, 1e-6f);
+}
+
+// Glowpoints are allowed to carry a zero normal, and the thruster renderer reads
+// that as "shines every way". The flare has to agree, or such a nozzle would
+// silently never flare.
+TEST(LensFlareThrusters, NullNormalShinesEveryWay)
+{
+ EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, vmd_zero_vector, 1.0f)), PI / 10000.0f, 1e-9f);
+}
+
+// lens_flare_point_visible() is the occlusion gate both the thruster and beam
+// gathers run against a source's position before ever queuing it for a draw.
+// A real occluding hit needs a scene with real ship geometry in it, which this
+// test suite has no scaffolding for (the AI line-of-sight test it is built on,
+// test_line_of_sight(), has none either) -- but the degenerate "eye sitting
+// exactly on the source" case is pure vector math and worth pinning: a source
+// coincident with the eye has no direction to test along, and must not be
+// mistaken for occluded.
+TEST(LensFlareVisibility, EyeExactlyOnSourceIsVisible)
+{
+ const vec3d saved_eye = Eye_position;
+ Eye_position = vmd_zero_vector;
+
+ EXPECT_TRUE(graphics::lens_flare_point_visible(vmd_zero_vector));
+
+ Eye_position = saved_eye;
+}
+
+// The lab overrides every species at once and leaves the tabled values alone, so
+// switching it off restores them without anything having to be backed up -- and
+// leaving a mission drops it, the same way a lab lens is dropped.
+TEST(LensFlareThrusters, LabOverrideBeatsTheSpeciesTableAndIsDroppedOnLevelReset)
+{
+ const auto saved_species = Species_info;
+ Species_info.clear();
+
+ species_info tabled;
+ strcpy_s(tabled.species_name, "TestSpecies");
+ tabled.thruster_flare.enabled = true;
+ tabled.thruster_flare.intensity = 3.0f;
+ Species_info.push_back(tabled);
+
+ EXPECT_TRUE(lens_flare_thruster_settings(0).enabled);
+ EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 3.0f);
+ EXPECT_FALSE(lens_flare_thruster_settings(-1).enabled) << "an unknown species must not flare";
+
+ thruster_flare_info override_settings;
+ override_settings.enabled = true;
+ override_settings.intensity = 7.0f;
+ lens_flare_lab_thruster_flare() = override_settings;
+
+ EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 7.0f);
+ EXPECT_FLOAT_EQ(Species_info[0].thruster_flare.intensity, 3.0f) << "the override must not write to the table";
+ // it applies to every species, including ones with no table entry of their own
+ EXPECT_TRUE(lens_flare_thruster_settings(-1).enabled);
+
+ lens_flare_reset_for_level();
+ EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 3.0f);
+
+ Species_info = saved_species;
+}
+
+// The species-table half of the feature. Parsed here rather than through
+// species_init(), which would drag in iff_defs for the shipped table's
+// "$Default IFF:"; every entry a mod actually writes is a +nocreate that needs
+// none of that.
+extern void parse_species_tbl(const char* filename);
+
+class ThrusterFlareTbmTest : public test::FSTestFixture {
+ public:
+ ThrusterFlareTbmTest() : test::FSTestFixture(INIT_CFILE)
+ {
+ pushModDir("graphics");
+ pushModDir("lens_flare");
+ pushModDir("thruster_flare");
+ }
+
+ void SetUp() override
+ {
+ test::FSTestFixture::SetUp();
+
+ // Stand in for whatever table the mod underneath us defined
+ saved_species = Species_info;
+ Species_info.clear();
+
+ species_info existing;
+ strcpy_s(existing.species_name, "ThrusterFlareTestSpecies");
+ Species_info.push_back(existing);
+
+ species_info bare;
+ strcpy_s(bare.species_name, "ThrusterFlareDefaultsSpecies");
+ Species_info.push_back(bare);
+
+ parse_species_tbl("test-sdf.tbm");
+ }
+
+ void TearDown() override
+ {
+ Species_info = saved_species;
+ test::FSTestFixture::TearDown();
+ }
+
+ private:
+ SCP_vector saved_species;
+};
+
+// Guards the exact syntax a mod writes, "$Thruster Flare:" in a +nocreate entry
+// with nothing else in it -- which only parses because every option ahead of it
+// is optional under +nocreate. Reordering the parse would break real tables
+// silently, since an option read out of order is skipped rather than diagnosed.
+TEST_F(ThrusterFlareTbmTest, NocreateEntryAddsAFlareToAnExistingSpecies)
+{
+ const int idx = species_info_lookup("ThrusterFlareTestSpecies");
+ ASSERT_GE(idx, 0);
+
+ const auto& flare = Species_info[idx].thruster_flare;
+ EXPECT_TRUE(flare.enabled) << "the block was skipped entirely";
+ EXPECT_FLOAT_EQ(flare.intensity, 6.0f);
+ EXPECT_FLOAT_EQ(flare.afterburner_intensity, 15.0f);
+ EXPECT_NEAR(flare.color.xyz.x, 170.0f / 255.0f, 1e-4f);
+ EXPECT_NEAR(flare.color.xyz.y, 205.0f / 255.0f, 1e-4f);
+ EXPECT_NEAR(flare.color.xyz.z, 255.0f / 255.0f, 1e-4f);
+}
+
+// The block on its own is the opt-in; its fields are all optional and must fall
+// back to the struct's defaults, not to zero.
+TEST_F(ThrusterFlareTbmTest, EmptyBlockIsTheOptInAndKeepsTheDefaults)
+{
+ const int idx = species_info_lookup("ThrusterFlareDefaultsSpecies");
+ ASSERT_GE(idx, 0);
+
+ const auto& flare = Species_info[idx].thruster_flare;
+ const thruster_flare_info defaults;
+ EXPECT_TRUE(flare.enabled);
+ EXPECT_FLOAT_EQ(flare.intensity, defaults.intensity);
+ EXPECT_FLOAT_EQ(flare.afterburner_intensity, defaults.afterburner_intensity);
+}
+
+// A table written for one mod has to be harmless in another: a +nocreate naming
+// a species nothing defined is discarded rather than creating a stub. This is
+// what lets one -sdf.tbm carry entries for several mods' species at once.
+TEST_F(ThrusterFlareTbmTest, NocreateForAnUnknownSpeciesCreatesNothing)
+{
+ EXPECT_LT(species_info_lookup("NoSuchSpecies"), 0);
+ EXPECT_EQ(Species_info.size(), 2u);
+}
+
+// ---- +override in a *-lens.tbm ----
+
+class LensFlareOverrideTbmTest : public test::FSTestFixture {
+ public:
+ LensFlareOverrideTbmTest() : test::FSTestFixture(INIT_CFILE)
+ {
+ pushModDir("graphics");
+ pushModDir("lens_flare");
+ pushModDir("override");
+ }
+
+ void SetUp() override
+ {
+ test::FSTestFixture::SetUp();
+ lens_flare_init();
+ }
+
+ void TearDown() override
+ {
+ lens_flare_close();
+ test::FSTestFixture::TearDown();
+ }
+};
+
+// The point of +override: restyle a shipped lens without transcribing its
+// prescription. Everything the entry does not mention has to survive, which is
+// the part a hand-written copy of lens_system would quietly get wrong every time
+// a field was added.
+TEST_F(LensFlareOverrideTbmTest, OverrideKeepsEverythingItDoesNotMention)
+{
+ const int idx = lens_flare_lookup("angenieux_100mm");
+ ASSERT_GE(idx, 0);
+ const lens_system* ls = lens_flare_get_system(idx);
+ ASSERT_NE(ls, nullptr);
+
+ // what the tbm set
+ EXPECT_FLOAT_EQ(ls->anamorphic.squeeze, 2.0f);
+ EXPECT_FLOAT_EQ(ls->intensity, 0.75f);
+
+ // what it did not: these are the shipped table's values, and the twelve-surface
+ // prescription in particular must be intact
+ EXPECT_FLOAT_EQ(ls->entrance_radius, 22.0f);
+ EXPECT_FLOAT_EQ(ls->aperture_radius, 16.0f);
+ EXPECT_FLOAT_EQ(ls->sensor_width, 36.0f);
+ EXPECT_EQ(ls->aperture.blades, 6);
+ EXPECT_GT(ls->surfaces.size(), 8u) << "the override dropped the prescription it never touched";
+ EXPECT_FALSE(ls->ghosts.empty());
+
+ // an override is still a tabled value, so it is what a mission's own overrides
+ // are laid over and what dropping them goes back to
+ EXPECT_EQ(lens_flare_effective_settings(idx).aperture, ls->aperture);
+}
+
+// Opening a stack replaces the whole of it. A prescription is an ordered run
+// whose focal length, ghost set and iris position all follow from the run as a
+// whole, so a merged stack would be neither lens.
+TEST_F(LensFlareOverrideTbmTest, OverridingTheStackReplacesItEntirely)
+{
+ const int idx = lens_flare_lookup("tessar_50mm");
+ ASSERT_GE(idx, 0);
+ const lens_system* ls = lens_flare_get_system(idx);
+ ASSERT_NE(ls, nullptr);
+
+ ASSERT_EQ(ls->surfaces.size(), 3u) << "the tbm's stack was merged into the shipped one instead of replacing it";
+ EXPECT_FLOAT_EQ(ls->surfaces[0].radius, 60.0f);
+ EXPECT_FLOAT_EQ(ls->surfaces[0].abbe, 55.0f);
+ EXPECT_TRUE(ls->surfaces[1].is_stop);
+ EXPECT_FLOAT_EQ(ls->surfaces[2].radius, -60.0f);
+
+ // the entry named no other option, so everything else is still the shipped
+ // lens -- and the new prescription was re-solved rather than left stale
+ EXPECT_FLOAT_EQ(ls->entrance_radius, 7.0f);
+ EXPECT_GT(ls->efl, 0.0f);
+ EXPECT_FALSE(ls->ghosts.empty());
+}
+
+// Without +override an entry is a complete definition, and replaces a lens of
+// the same name outright -- the behaviour that predates +override, and the
+// reason +override had to be opt-in rather than the default for a tbm.
+TEST_F(LensFlareOverrideTbmTest, WithoutOverrideTheLensIsReplacedOutright)
+{
+ const int idx = lens_flare_lookup("kodak_100mm");
+ ASSERT_GE(idx, 0);
+ const lens_system* ls = lens_flare_get_system(idx);
+ ASSERT_NE(ls, nullptr);
+
+ EXPECT_EQ(ls->surfaces.size(), 3u);
+ EXPECT_FLOAT_EQ(ls->entrance_radius, 11.0f);
+ // the shipped kodak_100mm sets these; a replacement must be back on the
+ // struct's defaults rather than inheriting them
+ EXPECT_FLOAT_EQ(ls->sensor_width, 36.0f);
+ EXPECT_EQ(ls->aperture.blades, 6);
+}
diff --git a/test/src/parse/test_sexp_lens.cpp b/test/src/parse/test_sexp_lens.cpp
new file mode 100644
index 00000000000..e7780477698
--- /dev/null
+++ b/test/src/parse/test_sexp_lens.cpp
@@ -0,0 +1,212 @@
+#include
+
+#include
+
+#include
+#include
+
+#include
+
+// The six 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},
+ {"set-lens-flare-strength", OP_SET_LENS_FLARE_STRENGTH, 1, 5, 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);
+}
+
+// The four iris operators rebuild a 512^2 mask and its Fourier transform, which
+// is slow enough to be seen; set-lens-flare-strength deliberately does not. That
+// distinction is the whole reason the sixth operator exists, so the help text
+// has to actually make it -- a designer who drives the wrong one from a
+// repeating event gets a stuttering mission and no clue why.
+TEST(SexpLens, IrisOperatorsDocumentTheirCost)
+{
+ auto help_for = [](int op_const) -> SCP_string {
+ auto it = std::find_if(Sexp_help.begin(), Sexp_help.end(), [op_const](const sexp_help_struct& h) {
+ return h.id == op_const;
+ });
+ return (it == Sexp_help.end()) ? SCP_string() : it->help;
+ };
+
+ for (int op : {OP_SET_LENS_APERTURE, OP_SET_LENS_GRATING, OP_SET_LENS_SCRATCHES, OP_SET_LENS_DUST}) {
+ SCOPED_TRACE(op);
+ const SCP_string help = help_for(op);
+ ASSERT_FALSE(help.empty());
+ EXPECT_NE(help.find("COST:"), SCP_string::npos) << "no cost warning";
+ EXPECT_NE(help.find("repeating"), SCP_string::npos) << "doesn't warn against repeating events";
+ }
+
+ const SCP_string cheap = help_for(OP_SET_LENS_FLARE_STRENGTH);
+ ASSERT_FALSE(cheap.empty());
+ EXPECT_NE(cheap.find("cheap"), SCP_string::npos) << "doesn't say it is the cheap one";
+ EXPECT_EQ(cheap.find("COST:"), SCP_string::npos) << "warns about a cost it doesn't have";
+}
+
+// set-lens-flare-strength must reach every knob it documents. Its arguments are
+// written into lens_overrides by hand, so a copy-paste slip would silently make
+// one of them do nothing -- and unlike a wrong OPF, nothing would even warn.
+TEST_F(SexpLensTableTest, FlareStrengthOperatorTouchesEveryKnobItDocuments)
+{
+ const int idx = graphics::lens_flare_lookup("angenieux_100mm");
+ ASSERT_GE(idx, 0);
+ graphics::lens_flare_switch_to("angenieux_100mm");
+ ASSERT_EQ(graphics::lens_flare_active_lens(), idx);
+
+ // Halving everything is the interesting case: it exercises the "percentage of
+ // whatever is in force" rule rather than landing on a default by accident.
+ const graphics::lens_settings before = graphics::lens_flare_effective_settings(idx);
+
+ graphics::lens_overrides& ov = graphics::lens_flare_overrides();
+ ov.intensity = before.intensity * 0.5f;
+ ov.ghost_brightness = before.ghost_brightness * 0.5f;
+ ov.starburst_brightness = before.starburst_brightness * 0.5f;
+ ov.starburst_scale = before.starburst_scale * 0.5f;
+ ov.max_ghosts = 5;
+ graphics::lens_flare_overrides_changed();
+
+ const graphics::lens_settings after = graphics::lens_flare_effective_settings(idx);
+ EXPECT_FLOAT_EQ(after.intensity, before.intensity * 0.5f);
+ EXPECT_FLOAT_EQ(after.ghost_brightness, before.ghost_brightness * 0.5f);
+ EXPECT_FLOAT_EQ(after.starburst_brightness, before.starburst_brightness * 0.5f);
+ EXPECT_FLOAT_EQ(after.starburst_scale, before.starburst_scale * 0.5f);
+ EXPECT_EQ(after.max_ghosts, 5);
+
+ // None of it touches the iris, which is why this operator is the cheap one:
+ // the textures the aperture drives must be untouched.
+ EXPECT_EQ(after.aperture, before.aperture);
+
+ graphics::lens_flare_reset_for_level();
+}
diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake
index fa4c1c81d39..ef831b283aa 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)
@@ -52,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"
@@ -98,5 +100,6 @@ add_file_folder("Utils"
)
add_file_folder("Weapon"
+ weapon/test_beam.cpp
weapon/weapons.cpp
)
diff --git a/test/src/weapon/test_beam.cpp b/test/src/weapon/test_beam.cpp
new file mode 100644
index 00000000000..01c9545fbbb
--- /dev/null
+++ b/test/src/weapon/test_beam.cpp
@@ -0,0 +1,109 @@
+
+#include
+
+#include
+
+#include
+#include
+#include
+
+// beam_get_muzzle_glow() is what turns a beam's own muzzle light into a
+// lens-flare source (graphics/lens_flare_beams.cpp), so its ramp is what the
+// flare actually follows. These pin the exact shape of that ramp --
+// including the jump at fire onset and the early cutoff near the end of
+// warmdown -- because both come straight from beam_add_light_small()'s
+// pre-existing curve (the *0.5f halving and the *1.3f warmdown factor), and a
+// future edit that "smoothed" them out would make the flare disagree with the
+// light it is supposed to be tracking.
+class BeamMuzzleGlowTest : public test::FSTestFixture {
+ public:
+ BeamMuzzleGlowTest() { pushModDir("beam"); }
+
+ protected:
+ void SetUp() override
+ {
+ test::FSTestFixture::SetUp();
+
+ Weapon_info.emplace_back();
+ weapon_info_index = static_cast(Weapon_info.size()) - 1;
+ weapon_info& wip = Weapon_info[weapon_info_index];
+ wip.b_info.beam_warmup = 1000;
+ wip.b_info.beam_warmdown = 1000;
+ // Isolate the ramp from beam_current_light_radius()'s other multipliers,
+ // neither of which this test is about.
+ wip.b_info.beam_light_as_multiplier = false;
+ wip.b_info.beam_light_flicker = false;
+ wip.light_radius = 5.0f;
+ wip.light_color_set = true;
+ wip.light_color = hdr_color(1.0f, 1.0f, 1.0f, 1.0f, 4.0f);
+
+ bm = beam();
+ bm.weapon_info_index = weapon_info_index;
+ bm.warmup_stamp = -1;
+ bm.warmdown_stamp = -1;
+ bm.last_start = vmd_zero_vector;
+ }
+
+ void TearDown() override
+ {
+ Weapon_info.pop_back();
+
+ test::FSTestFixture::TearDown();
+ }
+
+ int weapon_info_index = -1;
+ beam bm;
+};
+
+TEST_F(BeamMuzzleGlowTest, FiringHoldsAtFullIntensity)
+{
+ bm.warmup_stamp = -1;
+ bm.warmdown_stamp = -1;
+
+ beam_muzzle_glow glow;
+ ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow));
+ EXPECT_NEAR(glow.intensity, 4.0f, 1e-3f);
+}
+
+TEST_F(BeamMuzzleGlowTest, WarmupStartsAtZero)
+{
+ bm.warmup_stamp = timestamp(1000); // the full warmup still ahead
+ bm.warmdown_stamp = -1;
+
+ beam_muzzle_glow glow;
+ EXPECT_FALSE(beam_get_muzzle_glow(&bm, &glow));
+}
+
+TEST_F(BeamMuzzleGlowTest, WarmupRisesToHalfAtMidpoint)
+{
+ bm.warmup_stamp = timestamp(500); // halfway through a 1000ms warmup
+ bm.warmdown_stamp = -1;
+
+ beam_muzzle_glow glow;
+ ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow));
+ // BEAM_WARMUP_PCT ~= 0.5, halved by the legacy muzzle-light curve
+ EXPECT_NEAR(glow.intensity, 4.0f * 0.25f, 0.05f);
+}
+
+TEST_F(BeamMuzzleGlowTest, WarmdownJumpsDownAtIgnition)
+{
+ bm.warmup_stamp = -1;
+ bm.warmdown_stamp = timestamp(1000); // the full warmdown still ahead
+
+ beam_muzzle_glow glow;
+ ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow));
+ // Firing was 1.0; warmdown's first instant is already halved -- the pop is
+ // inherited from beam_add_light_small(), not introduced by the flare.
+ EXPECT_NEAR(glow.intensity, 4.0f * 0.5f, 0.05f);
+}
+
+TEST_F(BeamMuzzleGlowTest, WarmdownFadesBeforeItsNominalEnd)
+{
+ bm.warmup_stamp = -1;
+ bm.warmdown_stamp = timestamp(10); // almost fully warmed down
+
+ beam_muzzle_glow glow;
+ // The *1.3f factor drives the ramp to zero before timestamp_elapsed()
+ // actually deletes the beam, so no flare should draw here.
+ EXPECT_FALSE(beam_get_muzzle_glow(&bm, &glow));
+}
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..58c814774b8
--- /dev/null
+++ b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm
@@ -0,0 +1,54 @@
+; 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
+$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
++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
+$Lens Stack Start:
+$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 )
+$Lens Stack End
+
+#End
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..75526fe8eab
--- /dev/null
+++ b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm
@@ -0,0 +1,25 @@
+; 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
+$Lens Stack Start:
+$Surface: ( 50.0, 4.0, 1.62 )
+$Stop: ( 3.0 )
+$Surface: ( -50.0, 40.0, 1.0 )
+$Lens Stack End
+
+#End
diff --git a/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm b/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm
new file mode 100644
index 00000000000..2a79ef656f1
--- /dev/null
+++ b/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm
@@ -0,0 +1,38 @@
+; A xxx-lens.tbm that edits lenses the engine's own lens_flares.tbl shipped,
+; which is what +override exists for. Named "zz-" so it parses after any other
+; *-lens.tbm a test mod dir might carry.
+
+#Lens Systems
+
+; Restyle a shipped lens without restating its twelve-surface prescription.
+; Everything not mentioned here -- the surfaces, the entrance pupil, the sensor
+; width, the blade count -- has to survive untouched.
+$Name: angenieux_100mm
++override
+$Anamorphic Squeeze: 2.0
+$Intensity: 0.75
+
+; Overriding the prescription replaces it outright: this two-element lens must
+; end up with exactly the surfaces below and none of the Tessar's.
+$Name: tessar_50mm
++override
+$Lens Stack Start:
+$Surface: ( 60.0, 5.0, 1.62 )
++Abbe: 55.0
+$Stop: ( 4.0 )
+$Surface: ( -60.0, 40.0, 1.0 )
+$Lens Stack End
+
+; No +override: a complete definition that replaces the shipped lens of this
+; name outright, so its surface count must be this one's and not the shipped
+; one's
+$Name: kodak_100mm
+$Entrance Pupil Radius: 11.0
+$Aperture Radius: 6.0
+$Lens Stack Start:
+$Surface: ( 55.0, 4.0, 1.62 )
+$Stop: ( 3.0 )
+$Surface: ( -55.0, 35.0, 1.0 )
+$Lens Stack End
+
+#End
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
diff --git a/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm b/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm
new file mode 100644
index 00000000000..852986512ee
--- /dev/null
+++ b/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm
@@ -0,0 +1,26 @@
+; Mirrors the shape a mod actually writes: a +nocreate entry that adds nothing
+; but a thruster flare to a species some earlier table defined.
+
+#SPECIES DEFS
+
+$Species_Name: ThrusterFlareTestSpecies
++nocreate
+$Thruster Flare:
+ +Intensity: 6.0
+ +Afterburner Intensity: 15.0
+ +Color: ( 170, 205, 255 )
+
+; A species nothing has defined: must be discarded, not created, so a table
+; written for one mod is harmless in another
+$Species_Name: NoSuchSpecies
++nocreate
+$Thruster Flare:
+ +Intensity: 99.0
+
+; An entry that opts in without setting anything, which must land on the
+; defaults rather than on zero
+$Species_Name: ThrusterFlareDefaultsSpecies
++nocreate
+$Thruster Flare:
+
+#End