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..03604121a3f --- /dev/null +++ b/code/def_files/data/tables/lens_flares.tbl @@ -0,0 +1,541 @@ +; Default physically-based lens systems for the "$Camera Lens:" option in a +; mission's info section (and the set-camera-lens sexp). +; +; A mission mounts ONE of these as its camera lens, and every sun in its +; background flares through it -- one camera, one lens, so the flares of two +; suns can never disagree about the glass they came through. Missions that name +; no lens fall back to "$Default Lens:" below, which the shipped table leaves +; unset, so retail content is unaffected until a mod opts in. +; +; Format: each $Surface: is ( curvature radius mm, thickness to the next surface +; mm, refractive index of the glass BEHIND the surface, 1.0 = air ), listed +; front-to-back; $Stop: ( thickness mm ) marks the iris plane. See +; graphics/lens_flare.cpp for the full syntax and the precompute math. +; +; Where these numbers come from and how to derive your own: +; +; - The radius/thickness/index triples are "lens prescriptions" as published in +; lens patents and optical-design references. Any such prescription can be +; transcribed directly into $Surface: rows; scale all radii/thicknesses +; uniformly to change the focal length. The engine derives everything else +; (effective focal length, sensor distance, ghost enumeration, per-ghost +; matrices) from the surface stack at table load. +; - +Abbe: dispersion (V-number) values are not usually part of patent claims; +; the ones below are typical catalog values for optical glasses of the given +; index (crown glasses ~55-60, dense flints ~30-40). They only drive the +; chromatic fringing, so approximate values are fine. +; - $Entrance Pupil Radius: is focal length / (2 * f-number) of the design; +; $Aperture Radius: is the iris half-opening (smaller = darker, crisper +; ghosts). Both may be fudged for looks, but $Aperture Radius: is the setting +; that decides whether a ghost is lit at all, so derive it before fudging: +; each ghost slides across the iris as the sun moves off-axis, by an amount +; that grows with the field angle and shrinks with $Aperture Radius:, and a +; ghost that slides off the iris goes black. Too small a value therefore +; confines the whole flare to a sun near the frame center, and too large a one +; shrinks the ghosts within the iris until the blades stop shaping them. The +; physical value is the paraxial marginal-ray height where it crosses the stop +; (trace height = entrance pupil radius, angle = 0 through the surfaces ahead +; of the $Stop:); 1.25x that is what angenieux_100mm uses (marginal height +; 12.79mm, tabled 16.0) and what the lenses below are derived from. +; - $Coating Wavelength: is the quarter-wave anti-reflection tuning; 500-570nm +; (green-centered, like real broadband coatings) gives the classic +; magenta/cyan ghost tints. 0 disables coatings (uncoated vintage look: +; brighter, neutral-grey ghosts). +; - $Intensity:, blade counts/rotation/curvature and the starburst settings are +; artistic; the shipped values were calibrated visually in the F3 lab +; (Render options -> Lens flare options) against retail suns. +; - $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 +$Surface: ( 164.13, 10.99, 1.6751 ) ++Abbe: 47.0 +$Surface: ( 559.20, 0.23, 1.0 ) +$Surface: ( 100.12, 11.45, 1.6689 ) ++Abbe: 44.0 +$Surface: ( 213.54, 0.23, 1.0 ) +$Surface: ( 58.04, 22.95, 1.6913 ) ++Abbe: 42.0 +$Surface: ( 2551.10, 2.58, 1.6751 ) ++Abbe: 33.0 +$Surface: ( 32.39, 15.66, 1.0 ) +$Stop: ( 15.00 ) +$Surface: ( -40.42, 2.74, 1.6992 ) ++Abbe: 30.0 +$Surface: ( 192.98, 27.92, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -55.53, 0.23, 1.0 ) +$Surface: ( 192.98, 7.98, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -92.62, 0.23, 1.0 ) +$Surface: ( 355.02, 8.86, 1.6751 ) ++Abbe: 47.0 +$Surface: ( -52.78, 60.0, 1.0 ) + +; Classic Tessar, f/3.5, f=50mm. Surface data transcribed from the textbook +; Zeiss Tessar prescription (Paul Rudolph's 1902 design, as reproduced in +; optical-design literature), uniformly scaled to a 50mm focal length; Abbe +; numbers are catalog values for the usual Tessar crown/flint glass pairing. +; Fewer elements, so a sparser and subtler ghost set. +$Name: tessar_50mm +$Entrance Pupil Radius: 7.0 +$Aperture Radius: 5.0 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 8 ++Blade Rotation: 0.0 ++Blade Curvature: 0.3 +$Starburst: YES ++Starburst Scale: 0.8 +$Intensity: 0.2 +$Max Ghosts: 24 +$Surface: ( 16.25, 2.90, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -285.90, 0.29, 1.0 ) +$Surface: ( -30.05, 1.20, 1.6053 ) ++Abbe: 43.6 +$Surface: ( 17.47, 1.38, 1.0 ) +$Stop: ( 1.15 ) +$Surface: ( 31.55, 1.20, 1.5123 ) ++Abbe: 51.0 +$Surface: ( 21.30, 3.50, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -23.70, 40.0, 1.0 ) + +; Modern multicoated telephoto zoom, f/2.8, nominally 70-200mm; the transcribed +; configuration solves to f=72mm. Prescription is the sixth embodiment of Canon +; patent US5537259 (1995), the design behind the EF 70-200mm f/2.8L USM. 33 +; refractive surfaces enumerate ~460 usable ghost pairs, so this is the busiest +; shipped lens: a dense, tightly clustered, strongly coated ghost train. +$Name: canon_70_200mm +$Entrance Pupil Radius: 12.32 +$Aperture Radius: 21.33 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 8 ++Blade Rotation: 22.5 ++Blade Curvature: 0.35 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 56 +$Surface: ( 355.855, 2.8, 1.75 ) ++Abbe: 35.0 +$Surface: ( 121.211, 0.42, 1.0 ) +$Surface: ( 131.256, 8.62, 1.497 ) ++Abbe: 81.6 +$Surface: ( -259.209, 0.1, 1.0 ) +$Surface: ( 80.584, 6.01, 1.497 ) ++Abbe: 81.6 +$Surface: ( 234.8, 8.69, 1.0 ) +$Surface: ( 51.45, 2.2, 1.847 ) ++Abbe: 23.8 +$Surface: ( 43.769, 1.28, 1.0 ) +$Surface: ( 49.946, 8.87, 1.487 ) ++Abbe: 70.2 +$Surface: ( 12148.909, 1.57, 1.0 ) +$Surface: ( -600.368, 1.4, 1.804 ) ++Abbe: 46.6 +$Surface: ( 34.801, 5.98, 1.0 ) +$Surface: ( -75.966, 1.4, 1.487 ) ++Abbe: 70.2 +$Surface: ( 37.777, 4.97, 1.847 ) ++Abbe: 23.9 +$Surface: ( 413.301, 2.64, 1.0 ) +$Surface: ( -66.4, 1.4, 1.729 ) ++Abbe: 54.7 +$Surface: ( 3021.469, 30.32, 1.0 ) +$Surface: ( 230.258, 3.51, 1.698 ) ++Abbe: 55.5 +$Surface: ( -98.917, 0.15, 1.0 ) +$Surface: ( -172.378, 4.66, 1.497 ) ++Abbe: 81.6 +$Surface: ( -40.226, 1.45, 1.834 ) ++Abbe: 37.2 +$Surface: ( -76.185, 13.86, 1.0 ) +$Surface: ( 57.653, 3.73, 1.804 ) ++Abbe: 46.6 +$Surface: ( 128.671, 3.05, 1.0 ) +$Stop: ( 0.34 ) +$Surface: ( 33.882, 6.26, 1.497 ) ++Abbe: 81.6 +$Surface: ( 1455.342, 3.99, 1.62 ) ++Abbe: 36.3 +$Surface: ( 31.129, 26.85, 1.0 ) +$Surface: ( 117.922, 5.91, 1.517 ) ++Abbe: 52.4 +$Surface: ( -81.244, 14.02, 1.0 ) +$Surface: ( -38.692, 1.8, 1.834 ) ++Abbe: 37.2 +$Surface: ( -102.301, 0.15, 1.0 ) +$Surface: ( 183.092, 3.91, 1.743 ) ++Abbe: 49.3 +$Surface: ( -129.948, 10.0, 1.0 ) + +; Symmetric double Gauss, f/3.8, f=100mm, from Kodak patent US2823583 (1958). +; Single-coated era: $Coating Wavelength: 550 models one MgF2 quarter-wave layer, +; which is exactly what lenses of this vintage carried. Few elements and a +; symmetric layout give a sparse, orderly ghost set strung along the sun axis, and +; the long focal length keeps it stable as the sun moves off-axis. +$Name: kodak_100mm +$Entrance Pupil Radius: 13.16 +$Aperture Radius: 8.88 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 10 ++Blade Rotation: 0.0 ++Blade Curvature: 0.50 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 28 +$Surface: ( 36.02, 3.1, 1.517 ) ++Abbe: 64.5 +$Surface: ( 418.3, 0.7, 1.0 ) +$Surface: ( 24.59, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -45.33, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( -44.52, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 13.42, 6.9, 1.0 ) +$Stop: ( 6.9 ) +$Surface: ( -13.42, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 44.52, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( 45.33, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -24.59, 0.7, 1.0 ) +$Surface: ( -74.42, 3.1, 1.72 ) ++Abbe: 29.3 +$Surface: ( -32.2, 50.0, 1.0 ) + +; Fast aspherical wide-angle prime, f/1.4, f=35mm, from Leica patent US5161060 +; (1992), fig. 1 - the Summilux-M 35mm f/1.4 ASPH design. The shortest focal +; length shipped (~54 degrees across the frame), so its ghosts sweep the furthest +; as the sun moves off-axis; the fast aperture keeps them large and soft. +$Name: leica_35mm +$Entrance Pupil Radius: 12.50 +$Aperture Radius: 12.05 +$Sensor Width: 36.0 +$Coating Wavelength: 530 +$Aperture Blades: 9 ++Blade Rotation: 10.0 ++Blade Curvature: 0.40 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 40 +$Surface: ( -110.114, 2.01, 1.503 ) ++Abbe: 56.1 +$Surface: ( 24.92, 7.4, 1.82 ) ++Abbe: 45.1 +$Surface: ( -305.0, 0.1, 1.0 ) +$Surface: ( 28.346, 6.07, 1.82 ) ++Abbe: 45.1 +$Surface: ( -57.56, 1.61, 1.694 ) ++Abbe: 31.0 +$Surface: ( 16.624, 4.34, 1.0 ) +$Stop: ( 1.66 ) +$Surface: ( -197.204, 6.07, 1.792 ) ++Abbe: 47.2 +$Surface: ( -38.628, 1.5, 1.0 ) +$Surface: ( -21.142, 1.72, 1.652 ) ++Abbe: 33.6 +$Surface: ( 101.985, 5.86, 1.82 ) ++Abbe: 45.1 +$Surface: ( -21.905, 0.11, 1.0 ) +$Surface: ( 60.026, 5.94, 1.82 ) ++Abbe: 45.1 +$Surface: ( -31.325, 2.05, 1.624 ) ++Abbe: 36.1 +$Surface: ( 31.325, 19.595, 1.0 ) + +; Multicoated telephoto zoom, f/3.5, nominally 50-135mm, from Nikon patent +; US4497547A (1981) - the AI Zoom-Nikkor 50-135mm f/3.5. The two variable zoom +; spacings are frozen at the transcribed values, which paraxially solve to f=36mm +; rather than any point in the marked 50-135mm range; the engine derives the focal +; length from the surfaces, so the flare matches the transcription, not the label +; (the shipped angenieux_100mm and tessar_50mm are named the same way). +$Name: nikon_50_135mm +$Entrance Pupil Radius: 12.86 +$Aperture Radius: 27.37 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 7 ++Blade Rotation: 0.0 ++Blade Curvature: 0.25 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 48 +$Surface: ( 95.858, 1.7, 1.805 ) ++Abbe: 25.4 +$Surface: ( 49.02, 8.0, 1.678 ) ++Abbe: 55.6 +$Surface: ( 214.552, 0.1, 1.0 ) +$Surface: ( 75.769, 5.0, 1.667 ) ++Abbe: 48.4 +$Surface: ( 691.304, 2.959, 1.0 ) +$Surface: ( -708.168, 1.25, 1.697 ) ++Abbe: 55.6 +$Surface: ( 22.809, 5.0, 1.0 ) +$Surface: ( -175.109, 1.15, 1.788 ) ++Abbe: 47.5 +$Surface: ( 87.266, 0.5, 1.0 ) +$Surface: ( 35.758, 3.1, 1.805 ) ++Abbe: 25.4 +$Surface: ( 165.776, 27.727, 1.0 ) +$Surface: ( -51.423, 1.15, 1.67 ) ++Abbe: 57.6 +$Surface: ( 81.327, 2.95, 1.672 ) ++Abbe: 38.9 +$Surface: ( -169.527, 8.846, 1.0 ) +$Stop: ( 1.0 ) +$Surface: ( 174.041, 3.25, 1.713 ) ++Abbe: 54.0 +$Surface: ( -63.18, 0.1, 1.0 ) +$Surface: ( 50.356, 5.0, 1.564 ) ++Abbe: 60.8 +$Surface: ( -70.071, 1.1, 1.796 ) ++Abbe: 41.0 +$Surface: ( 229.755, 0.1, 1.0 ) +$Surface: ( 25.187, 5.6, 1.518 ) ++Abbe: 59.0 +$Surface: ( -745.542, 1.0, 1.0 ) +$Surface: ( 262.417, 2.0, 1.795 ) ++Abbe: 28.6 +$Surface: ( 37.552, 10.15, 1.0 ) +$Surface: ( 111.689, 3.0, 1.517 ) ++Abbe: 64.1 +$Surface: ( -97.52, 20.85, 1.0 ) +$Surface: ( -18.386, 2.0, 1.67 ) ++Abbe: 47.1 +$Surface: ( -31.592, 0.1, 1.0 ) +$Surface: ( 941.473, 4.55, 1.702 ) ++Abbe: 41.0 +$Surface: ( -72.586, 14.0, 1.0 ) + +; Uncoated vintage prime, f/3.5, f=105mm, from A. W. Tronnier's patent US2645156 +; (1950) for the Voigtlander Color-Heliar. This is the table's uncoated reference: +; $Coating Wavelength: 0 gives bare-glass Fresnel reflections, ~75x stronger in +; total than the same prescription coated, and neutral grey rather than +; magenta/cyan. $Intensity: is scaled down by that factor so the lens lands at the +; bright end of the shipped calibration band instead of blowing out - the +; character (few, large, colourless, obvious ghosts, barely fading off-axis) is +; what the uncoated model buys, not raw brightness. The flat sixth surface is in +; the source prescription. +$Name: color_heliar_105mm +$Entrance Pupil Radius: 15.00 +$Aperture Radius: 15.08 +$Sensor Width: 36.0 +$Coating Wavelength: 0 +$Aperture Blades: 12 ++Blade Rotation: 0.0 ++Blade Curvature: 0.60 +$Starburst: YES ++Starburst Scale: 0.7 +$Intensity: 0.015 +$Max Ghosts: 16 +$Surface: ( 30.809, 7.702, 1.651 ) ++Abbe: 58.6 +$Surface: ( -89.35, 1.855, 1.603 ) ++Abbe: 38.4 +$Surface: ( 580.0, 3.521, 1.0 ) +$Surface: ( -80.063, 1.849, 1.643 ) ++Abbe: 47.9 +$Surface: ( 28.34, 4.625, 1.0 ) +$Stop: ( 2.554 ) +$Surface: ( 0.0, 1.849, 1.582 ) ++Abbe: 40.6 +$Surface: ( 32.19, 7.271, 1.693 ) ++Abbe: 53.5 +$Surface: ( -52.99, 92.03, 1.0 ) + +; Modern multicoated cine prime, T1.3, nominally 50mm (solves to f=65mm), from +; Zeiss patent US7446944B2 (2008) - the Master Prime 50mm. Large entrance pupil +; and 24 refractive surfaces: many ghosts, large and bright, with the heavy +; broadband coating pushing them well into magenta/cyan. The closest match to a +; contemporary cinema look. +$Name: zeiss_master_prime_50mm +$Entrance Pupil Radius: 19.23 +$Aperture Radius: 15.23 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 9 ++Blade Rotation: 20.0 ++Blade Curvature: 0.45 +$Starburst: YES ++Starburst Scale: 1.1 +$Intensity: 0.2 +$Max Ghosts: 56 +$Surface: ( 554.31, 4.31, 1.699 ) ++Abbe: 30.13 +$Surface: ( 82.937, 7.67, 1.0 ) +$Surface: ( 2539.9, 8.05, 1.805 ) ++Abbe: 25.42 +$Surface: ( -185.67, 4.67, 1.816 ) ++Abbe: 46.62 +$Surface: ( -188.36, 7.281, 1.0 ) +$Surface: ( 52.33, 16.11, 1.618 ) ++Abbe: 63.33 +$Surface: ( 12548.0, 0.11, 1.0 ) +$Surface: ( 70.795, 4.2, 1.717 ) ++Abbe: 29.62 +$Surface: ( 55.033, 2.534, 1.0 ) +$Surface: ( 42.474, 4.27, 1.805 ) ++Abbe: 25.42 +$Surface: ( 35.481, 7.82, 1.816 ) ++Abbe: 46.62 +$Surface: ( 46.639, 4.79, 1.0 ) +$Surface: ( 183.02, 4.2, 1.558 ) ++Abbe: 54.01 +$Surface: ( 25.119, 9.8, 1.0 ) +$Stop: ( 9.71 ) +$Surface: ( -23.041, 4.2, 1.654 ) ++Abbe: 39.63 +$Surface: ( 39.525, 16.23, 1.618 ) ++Abbe: 63.33 +$Surface: ( -44.668, 0.35, 1.0 ) +$Surface: ( 66.473, 10.02, 1.603 ) ++Abbe: 65.44 +$Surface: ( -240.57, 0.21, 1.0 ) +$Surface: ( 466.39, 7.51, 1.603 ) ++Abbe: 65.44 +$Surface: ( -88.453, 0.1, 1.0 ) +$Surface: ( 91.728, 4.2, 1.816 ) ++Abbe: 46.62 +$Surface: ( 27.982, 16.46, 1.618 ) ++Abbe: 63.33 +$Surface: ( -128.64, 39.014, 1.0 ) + +#End diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 9bf045e8931..859efcfd034 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -232,6 +232,8 @@ enum shader_type { SDR_TYPE_GAMMA_BLIT, + SDR_TYPE_LENS_FLARE, + NUM_SHADER_TYPES }; diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp new file mode 100644 index 00000000000..d1bd9c7742f --- /dev/null +++ b/code/graphics/lens_flare.cpp @@ -0,0 +1,720 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "graphics/2d.h" +#include "graphics/openxr.h" +#include "graphics/util/uniform_structs.h" +#include "io/timer.h" +#include "lighting/lighting.h" +#include "mission/missionparse.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/shipfx.h" +#include "starfield/starfield.h" + +#include +#include + +extern int Game_subspace_effect; + +namespace graphics { +namespace { + +// Overall brightness calibration of the ghost/starburst energy model relative to +// the HDR scene, plus the HDR headroom below. Runtime-tunable from the lab (and +// scaled per lens via $Intensity:); the field defaults are in lens_flare.h. +lens_flare_tuning Tuning; + +// SDR/HDR consistency (see lens_flare_output_scale()). The flare composites +// additively into the pre-tonemap HDR scene buffer, so the tonemapper is the +// 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; + +SCP_vector Lens_systems; + +// Bumped whenever a lens's cached textures are dropped, so the render backends +// can tell a re-generated aperture from the one they already uploaded +unsigned int Texture_generation = 1; + +// Live aperture editing in the lab: coalesce a slider drag into one +// regeneration every APERTURE_REGEN_INTERVAL ms +constexpr int APERTURE_REGEN_INTERVAL = 100; +bool Aperture_dirty = false; +int Aperture_dirty_lens = -1; +UI_TIMESTAMP Aperture_dirty_stamp; + +// Per-sun occlusion visibility, smoothed over frames. Touched only by +// sun_visibility() below. +SCP_vector Sun_visibility; +UI_TIMESTAMP Sun_visibility_stamp; + +// The camera lens: what the table declares as the default, what the mission +// mounted (its "$Camera Lens:", possibly changed by set-camera-lens), and the +// lab's live override of that. -1 means no lens, i.e. no flares. +// +// "$Default Lens:" is kept as a name until every table has been read, since a +// *-lens.tbm may name the default before (or without) defining it. +SCP_string Default_lens_name; +int Default_lens = -1; +int Mission_lens = -1; +std::optional Lab_lens; + +// Per-frame flare data of every drawing sun. Kept here (rather than handed out +// by value) because one lens_flare_data is several kilobytes; the draws only +// carry pointers into this, valid until the next build. +SCP_vector Frame_data; +SCP_vector Frame_draws; + +// 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 + // FRED and qtFRED, which draw the background without ever opening a scene + // texture, from having their sun sprites step aside for a pass that will + // never run. + if (!High_dynamic_range) { + return false; + } + if (Game_subspace_effect) { + return false; + } + if (openxr_enabled()) { + // A per-eye camera-lens artifact is wrong in VR + return false; + } + // same conditions under which the sun sprites themselves are drawn + if (The_mission.flags[Mission::Mission_Flags::Fullneb] || !Detail.planets_suns) { + return false; + } + return true; +} + +// How visible a sun is to the flare, in 0..1: the eye-in-shadow occlusion test +// smoothed over a few frames so shadow transitions fade instead of popping, +// times the off-axis falloff that takes the whole effect out as the sun leaves +// the frame. `dot` is the sun direction against the view axis, `dt` the frame +// time, and `snap` skips the smoothing when the pass hasn't run for a while. +float sun_visibility(int sun_n, int light_idx, float dot, float dt, bool snap) +{ + if (static_cast(Sun_visibility.size()) <= sun_n) { + Sun_visibility.resize(sun_n + 1, 0.0f); + } + + bool occluded = (dot <= 0.0f) || + (light_idx >= 0 && shipfx_eye_in_shadow(&Eye_position, Viewer_obj, light_idx)); + + float target = occluded ? 0.0f : 1.0f; + float& vis = Sun_visibility[sun_n]; + if (snap) { + vis = target; + } else { + vis += (target - vis) * MIN(dt * 8.0f, 1.0f); + } + + float axis_fade = std::clamp((dot - 0.2f) / 0.3f, 0.0f, 1.0f); + return vis * axis_fade; +} + +// The camera's film gate for this frame: the sensor half-extents the lens +// declares, and the screen the projection lands on. One gate for every sun, +// because the gate belongs to the camera. +struct film_gate { + float clip_w = 0.0f, clip_h = 0.0f; // pixels + float half_w = 0.0f, half_h = 0.0f; // mm +}; + +// Where a sun's image lands on the film. +struct sun_image { + float dist_mm = 0.0f; // distance from the sensor centre + float theta = 0.0f; // matching paraxial field angle + float axis_x = 1.0f, axis_y = 0.0f; // unit direction the flare is strung along +}; + +// Project a sun onto the film gate, the same way stars_draw_sun() projects its +// sprite. False when the sun isn't imaged onto the sensor at all. +bool project_sun(const vec3d& sun_pos, const film_gate& gate, float efl, sun_image* out) +{ + vertex sun_vex; + memset(&sun_vex, 0, sizeof(vertex)); + g3_rotate_faraway_vertex(&sun_vex, &sun_pos); + if (sun_vex.codes & CC_BEHIND) { + return false; + } + if (!(sun_vex.flags & PF_PROJECTED)) { + g3_project_vertex(&sun_vex); + } + if (sun_vex.flags & PF_OVERFLOW) { + return false; + } + + float sx = sun_vex.screen.xyw.x / gate.clip_w * 2.0f - 1.0f; + float sy = 1.0f - sun_vex.screen.xyw.y / gate.clip_h * 2.0f; + + float smx = sx * gate.half_w; + float smy = sy * gate.half_h; + + out->dist_mm = sqrtf(smx * smx + smy * smy); + out->theta = out->dist_mm / efl; + out->axis_x = 1.0f; + out->axis_y = 0.0f; + if (out->dist_mm > 1e-4f) { + out->axis_x = smx / out->dist_mm; + out->axis_y = smy / out->dist_mm; + } + return true; +} + +// Apply a pending live aperture edit, at most once per APERTURE_REGEN_INTERVAL. +// Regenerating means a 512^2 mask plus its starburst FFT (a good fraction of a +// second in a debug build), while ImGui sliders fire every frame a drag is +// held, so a drag has to be coalesced into a few rebuilds rather than sixty. +void flush_pending_aperture_edit() +{ + if (!Aperture_dirty) { + return; + } + if (Aperture_dirty_stamp.isValid() && !ui_timestamp_elapsed(Aperture_dirty_stamp)) { + return; + } + lens_flare_invalidate_textures(Aperture_dirty_lens); + Aperture_dirty = false; + Aperture_dirty_stamp = ui_timestamp(APERTURE_REGEN_INTERVAL); +} + +} // namespace + +void lens_flare_init() +{ + lens_flare_close(); + + lens_flare_parse_tables(Lens_systems, Default_lens_name); + + // Resolved once every table has been read, so the default may be named + // before it is defined + if (!Default_lens_name.empty()) { + Default_lens = lens_flare_lookup(Default_lens_name.c_str()); + if (Default_lens < 0) { + Warning(LOCATION, "$Default Lens: names '%s', which no lens table defines.", Default_lens_name.c_str()); + } + } + Mission_lens = Default_lens; + + mprintf(("Lens flares: %d lens system(s) loaded, default lens '%s'\n", static_cast(Lens_systems.size()), + lens_flare_default_name())); +} + +void lens_flare_close() +{ + Lens_systems.clear(); + Sun_visibility.clear(); + Default_lens_name.clear(); + Default_lens = -1; + Mission_lens = -1; + lens_flare_clear_lab_lens(); + Frame_data.clear(); + Frame_draws.clear(); + Sun_starburst_drawn.clear(); + Logged_lens = -2; +} + +int lens_flare_lookup(const char* name) +{ + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (!stricmp(Lens_systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +int lens_flare_num_systems() +{ + return static_cast(Lens_systems.size()); +} + +const lens_system* lens_flare_get_system(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +lens_system* lens_flare_get_system_mutable(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +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; + } + lens_system& lens = Lens_systems[lens_idx]; + if (!lens.textures) { + auto tex = std::make_unique(); + lens_flare_generate_textures(lens.aperture, tex.get()); + lens.textures = std::move(tex); + } + return lens.textures.get(); +} + +void lens_flare_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(); + Logged_lens = -2; + + // The next mission's suns are not this one's; drop the published frame so + // nothing consumes it across the level change + lens_flare_clear_frame(); + + // Drop any pending live edit first; it belongs to the mission being left. The + // throttle stamp goes too, so the next mission's first edit applies at once + // instead of waiting out an interval started by the previous one. + Aperture_dirty = false; + Aperture_dirty_lens = -1; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); + + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (Lens_systems[i].aperture != Lens_systems[i].tabled_aperture) { + Lens_systems[i].aperture = Lens_systems[i].tabled_aperture; + lens_flare_invalidate_textures(i); + } + } +} + +void lens_flare_invalidate_textures(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return; + } + Lens_systems[lens_idx].textures.reset(); + Texture_generation++; +} + +unsigned int lens_flare_get_texture_generation() { return Texture_generation; } + +bool lens_flare_aperture_edit_pending() { return Aperture_dirty; } + +void lens_flare_aperture_changed(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return; + } + if (Aperture_dirty && Aperture_dirty_lens != lens_idx) { + // switching lenses mid-edit: flush the pending one first + lens_flare_invalidate_textures(Aperture_dirty_lens); + } + Aperture_dirty = true; + Aperture_dirty_lens = lens_idx; + + // Apply straight away if the interval has already elapsed; if it hasn't, this + // is a no-op and the per-frame flush picks the edit up when it does. (The + // interval check lives in flush_pending_aperture_edit() alone -- repeating it + // here as a guard would just be the same condition written twice.) + flush_pending_aperture_edit(); +} + +namespace { + +// The three quad kinds share one instance slot but read its fields differently +// (see lens_flare_instance_data in graphics/util/uniform_structs.h for the +// per-kind table). Each emit_* below is the sole writer of its kind, so the +// convention lives in exactly one place per artifact instead of being spread +// across one long packing function. + +// Blank a slot and tag its kind, so each emitter only writes the fields it +// actually means and never has to remember to zero the rest. +void instance_init(generic_data::lens_flare_instance_data& inst, float kind) +{ + inst = {}; + inst.center.xyzw.w = kind; +} + +// Sub-pixel guard: a nearly focused ghost would otherwise collapse to a point +// (and its energy concentration to infinity). +float ghost_min_halfext(const lens_system& lens) +{ + return lens.sensor_width * 0.004f; +} + +// A ghost: the aperture as imaged by one two-reflection path, evaluated at each +// of the three design wavelengths, so every xyz triple here is per-channel. +void emit_ghost(generic_data::lens_flare_instance_data& inst, const lens_system& lens, + const lens_flare_ghost& ghost, float theta) +{ + instance_init(inst, generic_data::LENS_QUAD_GHOST); + + const float pupil = lens.entrance_radius; + const float min_halfext = 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 hands out the tuning + // struct for direct editing, so this is the boundary that has to hold + inst.color.a1d[k] = ghost.reflectance[k] * energy * MAX(Tuning.ghost_brightness, 0.0f); + } +} + +// The starburst: the Fraunhofer transform of the iris, sitting exactly on the +// sun's image. Achromatic here, because the texture carries its own per-channel +// diffraction scaling. `sdist` is the image's distance from the sensor centre. +void emit_starburst(generic_data::lens_flare_instance_data& inst, const lens_system& lens, float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STARBURST); + + const float halfext = lens.starburst_scale * lens.sensor_width * 0.12f; + for (int k = 0; k < 3; k++) { + inst.center.a1d[k] = sdist; + inst.halfext.a1d[k] = halfext; + inst.color.a1d[k] = MAX(Tuning.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, float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STREAK); + + const float min_halfext = ghost_min_halfext(lens); + const float half_len = MAX(lens.streak.length * lens.sensor_width * 0.5f, min_halfext); + + inst.center.xyzw.x = sdist; // the sun's image, same as the starburst + inst.halfext.xyzw.x = half_len; + inst.halfext.xyzw.y = MAX(half_len * lens.streak.thickness, min_halfext * 0.25f); + + // The lens tint is a colour cast on top of the sun's own colour, which the + // shared `tint` already applies -- so a red sun keeps a reddish streak + // instead of the table's blue overriding it + for (int k = 0; k < 3; k++) { + inst.color.a1d[k] = lens.streak.tint[k] * lens.streak.strength; + } +} + +} // namespace + +// Pack one sun's quads into a uniform block and return how many instance slots +// were written. Depends only on the lens and where the sun's image lands on the +// sensor -- `sdist` is that image's distance from the sensor centre in mm, +// `theta` its paraxial field angle. +static int pack_sun_instances(const lens_system& lens, float theta, float sdist, + generic_data::lens_flare_data* out) +{ + // Each non-ghost artifact reserves its slot out of the budget up front, so the + // ghosts can never crowd it out and the emits below need no second bounds + // check. The predicates are named once and used for both the reservation and + // the emission, so a new artifact cannot be added to one without the other -- + // which is what lets lens_flare_frame_update() conclude that a + // starburst-enabled lens has certainly drawn its starburst, and hence what the + // sprite sun steps aside for. + const bool wants_starburst = lens.starburst; + const bool wants_streak = lens.streak.strength > 0.0f; + + const int reserved = (wants_starburst ? 1 : 0) + (wants_streak ? 1 : 0); + const int ghost_budget = generic_data::MAX_LENS_FLARE_INSTANCES - reserved; + + int count = 0; + for (const auto& ghost : lens.ghosts) { + if (count >= ghost_budget) { + break; + } + emit_ghost(out->instances[count++], lens, ghost, theta); + } + if (wants_starburst) { + emit_starburst(out->instances[count++], lens, sdist); + } + if (wants_streak) { + emit_streak(out->instances[count++], lens, sdist); + } + Assertion(count <= generic_data::MAX_LENS_FLARE_INSTANCES, + "Lens flare packed %d instances into %d slots -- the ghost budget no longer reserves the " + "starburst/streak slots correctly", + count, generic_data::MAX_LENS_FLARE_INSTANCES); + + out->n_instances = count; + // Single choke point for the squeeze, so a table typo, a lab slider and any + // future sexp all get the same guard against a divide by zero in the shader + out->squeeze = MAX(lens.anamorphic_squeeze, 0.01f); + out->pad[0] = out->pad[1] = 0.0f; + return count; +} + +void lens_flare_clear_frame() +{ + Frame_draws.clear(); + Sun_starburst_drawn.clear(); +} + +void lens_flare_frame_update() +{ + lens_flare_clear_frame(); + + // live aperture edits from the lab land here, throttled + flush_pending_aperture_edit(); + + const int lens_idx = lens_flare_active_lens(); + if (lens_idx < 0 || !pass_globally_possible()) { + return; + } + const lens_system& lens = Lens_systems[lens_idx]; + + // Frame time for visibility smoothing (snap if we haven't run for a while) + float dt = 0.25f; + if (Sun_visibility_stamp.isValid()) { + dt = ui_timestamp_since(Sun_visibility_stamp) * 0.001f; + } + Sun_visibility_stamp = ui_timestamp(); + bool snap = (dt > 1.0f) || (dt < 0.0f); + + int num_suns = stars_get_num_suns(); + // Sized up front so the pointers handed out below stay valid: the loop only + // writes into slots, it never grows this + if (static_cast(Frame_data.size()) < num_suns) { + Frame_data.resize(num_suns); + } + Sun_starburst_drawn.resize(num_suns, false); + + film_gate gate; + gate.clip_w = i2fl(gr_screen.clip_width); + gate.clip_h = i2fl(gr_screen.clip_height); + if (gate.clip_w <= 0.0f || gate.clip_h <= 0.0f) { + return; + } + gate.half_w = lens.sensor_width * 0.5f; + gate.half_h = gate.half_w * gate.clip_h / gate.clip_w; + + // Also the camera's, not any sun's, so it is a frame constant too + const float out_scale = lens_flare_output_scale(); + + for (int sun_n = 0; sun_n < num_suns; sun_n++) { + const auto sun_light = stars_get_sun_rgbi(sun_n); + if (!sun_light) { + continue; + } + + // 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; + } + + sun_image image; + if (!project_sun(sun_pos, gate, lens.efl, &image)) { + continue; + } + + // The slot is only committed by the push_back below, so a sun that packs + // nothing leaves it to the next one + generic_data::lens_flare_data* out = &Frame_data[Frame_draws.size()]; + + out->axis.x = image.axis_x; + out->axis.y = image.axis_y; + out->ndc_scale.x = 1.0f / gate.half_w; + out->ndc_scale.y = 1.0f / gate.half_h; + // Master multiplier for every ghost and the starburst (lensflare-f.sdr + // applies tint.rgb to both paths). The output scale keeps SDR and HDR + // visually consistent without per-lens re-tuning. + const float tint_scale = sun_light->intensity * total_vis * lens.intensity * out_scale; + out->tint.xyzw.x = sun_light->color.xyz.x * tint_scale; + out->tint.xyzw.y = sun_light->color.xyz.y * tint_scale; + out->tint.xyzw.z = sun_light->color.xyz.z * tint_scale; + out->tint.xyzw.w = 0.0f; + + int count = pack_sun_instances(lens, image.theta, image.dist_mm, out); + if (count == 0) { + continue; + } + + lens_flare_draw draw; + draw.sun_index = sun_n; + draw.instances = count; + draw.data = out; + draw.visibility = total_vis; + draw.off_axis_deg = image.theta * (180.0f / PI); + draw.output_scale = out_scale; + Frame_draws.push_back(draw); + + // This sun is committed, and pack_sun_instances() reserves the starburst a + // slot up front, so a starburst-enabled lens has certainly drawn one. The + // sprite sun can now safely step aside for it. + Sun_starburst_drawn[sun_n] = lens.starburst; + } + + if (!Frame_draws.empty() && lens_idx != Logged_lens) { + Logged_lens = lens_idx; + mprintf(("Lens flare: rendering through lens '%s' (%d ghosts + %s)\n", lens.name.c_str(), + static_cast(lens.ghosts.size()), lens.starburst ? "starburst" : "no starburst")); + } +} + + +} // namespace graphics diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h new file mode 100644 index 00000000000..f720cd9d793 --- /dev/null +++ b/code/graphics/lens_flare.h @@ -0,0 +1,425 @@ +#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. +// Every imperfection layer below is a named type with its own operator==, rather +// than an anonymous struct compared field by field from the aperture. The +// comparison is load-bearing -- lens_flare_reset_for_level() uses it to decide +// whether a lens needs restoring, which is what keeps one mission's iris out of +// the next -- and a field added without extending it would break that silently. +// Keeping each layer's comparison next to its own fields is what makes that hard +// to get wrong. (C++20 would make all four `= default`.) + +// Diffraction grating around the rim: fine radial ridges that throw extra spikes +// into the starburst. +struct lens_aperture_grating { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 360 possible ridges + float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius + float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges + float softness = 0.0f; + + bool operator==(const lens_aperture_grating& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + softness == o.softness; + } + bool operator!=(const lens_aperture_grating& o) const { return !(*this == o); } +}; + +// Scratches on the glass: randomly placed and oriented slivers. +struct lens_aperture_scratches { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible scratches + float length = 0.5f; + float width = 0.25f; + float rotation = 0.0f; // degrees + float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random + float softness = 0.0f; + + bool operator==(const lens_aperture_scratches& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + rotation == o.rotation && rotation_variation == o.rotation_variation && softness == o.softness; + } + bool operator!=(const lens_aperture_scratches& o) const { return !(*this == o); } +}; + +// Dust on the glass: randomly placed specks. +struct lens_aperture_dust { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible specks + float radius = 0.5f; + float softness = 0.0f; + + bool operator==(const lens_aperture_dust& o) const + { + return strength == o.strength && density == o.density && radius == o.radius && softness == o.softness; + } + bool operator!=(const lens_aperture_dust& o) const { return !(*this == o); } +}; + +struct lens_aperture { + // Iris opening. Blade count/rotation/curvature are the original fields; + // curvature 0 = straight blades, 1 = circular, and negative values bow the + // 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; + + // Lets a caller that reassigns the whole aperture tell whether anything + // actually moved -- regenerating costs a 512^2 mask plus an FFT, which a + // repeating mission event must not pay every frame. Each layer compares + // itself, so this only has to cover the iris fields and the three layers. + bool operator==(const lens_aperture& o) const + { + return blades == o.blades && rotation == o.rotation && curvature == o.curvature && + softness == o.softness && grating == 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 +}; + +struct lens_system { + SCP_string name; + SCP_vector surfaces; + + float entrance_radius = 10.0f; // entrance pupil (front element) radius, mm + float aperture_radius = 5.0f; // iris half-opening, mm + float sensor_width = 36.0f; // film-gate width, mm + + // Anamorphic squeeze: how much wider than tall the flare footprints are, + // 1.0 = spherical. A front anamorphot is an afocal cylindrical telescope, so + // to first order all it does is magnify one meridian -- which is why this is + // a single number and not a second set of ray-transfer matrices. The lens + // behind it stays rotationally symmetric, so the iris (and hence the mask and + // its transform) is unaffected; only the imaging of it is stretched. + // + // Note that FSO draws the flare into an already-composed frame with no + // desqueeze stage, so this is a look control rather than a 2x-squeeze + // capture pipeline: ghost positions follow the sun as always, and only their + // footprints are stretched, which is what a desqueezed anamorphic frame + // shows. + float anamorphic_squeeze = 1.0f; + + // The anamorphic streak: the long horizontal flare a cylindrical element + // throws across the frame, and the half of the look the squeeze alone does + // not buy -- stretching the starburst only ever reads as a wider sun, since + // a real streak runs 20-50 times longer than it is thick. + // + // It is its own quad rather than a reshaped starburst because it is a + // different artifact: the starburst is the iris seen end-on and rotates with + // the sun, while the streak lies along the cylindrical element and so stays + // horizontal wherever the sun is. Off by default, which keeps every lens + // written before it existed untouched. + struct { + float strength = 0.0f; // 0 = off + float length = 1.0f; // half-length as a fraction of the sensor width + float thickness = 0.02f; // as a fraction of the length, so 0.02 = 50:1 + float tint[3] = {0.35f, 0.55f, 1.0f}; // multiplies the sun's own colour + } streak; + + float coating_wavelength = 540.0f; // default AR coating tuning, nm (0 = uncoated) + + lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst + + bool starburst = true; + float starburst_scale = 1.0f; + float intensity = 0.2f; + int max_ghosts = 40; + + // The aperture exactly as the table declared it. Missions and the lab edit + // `aperture` in place, so this is what lens_flare_reset_for_level() restores + // between missions -- one mission's lens must never carry into the next. + lens_aperture tabled_aperture; + + // --- filled by lens_flare_precompute() --- + SCP_vector ghosts; + float efl = 50.0f; // effective focal length (green), mm + float bfd = 40.0f; // back focal distance last surface -> sensor (green), mm + + // Iris/starburst textures of this lens, generated on demand by + // lens_flare_get_textures() and dropped by lens_flare_invalidate_textures(). + // Owning them here rather than in a vector alongside Lens_systems is what + // keeps them from ever going out of step with the lens they belong to; it + // makes lens_system move-only, which is all the engine ever needs. + std::unique_ptr textures; +}; + +// Parse lens_flares.tbl + *-lens.tbm (embedded default as fallback) and +// precompute all ghost data. Called once from stars_init(); safe to call again +// (reloads). +void lens_flare_init(); +void lens_flare_close(); + +// Index of a tabled lens system by name, -1 if unknown. +int lens_flare_lookup(const char* name); + +int lens_flare_num_systems(); +const lens_system* lens_flare_get_system(int lens_idx); + +// Lazily generate (and cache) the aperture/starburst textures of a lens. +// Returns nullptr for an invalid index. +const lens_flare_textures* lens_flare_get_textures(int lens_idx); + +// Generate the mounted lens's textures now, so the render backends find them +// already cached instead of paying for them mid-frame. +// +// Building them is a 512^2 iris mask plus a 2D FFT of it -- a visible hitch if it +// lands on the first frame a sun flares. Call it from wherever a lens has just +// been mounted for a scene that is about to be rendered and a moment's work is +// already expected: stars_post_level_init() for a mission, and the lab's +// useBackground(). Not from lens_flare_switch_to() itself, which also runs during +// mission-info scans (FRED's file dialog) that mount lenses they never render. +// +// No-op in FRED/qtFRED, which draw the background without a scene texture and so +// never reach the flare pass. +void lens_flare_prime_textures(); + +// Drop a lens's cached textures so the next lens_flare_get_textures() rebuilds +// them (after its lens_aperture was edited). +void lens_flare_invalidate_textures(int lens_idx); + +// Bumped on every invalidation. Render backends cache the uploaded textures of +// the mounted lens, so they must key that cache on this as well to notice a +// rebuild. +unsigned int lens_flare_get_texture_generation(); + +// Live aperture editing (lab): note that a lens's aperture changed, and poll +// whether a regeneration is still pending. Regenerating a 512^2 mask and its +// starburst FFT is far too slow to do on every frame of a slider drag, so edits +// are coalesced and applied a few times a second. Call the poll once per frame. +void lens_flare_aperture_changed(int lens_idx); +bool lens_flare_aperture_edit_pending(); + +// Undo everything a mission or the lab did to the lens state: unmount whatever +// lens was mounted (back to $Default Lens:) and restore every lens's aperture to +// what its table declared, so one mission's camera can't carry into the next. +// Called from stars_pre_level_init(), which runs before the mission's +// $Camera Lens: is parsed. +void lens_flare_reset_for_level(); + +// Mutable access for live tuning in the lab (e.g. a lens's $Intensity:). +// Changes take effect the next frame; they are lost on table reload. +lens_system* lens_flare_get_system_mutable(int lens_idx); + +// The calibration that isn't per-lens: overall brightness of the energy model +// against the HDR scene, plus the SDR/HDR consistency headroom. Defaults match +// the shipped calibration. +// +// Handed out mutably, the same way lens_flare_get_system_mutable() hands out a +// lens for the lab to edit in place -- one idiom for live tuning rather than +// clamping accessors for the globals and direct access for everything else. +// Values are sanitized where they are consumed, so a caller cannot break the +// renderer by writing a silly number here. +struct lens_flare_tuning { + // multiplies every ghost / the starburst respectively + float ghost_brightness = 64.0f; + float starburst_brightness = 1.6f; + + // How many multiples of paper white the flare may reach in HDR output. SDR is + // the calibration reference and is unaffected; this only rescales the HDR path + // so an SDR-tuned flare doesn't blow out. The default keeps a little HDR "pop". + float hdr_headroom = 2.5f; +}; + +lens_flare_tuning& lens_flare_get_tuning(); + +// ---- the camera lens ---- +// +// 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 -- it does not re-derive it. Predicting it independently is how the +// sprite and the flare came to disagree about occluded and off-screen suns. +bool lens_flare_sun_starburst_drawn(int sun_n); + +// One sun's worth of flare quads. All suns share the mounted lens (hence one +// aperture/starburst texture for the whole pass), but each has its own flare axis +// and tint, so each gets its own uniform block and instanced draw. +// +// The trailing fields are diagnostics for the lab; the renderer ignores them. +struct lens_flare_draw { + int sun_index = -1; + int instances = 0; // quads to draw (ghosts + optional starburst) + // Uniform block for this draw, owned by lens_flare.cpp; valid until the + // next lens_flare_frame_update() call. + const generic_data::lens_flare_data* data = nullptr; + + float visibility = 0.0f; // smoothed occlusion * off-axis fade + float off_axis_deg = 0.0f; // paraxial field angle of the sun + float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame +}; + +// 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 sun that has +// something to draw, in sun order (empty = skip the pass entirely). Every entry +// is drawn with the textures of lens_flare_active_lens(). +// +// The single source of truth for the pass -- the render backends draw exactly +// these, the sun renderer asks lens_flare_sun_starburst_drawn() about them, and +// the lab reports on them. +const SCP_vector& lens_flare_get_frame_draws(); + +// ---- internals exposed for unit testing ---- + +// 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_internal.h b/code/graphics/lens_flare_internal.h new file mode 100644 index 00000000000..442bb306c0c --- /dev/null +++ b/code/graphics/lens_flare_internal.h @@ -0,0 +1,39 @@ +#pragma once + +#include "graphics/lens_flare.h" + +// Private interface between the four lens-flare translation units. Nothing here +// is part of the module's API -- see graphics/lens_flare.h for that. +// +// lens_flare.cpp module state, the camera lens, texture cache, and +// the per-frame build the render backends consume +// lens_flare_optics.cpp the paraxial model: ray-transfer matrices, ghost +// enumeration, coated-Fresnel reflectance +// lens_flare_aperture.cpp image synthesis: the iris mask and the starburst +// that is its Fraunhofer transform +// lens_flare_table.cpp lens_flares.tbl / *-lens.tbm parsing +// +// The optics and image-synthesis halves touch no engine state at all; they are +// pure functions of a lens_system / lens_aperture. + +namespace graphics { + +// Fraunhofer C / d / F lines (red / green / blue), in micrometers. The three +// wavelengths everything chromatic in the flare is evaluated at: dispersion and +// coating reflectance in the optics, diffraction scaling in the starburst. +constexpr float Wavelengths_um[3] = {0.65627f, 0.58756f, 0.48613f}; + +// Render the iris mask of an aperture and the starburst that follows from it, +// filling both halves of `out`. This is the expensive one: a 512^2 mask plus a +// 2D FFT of it. (graphics/lens_flare.h's lens_flare_generate_aperture_mask() +// stops after the mask, for callers that don't need the transform.) +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out); + +// Parse lens_flares.tbl (falling back to the embedded default) plus every +// *-lens.tbm, appending to `systems` -- a later table redefining a lens by name +// replaces the earlier entry -- and precomputing each one's ghosts. Also reports +// the "$Default Lens:" name, unresolved: it may be declared before, or by a +// different table than, the lens it names. +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name); + +} // namespace graphics diff --git a/code/graphics/lens_flare_optics.cpp b/code/graphics/lens_flare_optics.cpp new file mode 100644 index 00000000000..d8d5fb86d5a --- /dev/null +++ b/code/graphics/lens_flare_optics.cpp @@ -0,0 +1,290 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +// for MAX_LENS_FLARE_INSTANCES: a ghost the shader has no instance slot for is +// not worth enumerating, so the budget bounds the precompute +#include "graphics/util/uniform_structs.h" + +#include +#include + +// The paraxial optics behind the flare: a lens_system is reduced here to a set +// of two-reflection ghost paths, each with its own ray-transfer matrices and +// coated-Fresnel tint, once at table load. Everything in this file is a pure +// function of the prescription -- no engine state, no frame, no screen -- with +// one exception: the ghost count is capped at the shader's instance budget, +// since a ghost with no instance slot to draw it in is not worth enumerating. + +namespace graphics { +namespace { + +// ---- 2x2 ray-transfer matrix helpers ([A B; C D] acting on [height; angle]) ---- + +struct mat2 { + float a, b, c, d; +}; + +mat2 m2_identity() { return {1.0f, 0.0f, 0.0f, 1.0f}; } + +mat2 m2_mul(const mat2& m, const mat2& n) +{ + return {m.a * n.a + m.b * n.c, m.a * n.b + m.b * n.d, m.c * n.a + m.d * n.c, m.c * n.b + m.d * n.d}; +} + +mat2 m2_translate(float t) { return {1.0f, t, 0.0f, 1.0f}; } + +// Refraction at a spherical interface with signed curvature 1/R, from index n1 into n2 +mat2 m2_refract(float n1, float n2, float inv_r) { return {1.0f, 0.0f, (n1 - n2) * inv_r / n2, n1 / n2}; } + +// Mirror reflection at a spherical surface with signed curvature 1/R +mat2 m2_reflect(float inv_r) { return {1.0f, 0.0f, 2.0f * inv_r, 1.0f}; } + +mat2 m2_inverse(const mat2& m) +{ + float det = m.a * m.d - m.b * m.c; + return {m.d / det, -m.b / det, -m.c / det, m.a / det}; +} + +float surface_inv_radius(const lens_surface& s) +{ + return (s.radius != 0.0f) ? 1.0f / s.radius : 0.0f; +} + +// Refractive index behind a surface at one of the three design wavelengths, +// with Cauchy 2-term dispersion fitted through n_d and the Abbe number. +float surface_index(const lens_surface& s, int wl) +{ + if (s.n <= 1.0005f || s.abbe <= 0.0f) { + return s.n; + } + constexpr float lF = 0.48613f, lC = 0.65627f, lD = 0.58756f; + float B = (s.n - 1.0f) / (s.abbe * (1.0f / (lF * lF) - 1.0f / (lC * lC))); + float A = s.n - B / (lD * lD); + float l = Wavelengths_um[wl]; + return A + B / (l * l); +} + +float index_after(const lens_system& lens, int surf, int wl) +{ + return surface_index(lens.surfaces[surf], wl); +} + +float index_before(const lens_system& lens, int surf, int wl) +{ + return (surf == 0) ? 1.0f : surface_index(lens.surfaces[surf - 1], wl); +} + +int find_stop_index(const lens_system& lens) +{ + for (int i = 0; i < static_cast(lens.surfaces.size()); i++) { + if (lens.surfaces[i].is_stop) { + return i; + } + } + return -1; +} + +// Forward system matrix (no reflections) from the first surface to just after +// the last surface, at the given wavelength. +mat2 system_matrix(const lens_system& lens, int wl) +{ + mat2 m = m2_identity(); + int n = static_cast(lens.surfaces.size()); + for (int s = 0; s < n; s++) { + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(lens.surfaces[s])), m); + if (s < n - 1) { + m = m2_mul(m2_translate(lens.surfaces[s].thickness), m); + } + } + return m; +} + +// Compose the ray-transfer matrices of one two-reflection ghost path +// (first reflection at surface hi going forward, second at surface lo going +// backward, lo < hi), splitting at the LAST aperture-stop crossing. +void trace_ghost_path(const lens_system& lens, int hi, int lo, int stop, int wl, float bfd, + float out_ma[4], float out_ms[4]) +{ + mat2 m = m2_identity(); + mat2 ma = m2_identity(); + bool have_ma = false; + + auto note_stop = [&]() { + ma = m; + have_ma = true; + }; + + const auto& surf = lens.surfaces; + int n = static_cast(surf.size()); + + // Phase 1: forward from surface 0 up to surface hi + for (int s = 0; s < hi; s++) { + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + m = m2_mul(m2_translate(surf[s].thickness), m); + } + + // Reflect at surface hi (now travelling backward; radii of surfaces crossed + // backward flip sign in the unfolded system) + m = m2_mul(m2_reflect(surface_inv_radius(surf[hi])), m); + + // Phase 2: backward from surface hi down to surface lo + for (int s = hi - 1; s > lo; s--) { + m = m2_mul(m2_translate(surf[s].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_after(lens, s, wl), index_before(lens, s, wl), -surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(surf[lo].thickness), m); + + // Reflect at surface lo (hit from behind; forward again) + m = m2_mul(m2_reflect(-surface_inv_radius(surf[lo])), m); + + // Phase 3: forward from surface lo to the sensor + for (int s = lo + 1; s < n; s++) { + m = m2_mul(m2_translate(surf[s - 1].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(bfd), m); + + if (!have_ma) { + // Degenerate prescription (no stop crossing); treat the whole path as Ms + ma = m2_identity(); + } + + mat2 ms = m2_mul(m, m2_inverse(ma)); + + out_ma[0] = ma.a; + out_ma[1] = ma.b; + out_ma[2] = ma.c; + out_ma[3] = ma.d; + out_ms[0] = ms.a; + out_ms[1] = ms.b; + out_ms[2] = ms.c; + out_ms[3] = ms.d; +} + +float ghost_coating_wavelength(const lens_system& lens, const lens_surface& s) +{ + return (s.coating_wavelength < 0.0f) ? lens.coating_wavelength : s.coating_wavelength; +} + +} // namespace + +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm) +{ + if (lambda0_nm <= 0.0f) { + float r = (n1 - n2) / (n1 + n2); + return r * r; + } + + // Single quarter-wave layer (tuned to lambda0) between n1 and n2, ideally + // index sqrt(n1*n2) but no better than MgF2 (1.38), at normal incidence + float nc = MAX(1.38f, sqrtf(n1 * n2)); + float r1 = (n1 - nc) / (n1 + nc); + float r2 = (nc - n2) / (nc + n2); + float cphi = cosf(PI * lambda0_nm / lambda_nm); + float num = r1 * r1 + r2 * r2 + 2.0f * r1 * r2 * cphi; + float den = 1.0f + r1 * r1 * r2 * r2 + 2.0f * r1 * r2 * cphi; + return num / den; +} + +bool lens_flare_precompute(lens_system& lens) +{ + lens.ghosts.clear(); + + int n = static_cast(lens.surfaces.size()); + if (n < 2) { + return false; + } + + // Normalize stop surfaces: flat, index-continuous with the preceding medium + for (int i = 0; i < n; i++) { + if (lens.surfaces[i].is_stop) { + lens.surfaces[i].radius = 0.0f; + lens.surfaces[i].n = (i == 0) ? 1.0f : lens.surfaces[i - 1].n; + lens.surfaces[i].abbe = (i == 0) ? 0.0f : lens.surfaces[i - 1].abbe; + } + } + + int stop = find_stop_index(lens); + if (stop < 0) { + // No explicit stop: use the middle surface's plane for aperture clipping + stop = n / 2; + } + + // Effective focal length and back focal distance (green), sensor placed at + // the infinity focus + mat2 sys = system_matrix(lens, 1); + if (fabsf(sys.c) < 1e-6f) { + return false; // afocal; can't image onto a sensor + } + lens.efl = -1.0f / sys.c; + lens.bfd = -sys.a / sys.c; + if (lens.efl <= 0.0f || lens.bfd <= 0.0f) { + return false; + } + + // Enumerate all two-reflection ghost paths between refractive surfaces + struct scored_ghost { + lens_flare_ghost g; + float key; + }; + SCP_vector scored; + + for (int hi = 1; hi < n; hi++) { + if (fabsf(index_before(lens, hi, 1) - index_after(lens, hi, 1)) < 1e-4f) { + continue; // no index step -> no reflection (also skips the stop) + } + for (int lo = 0; lo < hi; lo++) { + if (fabsf(index_before(lens, lo, 1) - index_after(lens, lo, 1)) < 1e-4f) { + continue; + } + + scored_ghost sg; + sg.g.surf_first = hi; + sg.g.surf_second = lo; + + for (int wl = 0; wl < 3; wl++) { + trace_ghost_path(lens, hi, lo, stop, wl, lens.bfd, sg.g.ma[wl], sg.g.ms[wl]); + + float lambda_nm = Wavelengths_um[wl] * 1000.0f; + float r_first = lens_flare_fresnel_reflectance(index_before(lens, hi, wl), index_after(lens, hi, wl), + ghost_coating_wavelength(lens, lens.surfaces[hi]), lambda_nm); + float r_second = lens_flare_fresnel_reflectance(index_after(lens, lo, wl), index_before(lens, lo, wl), + ghost_coating_wavelength(lens, lens.surfaces[lo]), lambda_nm); + sg.g.reflectance[wl] = r_first * r_second; + } + + if (sg.g.reflectance[1] < 1e-6f) { + continue; + } + + // Brightness-ish sort key: reflectance, boosted for concentrated + // (small-footprint) ghosts + float a_g = sg.g.ms[1][0] * sg.g.ma[1][0] + sg.g.ms[1][1] * sg.g.ma[1][2]; + sg.key = sg.g.reflectance[1] * MIN(1.0f / (a_g * a_g + 1e-3f), 100.0f); + scored.push_back(sg); + } + } + + std::sort(scored.begin(), scored.end(), [](const scored_ghost& x, const scored_ghost& y) { return x.key > y.key; }); + + int cap = std::clamp(lens.max_ghosts, 1, generic_data::MAX_LENS_FLARE_INSTANCES - 1); + for (const auto& sg : scored) { + if (static_cast(lens.ghosts.size()) >= cap) { + break; + } + lens.ghosts.push_back(sg.g); + } + + return !lens.ghosts.empty(); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_table.cpp b/code/graphics/lens_flare_table.cpp new file mode 100644 index 00000000000..7764d999eb5 --- /dev/null +++ b/code/graphics/lens_flare_table.cpp @@ -0,0 +1,251 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +#include + +// lens_flares.tbl / *-lens.tbm parsing. Owns no state: the systems it reads are +// appended to the vector the caller hands over, and each is precomputed on the +// way in so an unusable prescription never reaches the renderer. + +namespace graphics { +namespace { + +// Where the parsed lenses go for the duration of one parse run. File-scope +// pointers rather than parameters because parse_modular_table() takes a plain +// function pointer, so the per-file callback cannot capture anything. Set and +// cleared by lens_flare_parse_tables(), which is the only entry point. +SCP_vector* Parse_systems = nullptr; +SCP_string* Parse_default_name = nullptr; + +int find_system(const SCP_vector& systems, const char* name) +{ + for (int i = 0; i < static_cast(systems.size()); i++) { + if (!stricmp(systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +// ---- table parsing ---- + +void parse_lens_table_core() +{ + Assertion(Parse_systems != nullptr && Parse_default_name != nullptr, + "Lens tables parsed outside lens_flare_parse_tables()!"); + auto& systems = *Parse_systems; + auto& default_name = *Parse_default_name; + + reset_parse(); + + required_string("#Lens Systems"); + + // The lens a mission gets when it doesn't name one itself. Left empty by the + // shipped table, so content that never asks for a lens keeps the retail look; + // a mod opts its whole campaign in with one line in a *-lens.tbm. + if (optional_string("$Default Lens:")) { + stuff_string(default_name, F_NAME); + } + + while (optional_string("$Name:")) { + lens_system ls; + stuff_string(ls.name, F_NAME); + + if (optional_string("$Entrance Pupil Radius:")) { + stuff_float(&ls.entrance_radius); + } + if (optional_string("$Aperture Radius:")) { + stuff_float(&ls.aperture_radius); + } + if (optional_string("$Sensor Width:")) { + stuff_float(&ls.sensor_width); + } + if (optional_string("$Anamorphic Squeeze:")) { + stuff_float(&ls.anamorphic_squeeze); + } + if (optional_string("$Anamorphic Streak:")) { + stuff_float(&ls.streak.strength); + if (optional_string("+Length:")) { + stuff_float(&ls.streak.length); + } + if (optional_string("+Thickness:")) { + stuff_float(&ls.streak.thickness); + } + if (optional_string("+Tint:")) { + float rgb[3] = {1.0f, 1.0f, 1.0f}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Lens system '%s': +Tint: needs ( r, g, b )", ls.name.c_str()); + } + ls.streak.tint[0] = rgb[0]; + ls.streak.tint[1] = rgb[1]; + ls.streak.tint[2] = rgb[2]; + } + } + if (optional_string("$Coating Wavelength:")) { + stuff_float(&ls.coating_wavelength); + } + if (optional_string("$Aperture Blades:")) { + stuff_int(&ls.aperture.blades); + } + if (optional_string("+Blade Rotation:")) { + stuff_float(&ls.aperture.rotation); + } + if (optional_string("+Blade Curvature:")) { + stuff_float(&ls.aperture.curvature); + } + if (optional_string("+Edge Softness:")) { + stuff_float(&ls.aperture.softness); + } + if (optional_string("$Aperture Grating:")) { + stuff_float(&ls.aperture.grating.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.grating.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.grating.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.grating.width); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.grating.softness); + } + } + if (optional_string("$Aperture Scratches:")) { + stuff_float(&ls.aperture.scratches.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.scratches.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.scratches.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.scratches.width); + } + if (optional_string("+Rotation:")) { + stuff_float(&ls.aperture.scratches.rotation); + } + if (optional_string("+Rotation Variation:")) { + stuff_float(&ls.aperture.scratches.rotation_variation); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.scratches.softness); + } + } + if (optional_string("$Aperture Dust:")) { + stuff_float(&ls.aperture.dust.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.dust.density); + } + if (optional_string("+Radius:")) { + stuff_float(&ls.aperture.dust.radius); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.dust.softness); + } + } + if (optional_string("$Starburst:")) { + stuff_boolean(&ls.starburst); + } + if (optional_string("+Starburst Scale:")) { + stuff_float(&ls.starburst_scale); + } + if (optional_string("$Intensity:")) { + stuff_float(&ls.intensity); + } + if (optional_string("$Max Ghosts:")) { + stuff_int(&ls.max_ghosts); + } + + while (true) { + if (optional_string("$Surface:")) { + float vals[3] = {0.0f, 0.0f, 1.0f}; + size_t count = stuff_float_list(vals, 3); + if (count != 3) { + error_display(0, "Lens system '%s': $Surface: needs ( radius, thickness, index )", ls.name.c_str()); + } + lens_surface s; + s.radius = vals[0]; + s.thickness = vals[1]; + s.n = vals[2]; + if (optional_string("+Abbe:")) { + stuff_float(&s.abbe); + } + if (optional_string("+Coating Wavelength:")) { + stuff_float(&s.coating_wavelength); + } + ls.surfaces.push_back(s); + } else if (optional_string("$Stop:")) { + float d = 0.0f; + size_t count = stuff_float_list(&d, 1); + if (count != 1) { + error_display(0, "Lens system '%s': $Stop: needs ( thickness )", ls.name.c_str()); + } + lens_surface s; + s.thickness = d; + s.is_stop = true; + ls.surfaces.push_back(s); + } else { + break; + } + } + + if (!lens_flare_precompute(ls)) { + error_display(0, "Lens system '%s' has an unusable prescription and will be ignored", ls.name.c_str()); + continue; + } + + // what a mission's sexps (or the lab) will be reset back to + ls.tabled_aperture = ls.aperture; + + // a later table may redefine a lens the engine (or an earlier tbm) shipped + int existing = find_system(systems, ls.name.c_str()); + if (existing >= 0) { + systems[existing] = std::move(ls); + } else { + systems.push_back(std::move(ls)); + } + } + + required_string("#End"); +} + +void parse_lens_table_file(const char* filename) +{ + try { + if (filename == nullptr) { + read_file_text_from_default(defaults_get_file("lens_flares.tbl")); + } else { + read_file_text(filename, CF_TYPE_TABLES); + } + parse_lens_table_core(); + } catch (const parse::ParseException& e) { + mprintf(("Unable to parse '%s'! Error message = %s.\n", (filename != nullptr) ? filename : "", e.what())); + } +} + +} // namespace + +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name) +{ + Parse_systems = &systems; + Parse_default_name = &default_lens_name; + + if (cf_exists_full("lens_flares.tbl", CF_TYPE_TABLES)) { + parse_lens_table_file("lens_flares.tbl"); + } else { + parse_lens_table_file(nullptr); + } + + parse_modular_table("*-lens.tbm", [](const char* filename) { parse_lens_table_file(filename); }); + + Parse_systems = nullptr; + Parse_default_name = nullptr; +} + +} // namespace graphics diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..6360c5fc006 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -9,12 +9,15 @@ #include "gropengldraw.h" #include "gropenglshader.h" #include "gropenglstate.h" +#include "gropengltnl.h" #include "cmdline/cmdline.h" #include "def_files/def_files.h" +#include "graphics/lens_flare.h" #include "graphics/shader_types.h" #include "graphics/grinternal.h" #include "graphics/openxr.h" +#include "graphics/render.h" #include "graphics/util/uniform_structs.h" #include "io/timer.h" #include "lighting/lighting.h" @@ -63,6 +66,14 @@ static GLuint Smaa_output_tex = 0; static GLuint Smaa_search_tex = 0; static GLuint Smaa_area_tex = 0; +// physically-based lens flare resources (created lazily on first use). There is +// one camera lens, so one iris mask and one starburst serve every sun. +static GLuint Lens_flare_framebuffer = 0; +static GLuint Lens_flare_aperture_tex = 0; +static GLuint Lens_flare_starburst_tex = 0; +static int Lens_flare_tex_lens_idx = -1; +static unsigned int Lens_flare_tex_generation = 0; + namespace ltp = lighting_profiles; using namespace ltp; @@ -500,6 +511,137 @@ void opengl_post_lightshafts() } } +// drop the uploaded aperture/starburst textures (regenerated lazily on demand) +static void opengl_lens_flare_release_textures() +{ + if (Lens_flare_aperture_tex) { + glDeleteTextures(1, &Lens_flare_aperture_tex); + Lens_flare_aperture_tex = 0; + } + if (Lens_flare_starburst_tex) { + glDeleteTextures(1, &Lens_flare_starburst_tex); + Lens_flare_starburst_tex = 0; + } + Lens_flare_tex_lens_idx = -1; +} + +// Upload the CPU-generated aperture/starburst textures of the mounted lens, or +// keep the ones already uploaded for it +static bool opengl_lens_flare_ensure_textures(int lens_idx) +{ + const unsigned int generation = graphics::lens_flare_get_texture_generation(); + if (lens_idx == Lens_flare_tex_lens_idx && generation == Lens_flare_tex_generation && + Lens_flare_aperture_tex != 0) { + return true; + } + + const auto* tex = graphics::lens_flare_get_textures(lens_idx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return false; + } + + opengl_lens_flare_release_textures(); + + auto create_tex = [](GLsizei size, GLenum internal_format, GLenum format, GLenum type, const void* pixels, + const char* name) { + GLuint handle; + glGenTextures(1, &handle); + + GL_state.Texture.SetActiveUnit(0); + GL_state.Texture.SetTarget(GL_TEXTURE_2D); + GL_state.Texture.Enable(handle); + + opengl_set_object_label(GL_TEXTURE, handle, name); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + glTexImage2D(GL_TEXTURE_2D, 0, internal_format, size, size, 0, format, type, pixels); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + + return handle; + }; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + Lens_flare_aperture_tex = create_tex(tex->aperture_size, GL_R8, GL_RED, GL_UNSIGNED_BYTE, tex->aperture.data(), + "Lens flare aperture"); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Lens_flare_starburst_tex = create_tex(tex->starburst_size, GL_RGBA32F, GL_RGBA, GL_FLOAT, tex->starburst.data(), + "Lens flare starburst"); + + Lens_flare_tex_lens_idx = lens_idx; + Lens_flare_tex_generation = generation; + return true; +} + +// physically-based lens flares: additive instanced ghost quads on the HDR +// scene color, immediately before bloom (so bloom/tonemap treat the flare +// energy like any other scene light). One draw per visible sun: they share the +// camera lens (hence its textures), but each has its own flare axis and tint. +static void opengl_post_pass_lens_flare() +{ + // 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 +657,9 @@ void gr_opengl_post_process_end() GL_state.PushFramebufferState(); + // physically-based lens flares composite into the HDR scene before bloom + opengl_post_pass_lens_flare(); + // do bloom, hopefully ;) opengl_post_pass_bloom(); @@ -1156,6 +1301,12 @@ void opengl_post_process_shutdown() opengl_post_process_shutdown_bloom(); + if (Lens_flare_framebuffer) { + glDeleteFramebuffers(1, &Lens_flare_framebuffer); + Lens_flare_framebuffer = 0; + } + opengl_lens_flare_release_textures(); + Post_in_frame = false; Post_active_shader_index = 0; diff --git a/code/graphics/shader_types.cpp b/code/graphics/shader_types.cpp index 80a733b7c2a..727fc70ff63 100644 --- a/code/graphics/shader_types.cpp +++ b/code/graphics/shader_types.cpp @@ -116,6 +116,9 @@ static ShaderTypeInfo SHADER_TYPES[] = { { SDR_TYPE_GAMMA_BLIT, "post-v.sdr", "gamma-correct-f.sdr", nullptr, { VATTRIB_POSITION, VATTRIB_TEXCOORD }, "Gamma correct blit", false }, + + { SDR_TYPE_LENS_FLARE, "lensflare-v.sdr", "lensflare-f.sdr", nullptr, + { VATTRIB_POSITION }, "Physically-based lens flare", false }, }; // clang-format on diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..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..bad6f012709 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.h +++ b/code/graphics/vulkan/VulkanPostProcessing.h @@ -273,6 +273,90 @@ class VulkanBloom { bool m_initialized = false; }; +/** + * @brief Physically-based lens flares (ghost quads + starburst billboard) + * + * Self-contained subsystem that additively composites the precomputed lens + * flare instances (see graphics/lens_flare.h) onto the HDR scene color, + * immediately before bloom. Owns a loadOp=eLoad render pass on the scene + * color, a small dedicated per-frame UBO ring (the per-ghost array exceeds + * the shared scratch ring's slot size), and the static aperture/starburst + * textures uploaded from the CPU-generated pixel data of the active lens. + */ +class VulkanLensFlare { +public: + /** + * @brief Create render pass/framebuffer/UBO resources + * @param sceneColor Scene HDR color target to composite into (must outlive this) + */ + bool init(PostProcessContext& ctx, const RenderTarget& sceneColor); + void shutdown(); + + /** + * @brief Recreate the scene-color framebuffer after a resize (render pass kept) + * + * Device must be idle. Returns false on failure (caller should shut the + * subsystem down). + */ + bool resize(); + + /** + * @brief Per-frame UBO ring cursor reset (called from VulkanPostProcessor::beginFrame) + */ + void beginFrame(uint32_t frameIndex) + { + if (m_ubo.isValid()) { + m_ubo.resetCursor(frameIndex); + } + } + + /** + * @brief Draw the flare instances of every flaring sun additively onto the scene color + * + * No-op when no lens-equipped sun is visible. Scene color must be in + * eShaderReadOnlyOptimal (the state after the scene render pass ends) and + * is returned to eShaderReadOnlyOptimal, matching what bloom expects. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void execute(vk::CommandBuffer cmd); + + bool isInitialized() const { return m_initialized; } + +private: + bool createFramebuffer(); + + /** + * @brief Upload the iris/starburst textures of the mounted lens, if not already uploaded + */ + bool ensureTextures(int lensIdx); + void releaseTextures(bool deferred); + + PostProcessContext* m_ctx = nullptr; + const RenderTarget* m_sceneColor = nullptr; + + vk::RenderPass m_renderPass; // Color-only RGBA16F, loadOp=eLoad (additive to scene) + vk::Framebuffer m_sceneColorFB; // Scene color as attachment 0 + + // Dedicated per-frame UBO ring: lens_flare_data (~5 KB) exceeds the shared + // scratch ring's slot size (see PostProcessContext::SCRATCH_UBO_SLOT_SIZE). + // One slot per visible sun per scene render (see LENS_FLARE_UBO_SLOTS). + PerFrameUboRing m_ubo; + + // Static iris/starburst textures of the mounted camera lens, shared by every + // sun's flare + vk::Image m_apertureImage; + vk::ImageView m_apertureView; + VulkanAllocation m_apertureAlloc; + vk::Image m_starburstImage; + vk::ImageView m_starburstView; + VulkanAllocation m_starburstAlloc; + int m_texLensIdx = -1; + unsigned int m_texGeneration = 0; + + bool m_initialized = false; +}; + /** * @brief Deferred geometry buffer (G-buffer) + optional MSAA G-buffer & resolve * @@ -784,7 +868,11 @@ class VulkanPostProcessor { * fullscreen pass of the frame (mid-scene fog included), so subsystems must * not reset it themselves. */ - void beginFrame(uint32_t frameIndex) { m_ctx.scratchRing.resetCursor(frameIndex); } + void beginFrame(uint32_t frameIndex) + { + m_ctx.scratchRing.resetCursor(frameIndex); + m_lensFlare.beginFrame(frameIndex); + } /** * @brief Get the HDR scene render pass (for 3D scene rendering) @@ -884,6 +972,17 @@ class VulkanPostProcessor { */ void executeBloom(vk::CommandBuffer cmd) { m_bloom.execute(cmd); } + /** + * @brief Execute the physically-based lens flare pass + * + * Called immediately before executeBloom() so the flare energy is bloomed + * and tonemapped like any other HDR scene content. No-op when no + * lens-equipped sun is visible. Must be called outside a render pass. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void executeLensFlare(vk::CommandBuffer cmd) { m_lensFlare.execute(cmd); } + /** * @brief Execute tonemapping pass (HDR scene → LDR) * @@ -1165,6 +1264,9 @@ class VulkanPostProcessor { // ---- Bloom (self-contained subsystem) ---- VulkanBloom m_bloom; + // ---- Physically-based lens flares (self-contained subsystem) ---- + VulkanLensFlare m_lensFlare; + // ---- LDR / FXAA / post-effects / lightshafts (self-contained subsystem) ---- VulkanLDR m_ldr; diff --git a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp new file mode 100644 index 00000000000..6fd76352bf6 --- /dev/null +++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp @@ -0,0 +1,376 @@ +#include "VulkanPostProcessing.h" + +#include + +#include "gr_vulkan.h" +#include "VulkanRenderer.h" +#include "VulkanPipeline.h" +#include "VulkanDescriptorManager.h" +#include "VulkanTexture.h" +#include "VulkanDeletionQueue.h" +#include "graphics/2d.h" +#include "graphics/grinternal.h" +#include "graphics/lens_flare.h" +#include "graphics/util/uniform_structs.h" + +namespace graphics::vulkan { + +// ===== Physically-based lens flare pass ===== + +namespace { +// One UBO slot per visible sun per scene render. Backgrounds have single-digit +// sun counts and each slot is only ~5 KB, so this is deliberately generous; the +// draw loop bails out rather than overflowing the ring if one ever exceeds it. +constexpr uint32_t LENS_FLARE_UBO_SLOTS = 32; +} // namespace + +bool VulkanLensFlare::init(PostProcessContext& ctx, const RenderTarget& sceneColor) +{ + m_ctx = &ctx; + m_sceneColor = &sceneColor; + + // Additive render pass on the scene color: identical shape to the bloom + // composite pass (loadOp=eLoad, ends in eShaderReadOnlyOptimal so the + // following bloom bright pass can sample the scene as usual) + { + vk::AttachmentDescription att; + att.format = HDR_COLOR_FORMAT; + att.samples = vk::SampleCountFlagBits::e1; + att.loadOp = vk::AttachmentLoadOp::eLoad; + att.storeOp = vk::AttachmentStoreOp::eStore; + att.stencilLoadOp = vk::AttachmentLoadOp::eDontCare; + att.stencilStoreOp = vk::AttachmentStoreOp::eDontCare; + att.initialLayout = vk::ImageLayout::eColorAttachmentOptimal; + att.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + + vk::AttachmentReference colorRef; + colorRef.attachment = 0; + colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal; + + vk::SubpassDescription subpass; + subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + + vk::SubpassDependency dep; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.dstStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.srcAccessMask = vk::AccessFlagBits::eShaderRead + | vk::AccessFlagBits::eColorAttachmentWrite; + dep.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + + vk::RenderPassCreateInfo rpInfo; + rpInfo.attachmentCount = 1; + rpInfo.pAttachments = &att; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dep; + + try { + m_renderPass = m_ctx->device.createRenderPass(rpInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create render pass: %s\n", e.what())); + return false; + } + } + + if (!createFramebuffer()) { + return false; + } + + // Dedicated per-frame UBO ring: lens_flare_data exceeds the shared scratch + // ring's slot size. One slot per visible sun, with room for the scene being + // rendered more than once per frame. + vk::DeviceSize slotSize = (sizeof(generic_data::lens_flare_data) + 255) & ~static_cast(255); + if (!m_ubo.init(m_ctx->device, m_ctx->memoryManager, LENS_FLARE_UBO_SLOTS, slotSize)) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create UBO ring!\n")); + shutdown(); + return false; + } + + m_initialized = true; + nprintf(("vulkan", "VulkanLensFlare: Initialized\n")); + return true; +} + +bool VulkanLensFlare::createFramebuffer() +{ + vk::FramebufferCreateInfo fbInfo; + fbInfo.renderPass = m_renderPass; + fbInfo.attachmentCount = 1; + fbInfo.pAttachments = &m_sceneColor->view; + fbInfo.width = m_ctx->sceneExtent.width; + fbInfo.height = m_ctx->sceneExtent.height; + fbInfo.layers = 1; + + try { + m_sceneColorFB = m_ctx->device.createFramebuffer(fbInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create framebuffer: %s\n", e.what())); + return false; + } + return true; +} + +bool VulkanLensFlare::resize() +{ + if (!m_initialized) { + return true; + } + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + return createFramebuffer(); +} + +void VulkanLensFlare::releaseTextures(bool deferred) +{ + auto* deletionQueue = deferred ? getDeletionQueue() : nullptr; + + auto release = [&](vk::Image& image, vk::ImageView& view, VulkanAllocation& alloc) { + if (view) { + if (deletionQueue) { + deletionQueue->queueImageView(view); + } else { + m_ctx->device.destroyImageView(view); + } + view = nullptr; + } + if (image) { + if (deletionQueue) { + deletionQueue->queueImage(image, alloc); + } else { + m_ctx->device.destroyImage(image); + m_ctx->memoryManager->freeAllocation(alloc); + } + image = nullptr; + alloc = {}; + } + }; + + release(m_apertureImage, m_apertureView, m_apertureAlloc); + release(m_starburstImage, m_starburstView, m_starburstAlloc); + + m_texLensIdx = -1; +} + +bool VulkanLensFlare::ensureTextures(int lensIdx) +{ + const unsigned int generation = graphics::lens_flare_get_texture_generation(); + if (lensIdx == m_texLensIdx && generation == m_texGeneration && m_apertureView) { + return true; + } + + const auto* tex = graphics::lens_flare_get_textures(lensIdx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return false; + } + + auto* texMgr = getTextureManager(); + if (texMgr == nullptr) { + return false; + } + + // The outgoing textures may still be referenced by in-flight frames + releaseTextures(true); + + if (!texMgr->createStaticTexture2D(tex->aperture_size, tex->aperture_size, vk::Format::eR8Unorm, + tex->aperture.data(), tex->aperture.size(), "Lens flare aperture", + m_apertureImage, m_apertureView, m_apertureAlloc)) { + return false; + } + + if (!texMgr->createStaticTexture2D(tex->starburst_size, tex->starburst_size, vk::Format::eR32G32B32A32Sfloat, + tex->starburst.data(), tex->starburst.size() * sizeof(float), "Lens flare starburst", + m_starburstImage, m_starburstView, m_starburstAlloc)) { + releaseTextures(false); + return false; + } + + m_texLensIdx = lensIdx; + m_texGeneration = generation; + return true; +} + +void VulkanLensFlare::execute(vk::CommandBuffer cmd) +{ + if (!m_initialized) { + return; + } + + // 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) + releaseTextures(false); + + m_ubo.shutdown(); + + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + if (m_renderPass) { + m_ctx->device.destroyRenderPass(m_renderPass); + m_renderPass = nullptr; + } + + m_initialized = false; +} + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanRendererLoop.cpp b/code/graphics/vulkan/VulkanRendererLoop.cpp index 601dad04485..3675e360a98 100644 --- a/code/graphics/vulkan/VulkanRendererLoop.cpp +++ b/code/graphics/vulkan/VulkanRendererLoop.cpp @@ -328,6 +328,7 @@ void VulkanRenderer::endSceneRendering() } // Execute post-processing passes (all between HDR scene pass and swap chain pass) + m_postProcessor->executeLensFlare(m_currentCommandBuffer); m_postProcessor->executeBloom(m_currentCommandBuffer); m_postProcessor->executeTonemap(m_currentCommandBuffer); m_postProcessor->executeFXAA(m_currentCommandBuffer); diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index a534a02aef4..b3af25649d5 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -6,10 +6,12 @@ #include "asteroid/asteroid.h" #include "graphics/2d.h" #include "graphics/debug_sphere.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/shadows.h" #include "lab/labv2_internal.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" #include "ship/shiphit.h" #include "weapon/weapon.h" #include "mission/missionload.h" @@ -730,6 +732,14 @@ void LabUi::show_render_options() } } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING && + stars_get_num_suns() > 0) { + with_CollapsingHeader("Lens flare options") + { + build_lens_flare_options(); + } + } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING) { if (Button("Export environment cubemap", ImVec2(-FLT_MIN, GetTextLineHeight()*2))) { gr_dump_envmap(getLabManager()->Renderer->currentMissionBackground.c_str()); diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..57bb5eb21ac 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -1,5 +1,6 @@ #pragma once +#include "graphics/lens_flare.h" #include "model/model.h" #include "model/animation/modelanimation.h" #include "species_defs/species_defs.h" @@ -47,6 +48,8 @@ class LabUi { static void build_max_rt_shadow_lights_slider(); static void build_rt_shadow_bias_sliders(); void build_tone_mapper_combobox(); + static void build_lens_flare_options(); + static void build_lens_aperture_options(int lens_idx, graphics::lens_aperture& ap); void build_model_info_box(ship_info* sip, polymodel* pm) const; void build_subsystem_list(object* objp, ship* shipp) const; void build_subsystem_list_entry(SCP_string& subsys_name, diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp new file mode 100644 index 00000000000..c065f654d62 --- /dev/null +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -0,0 +1,172 @@ +#include "lab_ui.h" + +#include "graphics/2d.h" +#include "graphics/lens_flare.h" +#include "lab/labv2_internal.h" +#include "starfield/starfield.h" + +using namespace ImGui; + +// The lab's "Lens flare options" panel: the camera lens the scene is shot +// through, live iris editing, the global brightness calibration, and what the +// last flare pass actually did. Split out of lab_ui.cpp, which has no room for +// another feature panel. + +// Live iris controls. One aperture drives both the ghosts and the starburst +// (the starburst is the Fraunhofer transform of this mask), so every slider +// here changes both at once. Edits are coalesced by lens_flare.cpp -- the mask +// and its FFT are far too expensive to rebuild on every frame of a drag. +void LabUi::build_lens_aperture_options(int lens_idx, graphics::lens_aperture& ap) +{ + bool changed = false; + + with_TreeNode("Aperture") + { + TextDisabled("Shared by ghosts and starburst"); + + changed |= SliderInt("Blades", &ap.blades, 2, 16); + changed |= SliderFloat("Blade rotation", &ap.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Blade curvature", &ap.curvature, -1.0f, 1.0f); + changed |= SliderFloat("Edge softness", &ap.softness, 0.0f, 0.5f); + + Separator(); + TextDisabled("Rim diffraction grating"); + changed |= SliderFloat("Grating strength", &ap.grating.strength, 0.0f, 1.0f); + if (ap.grating.strength > 0.0f) { + changed |= SliderFloat("Grating density", &ap.grating.density, 0.0f, 1.0f); + changed |= SliderFloat("Grating length", &ap.grating.length, 0.0f, 1.0f); + changed |= SliderFloat("Grating width", &ap.grating.width, 0.0f, 1.0f); + changed |= SliderFloat("Grating softness", &ap.grating.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Scratches"); + changed |= SliderFloat("Scratch strength", &ap.scratches.strength, 0.0f, 1.0f); + if (ap.scratches.strength > 0.0f) { + changed |= SliderFloat("Scratch density", &ap.scratches.density, 0.0f, 1.0f); + changed |= SliderFloat("Scratch length", &ap.scratches.length, 0.0f, 1.0f); + changed |= SliderFloat("Scratch width", &ap.scratches.width, 0.0f, 1.0f); + changed |= SliderFloat("Scratch rotation", &ap.scratches.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Scratch rot variation", &ap.scratches.rotation_variation, 0.0f, 1.0f); + changed |= SliderFloat("Scratch softness", &ap.scratches.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Dust"); + changed |= SliderFloat("Dust strength", &ap.dust.strength, 0.0f, 1.0f); + if (ap.dust.strength > 0.0f) { + changed |= SliderFloat("Dust density", &ap.dust.density, 0.0f, 1.0f); + changed |= SliderFloat("Dust radius", &ap.dust.radius, 0.0f, 1.0f); + changed |= SliderFloat("Dust softness", &ap.dust.softness, 0.0f, 0.5f); + } + + if (graphics::lens_flare_aperture_edit_pending()) { + TextDisabled("Rebuilding aperture + starburst..."); + } + TextDisabled("Grating/scratches/dust add off-axis energy, which the"); + TextDisabled("starburst normalizes against -- expect the core to dim"); + TextDisabled("as they come up. Changes are undone on table reload and"); + TextDisabled("whenever the background changes (same as set-lens-* sexps)."); + } + + if (changed) { + graphics::lens_flare_aperture_changed(lens_idx); + } +} + +void LabUi::build_lens_flare_options() +{ + // Global calibration, edited in place -- same as the per-lens sliders below + // (see graphics/lens_flare.h). The sliders' own bounds keep the values sane. + auto& tuning = graphics::lens_flare_get_tuning(); + SliderFloat("Ghost brightness", &tuning.ghost_brightness, 0.0f, 500.0f, "%.1f", ImGuiSliderFlags_Logarithmic); + SliderFloat("Starburst brightness", &tuning.starburst_brightness, 0.0f, 10.0f); + SliderFloat("HDR headroom (x paper white)", &tuning.hdr_headroom, 0.0f, 8.0f); + if (Gr_hdr_output_active) { + TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", 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 int active_lens = graphics::lens_flare_active_lens(); + + const char* mission_lens_name = graphics::lens_flare_mission_lens_name(); + SCP_string mission_label = "Mission default ("; + mission_label += (*mission_lens_name != '\0') ? mission_lens_name : "none"; + mission_label += ")"; + + const auto* active_system = graphics::lens_flare_get_system(active_lens); + const char* preview = mission_label.c_str(); + if (lab_lens) { + preview = (active_system != nullptr) ? active_system->name.c_str() : "None"; + } + + with_Combo("Camera lens", preview) + { + if (Selectable(mission_label.c_str(), !lab_lens)) { + graphics::lens_flare_clear_lab_lens(); + } + if (Selectable("None", lab_lens && *lab_lens < 0)) { + graphics::lens_flare_set_lab_lens(-1); + } + for (int lens_idx = 0; lens_idx < graphics::lens_flare_num_systems(); lens_idx++) { + bool is_selected = (lab_lens == lens_idx); + + if (Selectable(graphics::lens_flare_get_system(lens_idx)->name.c_str(), is_selected)) { + graphics::lens_flare_set_lab_lens(lens_idx); + } + + if (is_selected) + SetItemDefaultFocus(); + } + } + + if (auto* lens = graphics::lens_flare_get_system_mutable(active_lens)) { + SliderFloat("Lens intensity", &lens->intensity, 0.0f, 10.0f); + // Costs nothing to change: the squeeze is applied when the quads are + // drawn, so unlike the aperture sliders it needs no texture rebuild + SliderFloat("Anamorphic squeeze", &lens->anamorphic_squeeze, 1.0f, 3.0f, "%.2fx"); + + with_TreeNode("Anamorphic streak") + { + TextDisabled("Stays horizontal wherever the sun is"); + SliderFloat("Streak strength", &lens->streak.strength, 0.0f, 2.0f); + if (lens->streak.strength > 0.0f) { + SliderFloat("Streak length", &lens->streak.length, 0.0f, 4.0f); + SliderFloat("Streak thickness", &lens->streak.thickness, 0.001f, 0.2f, "%.3f"); + ColorEdit3("Streak tint", lens->streak.tint); + } + } + Text("%d ghosts | EFL %.1f mm | f/%.1f | %s", + static_cast(lens->ghosts.size()), + lens->efl, + lens->efl / (2.0f * lens->entrance_radius), + lens->starburst ? "starburst" : "no starburst"); + + build_lens_aperture_options(active_lens, lens->aperture); + } else { + TextDisabled("No lens mounted: this background renders no physically-based flares"); + } + + // live pass state, refreshed every frame by lens_flare_frame_update(): + // one entry per sun that got a draw + Separator(); + const auto& draws = graphics::lens_flare_get_frame_draws(); + if (draws.empty()) { + TextUnformatted("Last pass: inactive (no visible sun, or no lens mounted)"); + } else { + Text("Last pass: %d sun(s) drawn", static_cast(draws.size())); + for (const auto& draw : draws) { + Text(" Sun %d (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + draw.sun_index, + stars_get_sun_name(draw.sun_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + } + } +} diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index eec2c2d1813..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..975ff743ee3 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -34,6 +34,7 @@ #include "io/timer.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/localize.h" #include "math/bitarray.h" @@ -1118,6 +1119,17 @@ 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()); + if (optional_string("$Sound Environment:")) { char preset[65] = { '\0' }; stuff_string(preset, F_NAME, sizeof(preset)-1); @@ -7395,6 +7407,8 @@ 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(); cutscenes.clear( ); diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 8e917787108..85a974690ab 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -236,6 +236,14 @@ 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; + SCP_vector cutscenes; SCP_map custom_data; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 72201544116..32c73f80dcb 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -3128,6 +3128,23 @@ 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:"); + } + // sound environment (EFX/EAX) - taylor sound_env* m_env = &The_mission.sound_environment; if ((m_env->id >= 0) && (m_env->id < static_cast(EFX_presets.size()))) { diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index dc29f09fa04..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 7c03ab42b83..771d85f498b 100644 --- a/code/parse/sexp.cpp +++ b/code/parse/sexp.cpp @@ -42,6 +42,7 @@ #include "globalincs/version.h" #include "graphics/2d.h" #include "graphics/font.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "hud/hud.h" #include "hud/hudartillery.h" @@ -782,6 +783,11 @@ SCP_vector Operators = { { "set-skybox-orientation", OP_SET_SKYBOX_ORIENT, 3, 3, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-skybox-alpha", OP_SET_SKYBOX_ALPHA, 1, 1, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-ambient-light", OP_SET_AMBIENT_LIGHT, 3, 3, SEXP_ACTION_OPERATOR, }, // Karajorma + { "set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-grating", OP_SET_LENS_GRATING, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-dust", OP_SET_LENS_DUST, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e { "toggle-asteroid-field", OP_TOGGLE_ASTEROID_FIELD, 1, 1, SEXP_ACTION_OPERATOR, }, // MjnMixael { "set-asteroid-field", OP_SET_ASTEROID_FIELD, 1, INT_MAX, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated { "set-debris-field", OP_SET_DEBRIS_FIELD, 1, 12, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated @@ -1151,6 +1157,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 +1414,7 @@ void init_sexp() // init data structures used by certain operators // (note, Sexp_music_handles are not cleared here because sexp_music_close() handled that at the end of the previous mission) Sexp_is_true_for_duration_times.clear(); + Sexp_lens_aperture_warned = false; } // done at the beginning of the game @@ -3930,6 +3966,14 @@ int check_sexp_syntax(int node, int desired_return_type, int recursive, int *bad } break; + case OPF_LENS_SYSTEM: + if (node_subtype != SEXP_ATOM_STRING) { + return SEXP_CHECK_TYPE_MISMATCH; + } else if (!sexp_lens_name_is_valid(CTEXT(node))) { + return SEXP_CHECK_INVALID_LENS_SYSTEM; + } + break; + case OPF_HUD_ELEMENT: if (node_subtype != SEXP_ATOM_STRING) { return SEXP_CHECK_TYPE_MISMATCH; @@ -16743,11 +16787,151 @@ 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 four aperture operators: hand the mounted lens's +// (mutable) aperture to the caller, then rebuild its textures only if something +// actually changed. A mission event without a guard re-evaluates constantly, and +// a rebuild is a 512^2 mask plus an FFT. +template +void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit) +{ + int lens_idx = graphics::lens_flare_active_lens(); + if (lens_idx < 0) { + // 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; + } + + auto* lens = graphics::lens_flare_get_system_mutable(lens_idx); + if (lens == nullptr) + return; + + graphics::lens_aperture edited = lens->aperture; + edit(edited); + + if (edited != lens->aperture) { + lens->aperture = edited; + graphics::lens_flare_aperture_changed(lens_idx); + } +} + +// Read an optional percentage argument into `dest` as a 0..1 fraction, leaving +// it alone when the argument was omitted or is nan. Advances the node. +void sexp_lens_next_pct(int& n, float& dest, float min_val, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f, min_val, max_val); + + n = CDR(n); +} + +// Same, for a plain integer argument. +void sexp_lens_next_int(int& n, int& dest, int min_val, int max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int val = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(val, min_val, max_val); + + n = CDR(n); +} + +void sexp_lens_next_degrees(int& n, float& dest) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int deg = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = i2fl(deg); + + n = CDR(n); +} + +void sexp_set_lens_aperture(int node) +{ + sexp_edit_lens_aperture("set-lens-aperture", [node](graphics::lens_aperture& ap) { + int n = node; + + sexp_lens_next_int(n, ap.blades, 0, 64); + sexp_lens_next_degrees(n, ap.rotation); + sexp_lens_next_pct(n, ap.curvature, -1.0f, 1.0f); + sexp_lens_next_pct(n, ap.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_grating(int node) +{ + sexp_edit_lens_aperture("set-lens-grating", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.grating.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.width, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_scratches(int node) +{ + sexp_edit_lens_aperture("set-lens-scratches", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.scratches.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.width, 0.0f, 1.0f); + sexp_lens_next_degrees(n, ap.scratches.rotation); + sexp_lens_next_pct(n, ap.scratches.rotation_variation, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_dust(int node) +{ + sexp_edit_lens_aperture("set-lens-dust", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.dust.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.radius, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.softness, 0.0f, 1.0f); + }); +} + void sexp_nebula_change_storm(int n) { if (!(The_mission.flags[Mission::Mission_Flags::Fullneb])) return; - + nebl_set_storm(CTEXT(n)); } @@ -30285,8 +30469,33 @@ int eval_sexp(int cur_node, int referenced_node) sexp_val = SEXP_TRUE; break; - case OP_SET_AMBIENT_LIGHT: - sexp_set_ambient_light(node); + case OP_SET_AMBIENT_LIGHT: + sexp_set_ambient_light(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_CAMERA_LENS: + sexp_set_camera_lens(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_APERTURE: + sexp_set_lens_aperture(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_GRATING: + sexp_set_lens_grating(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_SCRATCHES: + sexp_set_lens_scratches(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_DUST: + sexp_set_lens_dust(node); sexp_val = SEXP_TRUE; break; @@ -31988,6 +32197,11 @@ int query_operator_return_type(int op) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_SET_POST_EFFECT: case OP_RESET_POST_EFFECTS: case OP_CHANGE_IFF_COLOR: @@ -34300,7 +34514,23 @@ int query_operator_argument_type(int op_index, int argnum) case OP_SET_AMBIENT_LIGHT: return OPF_POSITIVE; - + + case OP_SET_CAMERA_LENS: + return OPF_LENS_SYSTEM; + + case OP_SET_LENS_APERTURE: + // blade count and rotation (both non-negative), then curvature, + // which may bow the blades inward + if (argnum == 2) + return OPF_NUMBER; + else + return OPF_POSITIVE; + + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + return OPF_POSITIVE; + case OP_SET_POST_EFFECT: if (argnum == 0) return OPF_POST_EFFECT; @@ -35577,6 +35807,9 @@ const char *sexp_error_message(int num) case SEXP_CHECK_INVALID_ANIMATION_TYPE: return "Invalid animation type"; + case SEXP_CHECK_INVALID_LENS_SYSTEM: + return "Invalid lens system"; + case SEXP_CHECK_INVALID_MISSION_MOOD: return "Invalid mission mood"; @@ -37112,6 +37345,11 @@ int get_category(int op_id) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_CHANGE_IFF_COLOR: case OP_TURRET_SUBSYS_TARGET_DISABLE: case OP_TURRET_SUBSYS_TARGET_ENABLE: @@ -37727,6 +37965,11 @@ int get_subcategory(int op_id) case OP_NEBULA_SET_RANGE: case OP_VOLUMETRICS_TOGGLE: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: case OP_TOGGLE_ASTEROID_FIELD: case OP_SET_ASTEROID_FIELD: case OP_SET_DEBRIS_FIELD: @@ -41870,6 +42113,74 @@ SCP_vector Sexp_help = { "\t3: Blue (0 - 255)." }, + { OP_SET_CAMERA_LENS, "set-camera-lens\r\n" + "\tMounts a physically-based camera lens, replacing the mission's $Camera Lens:.\r\n" + "\tThere is one lens for the whole mission, because there is one camera: every sun\r\n" + "\tin the background flares through the same glass, which is what keeps their\r\n" + "\tflares consistent with each other.\r\n\r\n" + "\tThe lens goes back to the mission's own when the mission ends. Not sent over\r\n" + "\tthe network, so in multiplayer this only affects the host.\r\n\r\n" + "\tTakes 1 argument...\r\n" + "\t1:\tLens system from lens_flares.tbl, or for no flares at all, or\r\n" + "\t\t for the one lens_flares.tbl declares as $Default Lens:." + }, + + { OP_SET_LENS_APERTURE, "set-lens-aperture\r\n" + "\tChanges the iris shape of the mounted camera lens. A lens has exactly one\r\n" + "\taperture and it drives both the ghosts and the starburst, so this restyles both\r\n" + "\tat once, for every sun.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. The iris goes back to its\r\n" + "\ttabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tNumber of iris blades; fewer than 3 gives a round iris.\r\n" + "\t2:\t(optional) Blade rotation in degrees.\r\n" + "\t3:\t(optional) Blade curvature as a percentage: 0 leaves the blades straight,\r\n" + "\t\t100 bows them out into a circle, and negative values bow them inward.\r\n" + "\t4:\t(optional) Edge softness as a percentage of the iris radius. 0 keeps the\r\n" + "\t\tsharp default; even a few percent visibly weakens the starburst spikes,\r\n" + "\t\tsince those come from the sharpness of that edge." + }, + + { OP_SET_LENS_GRATING, "set-lens-grating\r\n" + "\tSets the diffraction grating of the mounted lens's iris: radial ridges around\r\n" + "\tthe rim that throw extra spikes into the starburst. See set-lens-aperture for\r\n" + "\thow iris edits behave.\r\n\r\n" + "\tNote that the starburst is normalized against its own brightest value, so\r\n" + "\tadding grating dims the core spikes as it adds new ones.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the grating off.\r\n" + "\t2:\t(optional) Density as a percentage of the 360 possible ridges.\r\n" + "\t3:\t(optional) Length as a percentage: how far in from the rim they reach.\r\n" + "\t4:\t(optional) Width as a percentage of the spacing between ridges.\r\n" + "\t5:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_SCRATCHES, "set-lens-scratches\r\n" + "\tSets the scratches on the mounted lens's iris: randomly placed slivers, for a\r\n" + "\tworn or damaged lens. See set-lens-aperture for how iris edits behave, and\r\n" + "\tset-lens-grating for the note about starburst normalization.\r\n\r\n" + "\tTakes 1 to 7 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the scratches off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible scratches.\r\n" + "\t3:\t(optional) Length as a percentage.\r\n" + "\t4:\t(optional) Width as a percentage.\r\n" + "\t5:\t(optional) Rotation in degrees.\r\n" + "\t6:\t(optional) Rotation variation as a percentage; 0 leaves every scratch\r\n" + "\t\tparallel, 100 scatters them completely.\r\n" + "\t7:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_DUST, "set-lens-dust\r\n" + "\tSets the dust on the mounted lens's iris: randomly placed specks, for a dirty\r\n" + "\tlens. See set-lens-aperture for how iris edits behave, and set-lens-grating\r\n" + "\tfor the note about starburst normalization.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the dust off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible specks.\r\n" + "\t3:\t(optional) Speck radius as a percentage.\r\n" + "\t4:\t(optional) Softness as a percentage." + }, + { OP_SET_GRAVITY_ACCEL, "set-gravity-accel\r\n" "\tSets the gravity acceleration rate in units of 0.01 m/s^2\r\n" "\te.g. '981' would be earth gravity, 9.81 m/s^2.\r\n" diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 68299082412..e412c8710aa 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -150,6 +150,7 @@ enum sexp_opf_t : int { OPF_CHILD_LUA_ENUM, // MjnMixael - Used to let Lua Enums reference Enums OPF_MISSION_CUSTOM_STRING, // MjnMixael - The custom strings as defined in FRED OPF_MESSAGE_TYPE, // naomimyselfandi - A message type (Attack Target et al.) + OPF_LENS_SYSTEM, // the-e - a lens system from lens_flares.tbl, or / //Must always be at the end of the list First_available_opf_id @@ -941,7 +942,12 @@ enum : int { OP_SET_SKYBOX_ALPHA, // Goober5000 OP_NEBULA_SET_RANGE, // Goober5000 OP_SET_SQUADRON_WINGS, // Goober5000 - + OP_SET_CAMERA_LENS, // the-e + OP_SET_LENS_APERTURE, // the-e + OP_SET_LENS_GRATING, // the-e + OP_SET_LENS_SCRATCHES, // the-e + OP_SET_LENS_DUST, // the-e + // OP_CATEGORY_AI // defined for AI goals @@ -1304,6 +1310,7 @@ enum sexp_error_check SEXP_CHECK_MUST_BE_INTEGER, SEXP_CHECK_INVALID_CUSTOM_STRING, SEXP_CHECK_INVALID_MESSAGE_TYPE, + SEXP_CHECK_INVALID_LENS_SYSTEM, SEXP_CHECK_POTENTIAL_ISSUE, }; @@ -1488,6 +1495,10 @@ extern bool map_opf_to_opr(sexp_opf_t opf_type, sexp_opr_t &opr_type); const char *opr_type_name(sexp_opr_t opr_type); extern int query_operator_return_type(int op); extern int query_operator_argument_type(int op, int argnum); + +// True if the string names a lens system from lens_flares.tbl, or one of the +// / values set-camera-lens accepts in place of one. +extern bool sexp_lens_name_is_valid(const char* lens_name); extern void update_sexp_references(const char *old_name, const char *new_name); extern void update_sexp_references(const char *old_name, const char *new_name, int format); extern std::pair query_referenced_in_sexp(sexp_ref_type type, const char *name, int &node); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..b0c8feceffd 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -251,6 +251,8 @@ add_file_folder("Default files\\\\data\\\\effects" def_files/data/effects/gamma.sdr def_files/data/effects/gamma-correct-f.sdr def_files/data/effects/irrmap-f.sdr + def_files/data/effects/lensflare-f.sdr + def_files/data/effects/lensflare-v.sdr def_files/data/effects/lighting.sdr def_files/data/effects/ls-f.sdr def_files/data/effects/main-f.sdr @@ -310,6 +312,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/fonts.tbl def_files/data/tables/game_settings.tbl def_files/data/tables/iff_defs.tbl + def_files/data/tables/lens_flares.tbl def_files/data/tables/objecttypes.tbl def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl @@ -461,6 +464,12 @@ add_file_folder("Graphics" graphics/grbatch.h graphics/grinternal.cpp graphics/grinternal.h + graphics/lens_flare.cpp + graphics/lens_flare.h + graphics/lens_flare_aperture.cpp + graphics/lens_flare_internal.h + graphics/lens_flare_optics.cpp + graphics/lens_flare_table.cpp graphics/light.cpp graphics/light.h graphics/line_draw_list.cpp @@ -617,6 +626,7 @@ if (FSO_BUILD_WITH_VULKAN) graphics/vulkan/VulkanPostProcessing.cpp graphics/vulkan/VulkanPostProcessing.h graphics/vulkan/VulkanPostProcessingBloom.cpp + graphics/vulkan/VulkanPostProcessingLensFlare.cpp graphics/vulkan/VulkanPostProcessingCommon.cpp graphics/vulkan/VulkanPostProcessingDistortion.cpp graphics/vulkan/VulkanPostProcessingFog.cpp @@ -774,6 +784,7 @@ add_file_folder("Lab\\\\Dialogs" lab/dialogs/lab_ui.cpp lab/dialogs/lab_ui_helpers.h lab/dialogs/lab_ui_helpers.cpp + lab/dialogs/lab_ui_lens_flare.cpp ) add_file_folder("Lab\\\\Manager" diff --git a/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/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/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 5c1e47c175f..4cf76ae3213 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,51 @@ 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()); + refreshBackgroundPreview(); +} + } // namespace fso::fred::dialogs \ No newline at end of file diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 36118b3aa8d..6d30c94df4c 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -175,6 +175,15 @@ 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); + private: void initializeData(); void refreshBackgroundPreview(); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp index 075274477ed..72b2344a671 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp @@ -125,6 +125,10 @@ void BackgroundEditorDialog::initializeUi() ui->lightingProfileCombo->addItem(QString::fromStdString(s)); } + for (const auto& s : _model->getCameraLensOptions()) { + ui->cameraLensCombo->addItem(QString::fromStdString(s)); + } + updateMiscControls(); } @@ -406,6 +410,7 @@ void BackgroundEditorDialog::updateMiscControls() ui->subspaceCheckBox->setChecked(_model->getTakesPlaceInSubspace()); ui->envMapEdit->setText(QString::fromStdString(_model->getEnvironmentMapName())); ui->lightingProfileCombo->setCurrentIndex(ui->lightingProfileCombo->findText(QString::fromStdString(_model->getLightingProfileName()))); + ui->cameraLensCombo->setCurrentIndex(ui->cameraLensCombo->findText(QString::fromStdString(_model->getCameraLensName()))); } int BackgroundEditorDialog::pickBackgroundIndexDialog(QWidget* parent, int count, int defaultIndex) @@ -974,4 +979,13 @@ void BackgroundEditorDialog::on_lightingProfileCombo_currentIndexChanged(int ind _model->setLightingProfileName(text.toUtf8().constData()); } +void BackgroundEditorDialog::on_cameraLensCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + + const QString text = ui->cameraLensCombo->itemText(index); + _model->setCameraLensName(text.toUtf8().constData()); +} + } // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h index 42ea46fb77f..5cf913541c3 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h @@ -101,6 +101,7 @@ private slots: void on_envMapButton_clicked(); void on_envMapEdit_textChanged(const QString& arg1); void on_lightingProfileCombo_currentIndexChanged(int index); + void on_cameraLensCombo_currentIndexChanged(int index); protected: void closeEvent(QCloseEvent* e) override; diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui index 779d8ab0311..6a6361ae8e2 100644 --- a/qtfred/ui/BackgroundEditor.ui +++ b/qtfred/ui/BackgroundEditor.ui @@ -1103,6 +1103,16 @@ + + + + Camera Lens + + + + + + @@ -1188,6 +1198,7 @@ envMapButton envMapEdit lightingProfileCombo + cameraLensCombo oldNebulaPatternCombo oldNebulaColorCombo oldNebulaPitchSpinBox diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp new file mode 100644 index 00000000000..1d5f295cd3a --- /dev/null +++ b/test/src/graphics/test_lens_flare.cpp @@ -0,0 +1,683 @@ +#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()); + EXPECT_LE(static_cast(ls->ghosts.size()), ls->max_ghosts); + EXPECT_GT(ls->entrance_radius, 0.0f); + EXPECT_GT(ls->aperture_radius, 0.0f); + EXPECT_GT(ls->sensor_width, 0.0f); + // every shipped lens is spherical, so both anamorphic paths must be + // exactly off -- this is what keeps existing content identical + EXPECT_FLOAT_EQ(ls->anamorphic_squeeze, 1.0f); + EXPECT_FLOAT_EQ(ls->streak.strength, 0.0f); + + for (const auto& ghost : ls->ghosts) { + for (int wl = 0; wl < 3; wl++) { + const float* ma = ghost.ma[wl]; + const float* ms = ghost.ms[wl]; + for (int k = 0; k < 4; k++) { + ASSERT_TRUE(std::isfinite(ma[k])); + ASSERT_TRUE(std::isfinite(ms[k])); + } + // ghost paths start and end in air (see GhostMatricesAreUnimodular) + float a = ms[0] * ma[0] + ms[1] * ma[2]; + float b = ms[0] * ma[1] + ms[1] * ma[3]; + float c = ms[2] * ma[0] + ms[3] * ma[2]; + float d = ms[2] * ma[1] + ms[3] * ma[3]; + EXPECT_NEAR(a * d - b * c, 1.0f, 1e-2f); + EXPECT_GT(ghost.reflectance[wl], 0.0f); + EXPECT_LT(ghost.reflectance[wl], 1.0f); + } + } + } +} + +// The iris shape is the one definition behind both the ghosts and the +// starburst, so its geometry is worth pinning down: the polygon's corners sit +// on the iris radius, its blade midpoints pull in to the apothem, and curvature +// interpolates the midpoints out to a circle. (Mask only -- no FFT needed.) +TEST(LensFlareAperture, ShapeGeometry) +{ + lens_flare_textures tex; + + // probe the mask along a ray and report where transmission crosses 0.5 + auto boundary = [&tex](float angle_deg) { + const int size = tex.aperture_size; + float dx = cosf(fl_radians(angle_deg)); + float dy = sinf(fl_radians(angle_deg)); + float prev = 1.0f; + for (int i = 1; i < 2000; i++) { + float r = i / 2000.0f * 1.4f; + int x = static_cast((r * dx + 1.0f) * 0.5f * size); + int y = static_cast((r * dy + 1.0f) * 0.5f * size); + if (x < 0 || x >= size || y < 0 || y >= size) { + return r; + } + float v = tex.aperture[static_cast(y) * size + x] / 255.0f; + if (prev >= 0.5f && v < 0.5f) { + return r; + } + prev = v; + } + return -1.0f; + }; + + const float iris = 0.9f; + const float apothem = iris * cosf(PI / 6.0f); + + lens_aperture ap; + ap.blades = 6; + ap.rotation = 0.0f; + ap.curvature = 0.0f; + + // straight blades: corners on the +x axis (and every 60 deg from it), + // midpoints pulled in to the apothem + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(0.0f), iris, 0.01f); + EXPECT_NEAR(boundary(60.0f), iris, 0.01f); + EXPECT_NEAR(boundary(30.0f), apothem, 0.01f); + + // full curvature lifts the midpoints to the corner radius (a circle) + ap.curvature = 1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); + + // negative curvature bows them inward instead, past the straight-blade case + ap.curvature = -1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_LT(boundary(30.0f), apothem - 0.05f); + + // rotation carries the corners with it + ap.curvature = 0.0f; + ap.rotation = 30.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); +} + +// Every imperfection layer must actually occlude, and must leave the mask +// usable rather than blacking it out. +TEST(LensFlareAperture, ImperfectionLayers) +{ + lens_flare_textures tex; + auto transmission = [&tex](const lens_aperture& ap) { + lens_flare_generate_aperture_mask(ap, &tex); + double sum = 0.0; + for (auto v : tex.aperture) { + sum += v; + } + return sum / tex.aperture.size() / 255.0; + }; + + lens_aperture ap; // defaults: every layer off + const double base = transmission(ap); + EXPECT_GT(base, 0.1); + EXPECT_LT(base, 1.0); + + ap.grating.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.grating.strength = 0.0f; + + ap.scratches.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.scratches.strength = 0.0f; + + ap.dust.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.dust.strength = 0.0f; + + // strength 0 is exactly "off", whatever the other knobs say + ap.grating.density = 1.0f; + ap.scratches.density = 1.0f; + ap.dust.density = 1.0f; + EXPECT_NEAR(transmission(ap), base, 1e-9); +} + +// One aperture definition drives both outputs, so editing it has to rebuild the +// starburst as well as the ghost mask. +TEST_F(LensFlareTableTest, ApertureEditRebuildsStarburst) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + auto* lens = lens_flare_get_system_mutable(idx); + ASSERT_NE(lens, nullptr); + + const auto* before = lens_flare_get_textures(idx); + ASSERT_NE(before, nullptr); + SCP_vector starburst_before = before->starburst; + ASSERT_FALSE(starburst_before.empty()); + + lens->aperture.blades = 3; + lens->aperture.rotation = 40.0f; + lens_flare_invalidate_textures(idx); + + const auto* after = lens_flare_get_textures(idx); + ASSERT_NE(after, nullptr); + ASSERT_EQ(after->starburst.size(), starburst_before.size()); + EXPECT_NE(after->starburst, starburst_before) << "starburst did not follow the aperture"; +} + +// The backends cache their uploaded copy per lens, so an edit has to change the +// generation counter or the new mask never reaches the GPU. +TEST_F(LensFlareTableTest, TextureInvalidationBumpsGeneration) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int before = lens_flare_get_texture_generation(); + + lens_flare_invalidate_textures(idx); + EXPECT_NE(lens_flare_get_texture_generation(), before); + + // out-of-range invalidation is a no-op, not a bump + const unsigned int after = lens_flare_get_texture_generation(); + lens_flare_invalidate_textures(lens_flare_num_systems() + 5); + EXPECT_EQ(lens_flare_get_texture_generation(), after); +} + +// Same engine, but reached through the table parser and the *-lens.tbm modular +// path rather than by setting the struct directly. Every aperture field is +// wired up by hand, so only running a table through it proves each one lands in +// the member it names. +// 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->streak.strength, 0.81f); + EXPECT_FLOAT_EQ(ls->streak.length, 0.82f); + EXPECT_FLOAT_EQ(ls->streak.thickness, 0.083f); + EXPECT_FLOAT_EQ(ls->streak.tint[0], 0.84f); + EXPECT_FLOAT_EQ(ls->streak.tint[1], 0.85f); + EXPECT_FLOAT_EQ(ls->streak.tint[2], 0.86f); + + // and the tbm must not have disturbed the built-in table + EXPECT_GE(lens_flare_lookup("angenieux_100mm"), 0); +} + +// A mission's set-lens-* sexps edit the tabled lens in place, so the reset that +// stars_pre_level_init() performs is the only thing stopping one mission's lens +// from carrying into the next. +TEST_F(LensFlareTableTest, ResetForLevelRestoresTabledValues) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + auto* lens = lens_flare_get_system_mutable(idx); + ASSERT_NE(lens, nullptr); + + const lens_aperture tabled = lens->tabled_aperture; + ASSERT_EQ(lens->aperture, tabled) << "a freshly parsed lens must match its own snapshot"; + + // make sure the textures exist, so the reset has something to invalidate + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int generation = lens_flare_get_texture_generation(); + + // what a mission's sexps would do + lens->aperture.blades = 3; + lens->aperture.dust.strength = 0.8f; + lens->aperture.scratches.strength = 0.4f; + ASSERT_NE(lens->aperture, tabled); + + lens_flare_reset_for_level(); + + EXPECT_EQ(lens->aperture, tabled); + EXPECT_NE(lens_flare_get_texture_generation(), generation) << "backends would keep the edited textures"; + + // a second reset has nothing to do, and must not churn the backends' caches + const unsigned int settled = lens_flare_get_texture_generation(); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_get_texture_generation(), settled); +} + +// The sexps apply their arguments to a copy and only rebuild when the result +// differs, so that an unguarded repeating mission event doesn't regenerate a +// 512^2 mask and its FFT every frame. That guard is only as good as this +// comparison. +TEST(LensFlareAperture, EqualityCoversEveryField) +{ + lens_aperture a; + EXPECT_EQ(a, lens_aperture()); + + // every field, including the ones nested in the imperfection layers + SCP_vector> mutators = { + [](lens_aperture& x) { x.blades += 1; }, + [](lens_aperture& x) { x.rotation += 1.0f; }, + [](lens_aperture& x) { x.curvature += 0.5f; }, + [](lens_aperture& x) { x.softness += 0.5f; }, + [](lens_aperture& x) { x.grating.strength += 0.5f; }, + [](lens_aperture& x) { x.grating.density += 0.25f; }, + [](lens_aperture& x) { x.grating.length += 0.25f; }, + [](lens_aperture& x) { x.grating.width += 0.25f; }, + [](lens_aperture& x) { x.grating.softness += 0.25f; }, + [](lens_aperture& x) { x.scratches.strength += 0.5f; }, + [](lens_aperture& x) { x.scratches.density += 0.25f; }, + [](lens_aperture& x) { x.scratches.length += 0.25f; }, + [](lens_aperture& x) { x.scratches.width += 0.25f; }, + [](lens_aperture& x) { x.scratches.rotation += 1.0f; }, + [](lens_aperture& x) { x.scratches.rotation_variation += 0.25f; }, + [](lens_aperture& x) { x.scratches.softness += 0.25f; }, + [](lens_aperture& x) { x.dust.strength += 0.5f; }, + [](lens_aperture& x) { x.dust.density += 0.25f; }, + [](lens_aperture& x) { x.dust.radius += 0.25f; }, + [](lens_aperture& x) { x.dust.softness += 0.25f; }, + }; + + for (size_t i = 0; i < mutators.size(); i++) { + lens_aperture changed; + mutators[i](changed); + SCOPED_TRACE("field index " + std::to_string(i)); + EXPECT_NE(changed, a) << "a changed field is not covered by operator=="; + EXPECT_FALSE(changed == a); + } +} + +// The mounted lens is the mission's, so it has to survive nothing but a level +// change: mounting is what parse_mission_info() does with "$Camera Lens:", and +// the reset before it is what stops the previous mission's camera from leaking. +TEST_F(LensFlareTableTest, MountingAndOverridingTheCameraLens) +{ + const int tessar = lens_flare_lookup("tessar_50mm"); + const int angenieux = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(tessar, 0); + ASSERT_GE(angenieux, 0); + + // The shipped table declares no default, so 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(), ""); +} diff --git a/test/src/parse/test_sexp_lens.cpp b/test/src/parse/test_sexp_lens.cpp new file mode 100644 index 00000000000..5a4fad5e616 --- /dev/null +++ b/test/src/parse/test_sexp_lens.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include +#include + +#include + +// The five lens-flare operators are wired up by hand across five separate +// tables in sexp.cpp (the operator list, the argument-type switch, the category +// and subcategory switches, and the help text). Nothing makes those agree, and +// a desync is silent: an argument slot that returns the wrong OPF still parses, +// it just reads the designer's number into the wrong field. +namespace { + +struct lens_operator_expectation { + const char* name; + int op_const; + int min_args; + int max_args; + int first_arg_type; // OPF for argnum 0 +}; + +const lens_operator_expectation Lens_operators[] = { + // only set-camera-lens names a lens; the aperture operators restyle whatever + // lens the mission has mounted, so their first argument is already a value + {"set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, OPF_LENS_SYSTEM}, + {"set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, OPF_POSITIVE}, + {"set-lens-grating", OP_SET_LENS_GRATING, 1, 5, OPF_POSITIVE}, + {"set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, OPF_POSITIVE}, + {"set-lens-dust", OP_SET_LENS_DUST, 1, 4, OPF_POSITIVE}, +}; + +const sexp_oper* find_lens_operator(const char* name) +{ + auto it = std::find_if(Operators.begin(), Operators.end(), [name](const sexp_oper& op) { + return op.text == name; + }); + return (it == Operators.end()) ? nullptr : &(*it); +} + +} // namespace + +TEST(SexpLens, OperatorsAreRegisteredWithExpectedArity) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const sexp_oper* op = find_lens_operator(expected.name); + ASSERT_NE(op, nullptr) << "operator missing from the Operators table"; + EXPECT_EQ(op->value, expected.op_const); + EXPECT_EQ(op->min, expected.min_args); + EXPECT_EQ(op->max, expected.max_args); + } +} + +TEST(SexpLens, EveryArgumentSlotHasAnArgumentType) +{ + // note that query_operator_argument_type() wants the operator's index in the + // Operators table, not its OP_ constant + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const int op_index = find_operator_index(expected.op_const); + ASSERT_GE(op_index, 0); + + EXPECT_EQ(query_operator_argument_type(op_index, 0), expected.first_arg_type); + + // no slot may fall through to the switch default, which would hand the + // designer an argument the operator never reads + for (int argnum = 0; argnum < expected.max_args; argnum++) { + SCOPED_TRACE("argnum " + std::to_string(argnum)); + EXPECT_NE(query_operator_argument_type(op_index, argnum), OPF_NONE) + << "argument slot has no declared type"; + } + } + + // set-lens-aperture's curvature bows the blades inward at negative values, + // so that slot in particular must not be restricted to positive numbers + EXPECT_EQ(query_operator_argument_type(find_operator_index(OP_SET_LENS_APERTURE), 2), OPF_NUMBER); +} + +TEST(SexpLens, OperatorsAreCategorisedAndDocumented) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + + // an uncategorised operator doesn't appear in FRED's menus at all + EXPECT_EQ(get_category(expected.op_const), OP_CATEGORY_CHANGE); + EXPECT_EQ(get_subcategory(expected.op_const), CHANGE_SUBCATEGORY_BACKGROUND_AND_NEBULA); + + EXPECT_EQ(query_operator_return_type(expected.op_const), OPR_NULL); + + auto help = std::find_if(Sexp_help.begin(), Sexp_help.end(), [&expected](const sexp_help_struct& h) { + return h.id == expected.op_const; + }); + ASSERT_NE(help, Sexp_help.end()) << "operator has no help text"; + EXPECT_NE(help->help.find(expected.name), SCP_string::npos) << "help text doesn't name the operator"; + } +} + +// OPF_LENS_SYSTEM arguments are validated at mission load, where a rejection +// aborts the load outright. That is only safe because the built-in lens table +// is an engine default, so these names resolve whatever is installed. +class SexpLensTableTest : public test::FSTestFixture { + public: + SexpLensTableTest() : test::FSTestFixture(INIT_CFILE) {} + + void SetUp() override + { + test::FSTestFixture::SetUp(); + graphics::lens_flare_init(); + } + + void TearDown() override + { + graphics::lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(SexpLensTableTest, ValidatesLensNames) +{ + // every lens the default table ships must be nameable from a mission + const int count = graphics::lens_flare_num_systems(); + ASSERT_GT(count, 0); + for (int i = 0; i < count; i++) { + const char* name = graphics::lens_flare_get_system(i)->name.c_str(); + SCOPED_TRACE(name); + EXPECT_TRUE(sexp_lens_name_is_valid(name)); + } + + // the set-camera-lens sentinels, case-insensitively + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + + // and a name no table defines, which is what stops a typo from silently + // leaving the flare unchanged at runtime + EXPECT_FALSE(sexp_lens_name_is_valid("no_such_lens")); + EXPECT_FALSE(sexp_lens_name_is_valid("")); + EXPECT_FALSE(sexp_lens_name_is_valid(nullptr)); + + // the error code has a message, or FRED and the mission loader print nothing + EXPECT_STRNE(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM), nullptr); + EXPECT_GT(strlen(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM)), 0u); +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index fa4c1c81d39..0e3ef78b6eb 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" 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..7d28c17bf3c --- /dev/null +++ b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm @@ -0,0 +1,52 @@ +; 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 +$Surface: ( 100.0, 5.0, 1.6 ) ++Abbe: 55.0 +$Surface: ( -200.0, 3.0, 1.0 ) +$Stop: ( 4.0 ) +$Surface: ( 150.0, 4.0, 1.62 ) ++Abbe: 45.0 +$Surface: ( -120.0, 40.0, 1.0 ) + +#End diff --git a/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm new file mode 100644 index 00000000000..eb88c40236b --- /dev/null +++ b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm @@ -0,0 +1,23 @@ +; A table that actually declares a $Default Lens:, which the shipped +; lens_flares.tbl deliberately does not. +; +; Without a declared default, "take the tabled default" and "no flares at all" +; both come out as "no lens mounted", so the two cannot be told apart. That is +; exactly the case where a mission's explicit used to be lost: it was +; stored as an empty string, an empty string was not written out, and on reload an +; absent $Camera Lens: became the default. This table is what lets a test see the +; difference. + +#Lens Systems + +$Default Lens: default_test_lens + +$Name: default_test_lens +$Entrance Pupil Radius: 12.0 +$Aperture Radius: 6.0 +$Sensor Width: 36.0 +$Surface: ( 50.0, 4.0, 1.62 ) +$Stop: ( 3.0 ) +$Surface: ( -50.0, 40.0, 1.0 ) + +#End 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