diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index 22a9053f2a1..1b4e4d255c5 100644 --- a/code/def_files/data/effects/fxaa-v.sdr +++ b/code/def_files/data/effects/fxaa-v.sdr @@ -8,6 +8,7 @@ void main() { } #else in vec4 vertPosition; +in vec4 vertTexCoord; out vec2 v_rcpFrame; noperspective out vec2 v_pos; @@ -20,6 +21,8 @@ layout (std140) uniform genericData { void main() { gl_Position = vertPosition; v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h); - v_pos = vertPosition.xy*0.5 + 0.5; + // Use the real texcoord rather than deriving it from vertPosition: the draw call may ask for a + // sub-rectangle of the source texture, which the clip-space formula ignored. Matches post-v.sdr. + v_pos = vertTexCoord.xy; } #endif diff --git a/code/def_files/data/effects/lensflare-f.sdr b/code/def_files/data/effects/lensflare-f.sdr new file mode 100644 index 00000000000..d49ea017dd0 --- /dev/null +++ b/code/def_files/data/effects/lensflare-f.sdr @@ -0,0 +1,139 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Each ghost is the image of the iris (aperture texture) clipped by the image +// of the circular entrance pupil, evaluated per color channel for chromatic +// fringing. The starburst instance samples the precomputed FFT texture instead. + +struct lens_flare_instance { + vec4 center; + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +// Which artifact this quad draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + +#ifdef VULKAN +layout(location = 0) in vec2 sensorPos; +layout(location = 1) flat in vec4 g_center; +layout(location = 2) flat in vec4 g_halfext; +layout(location = 3) flat in vec4 g_apscale; +layout(location = 4) flat in vec4 g_apoff; +layout(location = 5) flat in vec4 g_color; +layout(location = 6) flat in float g_origin; + +layout(location = 0) out vec4 fragOut0; + +layout(set = 1, binding = 1) uniform sampler2D textures[16]; +#define apertureMap textures[0] +#define starburstMap textures[1] +#else +in vec2 sensorPos; +flat in vec4 g_center; +flat in vec4 g_halfext; +flat in vec4 g_apscale; +flat in vec4 g_apoff; +flat in vec4 g_color; +flat in float g_origin; + +out vec4 fragOut0; + +uniform sampler2D apertureMap; +uniform sampler2D starburstMap; +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; + vec2 ndc_scale; + vec4 tint; + int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +// Undo the anamorphic stretch the vertex shader applied to this quad, so +// everything below stays in the rotationally-symmetric frame the optics were +// solved in. Both shaders stretch about the instance's axial centre, which the +// vertex shader hands over in g_origin -- they have to agree on that origin, or +// off-axis ghosts shear instead of stretching. At squeeze == 1.0 this is exactly +// the identity, so a spherical lens renders bit-for-bit as it did before. +vec2 unsqueeze(vec2 p) +{ + vec2 d = p - axis * g_origin; + d.x /= squeeze; + return axis * g_origin + d; +} + +// The anamorphic streak, generated rather than sampled: it is a smooth 1D +// profile, so a texture would cost an upload and a sampler binding to store a +// curve that two lines of arithmetic describe exactly. +// +// `d` is the offset from the sun's image, `halfext` the half-length (x) and +// half-thickness (y) of the bar. The bar tapers toward the tips instead of +// keeping a constant width, because a streak of even thickness reads as a drawn +// line rather than a lens artifact; the taper is floored so the tip never +// narrows past the point where it would alias. +float streak_profile(vec2 d, vec2 halfext) +{ + float u = abs(d.x) / halfext.x; // 0 at the sun, 1 at the tip + if (u >= 1.0) { + return 0.0; + } + + float taper = 1.0 - u; + float v = d.y / (halfext.y * max(taper, 0.15)); + float across = exp(-v * v * 4.0); + // an even falloff down the length, plus a hot core so the streak visibly + // has a source rather than floating over the sun + float along = taper * taper + exp(-u * 12.0); + return across * along; +} + +float ghost_channel(vec2 sp, float center, float halfext, float apscale, float apoff, float intensity) +{ + vec2 q = (sp - axis * center) / halfext; + // image of the circular entrance pupil clips the bundle + float pupil = clamp((1.0 - length(q)) * 8.0, 0.0, 1.0); + vec2 uv = q * apscale + axis * apoff; + return texture(apertureMap, uv * 0.5 + 0.5).r * pupil * intensity; +} + +void main() +{ + vec3 result; + + if (quad_is(g_center.w, LENS_QUAD_STREAK)) { + // The streak is laid out in sensor space by the vertex shader, so unlike + // the ghosts it must not be un-squeezed on the way back. halfext.xy is a + // half-length and a half-thickness here, not a chromatic triple. + result = streak_profile(sensorPos - axis * g_center.x, g_halfext.xy) * g_color.rgb; + } else { + vec2 sp = unsqueeze(sensorPos); + if (quad_is(g_center.w, LENS_QUAD_STARBURST)) { + // starburst billboard centered on the sun + vec2 q = (sp - axis * g_center.x) / g_halfext.x; + result = texture(starburstMap, q * 0.5 + 0.5).rgb * g_color.rgb; + } else { + result.r = ghost_channel(sp, g_center.x, g_halfext.x, g_apscale.x, g_apoff.x, g_color.x); + result.g = ghost_channel(sp, g_center.y, g_halfext.y, g_apscale.y, g_apoff.y, g_color.y); + result.b = ghost_channel(sp, g_center.z, g_halfext.z, g_apscale.z, g_apoff.z, g_color.z); + } + } + + fragOut0 = vec4(result * tint.rgb, 1.0); +} diff --git a/code/def_files/data/effects/lensflare-v.sdr b/code/def_files/data/effects/lensflare-v.sdr new file mode 100644 index 00000000000..cd915c55162 --- /dev/null +++ b/code/def_files/data/effects/lensflare-v.sdr @@ -0,0 +1,123 @@ +// Physically-based lens flare (Lee & Eisemann 2013 matrix method). +// Instanced draw: one 4-vertex triangle-strip quad per ghost, plus one for the +// starburst billboard. All placement math was precomputed on the CPU into the +// per-instance data below; positions are in sensor-plane millimeters. + +struct lens_flare_instance { + vec4 center; // w = LENS_QUAD_*, the kind tag; xyz meaning depends on it + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +// Which artifact this slot draws. Mirrored from LENS_QUAD_* in +// graphics/util/uniform_structs.h, which also tabulates what each kind reads out +// of the fields above. +#define LENS_QUAD_GHOST 0.0 +#define LENS_QUAD_STARBURST 1.0 +#define LENS_QUAD_STREAK 2.0 + +// The tag travels as a float, so match it with a half-step tolerance rather than +// by equality. +bool quad_is(float tag, float kind) { return abs(tag - kind) < 0.5; } + +#ifdef VULKAN +layout(location = 0) out vec2 sensorPos; +layout(location = 1) flat out vec4 g_center; +layout(location = 2) flat out vec4 g_halfext; +layout(location = 3) flat out vec4 g_apscale; +layout(location = 4) flat out vec4 g_apoff; +layout(location = 5) flat out vec4 g_color; +layout(location = 6) flat out float g_origin; +#define INSTANCE_INDEX gl_InstanceIndex +#else +in vec4 vertPosition; +out vec2 sensorPos; +flat out vec4 g_center; +flat out vec4 g_halfext; +flat out vec4 g_apscale; +flat out vec4 g_apoff; +flat out vec4 g_color; +flat out float g_origin; +#define INSTANCE_INDEX gl_InstanceID +#endif + +#ifdef VULKAN +layout(std140, set = 2, binding = 0) +#else +layout(std140) +#endif +uniform genericData { + vec2 axis; // unit flare axis in sensor space + vec2 ndc_scale; // sensor mm -> NDC + vec4 tint; // sun color * visibility * lens intensity + int n_instances; + float squeeze; // anamorphic horizontal stretch, 1.0 = spherical + // keep the array size in sync with MAX_LENS_FLARE_INSTANCES (uniform_structs.h) + lens_flare_instance instances[64]; +}; + +void main() +{ +#ifdef VULKAN + vec2 corner = vec2(float(gl_VertexIndex & 1), float((gl_VertexIndex >> 1) & 1)) * 2.0 - 1.0; +#else + vec2 corner = vertPosition.xy; +#endif + + lens_flare_instance inst = instances[INSTANCE_INDEX]; + + vec2 p; + float origin; + + if (quad_is(inst.center.w, LENS_QUAD_STREAK)) { + // Anamorphic streak: a screen-horizontal bar centred on the sun's image, + // built straight in sensor space rather than in the axis/perp frame. The + // streak lies along the cylindrical element, not along the line to the + // frame centre, so it stays horizontal wherever the sun sits. It is also + // exempt from `squeeze` -- its length is an explicit control, and + // stretching it as well would count the anamorphic twice. + // The vertical bound is padded well past the half-thickness: the gaussian + // across the bar is still ~2% of peak at one half-thickness, so a quad + // cut exactly there would leave a hard horizontal line down the whole + // streak. At 3x the profile has decayed to exp(-36), i.e. nothing. This + // only pads the geometry -- halfext.y still means half-thickness, so + // +Thickness: keeps its meaning. + origin = inst.center.x; + p = axis * origin + vec2(corner.x * inst.halfext.x, corner.y * inst.halfext.y * 3.0); + } else { + // Ghosts and the starburst share this path: both are laid out per channel + // in the axis/perp frame, the starburst simply with all three channels + // equal. + // Bounds of the union of the three chromatic quads, along/around the axis + float cmin = min(inst.center.x - inst.halfext.x, min(inst.center.y - inst.halfext.y, inst.center.z - inst.halfext.z)); + float cmax = max(inst.center.x + inst.halfext.x, max(inst.center.y + inst.halfext.y, inst.center.z + inst.halfext.z)); + float caxis = 0.5 * (cmin + cmax); + float haxis = 0.5 * (cmax - cmin); + float hperp = max(inst.halfext.x, max(inst.halfext.y, inst.halfext.z)); + + // The anamorphic stretch is linear, so applying it to the corner offset + // takes the oriented rectangle to a parallelogram that still bounds the + // stretched footprint exactly -- no need to widen out to a screen-aligned + // box. The stretch is about the instance's axial centre, not the sensor + // origin, so ghosts stay put and only grow: on a desqueezed anamorphic + // frame the horizontal squeeze of capture cancels for positions but not + // for footprints. + vec2 perp = vec2(-axis.y, axis.x); + vec2 off = axis * (corner.x * haxis) + perp * (corner.y * hperp); + off.x *= squeeze; + origin = caxis; + p = axis * caxis + off; + } + + sensorPos = p; + g_origin = origin; + g_center = inst.center; + g_halfext = inst.halfext; + g_apscale = inst.apscale; + g_apoff = inst.apoff; + g_color = inst.color; + + gl_Position = vec4(p * ndc_scale, 0.0, 1.0); +} diff --git a/code/def_files/data/tables/lens_flares.tbl b/code/def_files/data/tables/lens_flares.tbl new file mode 100644 index 00000000000..cf7ed367745 --- /dev/null +++ b/code/def_files/data/tables/lens_flares.tbl @@ -0,0 +1,569 @@ +; Default physically-based lens systems for the "$Camera Lens:" option in a +; mission's info section (and the set-camera-lens sexp). +; +; A mission mounts ONE of these as its camera lens, and every sun in its +; background flares through it -- one camera, one lens, so the flares of two +; suns can never disagree about the glass they came through. Missions that name +; no lens fall back to "$Default Lens:" below, which the shipped table leaves +; unset, so retail content is unaffected until a mod opts in. +; +; Format: the prescription of each lens sits between $Lens Stack Start: and +; $Lens Stack End (note: no colon on the closing token). Inside it, each +; $Surface: is ( curvature radius mm, thickness to the next surface mm, +; refractive index of the glass BEHIND the surface, 1.0 = air ), listed +; front-to-back; $Stop: ( thickness mm ) marks the iris plane. See +; graphics/lens_flare.cpp for the full syntax and the precompute math. +; +; A xxx-lens.tbm can edit any lens here instead of restating it, by putting +; +override directly after the $Name: of an existing lens. Every option the +; override entry gives is applied; everything it leaves out keeps the value +; below. The one exception is the prescription: opening a $Lens Stack Start: +; replaces the whole stack, because a stack is an ordered run whose focal +; length, ghost set and iris position all follow from the run as a whole, so +; there is nothing a partial edit of it could mean. A xxx-lens.tbm entry with +; no +override is a complete definition, and replaces a lens of the same name +; outright. +; +; Where these numbers come from and how to derive your own: +; +; - The radius/thickness/index triples are "lens prescriptions" as published in +; lens patents and optical-design references. Any such prescription can be +; transcribed directly into $Surface: rows; scale all radii/thicknesses +; uniformly to change the focal length. The engine derives everything else +; (effective focal length, sensor distance, ghost enumeration, per-ghost +; matrices) from the surface stack at table load. +; - +Abbe: dispersion (V-number) values are not usually part of patent claims; +; the ones below are typical catalog values for optical glasses of the given +; index (crown glasses ~55-60, dense flints ~30-40). They only drive the +; chromatic fringing, so approximate values are fine. +; - $Entrance Pupil Radius: is focal length / (2 * f-number) of the design; +; $Aperture Radius: is the iris half-opening (smaller = darker, crisper +; ghosts). Both may be fudged for looks, but $Aperture Radius: is the setting +; that decides whether a ghost is lit at all, so derive it before fudging: +; each ghost slides across the iris as the sun moves off-axis, by an amount +; that grows with the field angle and shrinks with $Aperture Radius:, and a +; ghost that slides off the iris goes black. Too small a value therefore +; confines the whole flare to a sun near the frame center, and too large a one +; shrinks the ghosts within the iris until the blades stop shaping them. The +; physical value is the paraxial marginal-ray height where it crosses the stop +; (trace height = entrance pupil radius, angle = 0 through the surfaces ahead +; of the $Stop:); 1.25x that is what angenieux_100mm uses (marginal height +; 12.79mm, tabled 16.0) and what the lenses below are derived from. +; - $Coating Wavelength: is the quarter-wave anti-reflection tuning; 500-570nm +; (green-centered, like real broadband coatings) gives the classic +; magenta/cyan ghost tints. 0 disables coatings (uncoated vintage look: +; brighter, neutral-grey ghosts). +; - $Intensity:, blade counts/rotation/curvature and the starburst settings are +; artistic; the shipped values were calibrated visually in the F3 lab +; (Render options -> Lens flare options) against retail suns. +; - $Anamorphic Squeeze: stretches every ghost and the starburst horizontally, +; for the oval-ghost anamorphic look; 1.0 (the default, and what every lens +; below uses) is a spherical lens and costs nothing. 2.0 matches a classic 2x +; anamorphic; below 1.0 compresses rather than stretches, which is not what +; the option is for. A front anamorphot is an afocal cylindrical telescope, so to +; first order it only magnifies one meridian -- which is why this is one +; number rather than a second set of surfaces, and why the iris behind it is +; unaffected. Note that FSO composes the frame directly, with no desqueeze +; stage, so this stretches footprints only: ghosts stay on the sun where they +; always were, exactly as a desqueezed anamorphic frame shows them. +; - $Anamorphic Streak: is the other half of that look: the long horizontal +; flare a cylindrical element throws across the frame. It is a separate quad +; rather than a stretched starburst because it is a separate artifact -- the +; starburst is the iris seen end-on and swings around with the sun, while the +; streak lies along the element and stays horizontal wherever the sun is. It +; is also what actually reads as "anamorphic": squeezing the starburst alone +; only ever gives a wider sun, since a real streak runs 20-50x longer than it +; is thick. Off by default, like the aperture imperfection layers. +; +; $Anamorphic Streak: 0 turns it off. +; +Length: half-length as a fraction of the sensor width; 1.0 spans the +; frame. +; +Thickness: as a fraction of the length, so 0.02 is a 50:1 bar. +; +Tint: ( r, g, b ), multiplied onto the sun's own colour rather than +; replacing it, so a red sun keeps a reddish streak. The +; default leans blue, which is where the classic look comes +; from -- real streaks take their colour from the coating on +; the cylindrical element. + + +; +; The iris +; -------- +; +; A lens has exactly ONE aperture definition, and it drives both the ghosts and +; the starburst: every ghost is an image of this mask, and the starburst is its +; Fraunhofer transform. Changing a blade count therefore restyles both at once, +; and they can never disagree about what the iris looks like. +; +; $Aperture Blades: number of iris blades; fewer than 3 means a round iris. +; +Blade Rotation: degrees. +; +Blade Curvature: 0 leaves the blades straight, 1 bows their midpoints out +; to the corner radius (a circular iris), and negative +; values bow them inward into a star. +; +Edge Softness: iris edge feather, as a fraction of the iris radius. The +; default, 0.0039, is also the floor it is clamped to (a +; ~2px ramp, so the mask never aliases) and matches the edge +; the iris has always had; asking for less has no effect. +; Softening the edge visibly weakens the starburst spikes, +; because they come from that edge's sharpness. +; +; Fields are parsed in sequence, so they must appear in the order listed here +; (as everywhere else in an entry) -- a $Aperture Dust: placed after $Intensity: +; is not just ignored, it breaks the whole table. There is a worked example +; covering every field in test/test_data/graphics/lens_flare/. +; +; Three optional imperfection layers darken the mask on top of the shape. All +; are off (strength 0) by default, which is what every lens below uses -- turn +; them on for a dirty or damaged lens. Each takes its strength on the $-line and +; then optional +sub-options: +; +; $Aperture Grating: radial ridges around the iris rim, which +; throw extra spikes into the starburst. +; +Density: fraction of the 360 possible ridges. +; +Length: how far in the ridges reach, as a fraction of the iris. +; +Width: ridge width as a duty cycle of the spacing between +; ridges, so raising the density thins the ridges rather +; than merging them into a solid ring. +; +Softness: +; $Aperture Scratches: randomly placed slivers. +; +Density: fraction of the 1000 possible scratches. +; +Length: +Width: +; +Rotation: degrees. +; +Rotation Variation: 0 leaves every scratch parallel, 1 fully random. +; +Softness: +; $Aperture Dust: randomly placed specks. +; +Density: fraction of the 1000 possible specks. +; +Radius: +Softness: +; +; Be aware that the starburst is normalized against its own brightest value, so +; adding grating/scratches/dust dims the core spikes as it adds speckle around +; them, rather than only adding to what was there. +; +; The layers are ported from realflare's aperture kernels (see below), minus its +; texture-mask layer, and minus its split between a ghost aperture and a +; separate starburst aperture. +; +; The lenses below the first two were adapted from the lens models bundled with +; realflare (https://github.com/beatreichenbach/realflare, MIT), an offline +; renderer built on the same Hullin/Lee research; the prescriptions themselves +; are the published patent data cited per entry, transcribed one lens element +; per $Surface: row. Their $Intensity: values are not independent guesses: the +; total lit ghost energy (per-ghost Fresnel reflectance and footprint, times the +; fraction of the pupil image still inside the iris) was computed for each with +; the sun at 10%, 50% and 90% of the frame half-width, and kept inside the range +; the two hand-calibrated entries already span. Only color_heliar_105mm needed a +; non-default value, because uncoated glass reflects far more than a coated +; surface (~75x the total ghost energy, for that lens). +; +; Note that $Name: is the nominal focal length of the design, not the paraxial +; focal length the engine computes from the surfaces - the two differ for every +; shipped lens (angenieux_100mm solves to 63mm), and for the zooms the +; transcription freezes one configuration of a variable air gap. What the flare +; looks like follows the surfaces, not the name. + +#Lens Systems + +; The lens missions get when they don't name one themselves. Left unset here; +; a mod that wants its whole campaign shot on the same glass sets it in a +; *-lens.tbm, e.g. +; +; $Default Lens: tessar_50mm + +; Double-Gauss cine prime, f/2.2, f=100mm. Surface data follows the Angenieux +; double-Gauss prescription published with Hullin, Eisemann, Seidel, Lee, +; "Physically-Based Real-Time Lens Flare Rendering" (SIGGRAPH 2011) and reused +; by Lee & Eisemann 2013 (the paper this renderer implements); it traces back +; to P. Angenieux's 1950s double-Gauss patents. Abbe numbers estimated from +; glass catalogs as described above. Rich ghost set with strongly tinted +; coatings. +$Name: angenieux_100mm +$Entrance Pupil Radius: 22.0 +$Aperture Radius: 16.0 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 6 ++Blade Rotation: 15.0 ++Blade Curvature: 0.15 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 48 +$Lens Stack Start: +$Surface: ( 164.13, 10.99, 1.6751 ) ++Abbe: 47.0 +$Surface: ( 559.20, 0.23, 1.0 ) +$Surface: ( 100.12, 11.45, 1.6689 ) ++Abbe: 44.0 +$Surface: ( 213.54, 0.23, 1.0 ) +$Surface: ( 58.04, 22.95, 1.6913 ) ++Abbe: 42.0 +$Surface: ( 2551.10, 2.58, 1.6751 ) ++Abbe: 33.0 +$Surface: ( 32.39, 15.66, 1.0 ) +$Stop: ( 15.00 ) +$Surface: ( -40.42, 2.74, 1.6992 ) ++Abbe: 30.0 +$Surface: ( 192.98, 27.92, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -55.53, 0.23, 1.0 ) +$Surface: ( 192.98, 7.98, 1.6204 ) ++Abbe: 57.0 +$Surface: ( -92.62, 0.23, 1.0 ) +$Surface: ( 355.02, 8.86, 1.6751 ) ++Abbe: 47.0 +$Surface: ( -52.78, 60.0, 1.0 ) +$Lens Stack End + +; Classic Tessar, f/3.5, f=50mm. Surface data transcribed from the textbook +; Zeiss Tessar prescription (Paul Rudolph's 1902 design, as reproduced in +; optical-design literature), uniformly scaled to a 50mm focal length; Abbe +; numbers are catalog values for the usual Tessar crown/flint glass pairing. +; Fewer elements, so a sparser and subtler ghost set. +$Name: tessar_50mm +$Entrance Pupil Radius: 7.0 +$Aperture Radius: 5.0 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 8 ++Blade Rotation: 0.0 ++Blade Curvature: 0.3 +$Starburst: YES ++Starburst Scale: 0.8 +$Intensity: 0.2 +$Max Ghosts: 24 +$Lens Stack Start: +$Surface: ( 16.25, 2.90, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -285.90, 0.29, 1.0 ) +$Surface: ( -30.05, 1.20, 1.6053 ) ++Abbe: 43.6 +$Surface: ( 17.47, 1.38, 1.0 ) +$Stop: ( 1.15 ) +$Surface: ( 31.55, 1.20, 1.5123 ) ++Abbe: 51.0 +$Surface: ( 21.30, 3.50, 1.6116 ) ++Abbe: 56.9 +$Surface: ( -23.70, 40.0, 1.0 ) +$Lens Stack End + +; Modern multicoated telephoto zoom, f/2.8, nominally 70-200mm; the transcribed +; configuration solves to f=72mm. Prescription is the sixth embodiment of Canon +; patent US5537259 (1995), the design behind the EF 70-200mm f/2.8L USM. 33 +; refractive surfaces enumerate ~460 usable ghost pairs, so this is the busiest +; shipped lens: a dense, tightly clustered, strongly coated ghost train. +$Name: canon_70_200mm +$Entrance Pupil Radius: 12.32 +$Aperture Radius: 21.33 +$Sensor Width: 36.0 +$Coating Wavelength: 540 +$Aperture Blades: 8 ++Blade Rotation: 22.5 ++Blade Curvature: 0.35 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 56 +$Lens Stack Start: +$Surface: ( 355.855, 2.8, 1.75 ) ++Abbe: 35.0 +$Surface: ( 121.211, 0.42, 1.0 ) +$Surface: ( 131.256, 8.62, 1.497 ) ++Abbe: 81.6 +$Surface: ( -259.209, 0.1, 1.0 ) +$Surface: ( 80.584, 6.01, 1.497 ) ++Abbe: 81.6 +$Surface: ( 234.8, 8.69, 1.0 ) +$Surface: ( 51.45, 2.2, 1.847 ) ++Abbe: 23.8 +$Surface: ( 43.769, 1.28, 1.0 ) +$Surface: ( 49.946, 8.87, 1.487 ) ++Abbe: 70.2 +$Surface: ( 12148.909, 1.57, 1.0 ) +$Surface: ( -600.368, 1.4, 1.804 ) ++Abbe: 46.6 +$Surface: ( 34.801, 5.98, 1.0 ) +$Surface: ( -75.966, 1.4, 1.487 ) ++Abbe: 70.2 +$Surface: ( 37.777, 4.97, 1.847 ) ++Abbe: 23.9 +$Surface: ( 413.301, 2.64, 1.0 ) +$Surface: ( -66.4, 1.4, 1.729 ) ++Abbe: 54.7 +$Surface: ( 3021.469, 30.32, 1.0 ) +$Surface: ( 230.258, 3.51, 1.698 ) ++Abbe: 55.5 +$Surface: ( -98.917, 0.15, 1.0 ) +$Surface: ( -172.378, 4.66, 1.497 ) ++Abbe: 81.6 +$Surface: ( -40.226, 1.45, 1.834 ) ++Abbe: 37.2 +$Surface: ( -76.185, 13.86, 1.0 ) +$Surface: ( 57.653, 3.73, 1.804 ) ++Abbe: 46.6 +$Surface: ( 128.671, 3.05, 1.0 ) +$Stop: ( 0.34 ) +$Surface: ( 33.882, 6.26, 1.497 ) ++Abbe: 81.6 +$Surface: ( 1455.342, 3.99, 1.62 ) ++Abbe: 36.3 +$Surface: ( 31.129, 26.85, 1.0 ) +$Surface: ( 117.922, 5.91, 1.517 ) ++Abbe: 52.4 +$Surface: ( -81.244, 14.02, 1.0 ) +$Surface: ( -38.692, 1.8, 1.834 ) ++Abbe: 37.2 +$Surface: ( -102.301, 0.15, 1.0 ) +$Surface: ( 183.092, 3.91, 1.743 ) ++Abbe: 49.3 +$Surface: ( -129.948, 10.0, 1.0 ) +$Lens Stack End + +; Symmetric double Gauss, f/3.8, f=100mm, from Kodak patent US2823583 (1958). +; Single-coated era: $Coating Wavelength: 550 models one MgF2 quarter-wave layer, +; which is exactly what lenses of this vintage carried. Few elements and a +; symmetric layout give a sparse, orderly ghost set strung along the sun axis, and +; the long focal length keeps it stable as the sun moves off-axis. +$Name: kodak_100mm +$Entrance Pupil Radius: 13.16 +$Aperture Radius: 8.88 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 10 ++Blade Rotation: 0.0 ++Blade Curvature: 0.50 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 28 +$Lens Stack Start: +$Surface: ( 36.02, 3.1, 1.517 ) ++Abbe: 64.5 +$Surface: ( 418.3, 0.7, 1.0 ) +$Surface: ( 24.59, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -45.33, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( -44.52, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 13.42, 6.9, 1.0 ) +$Stop: ( 6.9 ) +$Surface: ( -13.42, 4.3, 1.617 ) ++Abbe: 36.6 +$Surface: ( 44.52, 3.5, 1.523 ) ++Abbe: 58.6 +$Surface: ( 45.33, 7.4, 1.611 ) ++Abbe: 58.8 +$Surface: ( -24.59, 0.7, 1.0 ) +$Surface: ( -74.42, 3.1, 1.72 ) ++Abbe: 29.3 +$Surface: ( -32.2, 50.0, 1.0 ) +$Lens Stack End + +; Fast aspherical wide-angle prime, f/1.4, f=35mm, from Leica patent US5161060 +; (1992), fig. 1 - the Summilux-M 35mm f/1.4 ASPH design. The shortest focal +; length shipped (~54 degrees across the frame), so its ghosts sweep the furthest +; as the sun moves off-axis; the fast aperture keeps them large and soft. +$Name: leica_35mm +$Entrance Pupil Radius: 12.50 +$Aperture Radius: 12.05 +$Sensor Width: 36.0 +$Coating Wavelength: 530 +$Aperture Blades: 9 ++Blade Rotation: 10.0 ++Blade Curvature: 0.40 +$Starburst: YES ++Starburst Scale: 1.0 +$Intensity: 0.2 +$Max Ghosts: 40 +$Lens Stack Start: +$Surface: ( -110.114, 2.01, 1.503 ) ++Abbe: 56.1 +$Surface: ( 24.92, 7.4, 1.82 ) ++Abbe: 45.1 +$Surface: ( -305.0, 0.1, 1.0 ) +$Surface: ( 28.346, 6.07, 1.82 ) ++Abbe: 45.1 +$Surface: ( -57.56, 1.61, 1.694 ) ++Abbe: 31.0 +$Surface: ( 16.624, 4.34, 1.0 ) +$Stop: ( 1.66 ) +$Surface: ( -197.204, 6.07, 1.792 ) ++Abbe: 47.2 +$Surface: ( -38.628, 1.5, 1.0 ) +$Surface: ( -21.142, 1.72, 1.652 ) ++Abbe: 33.6 +$Surface: ( 101.985, 5.86, 1.82 ) ++Abbe: 45.1 +$Surface: ( -21.905, 0.11, 1.0 ) +$Surface: ( 60.026, 5.94, 1.82 ) ++Abbe: 45.1 +$Surface: ( -31.325, 2.05, 1.624 ) ++Abbe: 36.1 +$Surface: ( 31.325, 19.595, 1.0 ) +$Lens Stack End + +; Multicoated telephoto zoom, f/3.5, nominally 50-135mm, from Nikon patent +; US4497547A (1981) - the AI Zoom-Nikkor 50-135mm f/3.5. The two variable zoom +; spacings are frozen at the transcribed values, which paraxially solve to f=36mm +; rather than any point in the marked 50-135mm range; the engine derives the focal +; length from the surfaces, so the flare matches the transcription, not the label +; (the shipped angenieux_100mm and tessar_50mm are named the same way). +$Name: nikon_50_135mm +$Entrance Pupil Radius: 12.86 +$Aperture Radius: 27.37 +$Sensor Width: 36.0 +$Coating Wavelength: 520 +$Aperture Blades: 7 ++Blade Rotation: 0.0 ++Blade Curvature: 0.25 +$Starburst: YES ++Starburst Scale: 0.9 +$Intensity: 0.2 +$Max Ghosts: 48 +$Lens Stack Start: +$Surface: ( 95.858, 1.7, 1.805 ) ++Abbe: 25.4 +$Surface: ( 49.02, 8.0, 1.678 ) ++Abbe: 55.6 +$Surface: ( 214.552, 0.1, 1.0 ) +$Surface: ( 75.769, 5.0, 1.667 ) ++Abbe: 48.4 +$Surface: ( 691.304, 2.959, 1.0 ) +$Surface: ( -708.168, 1.25, 1.697 ) ++Abbe: 55.6 +$Surface: ( 22.809, 5.0, 1.0 ) +$Surface: ( -175.109, 1.15, 1.788 ) ++Abbe: 47.5 +$Surface: ( 87.266, 0.5, 1.0 ) +$Surface: ( 35.758, 3.1, 1.805 ) ++Abbe: 25.4 +$Surface: ( 165.776, 27.727, 1.0 ) +$Surface: ( -51.423, 1.15, 1.67 ) ++Abbe: 57.6 +$Surface: ( 81.327, 2.95, 1.672 ) ++Abbe: 38.9 +$Surface: ( -169.527, 8.846, 1.0 ) +$Stop: ( 1.0 ) +$Surface: ( 174.041, 3.25, 1.713 ) ++Abbe: 54.0 +$Surface: ( -63.18, 0.1, 1.0 ) +$Surface: ( 50.356, 5.0, 1.564 ) ++Abbe: 60.8 +$Surface: ( -70.071, 1.1, 1.796 ) ++Abbe: 41.0 +$Surface: ( 229.755, 0.1, 1.0 ) +$Surface: ( 25.187, 5.6, 1.518 ) ++Abbe: 59.0 +$Surface: ( -745.542, 1.0, 1.0 ) +$Surface: ( 262.417, 2.0, 1.795 ) ++Abbe: 28.6 +$Surface: ( 37.552, 10.15, 1.0 ) +$Surface: ( 111.689, 3.0, 1.517 ) ++Abbe: 64.1 +$Surface: ( -97.52, 20.85, 1.0 ) +$Surface: ( -18.386, 2.0, 1.67 ) ++Abbe: 47.1 +$Surface: ( -31.592, 0.1, 1.0 ) +$Surface: ( 941.473, 4.55, 1.702 ) ++Abbe: 41.0 +$Surface: ( -72.586, 14.0, 1.0 ) +$Lens Stack End + +; Uncoated vintage prime, f/3.5, f=105mm, from A. W. Tronnier's patent US2645156 +; (1950) for the Voigtlander Color-Heliar. This is the table's uncoated reference: +; $Coating Wavelength: 0 gives bare-glass Fresnel reflections, ~75x stronger in +; total than the same prescription coated, and neutral grey rather than +; magenta/cyan. $Intensity: is scaled down by that factor so the lens lands at the +; bright end of the shipped calibration band instead of blowing out - the +; character (few, large, colourless, obvious ghosts, barely fading off-axis) is +; what the uncoated model buys, not raw brightness. The flat sixth surface is in +; the source prescription. +$Name: color_heliar_105mm +$Entrance Pupil Radius: 15.00 +$Aperture Radius: 15.08 +$Sensor Width: 36.0 +$Coating Wavelength: 0 +$Aperture Blades: 12 ++Blade Rotation: 0.0 ++Blade Curvature: 0.60 +$Starburst: YES ++Starburst Scale: 0.7 +$Intensity: 0.015 +$Max Ghosts: 16 +$Lens Stack Start: +$Surface: ( 30.809, 7.702, 1.651 ) ++Abbe: 58.6 +$Surface: ( -89.35, 1.855, 1.603 ) ++Abbe: 38.4 +$Surface: ( 580.0, 3.521, 1.0 ) +$Surface: ( -80.063, 1.849, 1.643 ) ++Abbe: 47.9 +$Surface: ( 28.34, 4.625, 1.0 ) +$Stop: ( 2.554 ) +$Surface: ( 0.0, 1.849, 1.582 ) ++Abbe: 40.6 +$Surface: ( 32.19, 7.271, 1.693 ) ++Abbe: 53.5 +$Surface: ( -52.99, 92.03, 1.0 ) +$Lens Stack End + +; Modern multicoated cine prime, T1.3, nominally 50mm (solves to f=65mm), from +; Zeiss patent US7446944B2 (2008) - the Master Prime 50mm. Large entrance pupil +; and 24 refractive surfaces: many ghosts, large and bright, with the heavy +; broadband coating pushing them well into magenta/cyan. The closest match to a +; contemporary cinema look. +$Name: zeiss_master_prime_50mm +$Entrance Pupil Radius: 19.23 +$Aperture Radius: 15.23 +$Sensor Width: 36.0 +$Coating Wavelength: 550 +$Aperture Blades: 9 ++Blade Rotation: 20.0 ++Blade Curvature: 0.45 +$Starburst: YES ++Starburst Scale: 1.1 +$Intensity: 0.2 +$Max Ghosts: 56 +$Lens Stack Start: +$Surface: ( 554.31, 4.31, 1.699 ) ++Abbe: 30.13 +$Surface: ( 82.937, 7.67, 1.0 ) +$Surface: ( 2539.9, 8.05, 1.805 ) ++Abbe: 25.42 +$Surface: ( -185.67, 4.67, 1.816 ) ++Abbe: 46.62 +$Surface: ( -188.36, 7.281, 1.0 ) +$Surface: ( 52.33, 16.11, 1.618 ) ++Abbe: 63.33 +$Surface: ( 12548.0, 0.11, 1.0 ) +$Surface: ( 70.795, 4.2, 1.717 ) ++Abbe: 29.62 +$Surface: ( 55.033, 2.534, 1.0 ) +$Surface: ( 42.474, 4.27, 1.805 ) ++Abbe: 25.42 +$Surface: ( 35.481, 7.82, 1.816 ) ++Abbe: 46.62 +$Surface: ( 46.639, 4.79, 1.0 ) +$Surface: ( 183.02, 4.2, 1.558 ) ++Abbe: 54.01 +$Surface: ( 25.119, 9.8, 1.0 ) +$Stop: ( 9.71 ) +$Surface: ( -23.041, 4.2, 1.654 ) ++Abbe: 39.63 +$Surface: ( 39.525, 16.23, 1.618 ) ++Abbe: 63.33 +$Surface: ( -44.668, 0.35, 1.0 ) +$Surface: ( 66.473, 10.02, 1.603 ) ++Abbe: 65.44 +$Surface: ( -240.57, 0.21, 1.0 ) +$Surface: ( 466.39, 7.51, 1.603 ) ++Abbe: 65.44 +$Surface: ( -88.453, 0.1, 1.0 ) +$Surface: ( 91.728, 4.2, 1.816 ) ++Abbe: 46.62 +$Surface: ( 27.982, 16.46, 1.618 ) ++Abbe: 63.33 +$Surface: ( -128.64, 39.014, 1.0 ) +$Lens Stack End + +#End diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 2b0e6a556d2..1056434a463 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -856,6 +856,29 @@ bool gr_is_smaa_mode(AntiAliasMode mode) { return mode == AntiAliasMode::SMAA_Low || mode == AntiAliasMode::SMAA_Medium || mode == AntiAliasMode::SMAA_High || mode == AntiAliasMode::SMAA_Ultra; } +SCP_vector gr_get_supported_anisotropy_levels() +{ + float max; + if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { + return {}; + } + + if (max <= 2.0f) { + return {}; + } + + SCP_vector out; + + // We assume here that the anisotropy levels are powers of two... + float current = 1.0f; + while (current <= max) { + out.push_back(current); + current *= 2.0f; + } + + return out; +} + static void parse_post_processing_func() { bool value; @@ -1644,6 +1667,13 @@ void gr_screen_resize(int width, int height) gr_screen.save_max_h_unscaled_zoomed = gr_screen.max_h_unscaled_zoomed; gr_setup_viewport(); + + // The offscreen targets that back the scene/post-processing pipeline were sized for the old + // gr_screen; give the backend a chance to grow them before anything renders at the new size. + // Backends that already handle this elsewhere (Vulkan, via recreateSwapChain()) leave it unset. + if (gr_screen.gf_resize_render_targets) { + gr_screen.gf_resize_render_targets(); + } } void gr_window_to_render_pos(float& x, float& y) diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 0f66ba3539b..1460ebd3dbb 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -232,6 +232,8 @@ enum shader_type { SDR_TYPE_GAMMA_BLIT, + SDR_TYPE_LENS_FLARE, + NUM_SHADER_TYPES }; @@ -861,6 +863,12 @@ typedef struct screen { std::function gf_scene_texture_end; std::function gf_copy_effect_texture; + // Grow the offscreen render targets that back the scene/post-processing pipeline to cover the + // current gr_screen, if they don't already. Called from gr_screen_resize(). Optional: the + // Vulkan backend leaves this unset because VulkanRenderer::recreateSwapChain() already owns + // resizing its extent-sized targets. + std::function gf_resize_render_targets; + std::function gf_zbias; std::function gf_set_fill_mode; @@ -1403,6 +1411,12 @@ inline bool gr_get_property(gr_property property, void* destination) return gr_screen.gf_get_property(property, destination); } +// Anisotropic filtering levels the current hardware supports: 1.0 (off), then powers of two up to +// the reported maximum. Empty if anisotropy is unavailable or the hardware caps out below 4x, in +// which case there is nothing meaningful to offer. Backs both the in-game option's enumerator and +// qtFRED's Preferences combo, so the two can't drift. +SCP_vector gr_get_supported_anisotropy_levels(); + inline void gr_push_debug_group(const char* name) { gr_screen.gf_push_debug_group(name); diff --git a/code/graphics/lens_flare.cpp b/code/graphics/lens_flare.cpp new file mode 100644 index 00000000000..b1c55e6b37b --- /dev/null +++ b/code/graphics/lens_flare.cpp @@ -0,0 +1,974 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "ai/ai.h" +#include "graphics/2d.h" +#include "graphics/openxr.h" +#include "graphics/util/uniform_structs.h" +#include "io/timer.h" +#include "lighting/lighting.h" +#include "mission/missionparse.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/shipfx.h" +#include "starfield/starfield.h" + +#include +#include + +extern int Game_subspace_effect; + +namespace graphics { +namespace { + +// How the flare is fitted to HDR output, and whether nozzles draw ghosts. +// Runtime-tunable from the lab; the field defaults are in lens_flare.h. The +// brightness calibration that used to live here is per-camera and therefore +// overridable, so it moved into lens_overrides. +lens_flare_tuning Tuning; + +// What a mission, the lab, the set-lens-* sexps or an editor has restyled about +// the camera. One set, because there is one camera. +lens_overrides Overrides; + +// SDR/HDR consistency (see lens_flare_output_scale()). The flare composites +// additively into the pre-tonemap HDR scene buffer, so the tonemapper is the +// only thing that differs between the two output paths. In SDR a compressive +// curve (default = Uncharted2) squashes the flare's large linear values toward +// display white; in HDR the forced pass-through HdrScene tonemapper preserves +// them and the encode pass scales by paper-white nits, so a flare calibrated in +// SDR blows out. We rescale the HDR contribution by HEADROOM / reference-white +// so the flare's fraction of SDR display-white maps to the same fraction of HDR +// paper-white, with a little headroom left so it still reads as a highlight. +// +// Reference white is the linear input the default SDR tonemapper (Uncharted2, +// the reset default in lighting_profiles.cpp) maps to display white -- its +// W constant. HDR forces its own tonemapper, so this is a fixed calibration +// reference, not the live SDR curve. +constexpr float LENS_FLARE_SDR_REFERENCE_WHITE = 11.2f; + +// Cap on the (entrance pupil / ghost size)^2 energy concentration so nearly +// focused ghosts can't blow out to infinity +constexpr float GHOST_ENERGY_CAP = 400.0f; + +// The ceiling lens_flare.h publishes has to be the one the uniform block can +// actually hold, less the starburst and streak slots pack_source_instances() +// reserves. +static_assert(MAX_LENS_FLARE_GHOSTS == generic_data::MAX_LENS_FLARE_INSTANCES - 2, + "MAX_LENS_FLARE_GHOSTS no longer matches the uniform block's instance budget"); + +SCP_vector Lens_systems; + +// The iris/starburst textures currently generated, and the exact (lens, iris) +// pair they were generated from. One cache, because there is one camera: no +// other pair is ever drawn with, so keying on the pair rather than owning a set +// per lens is both smaller and impossible to get out of step. +// +// Keying on the aperture *value* is also what makes a no-op edit free: a slider +// dragged out and back lands on an equal aperture, matches here, and neither +// rebuilds nor bumps the generation the backends watch. +struct texture_cache { + int lens_idx = -1; + lens_aperture aperture; + std::unique_ptr textures; +}; +texture_cache Tex_cache; + +// Bumped whenever those textures are rebuilt, so the render backends can tell a +// re-generated iris from the one they already uploaded +unsigned int Texture_generation = 1; + +// Live iris editing: coalesce a slider drag into one regeneration every +// APERTURE_REGEN_INTERVAL ms +constexpr int APERTURE_REGEN_INTERVAL = 100; +bool Aperture_dirty = false; +UI_TIMESTAMP Aperture_dirty_stamp; + +// Per-sun occlusion visibility, smoothed over frames. Touched only by +// sun_visibility() below. +SCP_vector Sun_visibility; +UI_TIMESTAMP Sun_visibility_stamp; + +// The camera lens: what the table declares as the default, what the mission +// mounted (its "$Camera Lens:", possibly changed by set-camera-lens), and the +// lab's live override of that. -1 means no lens, i.e. no flares. +// +// "$Default Lens:" is kept as a name until every table has been read, since a +// *-lens.tbm may name the default before (or without) defining it. +SCP_string Default_lens_name; +int Default_lens = -1; +int Mission_lens = -1; +std::optional Lab_lens; + +// Per-frame flare data of every drawing sun. Kept here (rather than handed out +// by value) because one lens_flare_data is several kilobytes; the draws only +// carry pointers into this, valid until the next build. +SCP_vector Frame_data; +SCP_vector Frame_draws; + +// Indexed by sun: did this frame's draw for that sun include a starburst quad? +// Published by lens_flare_frame_update() alongside Frame_draws and read back by +// lens_flare_sun_starburst_drawn(), so the sun renderer and the flare pass can +// never disagree about which suns the starburst has taken over. +SCP_vector Sun_starburst_drawn; + +// Lens the "flares are running through X" breadcrumb last reported. Logged from +// the frame build rather than from lens_flare_switch_to(), because mission-info +// scans (FRED opening a file dialog) mount lenses they never render with. +int Logged_lens = -2; + +// True while the global conditions allow the flare pass to render at all +// (independent of any particular sun) +bool pass_globally_possible() +{ + if (gr_screen.mode == GraphicsAPI::Stub || Lens_systems.empty()) { + return false; + } + // The flare composites into the HDR scene buffer from the post-processing + // chain, so it can only draw when this scene render actually goes through + // that chain. Both backends raise this in their scene_texture_begin() + // precisely when post-processing is on and drop it again at + // scene_texture_end(), which makes it the one authoritative signal -- rather + // than a second copy of the backends' own conditions. It is also what keeps + // plain FRED, and qtFred unless its View menu's "Enable Post Processing" + // toggle is on, from having their sun sprites step aside for a pass that + // will never run -- neither draws the background through a scene texture + // otherwise. + if (!High_dynamic_range) { + return false; + } + if (Game_subspace_effect) { + return false; + } + if (openxr_enabled()) { + // A per-eye camera-lens artifact is wrong in VR + return false; + } + // same conditions under which the sun sprites themselves are drawn + if (The_mission.flags[Mission::Mission_Flags::Fullneb] || !Detail.planets_suns) { + return false; + } + return true; +} + +// How visible a sun is to the flare, in 0..1: the eye-in-shadow occlusion test +// smoothed over a few frames so shadow transitions fade instead of popping, +// times the off-axis falloff that takes the whole effect out as the sun leaves +// the frame. `dot` is the sun direction against the view axis, `dt` the frame +// time, and `snap` skips the smoothing when the pass hasn't run for a while. +float sun_visibility(int sun_n, int light_idx, float dot, float dt, bool snap) +{ + if (static_cast(Sun_visibility.size()) <= sun_n) { + Sun_visibility.resize(sun_n + 1, 0.0f); + } + + bool occluded = (dot <= 0.0f) || + (light_idx >= 0 && shipfx_eye_in_shadow(&Eye_position, Viewer_obj, light_idx)); + + float target = occluded ? 0.0f : 1.0f; + float& vis = Sun_visibility[sun_n]; + if (snap) { + vis = target; + } else { + vis += (target - vis) * MIN(dt * 8.0f, 1.0f); + } + + float axis_fade = std::clamp((dot - 0.2f) / 0.3f, 0.0f, 1.0f); + return vis * axis_fade; +} + +// The camera's film gate for this frame: the sensor half-extents the lens +// declares, and the screen the projection lands on. One gate for every sun, +// because the gate belongs to the camera. +struct film_gate { + float clip_w = 0.0f, clip_h = 0.0f; // pixels + float half_w = 0.0f, half_h = 0.0f; // mm +}; + +// Where a light source's image lands on the film. +struct film_image { + float dist_mm = 0.0f; // distance from the sensor centre + float theta = 0.0f; // matching paraxial field angle + float axis_x = 1.0f, axis_y = 0.0f; // unit direction the flare is strung along +}; + +// Project a flare source onto the film gate, each kind the same way the thing it +// stands for is drawn: a sun through the faraway path stars_draw_sun() uses for +// its sprite, an engine as the ordinary finite point its glow is drawn at. False +// when the source isn't imaged onto the sensor at all. +// +// The field angle below is derived from where the image lands, which treats the +// source as collimated -- true for a sun, an approximation for an engine a few +// hundred metres away. Getting it exactly right would mean re-tracing the ghost +// matrices per source and per frame for an object whose flare is a few pixels +// wide, so the ghosts of a near source are placed a little as if it were far. +bool project_source(const flare_source& src, const film_gate& gate, float efl, film_image* out) +{ + vertex vex; + memset(&vex, 0, sizeof(vertex)); + if (src.at_infinity) { + g3_rotate_faraway_vertex(&vex, &src.pos); + } else { + g3_rotate_vertex(&vex, &src.pos); + } + + if (vex.codes & CC_BEHIND) { + return false; + } + if (!(vex.flags & PF_PROJECTED)) { + g3_project_vertex(&vex); + } + if (vex.flags & PF_OVERFLOW) { + return false; + } + + float sx = vex.screen.xyw.x / gate.clip_w * 2.0f - 1.0f; + float sy = 1.0f - vex.screen.xyw.y / gate.clip_h * 2.0f; + + float smx = sx * gate.half_w; + float smy = sy * gate.half_h; + + out->dist_mm = sqrtf(smx * smx + smy * smy); + out->theta = out->dist_mm / efl; + out->axis_x = 1.0f; + out->axis_y = 0.0f; + if (out->dist_mm > 1e-4f) { + out->axis_x = smx / out->dist_mm; + out->axis_y = smy / out->dist_mm; + } + return true; +} + +} // namespace + +lens_overrides& lens_flare_overrides() { return Overrides; } + +lens_settings lens_flare_effective_settings(int lens_idx) +{ + lens_settings s; + if (const lens_system* lens = lens_flare_get_system(lens_idx)) { + s.aperture = lens->aperture; + s.anamorphic = lens->anamorphic; + s.intensity = lens->intensity; + s.starburst = lens->starburst; + s.starburst_scale = lens->starburst_scale; + s.max_ghosts = lens->max_ghosts; + } + + // Whatever the camera has been restyled with wins over what the glass tables. + // The two brightness figures have no per-lens baseline to fall back to -- they + // calibrate the energy model itself -- so lens_settings' own defaults stand in. + if (Overrides.aperture) + s.aperture = *Overrides.aperture; + if (Overrides.anamorphic) + s.anamorphic = *Overrides.anamorphic; + if (Overrides.intensity) + s.intensity = *Overrides.intensity; + if (Overrides.starburst) + s.starburst = *Overrides.starburst; + if (Overrides.starburst_scale) + s.starburst_scale = *Overrides.starburst_scale; + if (Overrides.max_ghosts) + s.max_ghosts = *Overrides.max_ghosts; + if (Overrides.ghost_brightness) + s.ghost_brightness = *Overrides.ghost_brightness; + if (Overrides.starburst_brightness) + s.starburst_brightness = *Overrides.starburst_brightness; + + return s; +} + +namespace { + +// The iris the camera is actually looking through, which is what the textures +// have to be generated from. +const lens_aperture& effective_aperture(int lens_idx) +{ + if (Overrides.aperture) { + return *Overrides.aperture; + } + static const lens_aperture Fallback; + const lens_system* lens = lens_flare_get_system(lens_idx); + return (lens != nullptr) ? lens->aperture : Fallback; +} + +// Drop the cache so the next lens_flare_get_textures() rebuilds it, and tell the +// render backends their uploaded copy is stale. +void drop_texture_cache() +{ + Tex_cache.textures.reset(); + Tex_cache.lens_idx = -1; + Texture_generation++; +} + +// Act on a scheduled iris edit, at most once per APERTURE_REGEN_INTERVAL. +// Regenerating means a 512^2 mask plus its starburst FFT (a good fraction of a +// second in a debug build), while sliders fire every frame a drag is held, so a +// drag has to be coalesced into a few rebuilds rather than sixty. +// +// This is the *only* place a changed iris invalidates the cache. The lazy +// generate in lens_flare_get_textures() deliberately does not, or a drag would +// pull the FFT into the render path once a frame -- it fills an empty cache, +// never replaces a merely outdated one. +void flush_pending_aperture_edit() +{ + if (!Aperture_dirty) { + return; + } + if (Aperture_dirty_stamp.isValid() && !ui_timestamp_elapsed(Aperture_dirty_stamp)) { + return; + } + Aperture_dirty = false; + Aperture_dirty_stamp = ui_timestamp(APERTURE_REGEN_INTERVAL); + + // Only a genuinely different iris is worth the rebuild. An edit that landed + // back where it started, or one that touched a field the textures don't + // depend on, stops here. + if (Tex_cache.textures != nullptr && Tex_cache.aperture == effective_aperture(Tex_cache.lens_idx)) { + return; + } + drop_texture_cache(); +} + +} // namespace + +void lens_flare_overrides_changed() +{ + Aperture_dirty = true; + + // Act straight away if the interval has already elapsed; if it hasn't, this is + // a no-op and the per-frame flush picks the edit up when it does. (The interval + // check lives in flush_pending_aperture_edit() alone -- repeating it here would + // just be the same condition written twice.) + flush_pending_aperture_edit(); +} + +void lens_flare_init() +{ + lens_flare_close(); + + lens_flare_parse_tables(Lens_systems, Default_lens_name); + + // Resolved once every table has been read, so the default may be named + // before it is defined + if (!Default_lens_name.empty()) { + Default_lens = lens_flare_lookup(Default_lens_name.c_str()); + if (Default_lens < 0) { + Warning(LOCATION, "$Default Lens: names '%s', which no lens table defines.", Default_lens_name.c_str()); + } + } + Mission_lens = Default_lens; + + mprintf(("Lens flares: %d lens system(s) loaded, default lens '%s'\n", static_cast(Lens_systems.size()), + lens_flare_default_name())); +} + +void lens_flare_close() +{ + Lens_systems.clear(); + Sun_visibility.clear(); + Default_lens_name.clear(); + Default_lens = -1; + Mission_lens = -1; + Overrides.clear(); + drop_texture_cache(); + lens_flare_clear_lab_lens(); + lens_flare_lab_thruster_flare().reset(); + Frame_data.clear(); + Frame_draws.clear(); + Sun_starburst_drawn.clear(); + Logged_lens = -2; + + // As in lens_flare_reset_for_level(), and for the same reason: a scheduled + // rebuild belongs to the table being torn down, and the throttle stamp has + // to go with it, since a deadline that outlives the clock it was taken + // against sits in that clock's future and swallows every edit until it + // passes. + Aperture_dirty = false; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); +} + +int lens_flare_lookup(const char* name) +{ + for (int i = 0; i < static_cast(Lens_systems.size()); i++) { + if (!stricmp(Lens_systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +int lens_flare_num_systems() +{ + return static_cast(Lens_systems.size()); +} + +const lens_system* lens_flare_get_system(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + return &Lens_systems[lens_idx]; +} + +lens_flare_tuning& lens_flare_get_tuning() { return Tuning; } + +// Extra multiplier applied to the whole flare so its brightness reads +// consistently in SDR and HDR output without per-lens re-tuning. SDR is the +// reference (calibration was done there), so it is left at 1.0; HDR is rescaled +// down to sit near paper white. See LENS_FLARE_SDR_REFERENCE_WHITE. +static float lens_flare_output_scale() +{ + if (Gr_hdr_output_active) { + return MAX(Tuning.hdr_headroom, 0.0f) / LENS_FLARE_SDR_REFERENCE_WHITE; + } + return 1.0f; +} + +const char* lens_flare_default_name() +{ + return SCP_vector_inbounds(Lens_systems, Default_lens) ? Lens_systems[Default_lens].name.c_str() : ""; +} + +void lens_flare_switch_to(const char* lens_name) +{ + // No opinion (a mission with no "$Camera Lens:" at all), or the default asked + // for by name -- see the vocabulary in lens_flare.h + if (lens_name == nullptr || *lens_name == '\0' || !stricmp(lens_name, LENS_NAME_DEFAULT)) { + Mission_lens = Default_lens; + return; + } + + // The one way to say "no flares even though a default exists" + if (!stricmp(lens_name, LENS_NAME_NONE)) { + Mission_lens = -1; + return; + } + + Mission_lens = lens_flare_lookup(lens_name); + if (Mission_lens < 0) { + // An unknown lens falls back to the table default rather than to no + // flares: a typo shouldn't silently look like LENS_NAME_NONE + Warning(LOCATION, "No lens system named '%s' is defined in lens_flares.tbl; using the default lens.", + lens_name); + Mission_lens = Default_lens; + } +} + +int lens_flare_active_lens() +{ + int lens_idx = Lab_lens.value_or(Mission_lens); + return SCP_vector_inbounds(Lens_systems, lens_idx) ? lens_idx : -1; +} + +const char* lens_flare_mission_lens_name() +{ + return SCP_vector_inbounds(Lens_systems, Mission_lens) ? Lens_systems[Mission_lens].name.c_str() : ""; +} + +void lens_flare_set_lab_lens(int lens_idx) +{ + Lab_lens = lens_idx; +} + +void lens_flare_clear_lab_lens() +{ + Lab_lens.reset(); +} + +std::optional lens_flare_get_lab_lens() +{ + return Lab_lens; +} + +const SCP_vector& lens_flare_get_frame_draws() +{ + return Frame_draws; +} + +bool lens_flare_sun_starburst_drawn(int sun_n) +{ + return SCP_vector_inbounds(Sun_starburst_drawn, sun_n) && Sun_starburst_drawn[sun_n]; +} + +const lens_flare_textures* lens_flare_get_textures(int lens_idx) +{ + if (!SCP_vector_inbounds(Lens_systems, lens_idx)) { + return nullptr; + } + + // Fill an empty cache, or one holding a different lens -- but never merely a + // different iris. Rebuilding for a changed iris is flush_pending_aperture_edit()'s + // job precisely so that it stays throttled; doing it here would put the FFT in + // whatever called us, which mid-frame is a render backend. + if (Tex_cache.textures == nullptr || Tex_cache.lens_idx != lens_idx) { + Tex_cache.aperture = effective_aperture(lens_idx); + auto tex = std::make_unique(); + lens_flare_generate_textures(Tex_cache.aperture, tex.get()); + Tex_cache.textures = std::move(tex); + Tex_cache.lens_idx = lens_idx; + Texture_generation++; + } + return Tex_cache.textures.get(); +} + +const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens, + unsigned int& cached_generation) +{ + const unsigned int generation = Texture_generation; + if (lens_idx == cached_lens && generation == cached_generation) { + return nullptr; + } + + const lens_flare_textures* tex = lens_flare_get_textures(lens_idx); + if (tex == nullptr || tex->aperture.empty() || tex->starburst.empty()) { + return nullptr; + } + + // Read back rather than reused: generating above may have bumped it. + cached_lens = lens_idx; + cached_generation = Texture_generation; + return tex; +} + +void lens_flare_prime_textures() +{ + // The editors draw the background without ever opening a scene texture, so the + // flare pass never runs there and generating the pair would be pure waste on + // every mission load + if (Fred_running) { + return; + } + lens_flare_get_textures(lens_flare_active_lens()); +} + +void lens_flare_reset_for_level() +{ + // Unmount: the mission being loaded sets its own $Camera Lens: right after + // this (see parse_mission_info), and the lab sets its override on demand + Mission_lens = Default_lens; + lens_flare_clear_lab_lens(); + lens_flare_lab_thruster_flare().reset(); + Logged_lens = -2; + + // Every lens is left exactly as its table declared it, so this one line is the + // whole of "one mission's camera cannot carry into the next" -- there is + // nothing stamped into a lens to put back. + Overrides.clear(); + drop_texture_cache(); + + // The next mission's suns are not this one's; drop the published frame so + // nothing consumes it across the level change + lens_flare_clear_frame(); + + // Any scheduled rebuild belongs to the mission being left. The throttle stamp + // goes too, so the next mission's first edit applies at once instead of waiting + // out an interval started by the previous one. + Aperture_dirty = false; + Aperture_dirty_stamp = UI_TIMESTAMP::invalid(); +} + +unsigned int lens_flare_get_texture_generation() { return Texture_generation; } + +bool lens_flare_aperture_edit_pending() { return Aperture_dirty; } + +namespace { + +// The three quad kinds share one instance slot but read its fields differently +// (see lens_flare_instance_data in graphics/util/uniform_structs.h for the +// per-kind table). Each emit_* below is the sole writer of its kind, so the +// convention lives in exactly one place per artifact instead of being spread +// across one long packing function. +// +// All three take the lens for its prescription -- pupil, iris radius, sensor +// width, none of which is overridable -- and lens_settings for everything about +// the look. Anything a mission can restyle must be read from the settings; the +// split in the signature is what keeps that hard to get wrong. + +// Blank a slot and tag its kind, so each emitter only writes the fields it +// actually means and never has to remember to zero the rest. +void instance_init(generic_data::lens_flare_instance_data& inst, float kind) +{ + inst = {}; + inst.center.xyzw.w = kind; +} + +// Sub-pixel guard: a nearly focused ghost would otherwise collapse to a point +// (and its energy concentration to infinity). +float ghost_min_halfext(const lens_system& lens) +{ + return lens.sensor_width * 0.004f; +} + +// A ghost: the aperture as imaged by one two-reflection path, evaluated at each +// of the three design wavelengths, so every xyz triple here is per-channel. +void emit_ghost(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + const lens_flare_ghost& ghost, float theta) +{ + instance_init(inst, generic_data::LENS_QUAD_GHOST); + + const float pupil = lens.entrance_radius; + const float min_halfext = ghost_min_halfext(lens); + + for (int k = 0; k < 3; k++) { + // Full path matrix F = Ms * Ma; only row 0 (heights) is needed + float f_a = ghost.ms[k][0] * ghost.ma[k][0] + ghost.ms[k][1] * ghost.ma[k][2]; + float f_b = ghost.ms[k][0] * ghost.ma[k][1] + ghost.ms[k][1] * ghost.ma[k][3]; + + float halfext = MAX(fabsf(f_a) * pupil, min_halfext); + float energy = MIN((pupil * pupil) / (halfext * halfext), GHOST_ENERGY_CAP); + + inst.center.a1d[k] = f_b * theta; + inst.halfext.a1d[k] = halfext; + inst.apscale.a1d[k] = ghost.ma[k][0] * pupil / lens.aperture_radius; + inst.apoff.a1d[k] = ghost.ma[k][1] * theta / lens.aperture_radius; + // clamped here rather than at the setter: the lab and the sexps write the + // overrides directly, so this is the boundary that has to hold + inst.color.a1d[k] = ghost.reflectance[k] * energy * MAX(set.ghost_brightness, 0.0f); + } +} + +// The starburst: the Fraunhofer transform of the iris, sitting exactly on the +// sun's image. Achromatic here, because the texture carries its own per-channel +// diffraction scaling. `sdist` is the image's distance from the sensor centre. +void emit_starburst(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STARBURST); + + const float halfext = MAX(set.starburst_scale, 0.0f) * lens.sensor_width * 0.12f; + for (int k = 0; k < 3; k++) { + inst.center.a1d[k] = sdist; + inst.halfext.a1d[k] = halfext; + inst.color.a1d[k] = MAX(set.starburst_brightness, 0.0f); // see emit_ghost + } +} + +// The anamorphic streak: screen-horizontal, so unlike the other two kinds it +// reads halfext as a half-length and a half-thickness rather than as three +// chromatic half-widths. +void emit_streak(generic_data::lens_flare_instance_data& inst, const lens_system& lens, const lens_settings& set, + float sdist) +{ + instance_init(inst, generic_data::LENS_QUAD_STREAK); + + const lens_streak& streak = set.anamorphic.streak; + const float min_halfext = ghost_min_halfext(lens); + const float half_len = MAX(streak.length * lens.sensor_width * 0.5f, min_halfext); + + inst.center.xyzw.x = sdist; // the sun's image, same as the starburst + inst.halfext.xyzw.x = half_len; + inst.halfext.xyzw.y = MAX(half_len * streak.thickness, min_halfext * 0.25f); + + // The lens tint is a colour cast on top of the sun's own colour, which the + // shared `tint` already applies -- so a red sun keeps a reddish streak + // instead of the table's blue overriding it + for (int k = 0; k < 3; k++) { + inst.color.a1d[k] = streak.tint[k] * streak.strength; + } +} + +} // namespace + +// Pack one source's quads into a uniform block and return how many instance +// slots were written. Depends only on the camera and where the source's image +// lands on the sensor -- `sdist` is that image's distance from the sensor centre +// in mm, `theta` its paraxial field angle -- so a sun and an engine that happen +// to land in the same place get the same quads, which is what "one camera, one +// lens" means. +static int pack_source_instances(const lens_system& lens, const lens_settings& set, float theta, float sdist, + bool with_ghosts, generic_data::lens_flare_data* out) +{ + // Each non-ghost artifact reserves its slot out of the budget up front, so the + // ghosts can never crowd it out and the emits below need no second bounds + // check. The predicates are named once and used for both the reservation and + // the emission, so a new artifact cannot be added to one without the other -- + // which is what lets lens_flare_frame_update() conclude that a + // starburst-enabled lens has certainly drawn its starburst, and hence what the + // sprite sun steps aside for. + const bool wants_starburst = set.starburst; + const bool wants_streak = set.anamorphic.streak.strength > 0.0f; + + const int reserved = (wants_starburst ? 1 : 0) + (wants_streak ? 1 : 0); + // lens.ghosts is enumerated brightest first, so taking a prefix of it is + // exactly what asking for fewer ghosts means. Applying it here rather than at + // enumeration is what lets $Max Ghosts: be overridden at all: the alternative + // would be re-running the whole paraxial precompute on every edit. + const int ghost_budget = + MIN(generic_data::MAX_LENS_FLARE_INSTANCES - reserved, MAX(set.max_ghosts, 0)); + + int count = 0; + if (with_ghosts) { + for (const auto& ghost : lens.ghosts) { + if (count >= ghost_budget) { + break; + } + emit_ghost(out->instances[count++], lens, set, ghost, theta); + } + } + if (wants_starburst) { + emit_starburst(out->instances[count++], lens, set, sdist); + } + if (wants_streak) { + emit_streak(out->instances[count++], lens, set, sdist); + } + Assertion(count <= generic_data::MAX_LENS_FLARE_INSTANCES, + "Lens flare packed %d instances into %d slots -- the ghost budget no longer reserves the " + "starburst/streak slots correctly", + count, generic_data::MAX_LENS_FLARE_INSTANCES); + + out->n_instances = count; + // Single choke point for the squeeze, so a table typo, a lab slider and a + // mission override all get the same guard against a divide by zero in the shader + out->squeeze = MAX(set.anamorphic.squeeze, 0.01f); + out->pad[0] = out->pad[1] = 0.0f; + return count; +} + +void lens_flare_clear_frame() +{ + Frame_draws.clear(); + Sun_starburst_drawn.clear(); +} + +bool lens_flare_point_visible(const vec3d& world_pos) +{ + vec3d to_eye; + vm_vec_sub(&to_eye, &Eye_position, &world_pos); + const float dist = vm_vec_normalize_safe(&to_eye, true); + if (dist <= 0.2f) { + // point-blank range: nothing can fit between the eye and the source + return true; + } + + // The point sits exactly on the emitting ship's own hull, and a segment + // ending precisely on a surface is the one case a poly test can register as + // a spurious self-hit. Pulling the far end back toward the eye by a flat, + // small offset dodges that without excluding the emitting ship, which is + // deliberate: a nozzle or muzzle on the far side of its own hull should + // occlude exactly like it would behind anything else. The dist <= 0.2f bail + // above guarantees this offset never reaches back past the eye. + vec3d test_point; + vm_vec_scale_add(&test_point, &world_pos, &to_eye, 0.1f); + + // A zero threshold is deliberate: test_line_of_sight()'s default (10.0f) + // exists to let AI weapon fire ignore stray debris, but it would just as + // happily skip a fighter-sized ship as an occluder -- including the + // emitting ship itself, which is the self-occlusion case this test exists + // for in the first place. + return test_line_of_sight(&Eye_position, &test_point, {}, 0.0f); +} + +void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget) +{ + if (static_cast(candidates.size()) > budget) { + std::partial_sort(candidates.begin(), candidates.begin() + budget, candidates.end(), + [](const flare_source& a, const flare_source& b) { return a.intensity > b.intensity; }); + candidates.resize(budget); + } + + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [](const flare_source& src) { return !lens_flare_point_visible(src.pos); }), + candidates.end()); + + out.insert(out.end(), candidates.begin(), candidates.end()); +} + +namespace { + +// Every sun the content asked to flare, with its occlusion and off-axis fades +// already folded into the source's brightness. +void gather_sun_sources(SCP_vector& out, float dt, bool snap) +{ + const int num_suns = stars_get_num_suns(); + + for (int sun_n = 0; sun_n < num_suns; sun_n++) { + const auto sun_light = stars_get_sun_rgbi(sun_n); + if (!sun_light) { + continue; + } + + // A sun the content never asked to flare gets nothing from the camera lens. + // stars.tbl decides whether a sun flares ("+Camera Lens Flare:", or a legacy + // $Flare: block for tables predating it); the mounted lens only decides how + // that flare is drawn. Mounting a lens must not invent flares on suns + // deliberately tabled without one. + if (!stars_sun_has_camera_lens_flare(sun_n)) { + continue; + } + + vec3d sun_pos = vmd_zero_vector; + sun_pos.xyz.y = 1.0f; + stars_get_sun_pos(sun_n, &sun_pos); + vec3d sun_dir = sun_pos; + vm_vec_normalize(&sun_dir); + + float dot = vm_vec_dot(&sun_dir, &Eye_matrix.vec.fvec); + + // a sun the engine gives no glare gets no flare either + int light_idx = light_find_for_sun(sun_n); + if (light_idx >= 0 && !light_has_glare(light_idx)) { + continue; + } + + float total_vis = sun_visibility(sun_n, light_idx, dot, dt, snap); + if (total_vis < 0.005f) { + continue; + } + + flare_source src; + src.pos = sun_pos; + src.at_infinity = true; + src.color = sun_light->color; + src.intensity = sun_light->intensity * total_vis; + src.visibility = total_vis; + src.kind = flare_source_kind::sun; + src.index = sun_n; + out.push_back(src); + } +} + +// How many nozzles the pass will image at most, brightest first. Every lit +// nozzle is its own source (a capital ship's engines are too far apart to +// average), so this is what stands between a fleet engagement and several +// hundred draws: each source costs a multi-kilobyte uniform block and its own +// instanced draw. +// +// It is generous rather than small because the thruster flares that motivate it +// are the ones on a big ship, where a dozen nozzles are visible at once. What +// makes that affordable is lens_flare_tuning::thruster_ghosts being off, which +// leaves each of them a single starburst quad instead of a full ghost train. +// +// Must stay within what the Vulkan backend's per-frame UBO ring can hold across +// however many times the scene is rendered in a frame -- see +// LENS_FLARE_UBO_SLOTS in VulkanPostProcessingLensFlare.cpp. +constexpr int MAX_THRUSTER_SOURCES = 32; + +// Beams are scarce even in a capital-ship engagement, so this is a backstop +// rather than something a normal frame is expected to reach -- which is also +// why they keep their ghost train where nozzles drop theirs: a handful of +// ghost trains reads as an optical effect rather than noise, and ghosts cost +// nothing extra once a source's uniform block is uploaded. +// +// The two budgets plus the suns are what LENS_FLARE_UBO_SLOTS in the Vulkan +// backend has to stay above. +constexpr int MAX_BEAM_SOURCES = 8; + +} // namespace + +void lens_flare_frame_update() +{ + lens_flare_clear_frame(); + + // scheduled iris rebuilds land here, throttled + flush_pending_aperture_edit(); + + const int lens_idx = lens_flare_active_lens(); + if (lens_idx < 0 || !pass_globally_possible()) { + return; + } + const lens_system& lens = Lens_systems[lens_idx]; + + // Resolved once: the camera is the camera for every source in the frame, and + // re-resolving it per source would copy an aperture forty times over. + const lens_settings settings = lens_flare_effective_settings(lens_idx); + + film_gate gate; + gate.clip_w = i2fl(gr_screen.clip_width); + gate.clip_h = i2fl(gr_screen.clip_height); + if (gate.clip_w <= 0.0f || gate.clip_h <= 0.0f) { + return; + } + gate.half_w = lens.sensor_width * 0.5f; + gate.half_h = gate.half_w * gate.clip_h / gate.clip_w; + + // Frame time for visibility smoothing (snap if we haven't run for a while) + float dt = 0.25f; + if (Sun_visibility_stamp.isValid()) { + dt = ui_timestamp_since(Sun_visibility_stamp) * 0.001f; + } + Sun_visibility_stamp = ui_timestamp(); + bool snap = (dt > 1.0f) || (dt < 0.0f); + + // Everything the camera images this frame, gathered before anything is packed + // so that the two kinds of light -- one lens, one film gate -- go through the + // identical projection and packing below + SCP_vector sources; + gather_sun_sources(sources, dt, snap); + lens_flare_gather_thruster_sources(sources, MAX_THRUSTER_SOURCES); + lens_flare_gather_beam_sources(sources, MAX_BEAM_SOURCES); + if (sources.empty()) { + return; + } + + // Sized once, now that the source list is final: the loop hands out pointers + // into this and must never grow it afterwards + if (Frame_data.size() < sources.size()) { + Frame_data.resize(sources.size()); + } + Sun_starburst_drawn.resize(stars_get_num_suns(), false); + + // The camera's, not any source's, so it is a frame constant too + const float out_scale = lens_flare_output_scale(); + + for (const auto& src : sources) { + film_image image; + if (!project_source(src, gate, lens.efl, &image)) { + continue; + } + + // The slot is only committed by the push_back below, so a source that packs + // nothing leaves it to the next one + generic_data::lens_flare_data* out = &Frame_data[Frame_draws.size()]; + + out->axis.x = image.axis_x; + out->axis.y = image.axis_y; + out->ndc_scale.x = 1.0f / gate.half_w; + out->ndc_scale.y = 1.0f / gate.half_h; + // Master multiplier for every ghost and the starburst (lensflare-f.sdr + // applies tint.rgb to both paths). The output scale keeps SDR and HDR + // visually consistent without per-lens re-tuning. + const float tint_scale = src.intensity * settings.intensity * out_scale; + out->tint.xyzw.x = src.color.xyz.x * tint_scale; + out->tint.xyzw.y = src.color.xyz.y * tint_scale; + out->tint.xyzw.z = src.color.xyz.z * tint_scale; + out->tint.xyzw.w = 0.0f; + + int count = pack_source_instances(lens, settings, image.theta, image.dist_mm, src.draw_ghosts, out); + if (count == 0) { + continue; + } + + lens_flare_draw draw; + draw.kind = src.kind; + draw.source_index = src.index; + draw.instances = count; + draw.data = out; + draw.visibility = src.visibility; + draw.off_axis_deg = image.theta * (180.0f / PI); + draw.output_scale = out_scale; + Frame_draws.push_back(draw); + + // This sun is committed, and pack_source_instances() reserves the starburst + // a slot up front, so a starburst-enabled lens has certainly drawn one. The + // sprite sun can now safely step aside for it. Thrusters are not in this + // bookkeeping on purpose: an engine's glow is the light the flare is *of*, + // not a second drawing of the same artifact, so it keeps rendering. + if (src.kind == flare_source_kind::sun) { + Sun_starburst_drawn[src.index] = settings.starburst; + } + } + + if (!Frame_draws.empty() && lens_idx != Logged_lens) { + Logged_lens = lens_idx; + mprintf(("Lens flare: rendering through lens '%s' (%d ghosts + %s)\n", lens.name.c_str(), + MIN(static_cast(lens.ghosts.size()), MAX(settings.max_ghosts, 0)), + settings.starburst ? "starburst" : "no starburst")); + } +} + + +} // namespace graphics diff --git a/code/graphics/lens_flare.h b/code/graphics/lens_flare.h new file mode 100644 index 00000000000..8e0f7cc8d4b --- /dev/null +++ b/code/graphics/lens_flare.h @@ -0,0 +1,600 @@ +#pragma once + +#include "globalincs/pstypes.h" + +#include +#include +#include + +// Physically-based lens flares (Lee & Eisemann 2013 matrix approximation). +// +// A lens system is an ordered stack of spherical surfaces parsed from +// lens_flares.tbl / *-lens.tbm. Every ordered pair of refractive surfaces +// produces one two-reflection "ghost" image of the aperture; each ghost is +// reduced at table-load time to a handful of paraxial ray-transfer matrices +// so the render backends only have to draw one textured quad per ghost. +// +// A mission mounts exactly one of these as the camera lens (see "the camera +// lens" below); with none mounted nothing changes. + +namespace graphics { + +namespace generic_data { +struct lens_flare_data; // graphics/util/uniform_structs.h +} + +struct lens_surface { + float radius = 0.0f; // signed curvature radius in mm, 0 = flat + float thickness = 0.0f; // distance to the next surface in mm + float n = 1.0f; // refractive index behind the surface (1.0 = air) + float abbe = 0.0f; // Abbe V-number for dispersion, 0 = dispersion-free + float coating_wavelength = -1.0f; // AR coating tuning in nm; < 0 = use lens default, 0 = uncoated + bool is_stop = false; // aperture stop (flat, non-refracting) +}; + +// The iris: a stack of multiplicative layers rendered into one R8 transmission +// mask. Ported from realflare's aperture kernels, with one deliberate +// difference: realflare keeps a separate aperture for ghosts and for the +// starburst, while here a single definition drives both (the starburst is the +// Fraunhofer transform of this very mask), so a lens has one iris and cannot +// contradict itself. +// +// Every layer below the shape defaults to strength 0 (off), which reproduces +// the plain-iris look of tables written before they existed. +// +// Each layer carries its own operator== because the iris is the one part of the +// camera whose textures cost real time to build (a 512^2 mask plus a 2D FFT), so +// the texture cache keys on it to tell a genuine edit from a slider that landed +// back where it started. Keeping each layer's comparison next to its own fields +// is what stops a newly added field from being silently left out of that check. +// (C++20 would make all four `= default`.) + +// Diffraction grating around the rim: fine radial ridges that throw extra spikes +// into the starburst. +struct lens_aperture_grating { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 360 possible ridges + float length = 0.5f; // how far in the ridges reach, as a fraction of the iris radius + float width = 0.25f; // ridge width as a duty cycle of the spacing between ridges + float softness = 0.0f; + + bool operator==(const lens_aperture_grating& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + softness == o.softness; + } + bool operator!=(const lens_aperture_grating& o) const { return !(*this == o); } +}; + +// Scratches on the glass: randomly placed and oriented slivers. +struct lens_aperture_scratches { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible scratches + float length = 0.5f; + float width = 0.25f; + float rotation = 0.0f; // degrees + float rotation_variation = 0.0f; // 0 = all parallel, 1 = fully random + float softness = 0.0f; + + bool operator==(const lens_aperture_scratches& o) const + { + return strength == o.strength && density == o.density && length == o.length && width == o.width && + rotation == o.rotation && rotation_variation == o.rotation_variation && softness == o.softness; + } + bool operator!=(const lens_aperture_scratches& o) const { return !(*this == o); } +}; + +// Dust on the glass: randomly placed specks. +struct lens_aperture_dust { + float strength = 0.0f; // 0 = off + float density = 0.5f; // fraction of the 1000 possible specks + float radius = 0.5f; + float softness = 0.0f; + + bool operator==(const lens_aperture_dust& o) const + { + return strength == o.strength && density == o.density && radius == o.radius && softness == o.softness; + } + bool operator!=(const lens_aperture_dust& o) const { return !(*this == o); } +}; + +struct lens_aperture { + // Iris opening. Blade count/rotation/curvature are the original fields; + // curvature 0 = straight blades, 1 = circular, and negative values bow the + // blades inward for a star-shaped iris. + int blades = 6; + float rotation = 0.0f; // degrees + float curvature = 0.0f; // -1 = concave .. 0 = straight .. 1 = circular + // Edge feather, as a fraction of the iris radius. The default is also the + // floor the generator clamps to (a ~2px ramp, so the mask never aliases), + // and reproduces the edge the iris had before this was tunable. A soft edge + // visibly weakens the starburst spikes, so it is not a free parameter. + float softness = 0.0039f; + + lens_aperture_grating grating; + lens_aperture_scratches scratches; + lens_aperture_dust dust; + + // Each layer compares itself, so this only has to cover the iris fields and + // the three layers. + bool operator==(const lens_aperture& o) const + { + return blades == o.blades && rotation == o.rotation && curvature == o.curvature && + softness == o.softness && grating == o.grating && scratches == o.scratches && dust == o.dust; + } + bool operator!=(const lens_aperture& o) const { return !(*this == o); } +}; + +// One two-reflection ghost, precomputed per wavelength (index 0 = red 656nm, +// 1 = green 588nm, 2 = blue 486nm). Matrices are row-major 2x2 ray-transfer +// matrices acting on [height; angle] column vectors: [0]=A [1]=B [2]=C [3]=D. +struct lens_flare_ghost { + float ma[3][4]; // entrance plane -> aperture stop (last stop crossing) + float ms[3][4]; // aperture stop -> sensor plane + float reflectance[3]; // product of the two (coated) Fresnel reflectances + int surf_first = -1; // surface indices of the reflection pair (diagnostics) + int surf_second = -1; +}; + +// CPU-generated texture payloads for one lens system (created on demand by +// lens_flare_get_textures(), uploaded by each render backend). +struct lens_flare_textures { + int aperture_size = 0; + SCP_vector aperture; // R8 iris transmission mask + int starburst_size = 0; + SCP_vector starburst; // RGBA32F Fraunhofer starburst +}; + +// The anamorphic streak: the long horizontal flare a cylindrical element +// throws across the frame, and the half of the look the squeeze alone does +// not buy -- stretching the starburst only ever reads as a wider sun, since +// a real streak runs 20-50 times longer than it is thick. +// +// It is its own quad rather than a reshaped starburst because it is a +// different artifact: the starburst is the iris seen end-on and rotates with +// the sun, while the streak lies along the cylindrical element and so stays +// horizontal wherever the sun is. Off by default, which keeps every lens +// written before it existed untouched. +struct lens_streak { + float strength = 0.0f; // 0 = off + float length = 1.0f; // half-length as a fraction of the sensor width + float thickness = 0.02f; // as a fraction of the length, so 0.02 = 50:1 + float tint[3] = {0.35f, 0.55f, 1.0f}; // multiplies the sun's own colour + + bool operator==(const lens_streak& o) const + { + return strength == o.strength && length == o.length && thickness == o.thickness && + tint[0] == o.tint[0] && tint[1] == o.tint[1] && tint[2] == o.tint[2]; + } + bool operator!=(const lens_streak& o) const { return !(*this == o); } +}; + +// The anamorphic look -- squeeze plus streak -- bundled the same way +// lens_aperture bundles the iris and its layers, so that it can be tabled, +// overridden and compared against its own neutral defaults as one thing. +// +// Anamorphic squeeze is how much wider than tall the flare footprints are, +// 1.0 = spherical. A front anamorphot is an afocal cylindrical telescope, so to +// first order all it does is magnify one meridian -- which is why this is a +// single number and not a second set of ray-transfer matrices. The lens behind +// it stays rotationally symmetric, so the iris (and hence the mask and its +// transform) is unaffected; only the imaging of it is stretched. +// +// Note that FSO draws the flare into an already-composed frame with no desqueeze +// stage, so this is a look control rather than a 2x-squeeze capture pipeline: +// ghost positions follow the sun as always, and only their footprints are +// stretched, which is what a desqueezed anamorphic frame shows. +struct lens_anamorphic { + float squeeze = 1.0f; + lens_streak streak; + + bool operator==(const lens_anamorphic& o) const { return squeeze == o.squeeze && streak == o.streak; } + bool operator!=(const lens_anamorphic& o) const { return !(*this == o); } +}; + +// A lens as the tables declare it. Everything here is either the lens's +// prescription -- the part that makes it *this* lens, and that nothing may +// override -- or the tabled baseline of a knob that lens_overrides can restyle. +struct lens_system { + SCP_string name; + + // ---- the prescription: a lens's identity, never overridden ---- + SCP_vector surfaces; + float entrance_radius = 10.0f; // entrance pupil (front element) radius, mm + float aperture_radius = 5.0f; // iris half-opening, mm + float sensor_width = 36.0f; // film-gate width, mm + float coating_wavelength = 540.0f; // default AR coating tuning, nm (0 = uncoated) + + // ---- the tabled look: the baseline lens_overrides lays over ---- + lens_aperture aperture; // iris shape + imperfections, shared by ghosts and starburst + lens_anamorphic anamorphic; // squeeze + streak + float intensity = 0.2f; + bool starburst = true; + float starburst_scale = 1.0f; + int max_ghosts = 40; + + // --- filled by lens_flare_precompute() --- + // Brightest first, so max_ghosts can be applied at draw time by simply + // taking a prefix of this. + SCP_vector ghosts; + float efl = 50.0f; // effective focal length (green), mm + float bfd = 40.0f; // back focal distance last surface -> sensor (green), mm +}; + +// ---- restyling the camera ---- +// +// A mission, the lab, the set-lens-* sexps and both editors all restyle the same +// one camera, so they all write to the same one set of overrides: an unset field +// means "whatever the mounted lens tables", exactly the way an unset Lab_lens +// means "whatever lens the mission mounted" (see lens_flare_active_lens()). +// +// Overriding rather than stamping the values into the lens is what keeps one +// mission's camera out of the next: lens_flare_reset_for_level() clears these +// and every lens is untouched, with nothing to restore and no backup copy that +// could go stale when a field is added. +// The most ghosts a single source's uniform block can ever hold, once the +// starburst and streak have taken their slots. Restated here rather than reached +// through graphics/util/uniform_structs.h so that the editors and the sexps can +// bound "$Max Ghosts:" without pulling the whole uniform layout in; lens_flare.cpp +// static_asserts the two against each other. +constexpr int MAX_LENS_FLARE_GHOSTS = 62; + +struct lens_overrides { + std::optional aperture; + std::optional anamorphic; + std::optional intensity; + std::optional starburst; + std::optional starburst_scale; + std::optional max_ghosts; + // The energy-model calibration. Per-camera rather than per-lens, since it + // scales the model itself rather than describing any particular glass. + std::optional ghost_brightness; + std::optional starburst_brightness; + + bool any() const + { + return aperture || anamorphic || intensity || starburst || starburst_scale || max_ghosts || + ghost_brightness || starburst_brightness; + } + void clear() { *this = lens_overrides(); } + + // Compared as a whole because that is how it is edited: FRED stores one of + // these per mission and needs to know whether a dialog actually changed + // anything. std::optional compares both the "is it set" and the value, which + // is exactly the distinction that matters here -- unset is not the same + // answer as set-to-the-default. + bool operator==(const lens_overrides& o) const + { + return aperture == o.aperture && anamorphic == o.anamorphic && intensity == o.intensity && + starburst == o.starburst && starburst_scale == o.starburst_scale && max_ghosts == o.max_ghosts && + ghost_brightness == o.ghost_brightness && starburst_brightness == o.starburst_brightness; + } + bool operator!=(const lens_overrides& o) const { return !(*this == o); } +}; + +// The camera as it actually is: a lens's tabled look with the overrides above +// laid over it. Resolved in one place by lens_flare_effective_settings(), so no +// caller re-implements the precedence, and passed down by value so that a +// consumer physically cannot read the un-overridden value off the lens instead. +struct lens_settings { + lens_aperture aperture; + lens_anamorphic anamorphic; + float intensity = 0.2f; + bool starburst = true; + float starburst_scale = 1.0f; + int max_ghosts = 40; + float ghost_brightness = 64.0f; + float starburst_brightness = 1.6f; +}; + +// Parse lens_flares.tbl + *-lens.tbm (embedded default as fallback) and +// precompute all ghost data. Called once from stars_init(); safe to call again +// (reloads). +void lens_flare_init(); +void lens_flare_close(); + +// Index of a tabled lens system by name, -1 if unknown. +int lens_flare_lookup(const char* name); + +int lens_flare_num_systems(); +const lens_system* lens_flare_get_system(int lens_idx); + +// The overrides in force, for reading and for editing in place. One set, because +// there is one camera: mission load, the set-lens-* sexps, the lab and both +// editors are all restyling the same glass. +// +// Anything that edits these must follow up with lens_flare_overrides_changed(). +lens_overrides& lens_flare_overrides(); + +// Note that the overrides were edited. Only the iris costs anything to change -- +// a 512^2 mask plus its 2D FFT -- so this schedules a texture rebuild, which the +// per-frame flush then performs at most once every few frames and only if the +// effective aperture really did move. Everything else takes effect next frame at +// no cost, so calling this after any edit is always correct and never wasteful. +void lens_flare_overrides_changed(); + +// Whether a scheduled iris rebuild is still outstanding. For the lab, which +// shows it while a slider drag is being coalesced. Call once per frame. +bool lens_flare_aperture_edit_pending(); + +// The mounted lens's tabled look with the overrides above laid over it -- the +// single resolver, so no caller re-implements the precedence. Returns the plain +// defaults for an invalid index. +lens_settings lens_flare_effective_settings(int lens_idx); + +// Lazily generate (and cache) the iris/starburst textures of the effective +// aperture of a lens. Returns nullptr for an invalid index. +// +// There is one cache, because there is one camera: whichever lens is mounted, +// with whatever iris is in force, is the only pair anything ever draws with. +const lens_flare_textures* lens_flare_get_textures(int lens_idx); + +// Generate the mounted lens's textures now, so the render backends find them +// already cached instead of paying for them mid-frame. +// +// Building them is a 512^2 iris mask plus a 2D FFT of it -- a visible hitch if it +// lands on the first frame a sun flares. Call it from wherever a lens has just +// been mounted for a scene that is about to be rendered and a moment's work is +// already expected: stars_post_level_init() for a mission, the lab's +// useBackground(), and qtFred's Background Editor when it switches the mission's +// lens interactively. Not from lens_flare_switch_to() itself, which is also +// reached from the editors while nothing is being rendered. +// +// A no-op everywhere the flare pass never runs regardless: plain FRED, and +// qtFred unless its View menu's "Enable Post Processing" toggle is on (qtFred +// otherwise draws the background without a scene texture, so High_dynamic_range +// never goes true -- see lens_flare.cpp's pass_globally_possible()). +void lens_flare_prime_textures(); + +// Bumped whenever the cached textures are rebuilt. Render backends cache the +// uploaded copy, so they must key that cache on this to notice a rebuild -- +// which lens_flare_textures_if_changed() below does for them. +unsigned int lens_flare_get_texture_generation(); + +// The whole staleness protocol a render backend needs, in one call: returns the +// textures to upload, or nullptr when the ones the caller already holds are +// still current. `cached_lens` / `cached_generation` are the backend's own record +// of what it last uploaded, and are updated on a non-null return. +// +// Backends own their GPU handles; they do not each need to re-derive when those +// handles went stale, which is a rule about this module and belongs here. +const lens_flare_textures* lens_flare_textures_if_changed(int lens_idx, int& cached_lens, + unsigned int& cached_generation); + +// Undo everything a mission or the lab did to the camera: unmount whatever lens +// was mounted (back to $Default Lens:) and drop every override, so one mission's +// camera can't carry into the next. Called from stars_pre_level_init(), which +// runs before the mission's $Camera Lens: is parsed. +void lens_flare_reset_for_level(); + +// The calibration that is neither per-lens nor per-mission: how the flare is fitted +// to HDR output, and whether nozzles draw ghosts. Handed out mutably for the lab +// to edit in place; values are sanitized where they are consumed, so a caller +// cannot break the renderer by writing a silly number here. +struct lens_flare_tuning { + // How many multiples of paper white the flare may reach in HDR output. SDR is + // the calibration reference and is unaffected; this only rescales the HDR path + // so an SDR-tuned flare doesn't blow out. The default keeps a little HDR "pop". + // Not overridable per mission: it describes the display, not the camera. + float hdr_headroom = 2.5f; + + // Whether a thruster flare draws the ghost train as well as its starburst. + // Off, because unlike a sun an engine is one of dozens of small sources in + // frame: a ghost train each is both the expensive part of the pass and, at + // that count, visual noise rather than an optical effect you can read. + // Exposed so the lab can turn them on and show what they cost and look like. + // Suns are unaffected and always draw theirs. + bool thruster_ghosts = false; +}; + +lens_flare_tuning& lens_flare_get_tuning(); + +// ---- the camera lens ---- +// +// There is one lens, because there is one camera: every light source in the +// scene is imaged through the same glass, so the flares of all suns share a +// prescription, an iris and a starburst. What differs per sun is only where it +// sits in the frame and how bright it is. +// +// The mounted lens comes from the mission's "$Camera Lens:" (defaulting to +// "$Default Lens:" in lens_flares.tbl), can be changed at runtime by the +// set-camera-lens sexp, and can be overridden live in the lab. + +// The two names that stand in for a lens instead of naming one. The mission's +// "$Camera Lens:", the set-camera-lens sexp and both editors all speak this same +// vocabulary, so it lives here with the code that resolves it rather than being +// re-spelled at each of those. +#define LENS_NAME_NONE "" +#define LENS_NAME_DEFAULT "" + +// Mount a lens, resolving the whole vocabulary above in one place: +// +// ""/nullptr the caller has no opinion -> the table default. This is what +// a mission without a "$Camera Lens:" gets, which is why it +// means "default" and not "none". +// no lens, hence no flares, even when a default exists. The +// only way to say that, and the reason it is a token rather +// than an empty string. +// the table default, said explicitly. +// a lens name that lens; an unknown name warns and falls back to the +// default, since a typo shouldn't silently look like . +// +// Called from mission parse, the set-camera-lens sexp, the lab and both editors. +void lens_flare_switch_to(const char* lens_name); + +// The lens actually in use (a lab override beats the mission's), -1 = none. +int lens_flare_active_lens(); + +// Name of the mission's own camera lens, ignoring any lab override. What the lab +// shows as the entry to fall back to, and what restores. +const char* lens_flare_mission_lens_name(); + +// Lab override of the mission's camera lens: unset means the mission's choice +// stands, a value of -1 forces "no flares". Cleared by +// lens_flare_reset_for_level(). +void lens_flare_set_lab_lens(int lens_idx); +void lens_flare_clear_lab_lens(); +std::optional lens_flare_get_lab_lens(); + +// True when the last lens_flare_frame_update() actually put a starburst quad in +// this sun's draw. Used by the sun renderer to skip the sprite sun and its glow +// so the two starbursts don't stack. +// +// This reports what the flare pass *is drawing*, read back out of the frame data +// below rather than re-derived, which is what keeps the sprite and the flare +// from disagreeing about occluded and off-screen suns. +// +// Suns only: an engine's glow is the light source the flare is *of*, not a +// competing sprite of the same artifact, so a thruster flare never makes the +// thruster glow step aside. +bool lens_flare_sun_starburst_drawn(int sun_n); + +// What a draw images. Diagnostics for the lab -- the pass draws every kind the +// same way, through the same lens. +enum class flare_source_kind { + sun, + thruster, + beam, +}; + +// One light source's worth of flare quads. Every source shares the mounted lens +// (hence one aperture/starburst texture for the whole pass), but each has its own +// flare axis and tint, so each gets its own uniform block and instanced draw. +// +// The trailing fields are diagnostics for the lab; the renderer ignores them. +struct lens_flare_draw { + flare_source_kind kind = flare_source_kind::sun; + // Which sun (a stars.tbl instance index), which ship (an objnum, for a + // thruster) or which beam (an objnum, for a beam) this draw images. + int source_index = -1; + int instances = 0; // quads to draw (ghosts + optional starburst) + // Uniform block for this draw, owned by lens_flare.cpp; valid until the + // next lens_flare_frame_update() call. + const generic_data::lens_flare_data* data = nullptr; + + // The 0..1 fade already folded into this draw's tint that isn't the source's + // own tabled brightness: for a sun, smoothed occlusion times the off-axis + // fade; for a thruster, the throttle; for a beam, its warmup/warmdown ramp. + float visibility = 0.0f; + float off_axis_deg = 0.0f; // paraxial field angle of the source + float output_scale = 1.0f; // SDR/HDR consistency multiplier applied this frame +}; + +// Decide what the flare pass will draw this frame and publish it, once per scene +// render. Does all the game-state access (sun projection, occlusion raycast, +// gates) and all the deferred work (flushing throttled aperture rebuilds), so +// that everything downstream is a pure read. +// +// Called from stars_draw(), which is the one place that both runs after the view +// and projection matrices are live and runs before the sun sprites and the +// post-processing pass consume the result. +void lens_flare_frame_update(); + +// Publish an empty frame: nothing flares, so every consumer reads "no". +// +// For a scene render that cannot reach the flare pass at all -- an environment map +// goes straight to a render target, outside the post-processing chain. Publishing +// nothing rather than skipping the publish is deliberate: it keeps the answer in +// one place, so no consumer has to know where it is being called from, and it +// stops the previous frame's published draws from being read by a render that +// isn't going to draw them. +void lens_flare_clear_frame(); + +// What the last lens_flare_frame_update() published: one entry per light source +// that has something to draw -- suns first, in sun order, then thrusters +// (empty = skip the pass entirely). Every entry is drawn with the textures of +// lens_flare_active_lens(). +// +// The single source of truth for the pass -- the render backends draw exactly +// these, the sun renderer asks lens_flare_sun_starburst_drawn() about them, and +// the lab reports on them. +const SCP_vector& lens_flare_get_frame_draws(); + +// ---- thruster flares ---- +// +// Engines are the other intensely bright thing in a FreeSpace scene, so they +// flare through the same camera lens the suns do. What differs is the source: a +// sun is a point at infinity with a tabled colour and a shadow test, while a +// nozzle is a finite source whose brightness follows the throttle, the +// afterburner, how squarely it faces the camera, and how far away it is. +// +// Every lit nozzle is its own source, because a capital ship's engines are set +// far enough apart to read as separate points in frame -- one flare at their +// centroid would sit where no engine is. +// +// Declared per species in species_defs.tbl ("$Thruster Flare:"), and off unless +// a species asks for it -- so no existing mod gains flares it never tabled, and +// a mission with no camera lens mounted still gets none either way. + +struct thruster_flare_info { + // False until a species_defs.tbl entry declares "$Thruster Flare:". Tables + // written before this existed have no such block, so their engines keep + // flaring exactly as much as they used to: not at all. + bool enabled = false; + + // Brightness at the reference apparent size -- one nozzle of radius r seen + // from 32r away (see lens_flare_thrusters.cpp) -- scaling linearly from there. + // + // The defaults are starting points for tuning in the lab, not derived values. + // They are this large because at any real combat range a nozzle subtends a + // small fraction of the reference, so a value near 1.0 puts the whole effect + // below the level a pixel can show. + // + // The afterburner figure *replaces* the normal one while the burner or a + // booster is lit rather than multiplying it, so a species can make the two + // states independently bright without doing division in the table. + float intensity = 6.0f; + float afterburner_intensity = 15.0f; + + // Linear rgb the flare is tinted with, multiplying the lens's own tint the + // same way a sun's colour does. + vec3d color = {{{1.0f, 1.0f, 1.0f}}}; +}; + +// The lab's live override of every species' thruster-flare settings: unset means +// each species' own table entry stands. +// +// One override for all species rather than one per species, because the lab +// shows one ship at a time -- and, more usefully, because it leaves the tabled +// values untouched, so nothing has to be backed up and restored between missions +// the way a lens's edited aperture does. +// +// Handed out mutably, like lens_flare_get_tuning(). Cleared by +// lens_flare_reset_for_level(). +std::optional& lens_flare_lab_thruster_flare(); + +// The settings that actually apply to a species this frame: its own, unless the +// lab is overriding. The single resolver, so no caller re-implements the +// precedence (compare lens_flare_active_lens()). An unknown species gets the +// defaults, which are "off". +thruster_flare_info lens_flare_thruster_settings(int species_idx); + +// ---- internals exposed for unit testing ---- + +// The name in "$Default Lens:", or "" when the table declares no default. +// +// Diagnostic only: nothing needs it to *resolve* a default any more, because +// lens_flare_switch_to() does that for every caller (an empty or name +// lands on it). Kept for the load-time log line and so a test can assert what a +// table declared. +const char* lens_flare_default_name(); + +// Run the ghost/matrix precompute on a hand-built lens_system. +// Returns false (with ghosts cleared) if the prescription is unusable. +bool lens_flare_precompute(lens_system& lens); + +// Normal-incidence reflectance of an interface n1 -> n2 with an optional +// quarter-wave AR coating tuned to lambda0_nm (0 = uncoated), evaluated at +// lambda_nm. Coating index is max(1.38, sqrt(n1*n2)). +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm); + +// In-place radix-2 2D FFT of a size x size complex grid (size must be a power +// of two). Used for the starburst; exposed for tests. +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse); + +// Render just the iris mask of an aperture, skipping the starburst transform +// the full generation path would also run. Tests that only care about the mask +// use this to avoid paying for the FFT. +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out); + +} // namespace graphics diff --git a/code/graphics/lens_flare_aperture.cpp b/code/graphics/lens_flare_aperture.cpp new file mode 100644 index 00000000000..bc5291daf11 --- /dev/null +++ b/code/graphics/lens_flare_aperture.cpp @@ -0,0 +1,409 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include +#include +#include + +// Image synthesis for the iris: one aperture definition is rasterized into an R8 +// transmission mask (blade shape plus the optional grating/scratch/dust layers, +// ported from realflare's kernels), and the starburst is that mask's Fraunhofer +// transform, i.e. |FFT(mask)|^2. Like the optics, none of this touches engine +// state -- it is a pure function of a lens_aperture. + +namespace graphics { +namespace { + +constexpr int APERTURE_TEXTURE_SIZE = 512; + +// ---- texture generation ---- + +const float IRIS_RADIUS = 0.9f; // in normalized [-1,1] texture space; keeps the border black + +// Hash used by realflare's aperture kernels to scatter scratches and dust. +// Kept bit-for-bit so a given density reproduces its layout. +float aperture_noise(float x, float y, float z) +{ + float ignored; + return modff(sinf(x * 112.9898f + y * 179.233f + z * 237.212f) * 43758.5453f, &ignored); +} + +// Signed distance to an axis-aligned rectangle of the given half-extents +float sdf_rectangle(float px, float py, float hx, float hy) +{ + float ex = fabsf(px) - hx; + float ey = fabsf(py) - hy; + float outside = sqrtf(MAX(ex, 0.0f) * MAX(ex, 0.0f) + MAX(ey, 0.0f) * MAX(ey, 0.0f)); + float inside = MIN(MAX(ex, ey), 0.0f); + return outside + inside; +} + +float smoothstep01(float edge0, float edge1, float x) +{ + if (edge0 == edge1) { + return (x < edge0) ? 0.0f : 1.0f; + } + float t = std::clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +// The iris opening (realflare's aperture_shape kernel). Returns transmission in +// [0,1] at a point in normalized texture space. +// +// The polygon is the intersection of `blades` half-planes; curvature bows each +// blade by adding a per-blade sine bulge to the distance field, exactly as +// realflare's "roundness" does, but scaled so that our curvature keeps its +// original meaning: 0 leaves the blades straight, 1 pushes each blade's midpoint +// out to the corner radius (a circular iris), and negative values bow the blades +// inward into a star. +float aperture_shape(const lens_aperture& ap, float px, float py) +{ + const int blades = ap.blades; + const float curvature = std::clamp(ap.curvature, -1.0f, 1.0f); + const float softness = MAX(ap.softness, 2.0f / APERTURE_TEXTURE_SIZE); // never sub-pixel + + if (blades < 3 || curvature >= 0.999f) { + // exact circle; the polygon path only approaches one + float r = sqrtf(px * px + py * py) / IRIS_RADIUS; + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, r); + } + + const float rot = ap.rotation * (PI / 180.0f); + const float c = cosf(rot), s = sinf(rot); + const float rx = px * c + py * s; + const float ry = py * c - px * s; + + // Half-plane intersection, normalized so the corners (not the blade + // midpoints) sit at the iris radius, matching the pre-curvature look. + // The half-sector phase keeps a corner on the +x axis at rotation 0, which + // is where the polygon used to put one. + const float sector = 2.0f * PI / blades; + const float apothem = IRIS_RADIUS * cosf(PI / blades); + float sdf = 0.0f; + for (int i = 0; i < blades; i++) { + float angle = (static_cast(i) + 0.5f) * sector; + sdf = MAX(sdf, (cosf(angle) * rx + sinf(angle) * ry) / apothem); + } + + // Per-blade bulge: realflare's sine gradient, phrased in our own frame -- + // 0 at the corners, 1 at each blade's midpoint + float t = atan2f(ry, rx) / sector; + t -= floorf(t); + float bulge = sinf(t * PI); + + // curvature 1 must lift the midpoints (sdf 1) to the corner radius + sdf -= bulge * curvature * (1.0f / cosf(PI / blades) - 1.0f); + + return 1.0f - smoothstep01(1.0f - softness, 1.0f + softness, sdf); +} + +// Multiply an occlusion primitive into the mask over its bounding box only. +// realflare evaluates min(sdf) over every primitive at every pixel, which is +// fine on a GPU but far too slow on the CPU; taking the max of the smoothstepped +// occlusion instead is equivalent (smoothstep is monotonically decreasing in +// sdf) and lets each primitive touch only the pixels it covers. +template +void aperture_stamp(SCP_vector& occlusion, float cx, float cy, float reach, float softness, Sdf&& sdf) +{ + const int size = APERTURE_TEXTURE_SIZE; + const float to_px = size * 0.5f; + int x0 = static_cast(floorf((cx - reach + 1.0f) * to_px)); + int x1 = static_cast(ceilf((cx + reach + 1.0f) * to_px)); + int y0 = static_cast(floorf((cy - reach + 1.0f) * to_px)); + int y1 = static_cast(ceilf((cy + reach + 1.0f) * to_px)); + x0 = MAX(x0, 0); + y0 = MAX(y0, 0); + x1 = MIN(x1, size - 1); + y1 = MIN(y1, size - 1); + + for (int y = y0; y <= y1; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = x0; x <= x1; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + float cover = smoothstep01(-softness, softness, -sdf(px, py)); + float& dst = occlusion[static_cast(y) * size + x]; + dst = MAX(dst, cover); + } + } +} + +// Rim diffraction grating (realflare's aperture_grating): radial ridges evenly +// spaced around the iris. Because they are evenly spaced in angle, each pixel +// only has to test the few ridges nearest its own bearing. +// +// realflare anchors the ridges at a fixed distance that lines up with the rim of +// *its* aperture; ours sits at IRIS_RADIUS, so the ridges are anchored there and +// `length` is how far in they reach as a fraction of the iris. `width` is a duty +// cycle of the spacing between neighbouring ridges rather than an absolute size, +// so raising the density thins the ridges instead of merging them into a ring. +void aperture_apply_grating(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.grating.density, 1.0f) * 360.0f); + if (count <= 0 || ap.grating.length <= 0.0f) { + return; + } + + const float step = 2.0f * PI / count; + const float hl = 0.5f * std::clamp(ap.grating.length, 0.0f, 1.0f) * IRIS_RADIUS; + const float centre = IRIS_RADIUS - hl; // outer end of every ridge sits on the rim + const float hw = 0.5f * std::clamp(ap.grating.width, 0.0f, 1.0f) * (step * IRIS_RADIUS); + const float softness = MAX(ap.grating.softness, 1.0f / size); + + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + + // nearest ridge to this pixel's bearing, plus neighbours: ridges + // converge towards the centre, so +-2 avoids gaps between them + int k = static_cast(lroundf(atan2f(py, px) / step)); + float cover = 0.0f; + for (int d = -2; d <= 2; d++) { + float angle = (k + d) * step; + float c = cosf(angle), s = sinf(angle); + // into the ridge's own frame, where it lies along +x + float rx = px * c + py * s; + float ry = py * c - px * s; + float sdf = sdf_rectangle(rx - centre, ry, hl, hw); + cover = MAX(cover, smoothstep01(-softness, softness, -sdf)); + } + mask[static_cast(y) * size + x] *= 1.0f - ap.grating.strength * cover; + } + } +} + +void aperture_apply_scratches(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.scratches.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float hw = ap.scratches.width * 0.1f * 0.5f; + const float hl = ap.scratches.length * 0.5f; + const float softness = MAX(ap.scratches.softness, 1.0f / size); + const float rot = ap.scratches.rotation * (PI / 180.0f); + const float rot_var = ap.scratches.rotation_variation * PI; + const float reach = sqrtf(hw * hw + hl * hl) + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + float angle = rot + (aperture_noise(fi, fc, 2.0f) - 0.5f) * rot_var; + float c = cosf(angle), s = sinf(angle); + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + // rotate about the scratch centre, then measure against the sliver + float dx = px - cx, dy = py - cy; + return sdf_rectangle(dx * c + dy * s, dy * c - dx * s, hw, hl); + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.scratches.strength * occlusion[i]; + } +} + +void aperture_apply_dust(const lens_aperture& ap, SCP_vector& mask) +{ + const int size = APERTURE_TEXTURE_SIZE; + const int count = static_cast(MIN(ap.dust.density, 1.0f) * 1000.0f); + if (count <= 0) { + return; + } + + const float radius = ap.dust.radius * 0.1f; + const float softness = MAX(ap.dust.softness, 1.0f / size); + const float reach = radius + softness; + + SCP_vector occlusion(static_cast(size) * size, 0.0f); + for (int i = 0; i < count; i++) { + auto fi = static_cast(i); + auto fc = static_cast(count); + float cx = aperture_noise(fi, fc, 0.0f) * 2.0f - 1.0f; + float cy = aperture_noise(fi, fc, 1.0f) * 2.0f - 1.0f; + + aperture_stamp(occlusion, cx, cy, reach, softness, [=](float px, float py) { + return sqrtf((px - cx) * (px - cx) + (py - cy) * (py - cy)) - radius; + }); + } + + for (size_t i = 0; i < mask.size(); i++) { + mask[i] *= 1.0f - ap.dust.strength * occlusion[i]; + } +} + +void generate_aperture(const lens_aperture& ap, lens_flare_textures* tex) +{ + const int size = APERTURE_TEXTURE_SIZE; + + tex->aperture_size = size; + tex->aperture.resize(static_cast(size) * size); + + SCP_vector mask(static_cast(size) * size); + for (int y = 0; y < size; y++) { + float py = (y + 0.5f) / size * 2.0f - 1.0f; + for (int x = 0; x < size; x++) { + float px = (x + 0.5f) / size * 2.0f - 1.0f; + mask[static_cast(y) * size + x] = aperture_shape(ap, px, py); + } + } + + // Imperfection layers, in realflare's order. All default to strength 0. + if (ap.grating.strength > 0.0f) { + aperture_apply_grating(ap, mask); + } + if (ap.scratches.strength > 0.0f) { + aperture_apply_scratches(ap, mask); + } + if (ap.dust.strength > 0.0f) { + aperture_apply_dust(ap, mask); + } + + for (size_t i = 0; i < mask.size(); i++) { + tex->aperture[i] = static_cast(std::clamp(mask[i], 0.0f, 1.0f) * 255.0f + 0.5f); + } +} + +void generate_starburst(const lens_flare_textures* apert, lens_flare_textures* tex) +{ + const int size = apert->aperture_size; + tex->starburst_size = size; + tex->starburst.resize(static_cast(size) * size * 4); + + // Fraunhofer diffraction pattern: |FFT(aperture)|^2. The (-1)^(x+y) + // modulation shifts the DC term to the texture center. + SCP_vector> grid(static_cast(size) * size); + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float v = apert->aperture[static_cast(y) * size + x] * (1.0f / 255.0f); + if ((x + y) & 1) { + v = -v; + } + grid[static_cast(y) * size + x] = v; + } + } + lens_flare_fft2d(grid, size, false); + + SCP_vector power(static_cast(size) * size); + for (size_t i = 0; i < power.size(); i++) { + power[i] = std::norm(grid[i]); + } + + // Normalize against the brightest off-DC value so the streaks (not the + // gigantic central spike) span the useful range + const int c = size / 2; + float pmax = 0.0f; + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + if (abs(x - c) <= 2 && abs(y - c) <= 2) { + continue; + } + pmax = MAX(pmax, power[static_cast(y) * size + x]); + } + } + if (pmax <= 0.0f) { + pmax = 1.0f; + } + + auto sample_power = [&](float fx, float fy) -> float { + fx = std::clamp(fx, 0.0f, size - 1.001f); + fy = std::clamp(fy, 0.0f, size - 1.001f); + int x0 = static_cast(fx), y0 = static_cast(fy); + float tx = fx - x0, ty = fy - y0; + float p00 = power[static_cast(y0) * size + x0]; + float p10 = power[static_cast(y0) * size + x0 + 1]; + float p01 = power[static_cast(y0 + 1) * size + x0]; + float p11 = power[static_cast(y0 + 1) * size + x0 + 1]; + return (p00 * (1 - tx) + p10 * tx) * (1 - ty) + (p01 * (1 - tx) + p11 * tx) * ty; + }; + + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + float* out = &tex->starburst[(static_cast(y) * size + x) * 4]; + + // Radial fade so the pattern reaches zero before the texture border + float nx = (x - c) / static_cast(c); + float ny = (y - c) / static_cast(c); + float rn = sqrtf(nx * nx + ny * ny); + float fade = std::clamp((1.0f - rn) / 0.15f, 0.0f, 1.0f); + + for (int k = 0; k < 3; k++) { + // Diffraction angles scale with wavelength: resample the green + // pattern per channel + float scale = Wavelengths_um[1] / Wavelengths_um[k]; + float p = sample_power(c + (x - c) * scale, c + (y - c) * scale) * scale * scale; + out[k] = sqrtf(MIN(p / pmax, 1.0f)) * fade; + } + out[3] = 1.0f; + } + } +} + +} // namespace + +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); + generate_starburst(out, out); +} + +void lens_flare_fft2d(SCP_vector>& data, int size, bool inverse) +{ + Assertion((size & (size - 1)) == 0, "FFT size must be a power of two, got %d", size); + Assertion(static_cast(data.size()) == size * size, "FFT data size mismatch"); + + auto fft_1d = [&](std::complex* base, int stride) { + // bit-reversal permutation + for (int i = 1, j = 0; i < size; i++) { + int bit = size >> 1; + for (; j & bit; bit >>= 1) { + j ^= bit; + } + j ^= bit; + if (i < j) { + std::swap(base[static_cast(i) * stride], base[static_cast(j) * stride]); + } + } + for (int len = 2; len <= size; len <<= 1) { + float ang = 2.0f * PI / len * (inverse ? 1.0f : -1.0f); + std::complex wlen(cosf(ang), sinf(ang)); + for (int i = 0; i < size; i += len) { + std::complex w(1.0f, 0.0f); + for (int k = 0; k < len / 2; k++) { + auto& lhs = base[static_cast(i + k) * stride]; + auto& rhs = base[static_cast(i + k + len / 2) * stride]; + std::complex u = lhs; + std::complex v = rhs * w; + lhs = u + v; + rhs = u - v; + w *= wlen; + } + } + } + if (inverse) { + for (int i = 0; i < size; i++) { + base[static_cast(i) * stride] /= static_cast(size); + } + } + }; + + for (int row = 0; row < size; row++) { + fft_1d(&data[static_cast(row) * size], 1); + } + for (int col = 0; col < size; col++) { + fft_1d(&data[col], size); + } +} + +void lens_flare_generate_aperture_mask(const lens_aperture& ap, lens_flare_textures* out) +{ + generate_aperture(ap, out); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_beams.cpp b/code/graphics/lens_flare_beams.cpp new file mode 100644 index 00000000000..a33e6113100 --- /dev/null +++ b/code/graphics/lens_flare_beams.cpp @@ -0,0 +1,78 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/linklist.h" +#include "object/object.h" +#include "render/3d.h" +#include "weapon/beam.h" + +#include + +// Firing beams as lens-flare sources: one flare at each beam's muzzle, for the +// whole time the beam exists. +// +// Brightness is not invented here. beam_get_muzzle_glow() reports what the +// beam's own muzzle light is emitting, which already ramps up over the warmup, +// holds while the beam fires and ramps back down over the warmdown -- so the +// flare grows and fades with the glow it belongs to instead of following a +// second curve that could disagree with it. +// +// Unlike thruster flares there is no table opt-in, because there is nothing for +// content to opt into that it has not already said: a beam that throws no muzzle +// light throws no flare, and no flare of any kind is drawn unless the mission +// mounts a camera lens in the first place. +// +// beam_get_muzzle_glow() deliberately does not check the Detail.lighting setting +// that gates the dynamic muzzle light itself (beam_light_sanity_and_setup()): a +// lens flare is an artifact of the camera, not a scene light, so lowering the +// lighting detail slider shouldn't make it vanish. + +namespace graphics { + +void lens_flare_gather_beam_sources(SCP_vector& out, int budget) +{ + if (budget <= 0) { + return; + } + + SCP_vector candidates; + + for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) { + if (objp->type != OBJ_BEAM || objp->instance < 0 || objp->instance >= MAX_BEAMS) { + continue; + } + + beam_muzzle_glow glow; + if (!beam_get_muzzle_glow(&Beams[objp->instance], &glow)) { + continue; + } + + // The muzzle sits somewhere out in front of the camera, so how large it + // looks matters as much as how hard it is burning -- the same reasoning, + // and the same calibration, as an engine nozzle + const float dist_sq = vm_vec_dist_squared(&glow.pos, &Eye_position); + if (dist_sq <= 0.0f) { + continue; + } + const float ratio = lens_flare_apparent_ratio(PI * glow.radius * glow.radius / dist_sq); + + flare_source src; + src.pos = glow.pos; + src.at_infinity = false; + src.color = glow.color; + src.intensity = glow.intensity * ratio; + src.visibility = MIN(glow.intensity, 1.0f); // the warmup/warmdown ramp, for the lab + src.kind = flare_source_kind::beam; + src.index = OBJ_INDEX(objp); + + if (src.intensity <= 0.0f) { + continue; + } + candidates.push_back(src); + } + + lens_flare_commit_candidates(out, candidates, budget); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_internal.h b/code/graphics/lens_flare_internal.h new file mode 100644 index 00000000000..33a3b66771b --- /dev/null +++ b/code/graphics/lens_flare_internal.h @@ -0,0 +1,151 @@ +#pragma once + +#include "graphics/lens_flare.h" + +// Private interface between the six lens-flare translation units. Nothing here +// is part of the module's API -- see graphics/lens_flare.h for that. +// +// lens_flare.cpp module state, the camera lens, texture cache, and +// the per-frame build the render backends consume +// lens_flare_optics.cpp the paraxial model: ray-transfer matrices, ghost +// enumeration, coated-Fresnel reflectance +// lens_flare_aperture.cpp image synthesis: the iris mask and the starburst +// that is its Fraunhofer transform +// lens_flare_table.cpp lens_flares.tbl / *-lens.tbm parsing +// lens_flare_thrusters.cpp finding and ranking the nozzles bright enough to +// flare +// lens_flare_beams.cpp the same for firing beam weapons +// +// The optics and image-synthesis halves touch no engine state at all; they are +// pure functions of a lens_system / lens_aperture. + +struct glow_point; // model/model.h + +namespace graphics { + +// One light the camera images this frame, already reduced to a world position, a +// colour and a brightness. The frame build in lens_flare.cpp projects and packs +// these without caring where they came from, which is what lets a sun, an engine +// nozzle and a firing beam share every step below the gather. +struct flare_source { + // World position -- or, when at_infinity, a direction from the eye. Suns are + // at infinity and engines are not, and it changes which g3 projection applies, + // so the two cannot simply be the same vector. + vec3d pos = {{{0.0f, 0.0f, 1.0f}}}; + bool at_infinity = false; + + vec3d color = {{{1.0f, 1.0f, 1.0f}}}; // linear rgb, 0..1 + float intensity = 0.0f; // multiplies the colour; every fade is already folded in + + // Whether this source draws the lens's ghost train as well as its starburst. + // Decided by the gather -- suns and beams always do, thrusters follow the + // lab's lens_flare_tuning::thruster_ghosts, since there are dozens of them + // and a ghost train each is noise -- so the packing below stays a plain + // function of the lens, the geometry and this flag. + bool draw_ghosts = true; + + // Diagnostics for the lab, reported straight through to lens_flare_draw. + // `visibility` is the fade that `intensity` above already accounts for, kept + // separately only so the lab can show it. + float visibility = 0.0f; + flare_source_kind kind = flare_source_kind::sun; + int index = -1; // sun index, or objnum for a thruster or beam source +}; + +// Append the nozzles bright enough to be worth a flare, brightest first and at +// most `budget` of them. A no-op when no species tables one. +// +// Budgeted rather than unbounded because every source costs a multi-kilobyte +// uniform block and its own instanced draw, and a fleet engagement has hundreds +// of lit nozzles on screen. The budget is a hard cap on the pass, not a hint. +void lens_flare_gather_thruster_sources(SCP_vector& out, int budget); + +// Where one nozzle images and how large it appears from `eye`: the solid angle it +// subtends (pi*r^2 over the square of its distance), scaled by how squarely it +// faces the camera. Nothing here is normalized against the calibration reference +// -- the caller does that -- so this stays a plain statement of geometry. +// +// `orient`/`pos` place the model in the world, and nothing else about the ship +// matters, which is what lets this be tested without a scene. +// +// Returns false when the nozzle faces away from the camera, or when the eye sits +// exactly on it and it therefore has no direction to face. +bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye, + vec3d* world_pnt, float* apparent); + +// Append the muzzles of every firing beam, brightest first and at most `budget` +// of them. Their brightness follows the beam's own muzzle light, so a beam ramps +// its flare up over its warmup and back down over its warmdown exactly as it +// ramps that light. +void lens_flare_gather_beam_sources(SCP_vector& out, int budget); + +// The tail every finite-source gather shares: rank `candidates` by brightness, +// cut to `budget`, drop whatever the eye cannot see, and append the rest to +// `out`. `candidates` is left in an unspecified state. +// +// The order matters and is the reason this is one function rather than a +// convention each gather follows. Ranking, not any brightness threshold, is what +// bounds the pass -- a flare that would have been drawn faintly is the one worth +// losing, and ranking by the same number the tint is built from means the pass +// degrades by dropping what was least visible anyway. Visibility comes last +// because it costs a scene-wide raycast per source, so testing before the cut +// would scale the cost with every candidate in the mission instead of with the +// budget. A source the raycast drops does not free its slot for the next +// brightest: one lost flare is cheaper than a second pass to refill it. +void lens_flare_commit_candidates(SCP_vector& out, SCP_vector& candidates, int budget); + +// Whether the eye has an unobstructed line of sight to a finite world point -- +// the same segment/model test AI targeting uses to decide whether a shot has a +// clear path to its target (test_line_of_sight(), ai/aicode.cpp). A nozzle or +// beam muzzle is bright and squarely faced just as often tucked behind its own +// ship's hull, a wing, or another ship entirely, so both gathers call this on +// the sources that survive their budget cut -- after the cut, not before, since +// this costs a scene-wide raycast and the budget is what bounds how many of +// those a frame can afford. Deliberately does not exclude the emitting ship: +// a nozzle or muzzle on the far side of its own hull should occlude exactly +// like it would behind anything else. +bool lens_flare_point_visible(const vec3d& world_pos); + +// Fraunhofer C / d / F lines (red / green / blue), in micrometers. The three +// wavelengths everything chromatic in the flare is evaluated at: dispersion and +// coating reflectance in the optics, diffraction scaling in the starburst. +constexpr float Wavelengths_um[3] = {0.65627f, 0.58756f, 0.48613f}; + +// Every finite source -- an engine nozzle, a beam muzzle -- is calibrated +// against one reference: a disc of radius r seen from thirty-two of its own +// radii away. Keeping it here rather than per source kind is what makes an +// intensity of 1.0 mean the same brightness whatever it was stated on, and means +// re-tuning the calibration moves one constant. +constexpr float Reference_radius = 1.0f; +constexpr float Reference_distance = 32.0f; +constexpr float Reference_apparent = PI * Reference_radius * Reference_radius / + (Reference_distance * Reference_distance); + +// Ceiling on how far past that reference a source may be driven. Flying down a +// destroyer's exhaust, or standing next to a firing beam, would otherwise put an +// unbounded number into the tint and white out the frame; a flare that has +// already saturated cannot usefully get brighter anyway. +constexpr float Max_apparent_ratio = 3.0f; + +// The solid angle a finite source subtends (pi*r^2 over the square of its +// distance), as the multiple of the reference above that an intensity of 1.0 is +// stated against. +inline float lens_flare_apparent_ratio(float solid_angle) +{ + return MIN(solid_angle / Reference_apparent, Max_apparent_ratio); +} + +// Render the iris mask of an aperture and the starburst that follows from it, +// filling both halves of `out`. This is the expensive one: a 512^2 mask plus a +// 2D FFT of it. (graphics/lens_flare.h's lens_flare_generate_aperture_mask() +// stops after the mask, for callers that don't need the transform.) +void lens_flare_generate_textures(const lens_aperture& ap, lens_flare_textures* out); + +// Parse lens_flares.tbl (falling back to the embedded default) plus every +// *-lens.tbm, appending to `systems` -- a later table redefining a lens by name +// replaces the earlier entry -- and precomputing each one's ghosts. Also reports +// the "$Default Lens:" name, unresolved: it may be declared before, or by a +// different table than, the lens it names. +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name); + +} // namespace graphics diff --git a/code/graphics/lens_flare_optics.cpp b/code/graphics/lens_flare_optics.cpp new file mode 100644 index 00000000000..66cce237485 --- /dev/null +++ b/code/graphics/lens_flare_optics.cpp @@ -0,0 +1,297 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +// for MAX_LENS_FLARE_INSTANCES: a ghost the shader has no instance slot for is +// not worth enumerating, so the budget bounds the precompute +#include "graphics/util/uniform_structs.h" + +#include +#include + +// The paraxial optics behind the flare: a lens_system is reduced here to a set +// of two-reflection ghost paths, each with its own ray-transfer matrices and +// coated-Fresnel tint, once at table load. Everything in this file is a pure +// function of the prescription -- no engine state, no frame, no screen -- with +// one exception: the ghost count is capped at the shader's instance budget, +// since a ghost with no instance slot to draw it in is not worth enumerating. + +namespace graphics { +namespace { + +// ---- 2x2 ray-transfer matrix helpers ([A B; C D] acting on [height; angle]) ---- + +struct mat2 { + float a, b, c, d; +}; + +mat2 m2_identity() { return {1.0f, 0.0f, 0.0f, 1.0f}; } + +mat2 m2_mul(const mat2& m, const mat2& n) +{ + return {m.a * n.a + m.b * n.c, m.a * n.b + m.b * n.d, m.c * n.a + m.d * n.c, m.c * n.b + m.d * n.d}; +} + +mat2 m2_translate(float t) { return {1.0f, t, 0.0f, 1.0f}; } + +// Refraction at a spherical interface with signed curvature 1/R, from index n1 into n2 +mat2 m2_refract(float n1, float n2, float inv_r) { return {1.0f, 0.0f, (n1 - n2) * inv_r / n2, n1 / n2}; } + +// Mirror reflection at a spherical surface with signed curvature 1/R +mat2 m2_reflect(float inv_r) { return {1.0f, 0.0f, 2.0f * inv_r, 1.0f}; } + +mat2 m2_inverse(const mat2& m) +{ + float det = m.a * m.d - m.b * m.c; + return {m.d / det, -m.b / det, -m.c / det, m.a / det}; +} + +float surface_inv_radius(const lens_surface& s) +{ + return (s.radius != 0.0f) ? 1.0f / s.radius : 0.0f; +} + +// Refractive index behind a surface at one of the three design wavelengths, +// with Cauchy 2-term dispersion fitted through n_d and the Abbe number. +float surface_index(const lens_surface& s, int wl) +{ + if (s.n <= 1.0005f || s.abbe <= 0.0f) { + return s.n; + } + constexpr float lF = 0.48613f, lC = 0.65627f, lD = 0.58756f; + float B = (s.n - 1.0f) / (s.abbe * (1.0f / (lF * lF) - 1.0f / (lC * lC))); + float A = s.n - B / (lD * lD); + float l = Wavelengths_um[wl]; + return A + B / (l * l); +} + +float index_after(const lens_system& lens, int surf, int wl) +{ + return surface_index(lens.surfaces[surf], wl); +} + +float index_before(const lens_system& lens, int surf, int wl) +{ + return (surf == 0) ? 1.0f : surface_index(lens.surfaces[surf - 1], wl); +} + +int find_stop_index(const lens_system& lens) +{ + for (int i = 0; i < static_cast(lens.surfaces.size()); i++) { + if (lens.surfaces[i].is_stop) { + return i; + } + } + return -1; +} + +// Forward system matrix (no reflections) from the first surface to just after +// the last surface, at the given wavelength. +mat2 system_matrix(const lens_system& lens, int wl) +{ + mat2 m = m2_identity(); + int n = static_cast(lens.surfaces.size()); + for (int s = 0; s < n; s++) { + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(lens.surfaces[s])), m); + if (s < n - 1) { + m = m2_mul(m2_translate(lens.surfaces[s].thickness), m); + } + } + return m; +} + +// Compose the ray-transfer matrices of one two-reflection ghost path +// (first reflection at surface hi going forward, second at surface lo going +// backward, lo < hi), splitting at the LAST aperture-stop crossing. +void trace_ghost_path(const lens_system& lens, int hi, int lo, int stop, int wl, float bfd, + float out_ma[4], float out_ms[4]) +{ + mat2 m = m2_identity(); + mat2 ma = m2_identity(); + bool have_ma = false; + + auto note_stop = [&]() { + ma = m; + have_ma = true; + }; + + const auto& surf = lens.surfaces; + int n = static_cast(surf.size()); + + // Phase 1: forward from surface 0 up to surface hi + for (int s = 0; s < hi; s++) { + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + m = m2_mul(m2_translate(surf[s].thickness), m); + } + + // Reflect at surface hi (now travelling backward; radii of surfaces crossed + // backward flip sign in the unfolded system) + m = m2_mul(m2_reflect(surface_inv_radius(surf[hi])), m); + + // Phase 2: backward from surface hi down to surface lo + for (int s = hi - 1; s > lo; s--) { + m = m2_mul(m2_translate(surf[s].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_after(lens, s, wl), index_before(lens, s, wl), -surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(surf[lo].thickness), m); + + // Reflect at surface lo (hit from behind; forward again) + m = m2_mul(m2_reflect(-surface_inv_radius(surf[lo])), m); + + // Phase 3: forward from surface lo to the sensor + for (int s = lo + 1; s < n; s++) { + m = m2_mul(m2_translate(surf[s - 1].thickness), m); + if (s == stop) { + note_stop(); + } + m = m2_mul(m2_refract(index_before(lens, s, wl), index_after(lens, s, wl), surface_inv_radius(surf[s])), m); + } + m = m2_mul(m2_translate(bfd), m); + + if (!have_ma) { + // Degenerate prescription (no stop crossing); treat the whole path as Ms + ma = m2_identity(); + } + + mat2 ms = m2_mul(m, m2_inverse(ma)); + + out_ma[0] = ma.a; + out_ma[1] = ma.b; + out_ma[2] = ma.c; + out_ma[3] = ma.d; + out_ms[0] = ms.a; + out_ms[1] = ms.b; + out_ms[2] = ms.c; + out_ms[3] = ms.d; +} + +float ghost_coating_wavelength(const lens_system& lens, const lens_surface& s) +{ + return (s.coating_wavelength < 0.0f) ? lens.coating_wavelength : s.coating_wavelength; +} + +} // namespace + +float lens_flare_fresnel_reflectance(float n1, float n2, float lambda0_nm, float lambda_nm) +{ + if (lambda0_nm <= 0.0f) { + float r = (n1 - n2) / (n1 + n2); + return r * r; + } + + // Single quarter-wave layer (tuned to lambda0) between n1 and n2, ideally + // index sqrt(n1*n2) but no better than MgF2 (1.38), at normal incidence + float nc = MAX(1.38f, sqrtf(n1 * n2)); + float r1 = (n1 - nc) / (n1 + nc); + float r2 = (nc - n2) / (nc + n2); + float cphi = cosf(PI * lambda0_nm / lambda_nm); + float num = r1 * r1 + r2 * r2 + 2.0f * r1 * r2 * cphi; + float den = 1.0f + r1 * r1 * r2 * r2 + 2.0f * r1 * r2 * cphi; + return num / den; +} + +bool lens_flare_precompute(lens_system& lens) +{ + lens.ghosts.clear(); + + int n = static_cast(lens.surfaces.size()); + if (n < 2) { + return false; + } + + // Normalize stop surfaces: flat, index-continuous with the preceding medium + for (int i = 0; i < n; i++) { + if (lens.surfaces[i].is_stop) { + lens.surfaces[i].radius = 0.0f; + lens.surfaces[i].n = (i == 0) ? 1.0f : lens.surfaces[i - 1].n; + lens.surfaces[i].abbe = (i == 0) ? 0.0f : lens.surfaces[i - 1].abbe; + } + } + + int stop = find_stop_index(lens); + if (stop < 0) { + // No explicit stop: use the middle surface's plane for aperture clipping + stop = n / 2; + } + + // Effective focal length and back focal distance (green), sensor placed at + // the infinity focus + mat2 sys = system_matrix(lens, 1); + if (fabsf(sys.c) < 1e-6f) { + return false; // afocal; can't image onto a sensor + } + lens.efl = -1.0f / sys.c; + lens.bfd = -sys.a / sys.c; + if (lens.efl <= 0.0f || lens.bfd <= 0.0f) { + return false; + } + + // Enumerate all two-reflection ghost paths between refractive surfaces + struct scored_ghost { + lens_flare_ghost g; + float key; + }; + SCP_vector scored; + + for (int hi = 1; hi < n; hi++) { + if (fabsf(index_before(lens, hi, 1) - index_after(lens, hi, 1)) < 1e-4f) { + continue; // no index step -> no reflection (also skips the stop) + } + for (int lo = 0; lo < hi; lo++) { + if (fabsf(index_before(lens, lo, 1) - index_after(lens, lo, 1)) < 1e-4f) { + continue; + } + + scored_ghost sg; + sg.g.surf_first = hi; + sg.g.surf_second = lo; + + for (int wl = 0; wl < 3; wl++) { + trace_ghost_path(lens, hi, lo, stop, wl, lens.bfd, sg.g.ma[wl], sg.g.ms[wl]); + + float lambda_nm = Wavelengths_um[wl] * 1000.0f; + float r_first = lens_flare_fresnel_reflectance(index_before(lens, hi, wl), index_after(lens, hi, wl), + ghost_coating_wavelength(lens, lens.surfaces[hi]), lambda_nm); + float r_second = lens_flare_fresnel_reflectance(index_after(lens, lo, wl), index_before(lens, lo, wl), + ghost_coating_wavelength(lens, lens.surfaces[lo]), lambda_nm); + sg.g.reflectance[wl] = r_first * r_second; + } + + if (sg.g.reflectance[1] < 1e-6f) { + continue; + } + + // Brightness-ish sort key: reflectance, boosted for concentrated + // (small-footprint) ghosts + float a_g = sg.g.ms[1][0] * sg.g.ma[1][0] + sg.g.ms[1][1] * sg.g.ma[1][2]; + sg.key = sg.g.reflectance[1] * MIN(1.0f / (a_g * a_g + 1e-3f), 100.0f); + scored.push_back(sg); + } + } + + std::sort(scored.begin(), scored.end(), [](const scored_ghost& x, const scored_ghost& y) { return x.key > y.key; }); + + // Enumerate every ghost the instance budget can hold, brightest first, and + // leave $Max Ghosts: to pack_source_instances() -- which just takes a prefix of + // this. Capping here instead would bake the tabled number into the precompute + // and so put a full paraxial re-trace behind every edit of it. + // + // The ceiling is what remains once the starburst and streak have reserved their + // slots, so enumerating more could never be drawn anyway. + const int cap = MAX_LENS_FLARE_GHOSTS; + for (const auto& sg : scored) { + if (static_cast(lens.ghosts.size()) >= cap) { + break; + } + lens.ghosts.push_back(sg.g); + } + + return !lens.ghosts.empty(); +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_table.cpp b/code/graphics/lens_flare_table.cpp new file mode 100644 index 00000000000..2d13e1bd776 --- /dev/null +++ b/code/graphics/lens_flare_table.cpp @@ -0,0 +1,300 @@ +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "cfile/cfile.h" +#include "def_files/def_files.h" +#include "parse/parselo.h" + +#include + +// lens_flares.tbl / *-lens.tbm parsing. Owns no state: the systems it reads are +// appended to the vector the caller hands over, and each is precomputed on the +// way in so an unusable prescription never reaches the renderer. + +namespace graphics { +namespace { + +// Where the parsed lenses go for the duration of one parse run. File-scope +// pointers rather than parameters because parse_modular_table() takes a plain +// function pointer, so the per-file callback cannot capture anything. Set and +// cleared by lens_flare_parse_tables(), which is the only entry point. +SCP_vector* Parse_systems = nullptr; +SCP_string* Parse_default_name = nullptr; + +int find_system(const SCP_vector& systems, const char* name) +{ + for (int i = 0; i < static_cast(systems.size()); i++) { + if (!stricmp(systems[i].name.c_str(), name)) { + return i; + } + } + return -1; +} + +// ---- table parsing ---- + +void parse_lens_table_core() +{ + Assertion(Parse_systems != nullptr && Parse_default_name != nullptr, + "Lens tables parsed outside lens_flare_parse_tables()!"); + auto& systems = *Parse_systems; + auto& default_name = *Parse_default_name; + + reset_parse(); + + required_string("#Lens Systems"); + + // The lens a mission gets when it doesn't name one itself. Left empty by the + // shipped table, so content that never asks for a lens keeps the retail look; + // a mod opts its whole campaign in with one line in a *-lens.tbm. + if (optional_string("$Default Lens:")) { + stuff_string(default_name, F_NAME); + } + + while (optional_string("$Name:")) { + SCP_string name; + stuff_string(name, F_NAME); + + // "+override" edits the lens of this name that an earlier table defined, + // leaving everything the entry does not mention exactly as it was -- + // which is how a mod restyles one shipped lens without transcribing its + // whole prescription. Without it the entry is a complete definition, and + // a name already in the table is replaced outright. + const bool overriding = optional_string("+override") != 0; + + const int existing = find_system(systems, name.c_str()); + + lens_system ls; + if (overriding) { + if (existing >= 0) { + ls = systems[existing]; + } else { + error_display(0, + "Lens system '%s': +override names a lens no earlier table defines; reading this entry as a " + "new lens system instead", + name.c_str()); + } + } + ls.name = name; + + if (optional_string("$Entrance Pupil Radius:")) { + stuff_float(&ls.entrance_radius); + } + if (optional_string("$Aperture Radius:")) { + stuff_float(&ls.aperture_radius); + } + if (optional_string("$Sensor Width:")) { + stuff_float(&ls.sensor_width); + } + if (optional_string("$Anamorphic Squeeze:")) { + stuff_float(&ls.anamorphic.squeeze); + } + if (optional_string("$Anamorphic Streak:")) { + stuff_float(&ls.anamorphic.streak.strength); + if (optional_string("+Length:")) { + stuff_float(&ls.anamorphic.streak.length); + } + if (optional_string("+Thickness:")) { + stuff_float(&ls.anamorphic.streak.thickness); + } + if (optional_string("+Tint:")) { + float rgb[3] = {1.0f, 1.0f, 1.0f}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Lens system '%s': +Tint: needs ( r, g, b )", ls.name.c_str()); + } + ls.anamorphic.streak.tint[0] = rgb[0]; + ls.anamorphic.streak.tint[1] = rgb[1]; + ls.anamorphic.streak.tint[2] = rgb[2]; + } + } + if (optional_string("$Coating Wavelength:")) { + stuff_float(&ls.coating_wavelength); + } + if (optional_string("$Aperture Blades:")) { + stuff_int(&ls.aperture.blades); + } + if (optional_string("+Blade Rotation:")) { + stuff_float(&ls.aperture.rotation); + } + if (optional_string("+Blade Curvature:")) { + stuff_float(&ls.aperture.curvature); + } + if (optional_string("+Edge Softness:")) { + stuff_float(&ls.aperture.softness); + } + if (optional_string("$Aperture Grating:")) { + stuff_float(&ls.aperture.grating.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.grating.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.grating.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.grating.width); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.grating.softness); + } + } + if (optional_string("$Aperture Scratches:")) { + stuff_float(&ls.aperture.scratches.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.scratches.density); + } + if (optional_string("+Length:")) { + stuff_float(&ls.aperture.scratches.length); + } + if (optional_string("+Width:")) { + stuff_float(&ls.aperture.scratches.width); + } + if (optional_string("+Rotation:")) { + stuff_float(&ls.aperture.scratches.rotation); + } + if (optional_string("+Rotation Variation:")) { + stuff_float(&ls.aperture.scratches.rotation_variation); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.scratches.softness); + } + } + if (optional_string("$Aperture Dust:")) { + stuff_float(&ls.aperture.dust.strength); + if (optional_string("+Density:")) { + stuff_float(&ls.aperture.dust.density); + } + if (optional_string("+Radius:")) { + stuff_float(&ls.aperture.dust.radius); + } + if (optional_string("+Softness:")) { + stuff_float(&ls.aperture.dust.softness); + } + } + if (optional_string("$Starburst:")) { + stuff_boolean(&ls.starburst); + } + if (optional_string("+Starburst Scale:")) { + stuff_float(&ls.starburst_scale); + } + if (optional_string("$Intensity:")) { + stuff_float(&ls.intensity); + } + if (optional_string("$Max Ghosts:")) { + stuff_int(&ls.max_ghosts); + } + + // The prescription, wrapped in a start/end pair so that a stack of twenty + // surfaces reads as one block rather than as twenty loose options. + // + // An entry that opens a stack replaces the whole of it. A prescription is + // an ordered run of surfaces whose every property (focal length, ghost + // enumeration, where the iris falls) comes from the run as a whole, so + // there is nothing a partial edit could mean -- which is why the clear() + // below is unconditional rather than something "+override" opts out of. + if (optional_string("$Lens Stack Start:")) { + ls.surfaces.clear(); + + while (true) { + if (optional_string("$Surface:")) { + float vals[3] = {0.0f, 0.0f, 1.0f}; + size_t count = stuff_float_list(vals, 3); + if (count != 3) { + error_display(0, "Lens system '%s': $Surface: needs ( radius, thickness, index )", + ls.name.c_str()); + } + lens_surface s; + s.radius = vals[0]; + s.thickness = vals[1]; + s.n = vals[2]; + if (optional_string("+Abbe:")) { + stuff_float(&s.abbe); + } + if (optional_string("+Coating Wavelength:")) { + stuff_float(&s.coating_wavelength); + } + ls.surfaces.push_back(s); + } else if (optional_string("$Stop:")) { + float d = 0.0f; + size_t count = stuff_float_list(&d, 1); + if (count != 1) { + error_display(0, "Lens system '%s': $Stop: needs ( thickness )", ls.name.c_str()); + } + lens_surface s; + s.thickness = d; + s.is_stop = true; + ls.surfaces.push_back(s); + } else { + break; + } + } + + // The closing token has no colon, but optional_string matches on a + // prefix, so a table that writes one anyway would otherwise leave it in + // the stream and abort the parse several lines later with an error + // naming the wrong thing. Checked longest-first for the same reason. + if (!optional_string("$Lens Stack End:") && !optional_string("$Lens Stack End")) { + error_display(1, "Lens system '%s': $Lens Stack Start: is never closed by $Lens Stack End", + ls.name.c_str()); + } + } else if (check_for_string("$Surface:") || check_for_string("$Stop:")) { + // Diagnosed rather than accepted: left to the loop above, a bare + // surface list ends the entry and then fails against "$Name:"/"#End" + // with a message that says nothing about surfaces + error_display(1, + "Lens system '%s': surfaces must be wrapped in $Lens Stack Start: ... $Lens Stack End", + ls.name.c_str()); + } + + if (!lens_flare_precompute(ls)) { + error_display(0, "Lens system '%s' has an unusable prescription and will be ignored", ls.name.c_str()); + continue; + } + + // a later table may redefine (or, with "+override", edit) a lens the + // engine or an earlier tbm shipped. Committed only now, so an entry whose + // prescription turned out to be unusable leaves the earlier one standing. + if (existing >= 0) { + systems[existing] = std::move(ls); + } else { + systems.push_back(std::move(ls)); + } + } + + required_string("#End"); +} + +void parse_lens_table_file(const char* filename) +{ + try { + if (filename == nullptr) { + read_file_text_from_default(defaults_get_file("lens_flares.tbl")); + } else { + read_file_text(filename, CF_TYPE_TABLES); + } + parse_lens_table_core(); + } catch (const parse::ParseException& e) { + mprintf(("Unable to parse '%s'! Error message = %s.\n", (filename != nullptr) ? filename : "", e.what())); + } +} + +} // namespace + +void lens_flare_parse_tables(SCP_vector& systems, SCP_string& default_lens_name) +{ + Parse_systems = &systems; + Parse_default_name = &default_lens_name; + + if (cf_exists_full("lens_flares.tbl", CF_TYPE_TABLES)) { + parse_lens_table_file("lens_flares.tbl"); + } else { + parse_lens_table_file(nullptr); + } + + parse_modular_table("*-lens.tbm", [](const char* filename) { parse_lens_table_file(filename); }); + + Parse_systems = nullptr; + Parse_default_name = nullptr; +} + +} // namespace graphics diff --git a/code/graphics/lens_flare_thrusters.cpp b/code/graphics/lens_flare_thrusters.cpp new file mode 100644 index 00000000000..f9a69c1cb88 --- /dev/null +++ b/code/graphics/lens_flare_thrusters.cpp @@ -0,0 +1,225 @@ + +#include "lens_flare.h" +#include "lens_flare_internal.h" + +#include "globalincs/systemvars.h" + +#include "model/model.h" +#include "object/object.h" +#include "render/3d.h" +#include "ship/ship.h" +#include "species_defs/species_defs.h" + +#include +#include + +// Engines as lens-flare sources. Everything here answers one question -- which +// ships' engines are bright enough this frame to be worth imaging, and how +// bright -- and hands the answer to lens_flare.cpp as plain flare_sources. It +// knows nothing about lenses, ghosts or quads. +// +// Every lit nozzle is its own source: a capital ship's engine banks are set far +// enough apart to read as separate points in frame, so one flare at their +// centroid would sit where no engine is. The cost of that is a uniform block and +// a draw call per nozzle, which is what the budget in +// lens_flare_gather_thruster_sources() bounds, and what +// lens_flare_tuning::thruster_ghosts keeps affordable by drawing only the +// starburst of each. +// +// None of modelrender.cpp's thruster geometry is duplicated here: submodel +// rotation, warp-plane clipping and the per-frame glow noise all move a nozzle by +// less than its own apparent size, so a plain rigid transform of the glow points +// is enough. + +namespace graphics { +namespace { + +// The lab's override of every species' settings; see lens_flare.h +std::optional Lab_thruster_flare; + +// The apparent-size calibration a nozzle is stated against, and the ceiling on +// how far past it one may be driven, are shared with beam muzzles -- +// lens_flare_apparent_ratio() in lens_flare_internal.h. Keeping one copy is what +// makes an intensity of 1.0 mean the same brightness whichever kind of source it +// was tabled on. + +// Floor below which a source cannot change a pixel: by the time it reaches the +// frame it has also been multiplied by the lens's own intensity, so this is +// already a small fraction of one display level. +// +// Deliberately far below anything you could see, because the budget's ranking -- +// not a threshold -- is what decides which flares are worth drawing. A threshold +// set where it could plausibly cull something visible is how normal-throttle +// engines came to look like they did not flare at all. +constexpr float MIN_SOURCE_INTENSITY = 0.002f; + +// Is anything at all asking for thruster flares? Almost always no, and that case +// has to cost nothing more than this loop over the (three, usually) species. +bool any_thruster_flares_enabled() +{ + if (Lab_thruster_flare) { + return Lab_thruster_flare->enabled; + } + return std::any_of(Species_info.begin(), Species_info.end(), + [](const species_info& species) { return species.thruster_flare.enabled; }); +} + +// Append one source per lit, camera-facing nozzle of this ship. Nothing at all +// when its engines are off, destroyed, or the ship isn't drawn. +void gather_ship_nozzles(const object* objp, SCP_vector& out) +{ + const ship* shipp = &Ships[objp->instance]; + const ship_info* sip = &Ship_info[shipp->ship_info_index]; + + const thruster_flare_info flare = lens_flare_thruster_settings(sip->species); + if (!flare.enabled) { + return; + } + + // The gates ship_render() applies before it ever asks for thruster geometry: + // a ship that isn't drawn has no glow for the camera to image, and engines + // that are dead or disrupted are exactly the ones the glow is suppressed for. + if (!(objp->flags[Object::Object_Flags::Renders]) || shipp->flags[Ship::Ship_Flags::Cloaked] || + shipp->flags[Ship::Ship_Flags::Disabled] || ship_subsys_disrupted(shipp, SUBSYSTEM_ENGINE)) { + return; + } + // The player's own ship isn't drawn from inside its own cockpit, so its + // engines -- a couple of metres behind the camera -- must not flare either + if (objp == Viewer_obj && !(Viewer_mode & VM_TOPDOWN)) { + return; + } + + // How hard the engines are running, which is the same quantity the thruster + // geometry is stretched by -- so a nozzle flares exactly when its glow is + // drawn, at every throttle setting and not only under afterburner. + const float throttle = std::clamp(vm_vec_mag(&objp->phys_info.linear_thrust), 0.0f, 1.0f); + if (throttle <= 0.0f) { + return; + } + const bool use_ab = (objp->phys_info.flags & (PF_AFTERBURNER_ON | PF_BOOSTER_ON)) != 0; + + if (sip->model_num < 0) { + return; + } + const polymodel* pm = model_get(sip->model_num); + if (pm == nullptr || pm->n_thrusters <= 0) { + return; + } + + // Ghosts are a property of the whole class of thruster flares, resolved once + // here rather than per nozzle + const bool draw_ghosts = lens_flare_get_tuning().thruster_ghosts; + const float brightness = use_ab ? flare.afterburner_intensity : flare.intensity; + + for (int i = 0; i < pm->n_thrusters; i++) { + const thruster_bank& bank = pm->thrusters[i]; + if (!bank.points || bank.num_points <= 0) { + continue; + } + if (!model_should_render_engine_glow(OBJ_INDEX(objp), bank.obj_num)) { + continue; + } + + for (int j = 0; j < bank.num_points; j++) { + vec3d world_pnt; + float apparent; + if (!lens_flare_nozzle_apparent(bank.points[j], objp->orient, objp->pos, Eye_position, &world_pnt, + &apparent)) { + continue; + } + + // Irradiance at the entrance pupil, as a multiple of the reference + // source. This is the term "brightness follows the throttle and the + // afterburner" leaves out, and the one that keeps a battle's worth of + // distant fighters from each throwing a full-strength flare. + const float ratio = lens_flare_apparent_ratio(apparent); + + flare_source src; + src.pos = world_pnt; + src.at_infinity = false; + src.color = flare.color; + src.intensity = brightness * throttle * ratio; + src.draw_ghosts = draw_ghosts; + src.visibility = throttle; + src.kind = flare_source_kind::thruster; + src.index = OBJ_INDEX(objp); + + if (src.intensity < MIN_SOURCE_INTENSITY) { + continue; + } + out.push_back(src); + } + } +} + +} // namespace + +std::optional& lens_flare_lab_thruster_flare() { return Lab_thruster_flare; } + +thruster_flare_info lens_flare_thruster_settings(int species_idx) +{ + if (Lab_thruster_flare) { + return *Lab_thruster_flare; + } + if (SCP_vector_inbounds(Species_info, species_idx)) { + return Species_info[species_idx].thruster_flare; + } + return {}; +} + +bool lens_flare_nozzle_apparent(const glow_point& gpt, const matrix& orient, const vec3d& pos, const vec3d& eye, + vec3d* world_pnt, float* apparent) +{ + vm_vec_unrotate(world_pnt, &gpt.pnt, &orient); + vm_vec_add2(world_pnt, &pos); + + vec3d to_eye; + vm_vec_sub(&to_eye, &eye, world_pnt); + const float dist = vm_vec_normalize_safe(&to_eye, true); + if (dist <= 0.0f) { + // the eye is exactly on the nozzle; it has no direction to face + return false; + } + + // A null normal is a legal glowpoint, and means the nozzle shines every way -- + // the same reading the thruster renderer gives it. + float facing = 1.0f; + if (!IS_VEC_NULL_SQ_SAFE(&gpt.norm)) { + vec3d world_norm; + vm_vec_unrotate(&world_norm, &gpt.norm, &orient); + // model normals are not guaranteed unit-length + if (vm_vec_normalize_safe(&world_norm, true) <= 0.0f) { + return false; + } + // The glow itself fades in over the first third of the hemisphere (the + // `d *= 3` in model_queue_render_thrusters), so the flare follows the same + // curve rather than inventing a second one. + facing = std::clamp(vm_vec_dot(&to_eye, &world_norm) * 3.0f, 0.0f, 1.0f); + } + if (facing <= 0.0f) { + return false; + } + + *apparent = facing * PI * gpt.radius * gpt.radius / (dist * dist); + return true; +} + +void lens_flare_gather_thruster_sources(SCP_vector& out, int budget) +{ + if (budget <= 0 || !any_thruster_flares_enabled()) { + return; + } + + SCP_vector candidates; + + for (const object* objp = GET_FIRST(&obj_used_list); objp != END_OF_LIST(&obj_used_list); objp = GET_NEXT(objp)) { + if (objp->type != OBJ_SHIP || objp->instance < 0) { + continue; + } + gather_ship_nozzles(objp, candidates); + } + + lens_flare_commit_candidates(out, candidates, budget); +} + +} // namespace graphics diff --git a/code/graphics/opengl/gropengl.cpp b/code/graphics/opengl/gropengl.cpp index 444515b797a..66938f670fa 100644 --- a/code/graphics/opengl/gropengl.cpp +++ b/code/graphics/opengl/gropengl.cpp @@ -1126,6 +1126,7 @@ void gr_opengl_init_function_pointers() gr_screen.gf_scene_texture_begin = gr_opengl_scene_texture_begin; gr_screen.gf_scene_texture_end = gr_opengl_scene_texture_end; gr_screen.gf_copy_effect_texture = gr_opengl_copy_effect_texture; + gr_screen.gf_resize_render_targets = gr_opengl_resize_render_targets; gr_screen.gf_deferred_lighting_begin = gr_opengl_deferred_lighting_begin; gr_screen.gf_deferred_lighting_msaa = gr_opengl_deferred_lighting_msaa; @@ -1513,7 +1514,7 @@ bool gr_opengl_init(std::unique_ptr&& graphicsOps) opengl_shader_init(); // post processing effects, after shaders are initialized - opengl_setup_scene_textures(); + opengl_setup_scene_textures(gr_screen.max_w, gr_screen.max_h); opengl_post_process_init(); // must be called after extensions are setup diff --git a/code/graphics/opengl/gropengldeferred.cpp b/code/graphics/opengl/gropengldeferred.cpp index 35d67419977..2b80207daa1 100644 --- a/code/graphics/opengl/gropengldeferred.cpp +++ b/code/graphics/opengl/gropengldeferred.cpp @@ -86,7 +86,7 @@ void gr_opengl_deferred_lighting_begin(bool clearNonColorBufs) Current_shader->program->Uniforms.setTextureUniform("tex", 0); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_NONE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + opengl_draw_full_screen_scene_texture(); } else { // Copy the existing color data into the emissive part of the G-buffer since everything that already existed is // treated as emissive @@ -159,7 +159,9 @@ void gr_opengl_deferred_lighting_msaa() }); GL_state.SetAlphaBlendMode(gr_alpha_blend::ALPHA_BLEND_NONE); GL_state.SetZbufferType(ZBUFFER_TYPE_WRITE); - opengl_draw_full_screen_textured(0, 0, 1, 1); + // msaa-f.sdr resolves via ivec2(textureSize(texColor) * fragTexCoord), so the texcoords have to + // stay inside the rendered sub-rectangle of the multisampled G-buffer. + opengl_draw_full_screen_scene_texture(); } void gr_opengl_deferred_lighting_end() @@ -321,8 +323,12 @@ void gr_opengl_deferred_lighting_finish() shadow_cascade_params_bind(offset, count); } - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // deferred-f.sdr turns gl_FragCoord into a G-buffer texture coordinate with these, so they + // have to normalize against the G-buffer's own dimensions. Those only equal gr_screen while + // the viewport exactly fills the scene textures -- not after a shrink, and not when the + // allocation was clamped by GL_max_renderbuffer_size. + header->invScreenWidth = 1.0f / Scene_texture_width; + header->invScreenHeight = 1.0f / Scene_texture_height; header->nearPlane = gr_near_plane; { @@ -557,7 +563,8 @@ void gr_opengl_deferred_lighting_finish() data->clip_dist = Neb2_fog_clip_distance; }); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // fog-f.sdr samples the composite and depth targets straight off fragTexCoord. + opengl_draw_full_screen_scene_texture(); if (bDrawNebVolumetrics) { glReadBuffer(GL_COLOR_ATTACHMENT0); @@ -653,6 +660,12 @@ void gr_opengl_deferred_lighting_finish() { GR_DEBUG_SCOPE("Volumetric Nebulae Draw"); + // Deliberately unscaled. volumetric-f.sdr uses fragTexCoord for two incompatible + // things: reconstructing an eye-space ray direction, which needs the full 0..1 range + // across the viewport, and sampling composite/depth/emissive, which needs the + // rendered sub-rectangle. Scaling here would fix the sampling and skew every ray. + // Separating the two needs a second varying (or a scale uniform) in the shader; until + // then volumetrics are only correct while the targets exactly match the viewport. opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); } GL_state.Texture.Enable(Scene_emissive_texture); diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp index 63ac62ffe6b..ea32c2950b7 100644 --- a/code/graphics/opengl/gropengldraw.cpp +++ b/code/graphics/opengl/gropengldraw.cpp @@ -70,6 +70,33 @@ int Scene_texture_height; GLfloat Scene_texture_u_scale = 1.0f; GLfloat Scene_texture_v_scale = 1.0f; +// Render targets are torn down and rebuilt mid-session by gr_opengl_resize_render_targets(), not +// just at shutdown, so deletion has to go through the state cache: the driver is free to hand a +// freed name straight back out, and a cache entry still holding that name would make a later +// Enable() of the recycled texture a no-op. +void opengl_delete_render_texture(GLuint& tex) +{ + if ( !tex ) { + return; + } + + GL_state.Texture.Delete(tex); + glDeleteTextures(1, &tex); + tex = 0; +} + +// Callers must have bound something else first (the resize path binds 0); the framebuffer cache +// has no equivalent of Texture.Delete() to unbind through. +void opengl_delete_render_framebuffer(GLuint& fbo) +{ + if ( !fbo ) { + return; + } + + glDeleteFramebuffers(1, &fbo); + fbo = 0; +} + inline GLenum opengl_primitive_type(primitive_type prim_type) { switch ( prim_type ) { @@ -98,7 +125,7 @@ void gr_opengl_sphere(material* material_def, float /*rad*/) } extern int opengl_check_framebuffer(); -void opengl_setup_scene_textures() +void opengl_setup_scene_textures(int width, int height) { Scene_texture_initialized = 0; @@ -113,10 +140,10 @@ void opengl_setup_scene_textures() return; } - // clamp size, if needed - Scene_texture_width = gr_screen.max_w; - Scene_texture_height = gr_screen.max_h; + Scene_texture_width = width; + Scene_texture_height = height; + // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { Scene_texture_width = GL_max_renderbuffer_size; } @@ -125,6 +152,13 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } + mprintf((" Scene textures: %dx%d (screen %dx%d, max renderbuffer %d)\n", + Scene_texture_width, + Scene_texture_height, + gr_screen.max_w, + gr_screen.max_h, + GL_max_renderbuffer_size)); + // create framebuffer glGenFramebuffers(1, &Scene_framebuffer); GL_state.BindFrameBuffer(Scene_framebuffer); @@ -333,32 +367,15 @@ void opengl_setup_scene_textures() if ( opengl_check_framebuffer() ) { GL_state.BindFrameBuffer(0); - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; + opengl_delete_render_framebuffer(Scene_framebuffer); - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - - glDeleteTextures(1, &Scene_luminance_texture); - Scene_luminance_texture = 0; - - //glDeleteTextures(1, &Scene_fxaa_output_texture); - //Scene_fxaa_output_texture = 0; + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_texture(Scene_luminance_texture); Gr_post_processing_enabled = false; Gr_enable_soft_particles = false; @@ -687,77 +704,103 @@ void opengl_scene_texture_shutdown() return; } - if ( Scene_color_texture ) { - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - } - - if ( Scene_position_texture ) { - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; - } - - if ( Scene_normal_texture ) { - glDeleteTextures(1, &Scene_normal_texture); - Scene_normal_texture = 0; - } - - if ( Scene_specular_texture ) { - glDeleteTextures(1, &Scene_specular_texture); - Scene_specular_texture = 0; - } + // Everything opengl_setup_scene_textures() generated, in the same order. Note that + // GammaBlit_texture is 0 when the gamma pass is aliasing Scene_ldr_texture, so the shared + // texture is only released once. + opengl_delete_render_texture(Scene_color_texture); + opengl_delete_render_texture(Scene_ldr_texture); + opengl_delete_render_texture(Scene_position_texture); + opengl_delete_render_texture(Scene_normal_texture); + opengl_delete_render_texture(Scene_specular_texture); + opengl_delete_render_texture(Scene_emissive_texture); + opengl_delete_render_texture(Scene_composite_texture); + opengl_delete_render_texture(Scene_luminance_texture); + opengl_delete_render_texture(Cockpit_depth_texture); + opengl_delete_render_texture(Scene_depth_texture); + opengl_delete_render_framebuffer(Scene_framebuffer); + + opengl_delete_render_texture(Scene_color_texture_ms); + opengl_delete_render_texture(Scene_position_texture_ms); + opengl_delete_render_texture(Scene_normal_texture_ms); + opengl_delete_render_texture(Scene_specular_texture_ms); + opengl_delete_render_texture(Scene_emissive_texture_ms); + opengl_delete_render_texture(Scene_depth_texture_ms); + opengl_delete_render_framebuffer(Scene_framebuffer_ms); + + opengl_delete_render_texture(Back_texture); + opengl_delete_render_texture(Back_depth_texture); + opengl_delete_render_framebuffer(Back_framebuffer); + + opengl_delete_render_texture(GammaBlit_texture); + opengl_delete_render_framebuffer(GammaBlit_framebuffer); + + opengl_delete_render_texture(Distortion_texture[0]); + opengl_delete_render_texture(Distortion_texture[1]); + opengl_delete_render_framebuffer(Distortion_framebuffer); - if (Scene_emissive_texture) { - glDeleteTextures(1, &Scene_emissive_texture); - Scene_emissive_texture = 0; - } - - if ( Scene_depth_texture ) { - glDeleteTextures(1, &Scene_depth_texture); - Scene_depth_texture = 0; - } - - if ( Scene_framebuffer ) { - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; - } - - if (Back_texture) { - glDeleteTextures(1, &Back_texture); - Back_texture = 0; - } - - if (Back_depth_texture) { - glDeleteTextures(1, &Back_depth_texture); - Back_depth_texture = 0; - } + Scene_texture_initialized = 0; + Scene_framebuffer_in_frame = false; +} - if (Back_framebuffer) { - glDeleteFramebuffers(1, &Back_framebuffer); - Back_framebuffer = 0; +void gr_opengl_resize_render_targets() +{ + // Nothing allocated yet (still inside gr_init()), or FBOs are unavailable entirely. + if ( !Scene_texture_initialized ) { + return; } - if (GammaBlit_texture) { - glDeleteTextures(1, &GammaBlit_texture); - GammaBlit_texture = 0; + // Grow only. Shrinking back would mean reallocating every G-buffer again the moment the window + // grew back, and the shrunk state is already handled correctly: Scene_texture_u_scale and + // _v_scale confine rendering to the sub-rectangle actually in use. The hardware limit is + // applied here rather than left to opengl_setup_scene_textures(), so that a viewport larger + // than anything the GPU can allocate compares equal below and stops asking. + const int new_width = MIN(MAX(gr_screen.max_w, Scene_texture_width), GL_max_renderbuffer_size); + const int new_height = MIN(MAX(gr_screen.max_h, Scene_texture_height), GL_max_renderbuffer_size); + + // The overwhelmingly common case: qtFred calls gr_screen_resize() every frame and the game + // calls it on every SDL resize event, almost always at a size the current targets already + // cover -- or, past the hardware limit, at one they never will. + if ( new_width == Scene_texture_width && new_height == Scene_texture_height ) { + return; } - if (GammaBlit_framebuffer) { - glDeleteFramebuffers(1, &GammaBlit_framebuffer); - GammaBlit_framebuffer = 0; + // Tearing down the framebuffer we are currently rendering into would corrupt the frame rather + // than fail cleanly, so refuse rather than trying to recover. Callers resize between frames. + // Scene_framebuffer_in_frame covers the post-processing passes too: they only ever run inside + // gr_scene_texture_begin()/end(), so it is set for the whole of Post_in_frame as well. + if ( Scene_framebuffer_in_frame ) { + Assertion(false, "Tried to resize the render targets to %dx%d while a scene was being " + "rendered into them! The resize has been skipped; the frame will be stretched.", + new_width, new_height); + return; } - glDeleteTextures(2, Distortion_texture); - Distortion_texture[0] = 0; - Distortion_texture[1] = 0; - - if ( Distortion_framebuffer ) { - glDeleteFramebuffers(1, &Distortion_framebuffer); - Distortion_framebuffer = 0; + mprintf(("Growing render targets from %dx%d to %dx%d to cover the new %dx%d viewport.\n", + Scene_texture_width, Scene_texture_height, new_width, new_height, + gr_screen.max_w, gr_screen.max_h)); + + // Leave the framebuffer cache pointing at a name that cannot be deleted out from under it. + GL_state.BindFrameBufferBoth(0, 0); + + // Only the size-dependent resources are touched. The post-processing table, the compiled + // shaders and the SMAA lookup textures are all resolution-independent and stay alive, which is + // what keeps this cheap enough to run off a window drag. The post-processing targets are + // rebuilt after the scene textures because they are sized to match them. + opengl_scene_texture_shutdown(); + opengl_setup_scene_textures(new_width, new_height); + + // Reallocating larger is exactly when running out of video memory is most likely, and + // opengl_setup_scene_textures() reports that by leaving the scene uninitialized (having + // already turned post-processing and soft particles off). Rebuilding the post-processing + // targets on top of scene textures that don't exist would only make it worse, so stop here; + // the renderer keeps drawing without the offscreen pipeline. + if ( !Scene_texture_initialized ) { + mprintf(("Failed to allocate %dx%d render targets! The offscreen rendering pipeline has " + "been disabled for the rest of this session.\n", new_width, new_height)); + return; } - Scene_texture_initialized = 0; - Scene_framebuffer_in_frame = false; + opengl_post_resize_render_targets(); } void gr_opengl_scene_texture_begin() @@ -776,20 +819,35 @@ void gr_opengl_scene_texture_begin() GL_state.PushFramebufferState(); GL_state.BindFrameBuffer(Scene_framebuffer); - if (GL_rendering_to_texture) - { - Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); - Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); - - CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); - CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); - } - else - { - Scene_texture_u_scale = 1.0f; - Scene_texture_v_scale = 1.0f; + // The fraction of the scene textures this frame actually renders into. Normally 1.0 -- the + // targets are grown to cover gr_screen (gr_opengl_resize_render_targets()) -- but they are + // never shrunk back, so a viewport that got smaller leaves the rest of the allocation stale. + // Every pass that samples these textures has to stay inside this sub-rectangle; use + // opengl_draw_full_screen_scene_texture() rather than open-coding the extents. + Scene_texture_u_scale = i2fl(gr_screen.max_w) / i2fl(Scene_texture_width); + Scene_texture_v_scale = i2fl(gr_screen.max_h) / i2fl(Scene_texture_height); + + // Above 1.0 means the viewport outgrew the allocation and the resize could not keep up -- only + // reachable when GL_max_renderbuffer_size capped the targets. Render what fits and let the + // blit stretch it; say so once rather than every frame. + if (Scene_texture_u_scale > 1.0f || Scene_texture_v_scale > 1.0f) { + static bool reported_undersized_scene_texture = false; + + if (!reported_undersized_scene_texture) { + reported_undersized_scene_texture = true; + nprintf(("OpenGL", + "Viewport (%dx%d) is larger than the scene texture backing it (%dx%d); " + "the post-processed image will be stretched to fit.\n", + gr_screen.max_w, + gr_screen.max_h, + Scene_texture_width, + Scene_texture_height)); + } } + CLAMP(Scene_texture_u_scale, 0.0f, 1.0f); + CLAMP(Scene_texture_v_scale, 0.0f, 1.0f); + if (!light_deferred_enabled()) { glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); @@ -1236,6 +1294,11 @@ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloa opengl_render_primitives_immediate(PRIM_TYPE_TRIS, &vert_def, 3, glVertices, sizeof(glVertices)); } +void opengl_draw_full_screen_scene_texture() +{ + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); +} + void gr_opengl_render_decals(decal_material* material_info, primitive_type prim_type, vertex_layout* layout, diff --git a/code/graphics/opengl/gropengldraw.h b/code/graphics/opengl/gropengldraw.h index 3cfd3183c80..5bfa170b341 100644 --- a/code/graphics/opengl/gropengldraw.h +++ b/code/graphics/opengl/gropengldraw.h @@ -55,8 +55,14 @@ void gr_opengl_render_shield_impact(shield_material* material_info, gr_buffer_handle buffer_handle, int n_verts); -void opengl_setup_scene_textures(); +void opengl_setup_scene_textures(int width, int height); void opengl_scene_texture_shutdown(); +void gr_opengl_resize_render_targets(); + +// Release a render target, keeping the GL state cache in sync. See the definitions for why the +// cache matters now that these are rebuilt mid-session. +void opengl_delete_render_texture(GLuint& tex); +void opengl_delete_render_framebuffer(GLuint& fbo); void gr_opengl_scene_texture_begin(); void gr_opengl_scene_texture_end(); void gr_opengl_copy_effect_texture(); @@ -147,6 +153,12 @@ void opengl_draw_textured_quad(GLfloat x1, */ void opengl_draw_full_screen_textured(GLfloat u1, GLfloat v1, GLfloat u2, GLfloat v2); +// Fullscreen pass over a source that is one of the scene/post-processing textures. Those are only +// filled out to Scene_texture_u_scale/v_scale of their allocation, so sampling them over the full +// [0,1] range would pull in whatever is beyond the rendered region. Prefer this over passing +// literal 1.0f extents whenever the bound texture came from that pipeline. +void opengl_draw_full_screen_scene_texture(); + inline GLenum opengl_primitive_type(primitive_type prim_type); void gr_opengl_start_decal_pass(); diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..cafb228d533 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -9,12 +9,15 @@ #include "gropengldraw.h" #include "gropenglshader.h" #include "gropenglstate.h" +#include "gropengltnl.h" #include "cmdline/cmdline.h" #include "def_files/def_files.h" +#include "graphics/lens_flare.h" #include "graphics/shader_types.h" #include "graphics/grinternal.h" #include "graphics/openxr.h" +#include "graphics/render.h" #include "graphics/util/uniform_structs.h" #include "io/timer.h" #include "lighting/lighting.h" @@ -28,6 +31,9 @@ #include "es_compatibility.h" #endif +static void opengl_post_setup_render_targets(); +static void opengl_post_shutdown_render_targets(); + extern bool PostProcessing_override; extern int opengl_check_framebuffer(); // Needed to track where the FXAA shaders are @@ -63,6 +69,14 @@ static GLuint Smaa_output_tex = 0; static GLuint Smaa_search_tex = 0; static GLuint Smaa_area_tex = 0; +// physically-based lens flare resources (created lazily on first use). There is +// one camera lens, so one iris mask and one starburst serve every sun. +static GLuint Lens_flare_framebuffer = 0; +static GLuint Lens_flare_aperture_tex = 0; +static GLuint Lens_flare_starburst_tex = 0; +static int Lens_flare_tex_lens_idx = -1; +static unsigned int Lens_flare_tex_generation = 0; + namespace ltp = lighting_profiles; using namespace ltp; @@ -99,7 +113,7 @@ void opengl_post_pass_tonemap() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } void opengl_post_pass_bloom() @@ -134,7 +148,10 @@ void opengl_post_pass_bloom() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, 1.0f, 1.0f); + // Reads the scene texture directly rather than an already-cropped intermediate, so it is + // the scaled variant. The blur/composite passes below read Bloom_textures, which this pass + // fills edge to edge, so those stay unscaled. + opengl_draw_full_screen_scene_texture(); } // ------ end bright pass ------ @@ -293,7 +310,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); // set and configure post shader .. opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0)); @@ -310,7 +327,7 @@ void opengl_post_pass_fxaa() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_luminance_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); opengl_shader_set_current(); } @@ -333,7 +350,7 @@ static void smaa_detect_edges() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_calculate_blending_weights() @@ -358,7 +375,7 @@ static void smaa_calculate_blending_weights() GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_area_tex); GL_state.Texture.Enable(2, GL_TEXTURE_2D, Smaa_search_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_neighborhood_blending() @@ -381,7 +398,7 @@ static void smaa_neighborhood_blending() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_ldr_texture); GL_state.Texture.Enable(1, GL_TEXTURE_2D, Smaa_blend_tex); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); } void smaa_resolve() @@ -491,7 +508,7 @@ void opengl_post_lightshafts() GL_state.Blend(GL_TRUE); GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); GL_state.Blend(GL_FALSE); break; @@ -500,6 +517,141 @@ void opengl_post_lightshafts() } } +// Delete the uploaded aperture/starburst handles, leaving the cache keys alone -- +// the re-upload path below has already set them to what it is about to upload. +static void opengl_lens_flare_delete_textures() +{ + if (Lens_flare_aperture_tex) { + glDeleteTextures(1, &Lens_flare_aperture_tex); + Lens_flare_aperture_tex = 0; + } + if (Lens_flare_starburst_tex) { + glDeleteTextures(1, &Lens_flare_starburst_tex); + Lens_flare_starburst_tex = 0; + } +} + +// drop them and forget what was uploaded, so the next frame uploads afresh +static void opengl_lens_flare_release_textures() +{ + opengl_lens_flare_delete_textures(); + Lens_flare_tex_lens_idx = -1; + Lens_flare_tex_generation = 0; +} + +// Upload the CPU-generated aperture/starburst textures of the mounted lens, or +// keep the ones already uploaded for it. When the pair is still current +// lens_flare_textures_if_changed() says so and there is nothing to do -- deciding +// *that* is a rule about the lens module, so it lives there rather than being +// re-derived identically in each backend. +static bool opengl_lens_flare_ensure_textures(int lens_idx) +{ + const auto* tex = + graphics::lens_flare_textures_if_changed(lens_idx, Lens_flare_tex_lens_idx, Lens_flare_tex_generation); + if (tex == nullptr) { + return Lens_flare_aperture_tex != 0; + } + + opengl_lens_flare_delete_textures(); + + auto create_tex = [](GLsizei size, GLenum internal_format, GLenum format, GLenum type, const void* pixels, + const char* name) { + GLuint handle; + glGenTextures(1, &handle); + + GL_state.Texture.SetActiveUnit(0); + GL_state.Texture.SetTarget(GL_TEXTURE_2D); + GL_state.Texture.Enable(handle); + + opengl_set_object_label(GL_TEXTURE, handle, name); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + glTexImage2D(GL_TEXTURE_2D, 0, internal_format, size, size, 0, format, type, pixels); + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + + return handle; + }; + + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + Lens_flare_aperture_tex = create_tex(tex->aperture_size, GL_R8, GL_RED, GL_UNSIGNED_BYTE, tex->aperture.data(), + "Lens flare aperture"); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + Lens_flare_starburst_tex = create_tex(tex->starburst_size, GL_RGBA32F, GL_RGBA, GL_FLOAT, tex->starburst.data(), + "Lens flare starburst"); + + return true; +} + +// physically-based lens flares: additive instanced ghost quads on the HDR +// scene color, immediately before bloom (so bloom/tonemap treat the flare +// energy like any other scene light). One draw per visible sun: they share the +// camera lens (hence its textures), but each has its own flare axis and tint. +static void opengl_post_pass_lens_flare() +{ + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flare_draws = graphics::lens_flare_get_frame_draws(); + if (flare_draws.empty()) { + return; + } + + if (!opengl_lens_flare_ensure_textures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + TRACE_SCOPE(tracing::LensFlare); + + if (Lens_flare_framebuffer == 0) { + glGenFramebuffers(1, &Lens_flare_framebuffer); + } + GL_state.BindFrameBuffer(Lens_flare_framebuffer); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, Scene_color_texture, 0); + glDrawBuffer(GL_COLOR_ATTACHMENT0); + + glViewport(0, 0, gr_screen.max_w, gr_screen.max_h); + + opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_LENS_FLARE, 0)); + + Current_shader->program->Uniforms.setTextureUniform("apertureMap", 0); + Current_shader->program->Uniforms.setTextureUniform("starburstMap", 1); + + GL_state.Texture.Enable(0, GL_TEXTURE_2D, Lens_flare_aperture_tex); + GL_state.Texture.Enable(1, GL_TEXTURE_2D, Lens_flare_starburst_tex); + + GLboolean scissor_test = GL_state.ScissorTest(GL_FALSE); + GL_state.Blend(GL_TRUE); + GL_state.SetAlphaBlendMode(ALPHA_BLEND_ADDITIVE); + + // one 4-vertex triangle-strip quad, instanced per ghost/starburst + GLfloat corners[4][2] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {-1.0f, 1.0f}, {1.0f, 1.0f}}; + + vertex_layout layout; + layout.add_vertex_component(vertex_format_data::POSITION2, sizeof(GLfloat) * 2, 0); + + size_t offset = gr_add_to_immediate_buffer(sizeof(corners), corners); + opengl_bind_vertex_layout(layout, opengl_buffer_get_id(GL_ARRAY_BUFFER, gr_immediate_buffer_handle), 0, offset); + + for (const auto& draw : flare_draws) { + opengl_set_generic_uniform_data( + [&](graphics::generic_data::lens_flare_data* data) { *data = *draw.data; }); + + glDrawArraysInstanced(GL_TRIANGLE_STRIP, 0, 4, draw.instances); + } + + GL_state.SetAlphaBlendMode(ALPHA_BLEND_NONE); + GL_state.Blend(GL_FALSE); + GL_state.ScissorTest(scissor_test); +} + void gr_opengl_post_process_end() { GR_DEBUG_SCOPE("Draw scene texture"); @@ -515,6 +667,9 @@ void gr_opengl_post_process_end() GL_state.PushFramebufferState(); + // physically-based lens flares composite into the HDR scene before bloom + opengl_post_pass_lens_flare(); + // do bloom, hopefully ;) opengl_post_pass_bloom(); @@ -625,7 +780,7 @@ void gr_opengl_post_process_end() // now render it to the screen ... GL_state.PopFramebufferState(); - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); + opengl_draw_full_screen_scene_texture(); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1020,77 +1175,93 @@ static GLuint load_smaa_texture(GLsizei width, GLsizei height, GLenum format, co return tex; } -static void setup_smaa_resources() +// The SMAA area and search textures are fixed-size lookup tables baked into the binary, so unlike +// everything else here they survive a resolution change untouched. +static void setup_smaa_lookup_textures() { - GL_state.PushFramebufferState(); - Smaa_area_tex = load_smaa_texture(AREATEX_WIDTH, AREATEX_HEIGHT, GL_RG8, areaTexBytes, "SMAA Area Texture"); Smaa_search_tex = load_smaa_texture(SEARCHTEX_WIDTH, SEARCHTEX_HEIGHT, GL_R8, searchTexBytes, "SMAA Search Texture"); +} +static void setup_smaa_render_targets() +{ setup_smaa_edges_resources(); setup_smaa_blending_weight_resources(); setup_smaa_neighborhood_blending_resources(); - - GL_state.PopFramebufferState(); } -// generate and test the framebuffer and textures that we are going to use -static bool opengl_post_init_framebuffer() +static void shutdown_smaa_render_targets() { - bool rval = false; + opengl_delete_render_texture(Smaa_edges_tex); + opengl_delete_render_framebuffer(Smaa_edge_detection_fb); - // clamp size, if needed - Post_texture_width = gr_screen.max_w; - Post_texture_height = gr_screen.max_h; + opengl_delete_render_texture(Smaa_blend_tex); + opengl_delete_render_framebuffer(Smaa_blending_weight_fb); - if (Post_texture_width > GL_max_renderbuffer_size) { - Post_texture_width = GL_max_renderbuffer_size; - } + opengl_delete_render_texture(Smaa_output_tex); + opengl_delete_render_framebuffer(Smaa_neighborhood_blending_fb); +} - if (Post_texture_height > GL_max_renderbuffer_size) { - Post_texture_height = GL_max_renderbuffer_size; - } +// Allocate every post-processing resource whose size follows the scene textures. Split out from +// opengl_post_process_init() so gr_opengl_resize_render_targets() can rebuild just these without +// re-parsing post_processing.tbl or recompiling shaders. +static void opengl_post_setup_render_targets() +{ + // These consume the scene textures pass by pass, so they have to match them exactly rather + // than being sized from gr_screen independently -- see gr_opengl_scene_texture_begin() for + // what the two sizes diverging would mean. + Post_texture_width = Scene_texture_width; + Post_texture_height = Scene_texture_height; + + GL_state.PushFramebufferState(); opengl_setup_bloom_textures(); // Always set up SMAA resources so the user can switch to an SMAA preset // at runtime even when starting with a non-SMAA AA mode, such as None. - //if (Gr_aa_mode != AntiAliasMode::None) { - setup_smaa_resources(); - //} + setup_smaa_render_targets(); - GL_state.BindFrameBuffer(0); + GL_state.PopFramebufferState(); - rval = true; + GL_state.BindFrameBuffer(0); +} - if ( opengl_check_for_errors("post_init_framebuffer()") ) { - rval = false; - } +void opengl_post_process_shutdown_bloom() +{ + opengl_delete_render_texture(Bloom_textures[0]); + opengl_delete_render_texture(Bloom_textures[1]); + opengl_delete_render_framebuffer(Bloom_framebuffer); +} - return rval; +static void opengl_post_shutdown_render_targets() +{ + opengl_post_process_shutdown_bloom(); + shutdown_smaa_render_targets(); } +void opengl_post_resize_render_targets() +{ + // Post-processing may have been disabled outright (no FBOs, missing shaders, or turned off in + // the table), in which case none of these resources exist and none should start existing now. + if ( !Post_initialized ) { + return; + } + opengl_post_shutdown_render_targets(); + opengl_post_setup_render_targets(); +} -void opengl_post_process_shutdown_bloom() +// generate and test the framebuffer and textures that we are going to use +static bool opengl_post_init_framebuffer() { - if ( Bloom_textures[0] ) { - glDeleteTextures(1, &Bloom_textures[0]); - Bloom_textures[0] = 0; - } + setup_smaa_lookup_textures(); - if ( Bloom_textures[1] ) { - glDeleteTextures(1, &Bloom_textures[1]); - Bloom_textures[1] = 0; - } + opengl_post_setup_render_targets(); - if ( Bloom_framebuffer > 0 ) { - glDeleteFramebuffers(1, &Bloom_framebuffer); - Bloom_framebuffer = 0; - } + return !opengl_check_for_errors("post_init_framebuffer()"); } void opengl_post_process_init() @@ -1141,20 +1312,22 @@ void opengl_post_process_shutdown() return; } - if (Post_framebuffer_id[0]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[0]); - Post_framebuffer_id[0] = 0; - - if (Post_framebuffer_id[1]) { - glDeleteFramebuffers(1, &Post_framebuffer_id[1]); - Post_framebuffer_id[1] = 0; - } - } + opengl_delete_render_framebuffer(Post_framebuffer_id[0]); + opengl_delete_render_framebuffer(Post_framebuffer_id[1]); graphics::Post_processing_manager->clear(); graphics::Post_processing_manager = nullptr; - opengl_post_process_shutdown_bloom(); + opengl_post_shutdown_render_targets(); + + opengl_delete_render_texture(Smaa_area_tex); + opengl_delete_render_texture(Smaa_search_tex); + + if (Lens_flare_framebuffer) { + glDeleteFramebuffers(1, &Lens_flare_framebuffer); + Lens_flare_framebuffer = 0; + } + opengl_lens_flare_release_textures(); Post_in_frame = false; Post_active_shader_index = 0; diff --git a/code/graphics/opengl/gropenglpostprocessing.h b/code/graphics/opengl/gropenglpostprocessing.h index d820222bb89..cdba2579d88 100644 --- a/code/graphics/opengl/gropenglpostprocessing.h +++ b/code/graphics/opengl/gropenglpostprocessing.h @@ -8,6 +8,10 @@ void opengl_post_process_init(); void opengl_post_process_shutdown(); +// Rebuild the resolution-dependent subset of the above for the current scene texture size, without +// re-parsing post_processing.tbl or recompiling shaders. No-op if post-processing isn't active. +void opengl_post_resize_render_targets(); + void gr_opengl_post_process_set_effect(const char *name, int x, const vec3d *rgb); void gr_opengl_post_process_set_defaults(); void gr_opengl_post_process_save_zbuffer(); diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index 9375b353468..54c3718999d 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -89,28 +89,6 @@ static auto TextureFilteringOption __UNUSED = options::OptionBuilder("Graph .parser(parse_texture_filtering_func) .finish(); -static SCP_vector anisotropic_value_enumerator() -{ - float max; - if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { - return SCP_vector(); - } - - if (max <= 2.0f) { - return SCP_vector(); - } - - SCP_vector out; - - // We assume here that the anisotropy levels are powers of two... - float current = 1.0f; - while (current <= max) { - out.push_back(current); - current *= 2.0f; - } - - return out; -} static SCP_string anisotropic_display(float val) { if (val < 2.0f) { @@ -134,7 +112,7 @@ static float anisotropic_default() static auto AnisotropyOption = options::OptionBuilder("Graphics.Anisotropy", std::pair{"Anistropic filtering", 1736}, std::pair{"Controls the amount of anistropic filtering of the textures", 1737}) - .enumerator(anisotropic_value_enumerator) + .enumerator(gr_get_supported_anisotropy_levels) .category(std::make_pair("Graphics", 1825)) .display(anisotropic_display) .default_func(anisotropic_default) @@ -189,8 +167,15 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear + // Seed from the legacy config key first: TextureFilteringOption's default_func returns + // GL_mipmap_filter, so this read is what supplies that default. Only then let the option + // override it, the same order the anisotropy setting below uses. GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + if (Using_in_game_options) { + GL_mipmap_filter = TextureFilteringOption->getValue(); + } + if (GL_mipmap_filter > 1) { GL_mipmap_filter = 1; } diff --git a/code/graphics/shader_types.cpp b/code/graphics/shader_types.cpp index 80a733b7c2a..727fc70ff63 100644 --- a/code/graphics/shader_types.cpp +++ b/code/graphics/shader_types.cpp @@ -116,6 +116,9 @@ static ShaderTypeInfo SHADER_TYPES[] = { { SDR_TYPE_GAMMA_BLIT, "post-v.sdr", "gamma-correct-f.sdr", nullptr, { VATTRIB_POSITION, VATTRIB_TEXCOORD }, "Gamma correct blit", false }, + + { SDR_TYPE_LENS_FLARE, "lensflare-v.sdr", "lensflare-f.sdr", nullptr, + { VATTRIB_POSITION }, "Physically-based lens flare", false }, }; // clang-format on diff --git a/code/graphics/util/uniform_structs.h b/code/graphics/util/uniform_structs.h index 974ad275732..4287b2b65e0 100644 --- a/code/graphics/util/uniform_structs.h +++ b/code/graphics/util/uniform_structs.h @@ -289,6 +289,71 @@ struct fxaa_data { float pad[2]; }; +// Keep in sync with the literal array size in lensflare-v.sdr / lensflare-f.sdr! +constexpr int MAX_LENS_FLARE_INSTANCES = 64; + +// Which of the three artifacts an instance slot draws, tagged in center.w. +// Mirrored by the LENS_QUAD_* defines in lensflare-v.sdr / lensflare-f.sdr; the +// emit_* helpers in graphics/lens_flare.cpp are the only writers. +constexpr float LENS_QUAD_GHOST = 0.0f; +constexpr float LENS_QUAD_STARBURST = 1.0f; +constexpr float LENS_QUAD_STREAK = 2.0f; + +// One quad of the physically-based lens flare pass. The three kinds share this +// one slot layout but read it differently, so the field meanings are per-kind: +// +// center halfext apscale/apoff color +// GHOST xyz per-channel xyz per-channel xyz per-channel rgb per-channel +// centre along half-extent aperture-plane intensity +// the flare axis parametrization +// STARBURST x = the sun's x = half-extent unused rgb intensity +// image +// STREAK x = the sun's x = half-length unused rgb tint +// image y = half-thickness +// +// Per-channel means red/green/blue in x/y/z. All positions and extents are in +// sensor-plane millimeters, along and around the flare axis -- except the +// streak, which is screen-horizontal and so carries a length and a thickness +// instead of three chromatic values. +struct lens_flare_instance_data { + vec4 center; // w = LENS_QUAD_*, the kind tag; see the table above for xyz + vec4 halfext; + vec4 apscale; + vec4 apoff; + vec4 color; +}; + +struct lens_flare_data { + vec2d axis; // unit flare axis in sensor space (sun -> screen center line) + vec2d ndc_scale; // sensor units -> NDC (x, y incl. aspect) + + vec4 tint; // rgb = sun color * visibility * lens intensity + + // Neither shader reads this -- the instance count comes from the draw call's + // instance parameter. Kept because it occupies a std140 slot the rest of the + // block is laid out around, and because it makes a captured frame readable. + int n_instances; + float squeeze; // anamorphic horizontal stretch of every footprint, 1.0 = spherical + float pad[2]; + + // The fragment shader declares this array too, and reads none of it: the + // per-instance values reach it as flat varyings. It is declared there purely so + // both stages agree on the block layout byte for byte. Splitting the per-draw + // constants above into their own block would let the fragment stage stop + // carrying ~5 KB it never touches, but it needs a second descriptor binding in + // the Vulkan set template, so it is not the free change it looks like. + lens_flare_instance_data instances[MAX_LENS_FLARE_INSTANCES]; +}; + +// This block is mirrored by hand in lensflare-v.sdr / lensflare-f.sdr, and the +// two must agree byte for byte. Nothing else can check that -- the GLSL side is +// only compiled at runtime -- so at least make a field added here (or a scalar +// silently promoted past its std140 slot) stop the build instead of quietly +// misaligning `instances` and corrupting every quad the pass draws. +static_assert(sizeof(lens_flare_data) == 48 + 80 * MAX_LENS_FLARE_INSTANCES, + "lens_flare_data no longer matches its std140 layout -- update the genericData block in " + "lensflare-v.sdr and lensflare-f.sdr to match, then fix this size"); + struct fog_data { vec3d fog_color; float fog_start; diff --git a/code/graphics/vulkan/VulkanPostProcessing.cpp b/code/graphics/vulkan/VulkanPostProcessing.cpp index 13d13675437..6c9dae4a5e0 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.cpp +++ b/code/graphics/vulkan/VulkanPostProcessing.cpp @@ -327,6 +327,11 @@ bool VulkanPostProcessor::init(vk::Device device, vk::PhysicalDevice physDevice, nprintf(("vulkan", "VulkanPostProcessor: Bloom initialization failed (non-fatal)\n")); } + // Initialize the physically-based lens flare pass (non-fatal if it fails) + if (!m_lensFlare.init(m_ctx, m_sceneColor)) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare initialization failed (non-fatal)\n")); + } + // Initialize LDR targets for tonemapping + FXAA (non-fatal if it fails) if (!m_ldr.init(m_ctx, m_sceneColor, m_sceneDepth, m_bloom)) { nprintf(("vulkan", "VulkanPostProcessor: LDR target initialization failed (non-fatal)\n")); @@ -376,6 +381,7 @@ void VulkanPostProcessor::shutdown() shutdownGBuffer(); m_smaa.shutdown(); m_ldr.shutdown(); + m_lensFlare.shutdown(); shutdownBloom(); m_ctx.shutdownScratchUBO(); @@ -526,6 +532,11 @@ bool VulkanPostProcessor::resize(vk::Extent2D newExtent) nprintf(("vulkan", "VulkanPostProcessor: Bloom resize failed, disabling bloom\n")); m_bloom.shutdown(); } + // The lens flare framebuffer attaches the (just recreated) scene color view. + if (m_lensFlare.isInitialized() && !m_lensFlare.resize()) { + nprintf(("vulkan", "VulkanPostProcessor: Lens flare resize failed, disabling lens flares\n")); + m_lensFlare.shutdown(); + } if (m_ldr.isInitialized() && !m_ldr.resize()) { nprintf(("vulkan", "VulkanPostProcessor: LDR resize failed, disabling LDR + SMAA\n")); m_smaa.shutdown(); diff --git a/code/graphics/vulkan/VulkanPostProcessing.h b/code/graphics/vulkan/VulkanPostProcessing.h index d505d70acde..3064e0d9b1e 100644 --- a/code/graphics/vulkan/VulkanPostProcessing.h +++ b/code/graphics/vulkan/VulkanPostProcessing.h @@ -273,6 +273,91 @@ class VulkanBloom { bool m_initialized = false; }; +/** + * @brief Physically-based lens flares (ghost quads + starburst billboard) + * + * Self-contained subsystem that additively composites the precomputed lens + * flare instances (see graphics/lens_flare.h) onto the HDR scene color, + * immediately before bloom. Owns a loadOp=eLoad render pass on the scene + * color, a small dedicated per-frame UBO ring (the per-ghost array exceeds + * the shared scratch ring's slot size), and the static aperture/starburst + * textures uploaded from the CPU-generated pixel data of the active lens. + */ +class VulkanLensFlare { +public: + /** + * @brief Create render pass/framebuffer/UBO resources + * @param sceneColor Scene HDR color target to composite into (must outlive this) + */ + bool init(PostProcessContext& ctx, const RenderTarget& sceneColor); + void shutdown(); + + /** + * @brief Recreate the scene-color framebuffer after a resize (render pass kept) + * + * Device must be idle. Returns false on failure (caller should shut the + * subsystem down). + */ + bool resize(); + + /** + * @brief Per-frame UBO ring cursor reset (called from VulkanPostProcessor::beginFrame) + */ + void beginFrame(uint32_t frameIndex) + { + if (m_ubo.isValid()) { + m_ubo.resetCursor(frameIndex); + } + } + + /** + * @brief Draw the flare instances of every flaring sun additively onto the scene color + * + * No-op when no lens-equipped sun is visible. Scene color must be in + * eShaderReadOnlyOptimal (the state after the scene render pass ends) and + * is returned to eShaderReadOnlyOptimal, matching what bloom expects. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void execute(vk::CommandBuffer cmd); + + bool isInitialized() const { return m_initialized; } + +private: + bool createFramebuffer(); + + /** + * @brief Upload the iris/starburst textures of the mounted lens, if not already uploaded + */ + bool ensureTextures(int lensIdx); + void releaseTextures(bool deferred); + void forgetTextures(); + + PostProcessContext* m_ctx = nullptr; + const RenderTarget* m_sceneColor = nullptr; + + vk::RenderPass m_renderPass; // Color-only RGBA16F, loadOp=eLoad (additive to scene) + vk::Framebuffer m_sceneColorFB; // Scene color as attachment 0 + + // Dedicated per-frame UBO ring: lens_flare_data (~5 KB) exceeds the shared + // scratch ring's slot size (see PostProcessContext::SCRATCH_UBO_SLOT_SIZE). + // One slot per visible sun per scene render (see LENS_FLARE_UBO_SLOTS). + PerFrameUboRing m_ubo; + + // Static iris/starburst textures of the mounted camera lens, shared by every + // sun's flare + vk::Image m_apertureImage; + vk::ImageView m_apertureView; + VulkanAllocation m_apertureAlloc; + vk::Image m_starburstImage; + vk::ImageView m_starburstView; + VulkanAllocation m_starburstAlloc; + int m_texLensIdx = -1; + unsigned int m_texGeneration = 0; + + bool m_initialized = false; +}; + /** * @brief Deferred geometry buffer (G-buffer) + optional MSAA G-buffer & resolve * @@ -784,7 +869,11 @@ class VulkanPostProcessor { * fullscreen pass of the frame (mid-scene fog included), so subsystems must * not reset it themselves. */ - void beginFrame(uint32_t frameIndex) { m_ctx.scratchRing.resetCursor(frameIndex); } + void beginFrame(uint32_t frameIndex) + { + m_ctx.scratchRing.resetCursor(frameIndex); + m_lensFlare.beginFrame(frameIndex); + } /** * @brief Get the HDR scene render pass (for 3D scene rendering) @@ -884,6 +973,17 @@ class VulkanPostProcessor { */ void executeBloom(vk::CommandBuffer cmd) { m_bloom.execute(cmd); } + /** + * @brief Execute the physically-based lens flare pass + * + * Called immediately before executeBloom() so the flare energy is bloomed + * and tonemapped like any other HDR scene content. No-op when no + * lens-equipped sun is visible. Must be called outside a render pass. + * + * @param cmd Active command buffer (must be outside a render pass) + */ + void executeLensFlare(vk::CommandBuffer cmd) { m_lensFlare.execute(cmd); } + /** * @brief Execute tonemapping pass (HDR scene → LDR) * @@ -1165,6 +1265,9 @@ class VulkanPostProcessor { // ---- Bloom (self-contained subsystem) ---- VulkanBloom m_bloom; + // ---- Physically-based lens flares (self-contained subsystem) ---- + VulkanLensFlare m_lensFlare; + // ---- LDR / FXAA / post-effects / lightshafts (self-contained subsystem) ---- VulkanLDR m_ldr; diff --git a/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp new file mode 100644 index 00000000000..50d6a3ea791 --- /dev/null +++ b/code/graphics/vulkan/VulkanPostProcessingLensFlare.cpp @@ -0,0 +1,389 @@ +#include "VulkanPostProcessing.h" + +#include + +#include "gr_vulkan.h" +#include "VulkanRenderer.h" +#include "VulkanPipeline.h" +#include "VulkanDescriptorManager.h" +#include "VulkanTexture.h" +#include "VulkanDeletionQueue.h" +#include "graphics/2d.h" +#include "graphics/grinternal.h" +#include "graphics/lens_flare.h" +#include "graphics/util/uniform_structs.h" + +namespace graphics::vulkan { + +// ===== Physically-based lens flare pass ===== + +namespace { +// One UBO slot per flare source per scene render. Sun counts are single-digit, +// but every lit nozzle is also a source, capped at MAX_THRUSTER_SOURCES (32) in +// lens_flare.cpp -- so this has to hold that plus the suns, several times over +// for a frame that renders the scene more than once. At ~5 KB a slot that is +// still under a megabyte per frame in flight. The draw loop bails out rather +// than overflowing the ring if a frame ever exceeds it anyway. +constexpr uint32_t LENS_FLARE_UBO_SLOTS = 128; +} // namespace + +bool VulkanLensFlare::init(PostProcessContext& ctx, const RenderTarget& sceneColor) +{ + m_ctx = &ctx; + m_sceneColor = &sceneColor; + + // Additive render pass on the scene color: identical shape to the bloom + // composite pass (loadOp=eLoad, ends in eShaderReadOnlyOptimal so the + // following bloom bright pass can sample the scene as usual) + { + vk::AttachmentDescription att; + att.format = HDR_COLOR_FORMAT; + att.samples = vk::SampleCountFlagBits::e1; + att.loadOp = vk::AttachmentLoadOp::eLoad; + att.storeOp = vk::AttachmentStoreOp::eStore; + att.stencilLoadOp = vk::AttachmentLoadOp::eDontCare; + att.stencilStoreOp = vk::AttachmentStoreOp::eDontCare; + att.initialLayout = vk::ImageLayout::eColorAttachmentOptimal; + att.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + + vk::AttachmentReference colorRef; + colorRef.attachment = 0; + colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal; + + vk::SubpassDescription subpass; + subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &colorRef; + + vk::SubpassDependency dep; + dep.srcSubpass = VK_SUBPASS_EXTERNAL; + dep.dstSubpass = 0; + dep.srcStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.dstStageMask = vk::PipelineStageFlagBits::eFragmentShader + | vk::PipelineStageFlagBits::eColorAttachmentOutput; + dep.srcAccessMask = vk::AccessFlagBits::eShaderRead + | vk::AccessFlagBits::eColorAttachmentWrite; + dep.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + + vk::RenderPassCreateInfo rpInfo; + rpInfo.attachmentCount = 1; + rpInfo.pAttachments = &att; + rpInfo.subpassCount = 1; + rpInfo.pSubpasses = &subpass; + rpInfo.dependencyCount = 1; + rpInfo.pDependencies = &dep; + + try { + m_renderPass = m_ctx->device.createRenderPass(rpInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create render pass: %s\n", e.what())); + return false; + } + } + + if (!createFramebuffer()) { + return false; + } + + // Dedicated per-frame UBO ring: lens_flare_data exceeds the shared scratch + // ring's slot size. One slot per visible sun, with room for the scene being + // rendered more than once per frame. + vk::DeviceSize slotSize = (sizeof(generic_data::lens_flare_data) + 255) & ~static_cast(255); + if (!m_ubo.init(m_ctx->device, m_ctx->memoryManager, LENS_FLARE_UBO_SLOTS, slotSize)) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create UBO ring!\n")); + shutdown(); + return false; + } + + m_initialized = true; + nprintf(("vulkan", "VulkanLensFlare: Initialized\n")); + return true; +} + +bool VulkanLensFlare::createFramebuffer() +{ + vk::FramebufferCreateInfo fbInfo; + fbInfo.renderPass = m_renderPass; + fbInfo.attachmentCount = 1; + fbInfo.pAttachments = &m_sceneColor->view; + fbInfo.width = m_ctx->sceneExtent.width; + fbInfo.height = m_ctx->sceneExtent.height; + fbInfo.layers = 1; + + try { + m_sceneColorFB = m_ctx->device.createFramebuffer(fbInfo); + } catch (const vk::SystemError& e) { + nprintf(("vulkan", "VulkanLensFlare: Failed to create framebuffer: %s\n", e.what())); + return false; + } + return true; +} + +bool VulkanLensFlare::resize() +{ + if (!m_initialized) { + return true; + } + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + return createFramebuffer(); +} + +void VulkanLensFlare::releaseTextures(bool deferred) +{ + auto* deletionQueue = deferred ? getDeletionQueue() : nullptr; + + auto release = [&](vk::Image& image, vk::ImageView& view, VulkanAllocation& alloc) { + if (view) { + if (deletionQueue) { + deletionQueue->queueImageView(view); + } else { + m_ctx->device.destroyImageView(view); + } + view = nullptr; + } + if (image) { + if (deletionQueue) { + deletionQueue->queueImage(image, alloc); + } else { + m_ctx->device.destroyImage(image); + m_ctx->memoryManager->freeAllocation(alloc); + } + image = nullptr; + alloc = {}; + } + }; + + release(m_apertureImage, m_apertureView, m_apertureAlloc); + release(m_starburstImage, m_starburstView, m_starburstAlloc); +} + +// Drop them and forget what was uploaded, so the next frame uploads afresh. +// Distinct from releaseTextures(), which the re-upload path uses to retire the +// outgoing pair *after* the cache keys have been set to the incoming one. +void VulkanLensFlare::forgetTextures() +{ + releaseTextures(true); + m_texLensIdx = -1; + m_texGeneration = 0; +} + +bool VulkanLensFlare::ensureTextures(int lensIdx) +{ + // Whether the pair we already hold is still current is a rule about the lens + // module, so it answers it -- rather than each backend re-deriving the same + // (lens, generation) comparison. A null return means nothing changed. + const auto* tex = graphics::lens_flare_textures_if_changed(lensIdx, m_texLensIdx, m_texGeneration); + if (tex == nullptr) { + return m_apertureView.operator bool(); + } + + auto* texMgr = getTextureManager(); + if (texMgr == nullptr) { + forgetTextures(); + return false; + } + + // The outgoing textures may still be referenced by in-flight frames + releaseTextures(true); + + if (!texMgr->createStaticTexture2D(tex->aperture_size, tex->aperture_size, vk::Format::eR8Unorm, + tex->aperture.data(), tex->aperture.size(), "Lens flare aperture", + m_apertureImage, m_apertureView, m_apertureAlloc)) { + forgetTextures(); + return false; + } + + if (!texMgr->createStaticTexture2D(tex->starburst_size, tex->starburst_size, vk::Format::eR32G32B32A32Sfloat, + tex->starburst.data(), tex->starburst.size() * sizeof(float), "Lens flare starburst", + m_starburstImage, m_starburstView, m_starburstAlloc)) { + forgetTextures(); + return false; + } + + return true; +} + +void VulkanLensFlare::execute(vk::CommandBuffer cmd) +{ + if (!m_initialized) { + return; + } + + // Whether there is anything to draw was decided by lens_flare_frame_update() + // during the scene render; this pass only draws what it published. In + // particular it must not second-guess the decision -- the sprite suns have + // already stepped aside for whatever is in here, so a backend that skipped a + // published draw would just delete the sun. + const auto& flareDraws = graphics::lens_flare_get_frame_draws(); + if (flareDraws.empty()) { + return; + } + + auto* pipelineMgr = getPipelineManager(); + auto* descriptorMgr = getDescriptorManager(); + if (pipelineMgr == nullptr || descriptorMgr == nullptr || !m_ubo.isValid()) { + return; + } + + // Uploaded before the render pass starts, since that path submits its own + // command buffer and waits + if (!ensureTextures(graphics::lens_flare_active_lens())) { + return; + } + + GR_DEBUG_SCOPE("Lens flare"); + + // Instanced ghost-quad pipeline (corners from gl_VertexIndex, no vertex input) + PipelineConfig config; + config.shaderType = SDR_TYPE_LENS_FLARE; + config.shaderFlags = 0; + config.vertexLayoutHash = 0; + config.primitiveType = PRIM_TYPE_TRISTRIP; + config.depthMode = ZBUFFER_TYPE_NONE; + config.blendMode = ALPHA_BLEND_ADDITIVE; + config.cullEnabled = false; + config.depthWriteEnabled = false; + config.renderPass = m_renderPass; + + vertex_layout emptyLayout; + vk::Pipeline pipeline = pipelineMgr->getPipeline(config, emptyLayout); + if (!pipeline) { + return; + } + + // Scene color: eShaderReadOnlyOptimal (after scene pass) -> eColorAttachmentOptimal + { + vk::ImageMemoryBarrier barrier; + barrier.srcAccessMask = vk::AccessFlagBits::eShaderRead; + barrier.dstAccessMask = vk::AccessFlagBits::eColorAttachmentRead + | vk::AccessFlagBits::eColorAttachmentWrite; + barrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal; + barrier.newLayout = vk::ImageLayout::eColorAttachmentOptimal; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = m_sceneColor->image; + barrier.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor; + barrier.subresourceRange.baseMipLevel = 0; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.baseArrayLayer = 0; + barrier.subresourceRange.layerCount = 1; + + cmd.pipelineBarrier( + vk::PipelineStageFlagBits::eFragmentShader, + vk::PipelineStageFlagBits::eColorAttachmentOutput, + {}, {}, {}, barrier); + } + + vk::PipelineLayout pipelineLayout = pipelineMgr->getPipelineLayout(); + + vk::RenderPassBeginInfo rpBegin; + rpBegin.renderPass = m_renderPass; + rpBegin.framebuffer = m_sceneColorFB; + rpBegin.renderArea.offset = vk::Offset2D(0, 0); + rpBegin.renderArea.extent = m_ctx->sceneExtent; + + cmd.beginRenderPass(rpBegin, vk::SubpassContents::eInline); + cmd.bindPipeline(vk::PipelineBindPoint::eGraphics, pipeline); + + // Negative viewport height (VK_KHR_maintenance1) for OpenGL-compatible + // Y-up NDC: the shader emits GL-convention positions, and the scene color + // image stores the screen top at row 0 (it was rendered with the same flip) + vk::Viewport viewport; + viewport.x = 0.0f; + viewport.y = static_cast(m_ctx->sceneExtent.height); + viewport.width = static_cast(m_ctx->sceneExtent.width); + viewport.height = -static_cast(m_ctx->sceneExtent.height); + viewport.minDepth = 0.0f; + viewport.maxDepth = 1.0f; + cmd.setViewport(0, viewport); + + vk::Rect2D scissor; + scissor.offset = vk::Offset2D(0, 0); + scissor.extent = m_ctx->sceneExtent; + cmd.setScissor(0, scissor); + + // Set 1: Material -- the mounted lens's iris + starburst, shared by every sun, + // so this is written and bound once for the whole pass + DescriptorWriter writer; + writer.reset(m_ctx->device, descriptorMgr->getFallbacks()); + + vk::DescriptorSet materialSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::Material); + Verify(materialSet); + writer.writeSet(materialSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::Material)); + { + std::array texArrayInfos; + texArrayInfos.fill(descriptorMgr->getFallbacks().texture2D); + texArrayInfos[0] = {m_ctx->linearSampler, m_apertureView, vk::ImageLayout::eShaderReadOnlyOptimal}; + texArrayInfos[1] = {m_ctx->linearSampler, m_starburstView, vk::ImageLayout::eShaderReadOnlyOptimal}; + writer.setImageArray(MaterialBinding::TextureArray, texArrayInfos); + } + + // One draw per visible sun: they share the lens, but each has its own flare + // axis and tint, hence its own uniform block + const uint32_t frameIndex = descriptorMgr->getCurrentFrame(); + for (size_t i = 0; i < flareDraws.size(); i++) { + if (m_ubo.cursor(frameIndex) >= m_ubo.slotsPerFrame()) { + // More flaring suns than the ring can hold this frame; drop the rest + // rather than trip the ring's overflow assertion + nprintf(("vulkan", "VulkanLensFlare: out of UBO slots, skipping %d flare draw(s)\n", + static_cast(flareDraws.size() - i))); + break; + } + + // Set 2: PerDraw -- this sun's flare data from the dedicated UBO ring + vk::DescriptorSet perDrawSet = descriptorMgr->allocateFrameSet(DescriptorSetIndex::PerDraw); + Verify(perDrawSet); + writer.writeSet(perDrawSet, VulkanDescriptorManager::getSetTemplate(DescriptorSetIndex::PerDraw)); + { + vk::DeviceSize slotOffset = m_ubo.alloc(frameIndex, flareDraws[i].data, + sizeof(generic_data::lens_flare_data)); + writer.setBuffer(PerDrawBinding::GenericData, {m_ubo.buffer(), slotOffset, m_ubo.slotSize()}); + } + writer.flush(); + + cmd.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, pipelineLayout, + static_cast(DescriptorSetIndex::Material), + {materialSet, perDrawSet}, {}); + + cmd.draw(4, flareDraws[i].instances, 0, 0); + } + + cmd.endRenderPass(); + + // Scene color is back in eShaderReadOnlyOptimal (render pass finalLayout), + // exactly what the following bloom bright pass expects +} + +void VulkanLensFlare::shutdown() +{ + if (m_ctx == nullptr) { + return; + } + + // Called with the device idle (VulkanPostProcessor::shutdown waits), so this + // destroys immediately rather than queueing, and forgets what was uploaded so + // a re-init starts from nothing. + releaseTextures(false); + m_texLensIdx = -1; + m_texGeneration = 0; + + m_ubo.shutdown(); + + if (m_sceneColorFB) { + m_ctx->device.destroyFramebuffer(m_sceneColorFB); + m_sceneColorFB = nullptr; + } + if (m_renderPass) { + m_ctx->device.destroyRenderPass(m_renderPass); + m_renderPass = nullptr; + } + + m_initialized = false; +} + +} // namespace graphics::vulkan diff --git a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp index 2985c0b6509..e353bf73e93 100644 --- a/code/graphics/vulkan/VulkanPostProcessingLighting.cpp +++ b/code/graphics/vulkan/VulkanPostProcessingLighting.cpp @@ -489,8 +489,12 @@ void VulkanDeferredLighting::render(vk::CommandBuffer cmd) { auto* header = reinterpret_cast(uboMapped); memset(header, 0, sizeof(graphics::deferred_global_data)); - header->invScreenWidth = 1.0f / gr_screen.max_w; - header->invScreenHeight = 1.0f / gr_screen.max_h; + // Same as the OpenGL backend: deferred-f.sdr normalizes gl_FragCoord against these to + // sample the G-buffer, so they must describe the G-buffer, not gr_screen. resize() keeps + // sceneExtent equal to gr_screen today, which is why deriving it from the extent is a + // no-op here -- but it states the actual requirement instead of relying on that. + header->invScreenWidth = 1.0f / static_cast(m_ctx->sceneExtent.width); + header->invScreenHeight = 1.0f / static_cast(m_ctx->sceneExtent.height); header->nearPlane = gr_near_plane; if (m_shadow->isInitialized() && Shadow_quality != ShadowQuality::Disabled) { diff --git a/code/graphics/vulkan/VulkanRendererLoop.cpp b/code/graphics/vulkan/VulkanRendererLoop.cpp index 601dad04485..3675e360a98 100644 --- a/code/graphics/vulkan/VulkanRendererLoop.cpp +++ b/code/graphics/vulkan/VulkanRendererLoop.cpp @@ -328,6 +328,7 @@ void VulkanRenderer::endSceneRendering() } // Execute post-processing passes (all between HDR scene pass and swap chain pass) + m_postProcessor->executeLensFlare(m_currentCommandBuffer); m_postProcessor->executeBloom(m_currentCommandBuffer); m_postProcessor->executeTonemap(m_currentCommandBuffer); m_postProcessor->executeFXAA(m_currentCommandBuffer); diff --git a/code/lab/dialogs/lab_ui.cpp b/code/lab/dialogs/lab_ui.cpp index a534a02aef4..b3af25649d5 100644 --- a/code/lab/dialogs/lab_ui.cpp +++ b/code/lab/dialogs/lab_ui.cpp @@ -6,10 +6,12 @@ #include "asteroid/asteroid.h" #include "graphics/2d.h" #include "graphics/debug_sphere.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/shadows.h" #include "lab/labv2_internal.h" #include "lighting/lighting_profiles.h" +#include "starfield/starfield.h" #include "ship/shiphit.h" #include "weapon/weapon.h" #include "mission/missionload.h" @@ -730,6 +732,14 @@ void LabUi::show_render_options() } } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING && + stars_get_num_suns() > 0) { + with_CollapsingHeader("Lens flare options") + { + build_lens_flare_options(); + } + } + if (getLabManager()->Renderer->currentMissionBackground != LAB_MISSION_NONE_STRING) { if (Button("Export environment cubemap", ImVec2(-FLT_MIN, GetTextLineHeight()*2))) { gr_dump_envmap(getLabManager()->Renderer->currentMissionBackground.c_str()); diff --git a/code/lab/dialogs/lab_ui.h b/code/lab/dialogs/lab_ui.h index b70c0e89a81..fe62319229f 100644 --- a/code/lab/dialogs/lab_ui.h +++ b/code/lab/dialogs/lab_ui.h @@ -1,5 +1,6 @@ #pragma once +#include "graphics/lens_flare.h" #include "model/model.h" #include "model/animation/modelanimation.h" #include "species_defs/species_defs.h" @@ -47,6 +48,10 @@ class LabUi { static void build_max_rt_shadow_lights_slider(); static void build_rt_shadow_bias_sliders(); void build_tone_mapper_combobox(); + static void build_lens_flare_options(); + static void build_lens_aperture_options(graphics::lens_aperture& ap); + static void build_thruster_flare_options(); + static void build_lens_flare_pass_report(); void build_model_info_box(ship_info* sip, polymodel* pm) const; void build_subsystem_list(object* objp, ship* shipp) const; void build_subsystem_list_entry(SCP_string& subsys_name, diff --git a/code/lab/dialogs/lab_ui_lens_flare.cpp b/code/lab/dialogs/lab_ui_lens_flare.cpp new file mode 100644 index 00000000000..f8929e3d24e --- /dev/null +++ b/code/lab/dialogs/lab_ui_lens_flare.cpp @@ -0,0 +1,344 @@ +#include "lab_ui.h" + +#include "graphics/2d.h" +#include "graphics/lens_flare.h" +#include "lab/labv2_internal.h" +#include "object/object.h" +#include "ship/ship.h" +#include "starfield/starfield.h" +#include "weapon/beam.h" + +using namespace ImGui; + +namespace { + +// A thruster draw names the ship by objnum, and the pass that produced it ran a +// frame ago -- so the object may already be gone by the time the panel reads it. +const char* flare_source_ship_name(int objnum) +{ + if (objnum < 0 || objnum >= MAX_OBJECTS || Objects[objnum].type != OBJ_SHIP) { + return ""; + } + return Ships[Objects[objnum].instance].ship_name; +} + +// A beam draw names the beam object itself, which isn't a ship -- what the +// panel actually wants to show is who is firing it. +const char* flare_source_beam_shooter_name(int beam_objnum) +{ + if (beam_objnum < 0 || beam_objnum >= MAX_OBJECTS || Objects[beam_objnum].type != OBJ_BEAM) { + return ""; + } + const int bm_idx = Objects[beam_objnum].instance; + if (bm_idx < 0 || bm_idx >= MAX_BEAMS) { + return ""; + } + const beam& bm = Beams[bm_idx]; + if (bm.objp == nullptr || bm.objp->type != OBJ_SHIP) { + return ""; + } + return Ships[bm.objp->instance].ship_name; +} + +} // namespace + +// The lab's "Lens flare options" panel: the camera lens the scene is shot +// through, live iris editing, the global brightness calibration, and what the +// last flare pass actually did. Split out of lab_ui.cpp, which has no room for +// another feature panel. + +// Live iris controls. One aperture drives both the ghosts and the starburst +// (the starburst is the Fraunhofer transform of this mask), so every slider +// here changes both at once. Edits are coalesced by lens_flare.cpp -- the mask +// and its FFT are far too expensive to rebuild on every frame of a drag. +void LabUi::build_lens_aperture_options(graphics::lens_aperture& ap) +{ + bool changed = false; + + with_TreeNode("Aperture") + { + TextDisabled("Shared by ghosts and starburst"); + + changed |= SliderInt("Blades", &ap.blades, 2, 16); + changed |= SliderFloat("Blade rotation", &ap.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Blade curvature", &ap.curvature, -1.0f, 1.0f); + changed |= SliderFloat("Edge softness", &ap.softness, 0.0f, 0.5f); + + Separator(); + TextDisabled("Rim diffraction grating"); + changed |= SliderFloat("Grating strength", &ap.grating.strength, 0.0f, 1.0f); + if (ap.grating.strength > 0.0f) { + changed |= SliderFloat("Grating density", &ap.grating.density, 0.0f, 1.0f); + changed |= SliderFloat("Grating length", &ap.grating.length, 0.0f, 1.0f); + changed |= SliderFloat("Grating width", &ap.grating.width, 0.0f, 1.0f); + changed |= SliderFloat("Grating softness", &ap.grating.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Scratches"); + changed |= SliderFloat("Scratch strength", &ap.scratches.strength, 0.0f, 1.0f); + if (ap.scratches.strength > 0.0f) { + changed |= SliderFloat("Scratch density", &ap.scratches.density, 0.0f, 1.0f); + changed |= SliderFloat("Scratch length", &ap.scratches.length, 0.0f, 1.0f); + changed |= SliderFloat("Scratch width", &ap.scratches.width, 0.0f, 1.0f); + changed |= SliderFloat("Scratch rotation", &ap.scratches.rotation, 0.0f, 180.0f, "%.1f deg"); + changed |= SliderFloat("Scratch rot variation", &ap.scratches.rotation_variation, 0.0f, 1.0f); + changed |= SliderFloat("Scratch softness", &ap.scratches.softness, 0.0f, 0.5f); + } + + Separator(); + TextDisabled("Dust"); + changed |= SliderFloat("Dust strength", &ap.dust.strength, 0.0f, 1.0f); + if (ap.dust.strength > 0.0f) { + changed |= SliderFloat("Dust density", &ap.dust.density, 0.0f, 1.0f); + changed |= SliderFloat("Dust radius", &ap.dust.radius, 0.0f, 1.0f); + changed |= SliderFloat("Dust softness", &ap.dust.softness, 0.0f, 0.5f); + } + + if (graphics::lens_flare_aperture_edit_pending()) { + TextDisabled("Rebuilding aperture + starburst..."); + } + TextDisabled("Grating/scratches/dust add off-axis energy, which the"); + TextDisabled("starburst normalizes against -- expect the core to dim"); + TextDisabled("as they come up. Changes are undone on table reload and"); + TextDisabled("whenever the background changes (same as set-lens-* sexps)."); + } + + if (changed) { + graphics::lens_flare_overrides().aperture = ap; + graphics::lens_flare_overrides_changed(); + } +} + +void LabUi::build_lens_flare_options() +{ + // The camera's own settings, resolved once: the controls below start from + // whatever is currently in force -- a lens's tabled values, or whatever this + // panel or a mission has already overridden them with -- so nothing here has + // to know which of the two it is looking at. + const int active_lens = graphics::lens_flare_active_lens(); + graphics::lens_settings settings = graphics::lens_flare_effective_settings(active_lens); + auto& overrides = graphics::lens_flare_overrides(); + bool settings_changed = false; + + // A control writes back *only its own* override, and only when it actually + // moved. Writing the whole set on any change would freeze the mounted lens's + // entire tabled look into the overrides the moment one slider was nudged -- + // after which switching lenses in the combo below would keep showing the old + // lens's intensity, starburst and squeeze, since an override quite correctly + // beats whatever the new lens tables. + auto edited = [&settings_changed](bool moved, auto& slot, const auto& value) { + if (moved) { + slot = value; + settings_changed = true; + } + return moved; + }; + + // Not per-camera and so not overridable: this one describes the display. + auto& tuning = graphics::lens_flare_get_tuning(); + edited(SliderFloat("Ghost brightness", &settings.ghost_brightness, 0.0f, 500.0f, "%.1f", + ImGuiSliderFlags_Logarithmic), + overrides.ghost_brightness, settings.ghost_brightness); + edited(SliderFloat("Starburst brightness", &settings.starburst_brightness, 0.0f, 10.0f), + overrides.starburst_brightness, settings.starburst_brightness); + SliderFloat("HDR headroom (x paper white)", &tuning.hdr_headroom, 0.0f, 8.0f); + if (Gr_hdr_output_active) { + TextDisabled("HDR output active: flare auto-scaled to ~%.1fx paper white", tuning.hdr_headroom); + } else { + TextDisabled("SDR output active: HDR headroom has no effect right now"); + } + + // The camera lens: one for the whole scene, so every sun flares through it + Separator(); + const auto lab_lens = graphics::lens_flare_get_lab_lens(); + + const char* mission_lens_name = graphics::lens_flare_mission_lens_name(); + SCP_string mission_label = "Mission default ("; + mission_label += (*mission_lens_name != '\0') ? mission_lens_name : "none"; + mission_label += ")"; + + const auto* active_system = graphics::lens_flare_get_system(active_lens); + const char* preview = mission_label.c_str(); + if (lab_lens) { + preview = (active_system != nullptr) ? active_system->name.c_str() : "None"; + } + + with_Combo("Camera lens", preview) + { + if (Selectable(mission_label.c_str(), !lab_lens)) { + graphics::lens_flare_clear_lab_lens(); + } + if (Selectable("None", lab_lens && *lab_lens < 0)) { + graphics::lens_flare_set_lab_lens(-1); + } + for (int lens_idx = 0; lens_idx < graphics::lens_flare_num_systems(); lens_idx++) { + bool is_selected = (lab_lens == lens_idx); + + if (Selectable(graphics::lens_flare_get_system(lens_idx)->name.c_str(), is_selected)) { + graphics::lens_flare_set_lab_lens(lens_idx); + } + + if (is_selected) + SetItemDefaultFocus(); + } + } + + if (const auto* lens = graphics::lens_flare_get_system(active_lens)) { + edited(SliderFloat("Lens intensity", &settings.intensity, 0.0f, 10.0f), overrides.intensity, + settings.intensity); + edited(Checkbox("Starburst", &settings.starburst), overrides.starburst, settings.starburst); + if (settings.starburst) { + edited(SliderFloat("Starburst scale", &settings.starburst_scale, 0.0f, 4.0f, "%.2fx"), + overrides.starburst_scale, settings.starburst_scale); + } + edited(SliderInt("Max ghosts", &settings.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS), + overrides.max_ghosts, settings.max_ghosts); + + // The squeeze and the streak are one artifact and are overridden together, + // so unlike the knobs above they share a slot -- any of the five moving + // writes the whole lens_anamorphic. All of them cost nothing to change: + // they are applied when the quads are drawn, with no texture to rebuild. + graphics::lens_streak& streak = settings.anamorphic.streak; + bool anamorphic_moved = SliderFloat("Anamorphic squeeze", &settings.anamorphic.squeeze, 1.0f, 3.0f, "%.2fx"); + with_TreeNode("Anamorphic streak") + { + TextDisabled("Stays horizontal wherever the sun is"); + anamorphic_moved |= SliderFloat("Streak strength", &streak.strength, 0.0f, 2.0f); + if (streak.strength > 0.0f) { + anamorphic_moved |= SliderFloat("Streak length", &streak.length, 0.0f, 4.0f); + anamorphic_moved |= SliderFloat("Streak thickness", &streak.thickness, 0.001f, 0.2f, "%.3f"); + anamorphic_moved |= ColorEdit3("Streak tint", streak.tint); + } + } + edited(anamorphic_moved, overrides.anamorphic, settings.anamorphic); + + Text("%d of %d ghosts | EFL %.1f mm | f/%.1f | %s", + MIN(static_cast(lens->ghosts.size()), MAX(settings.max_ghosts, 0)), + static_cast(lens->ghosts.size()), + lens->efl, + lens->efl / (2.0f * lens->entrance_radius), + settings.starburst ? "starburst" : "no starburst"); + + build_lens_aperture_options(settings.aperture); + } else { + TextDisabled("No lens mounted: this background renders no physically-based flares"); + } + + // The overrides themselves were written above, by whichever control moved. + // This only publishes the fact that something did -- the iris sliders do it + // for themselves, since theirs is the edit that costs a texture rebuild. + if (settings_changed) { + graphics::lens_flare_overrides_changed(); + } + + Separator(); + build_thruster_flare_options(); + + // live pass state, refreshed every frame by lens_flare_frame_update(): + // one entry per light source that got a draw + Separator(); + build_lens_flare_pass_report(); +} + +// What the last pass drew. Suns and beams are listed one by one -- neither is +// ever more than a handful -- while nozzles are summarised, because at full +// budget there are dozens of them and a line each would bury everything else +// in this window. +void LabUi::build_lens_flare_pass_report() +{ + const auto& draws = graphics::lens_flare_get_frame_draws(); + if (draws.empty()) { + TextUnformatted("Last pass: inactive (no visible source, or no lens mounted)"); + return; + } + + int thruster_draws = 0; + int thruster_instances = 0; + float max_off_axis = -1.0f; + int max_off_axis_obj = -1; + + Text("Last pass: %d source(s) drawn", static_cast(draws.size())); + for (const auto& draw : draws) { + if (draw.kind == graphics::flare_source_kind::sun) { + Text(" Sun (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + stars_get_sun_name(draw.source_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + continue; + } + + if (draw.kind == graphics::flare_source_kind::beam) { + Text(" Beam (%s): %d instances, visibility %.2f, %.1f deg off-axis, output scale %.3f", + flare_source_beam_shooter_name(draw.source_index), + draw.instances, + draw.visibility, + draw.off_axis_deg, + draw.output_scale); + continue; + } + + thruster_draws++; + thruster_instances += draw.instances; + if (draw.off_axis_deg > max_off_axis) { + max_off_axis = draw.off_axis_deg; + max_off_axis_obj = draw.source_index; + } + } + + if (thruster_draws > 0) { + Text(" Thrusters: %d nozzle(s), %d instances total, furthest off-axis %.1f deg on %s", + thruster_draws, + thruster_instances, + max_off_axis, + flare_source_ship_name(max_off_axis_obj)); + } +} + +// Thruster flares are tabled per species, but the lab overrides all species at +// once -- it shows one ship at a time, and a single override leaves every tabled +// value untouched, so nothing has to be restored on the way out (see +// lens_flare.h). +void LabUi::build_thruster_flare_options() +{ + auto& lab_override = graphics::lens_flare_lab_thruster_flare(); + + bool overriding = lab_override.has_value(); + if (Checkbox("Override thruster flares", &overriding)) { + if (overriding) { + // Start from what the displayed ship's own species tables, so switching + // the override on changes nothing until a slider is touched + const int species_idx = + getLabManager()->isSafeForShips() ? Ship_info[getLabManager()->CurrentClass].species : -1; + auto tabled = graphics::lens_flare_thruster_settings(species_idx); + tabled.enabled = true; + lab_override = tabled; + } else { + lab_override.reset(); + } + } + + // Not part of the override: this is a render policy, not content, so it + // applies whether or not the tabled values are being overridden + auto& tuning = graphics::lens_flare_get_tuning(); + Checkbox("Draw ghosts for thruster flares", &tuning.thruster_ghosts); + TextDisabled("Off by default: every lit nozzle is its own source, so a ghost"); + TextDisabled("train each is both the cost of the pass and, at that count,"); + TextDisabled("noise. Turn it on to see what it buys and what it costs."); + + if (!lab_override) { + TextDisabled("Using each species' own species_defs.tbl settings"); + return; + } + + Checkbox("Thruster flares enabled", &lab_override->enabled); + SliderFloat("Thruster intensity", &lab_override->intensity, 0.0f, 50.0f); + SliderFloat("Afterburner intensity", &lab_override->afterburner_intensity, 0.0f, 50.0f); + ColorEdit3("Thruster flare tint", lab_override->color.a1d); + TextDisabled("1.0 = one nozzle of radius r seen from 32r away. At combat"); + TextDisabled("range a nozzle is a small fraction of that, which is why the"); + TextDisabled("useful values are large. Brightness also follows throttle,"); + TextDisabled("nozzle facing and distance, so it is never constant per ship."); +} diff --git a/code/lab/renderer/lab_renderer.cpp b/code/lab/renderer/lab_renderer.cpp index eec2c2d1813..cf5ba45070c 100644 --- a/code/lab/renderer/lab_renderer.cpp +++ b/code/lab/renderer/lab_renderer.cpp @@ -2,6 +2,7 @@ #include "asteroid/asteroid.h" #include "globalincs/vmallocator.h" #include "graphics/2d.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "graphics/matrix.h" #include "lab/labv2_internal.h" @@ -401,7 +402,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { if (optional_string("+Flags:")) stuff_flagset(&flags); - skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "+Volumetric Nebula:", "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); if (optional_string("+Volumetric Nebula:")) { //Rendering usually happens in post-mission-init, just do it now in the lab The_mission.volumetrics.emplace().parse_volumetric_nebula().renderVolumeBitmap(); @@ -413,7 +414,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { // Are we using a skybox? //skip will skip to the end of the file (or to the 'end' string) if any string is absent, //so be sure to include any section that might be found - skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "#Background bitmaps" }); + skip_to_start_of_string_one_of(SCP_vector{ "$Skybox Model:", "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); strcpy_s(skybox_model, ""); if (optional_string("$Skybox Model:")) { stuff_string(skybox_model, F_NAME, MAX_FILENAME_LEN); @@ -434,7 +435,7 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { stars_set_background_orientation(&skybox_orientation); } - skip_to_start_of_string_either("$Lighting Profile:", "#Background bitmaps"); + skip_to_start_of_string_one_of(SCP_vector{ "$Lighting Profile:", "$Camera Lens:", "#Background bitmaps" }); ltp_name = ltp::default_name(); if(optional_string("$Lighting Profile:")){ stuff_string(ltp_name,F_NAME); @@ -445,6 +446,23 @@ void LabRenderer::useBackground(const SCP_string& mission_name) { ltp::switch_to(ltp_name); } + // The camera lens all sun flares are imaged through. Same as + // parse_mission_info(): hand the token over as written and let + // lens_flare_switch_to() resolve it, empty (no "$Camera Lens:" at all) + // included. + skip_to_start_of_string_either("$Camera Lens:", "#Background bitmaps"); + SCP_string lens_name; + if (optional_string("$Camera Lens:")) + stuff_string(lens_name, F_NAME); + graphics::lens_flare_switch_to(lens_name.c_str()); + + // Loading a background is the lab's level load, and stars_pre_level_init() + // above has just dropped the cached textures -- so build them here, as + // stars_post_level_init() does in the game. The lab is where the aperture + // sliders live, which makes it the worst place to leave the rebuild to the + // first flaring frame. + graphics::lens_flare_prime_textures(); + // Mission headers include additional fields between lighting profile and the // background section. If we stopped at the lighting profile, we need to seek again. skip_to_start_of_string("#Background bitmaps"); diff --git a/code/mission/missionparse.cpp b/code/mission/missionparse.cpp index b5090b26fd0..5c15d5bb3dc 100644 --- a/code/mission/missionparse.cpp +++ b/code/mission/missionparse.cpp @@ -34,6 +34,7 @@ #include "io/timer.h" #include "jumpnode/jumpnode.h" #include "lighting/lighting.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "localization/localize.h" #include "math/bitarray.h" @@ -774,6 +775,127 @@ void parse_custom_bitmap(const char *expected_string_640, const char *expected_s } } +// Read a mission-file option into an override slot, leaving it unset when the +// mission doesn't mention it. Unset is what makes the mounted lens's own tabled +// value stand, so it has to stay distinct from a value that happens to equal it. +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_float(&dest.emplace()); +} + +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_int(&dest.emplace()); +} + +static void stuff_lens_override(const char *token, std::optional &dest) +{ + if (optional_string(token)) + stuff_boolean(&dest.emplace()); +} + +// How this mission restyles the camera lens (see graphics/lens_flare.h). Three +// blocks, each independently optional: +// +// "$Lens Aperture:" the iris, replaced as a whole +// "$Lens Anamorphic:" the squeeze and streak, replaced as a whole +// "$Lens Flare Strength:" the brightness knobs, each on its own +// +// The first two are whole-struct replacements because they are single artifacts +// -- one iris drives both the ghosts and the starburst, and one squeeze governs +// the streak that goes with it -- so a partial "$Lens Aperture:" that only names +// "+Dust Strength:" also takes the *default* blades and curvature rather than the +// mounted lens's. FRED writes every field it doesn't leave at default, so this +// only bites a hand-edited mission file. The strength knobs have no such coupling +// and so are overridden one at a time. +static void parse_camera_lens_overrides(graphics::lens_overrides &overrides) +{ + if (optional_string("$Lens Aperture:")) { + graphics::lens_aperture &ap = overrides.aperture.emplace(); + + if (optional_string("+Blades:")) + stuff_int(&ap.blades); + if (optional_string("+Rotation:")) + stuff_float(&ap.rotation); + if (optional_string("+Curvature:")) + stuff_float(&ap.curvature); + if (optional_string("+Softness:")) + stuff_float(&ap.softness); + + if (optional_string("+Grating Strength:")) + stuff_float(&ap.grating.strength); + if (optional_string("+Grating Density:")) + stuff_float(&ap.grating.density); + if (optional_string("+Grating Length:")) + stuff_float(&ap.grating.length); + if (optional_string("+Grating Width:")) + stuff_float(&ap.grating.width); + if (optional_string("+Grating Softness:")) + stuff_float(&ap.grating.softness); + + if (optional_string("+Scratches Strength:")) + stuff_float(&ap.scratches.strength); + if (optional_string("+Scratches Density:")) + stuff_float(&ap.scratches.density); + if (optional_string("+Scratches Length:")) + stuff_float(&ap.scratches.length); + if (optional_string("+Scratches Width:")) + stuff_float(&ap.scratches.width); + if (optional_string("+Scratches Rotation:")) + stuff_float(&ap.scratches.rotation); + if (optional_string("+Scratches Rotation Variation:")) + stuff_float(&ap.scratches.rotation_variation); + if (optional_string("+Scratches Softness:")) + stuff_float(&ap.scratches.softness); + + if (optional_string("+Dust Strength:")) + stuff_float(&ap.dust.strength); + if (optional_string("+Dust Density:")) + stuff_float(&ap.dust.density); + if (optional_string("+Dust Radius:")) + stuff_float(&ap.dust.radius); + if (optional_string("+Dust Softness:")) + stuff_float(&ap.dust.softness); + } + + if (optional_string("$Lens Anamorphic:")) { + graphics::lens_anamorphic &an = overrides.anamorphic.emplace(); + + if (optional_string("+Squeeze:")) + stuff_float(&an.squeeze); + + if (optional_string("+Streak Strength:")) + stuff_float(&an.streak.strength); + if (optional_string("+Streak Length:")) + stuff_float(&an.streak.length); + if (optional_string("+Streak Thickness:")) + stuff_float(&an.streak.thickness); + if (optional_string("+Streak Tint:")) { + float rgb[3] = {an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]}; + size_t count = stuff_float_list(rgb, 3); + if (count != 3) { + error_display(0, "Mission '%s': $Lens Anamorphic:'s +Streak Tint: needs ( r, g, b )", + The_mission.name.c_str()); + } else { + an.streak.tint[0] = rgb[0]; + an.streak.tint[1] = rgb[1]; + an.streak.tint[2] = rgb[2]; + } + } + } + + if (optional_string("$Lens Flare Strength:")) { + stuff_lens_override("+Intensity:", overrides.intensity); + stuff_lens_override("+Ghost Brightness:", overrides.ghost_brightness); + stuff_lens_override("+Starburst Brightness:", overrides.starburst_brightness); + stuff_lens_override("+Starburst:", overrides.starburst); + stuff_lens_override("+Starburst Scale:", overrides.starburst_scale); + stuff_lens_override("+Max Ghosts:", overrides.max_ghosts); + } +} + void parse_mission_info(mission *pm, bool basic = false) { char game_string[NAME_LENGTH]; @@ -1118,6 +1240,26 @@ void parse_mission_info(mission *pm, bool basic = false) The_mission.lighting_profile_name = lighting_profiles::default_name(); lighting_profiles::switch_to(The_mission.lighting_profile_name); + // The camera lens every sun's flare is imaged through (graphics/lens_flare.h). + // Stored as the token the mission actually wrote, so that "this mission says + // nothing" (empty, taking the tabled default) stays distinct from an explicit + // -- otherwise a mod adding a $Default Lens: would silently override + // missions that had deliberately asked for no flares. lens_flare_switch_to() + // resolves all of it, including the empty case. + The_mission.camera_lens_name.clear(); + if (optional_string("$Camera Lens:")) + stuff_string(The_mission.camera_lens_name, F_NAME); + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); + + parse_camera_lens_overrides(The_mission.camera_lens_overrides); + + // One camera, so the mission's overrides simply *are* the camera's until + // something else (a set-lens-* sexp, the lab) restyles it again. Nothing is + // stamped into the mounted lens, which is why nothing has to be restored when + // this mission ends -- lens_flare_reset_for_level() just drops these. + graphics::lens_flare_overrides() = The_mission.camera_lens_overrides; + graphics::lens_flare_overrides_changed(); + if (optional_string("$Sound Environment:")) { char preset[65] = { '\0' }; stuff_string(preset, F_NAME, sizeof(preset)-1); @@ -7395,6 +7537,10 @@ void mission::Reset() ai_profile = &Ai_profiles[Default_ai_profile]; lighting_profile_name = lighting_profiles::default_name(); + // empty = this mission names no lens, so the tabled default applies + camera_lens_name.clear(); + // all unset = this mission restyles nothing, so the mounted lens stands as tabled + camera_lens_overrides.clear(); cutscenes.clear( ); diff --git a/code/mission/missionparse.h b/code/mission/missionparse.h index 8e917787108..0223c20e191 100644 --- a/code/mission/missionparse.h +++ b/code/mission/missionparse.h @@ -17,6 +17,7 @@ #include "ai/ai_profiles.h" #include "globalincs/version.h" #include "graphics/2d.h" +#include "graphics/lens_flare.h" #include "io/keycontrol.h" #include "model/model.h" #include "model/animation/modelanimation.h" @@ -236,6 +237,26 @@ typedef struct mission { SCP_string lighting_profile_name; + // The camera lens all sun flares are imaged through: the literal + // "$Camera Lens:" token, resolved by lens_flare_switch_to() (see the + // vocabulary in graphics/lens_flare.h). Empty means the mission names no lens + // and so takes the tabled default; LENS_NAME_NONE is how it asks for no + // flares at all. Keeping those two apart is what lets the field round-trip + // through FRED unchanged. + SCP_string camera_lens_name; + + // How this mission restyles the camera: iris shape, anamorphic look, and the + // flare's strength. Settable in FRED via the Background Editor's + // "Lens Aperture..." dialog, and the same thing the set-lens-* sexps write -- + // except applied at mission load rather than by an event. + // + // Every field is independently optional, so "this mission says nothing about + // the iris" stays distinct from "this mission wants the default iris". + // Independent of which lens is mounted, since there is only ever one camera + // (graphics/lens_flare.h) -- if $Camera Lens: changes, these still apply to + // whichever lens ends up mounted. + graphics::lens_overrides camera_lens_overrides; + SCP_vector cutscenes; SCP_map custom_data; diff --git a/code/missioneditor/missionsave.cpp b/code/missioneditor/missionsave.cpp index 72201544116..b7680b6ced6 100644 --- a/code/missioneditor/missionsave.cpp +++ b/code/missioneditor/missionsave.cpp @@ -393,6 +393,57 @@ int Fred_mission_save::fout_version(const char* format, ...) return 0; } +void Fred_mission_save::fout_lens_field(const char* token, float val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %f", val); + } else { + fout_version("\n%s %f", token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, int val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %d", val); + } else { + fout_version("\n%s %d", token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, bool val) +{ + if (optional_string_fred(token)) { + parse_comments(1); + fout(" %s", val ? "YES" : "NO"); + } else { + fout_version("\n%s %s", token, val ? "YES" : "NO"); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, float val, float def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, int val, int def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + +void Fred_mission_save::fout_lens_field(const char* token, bool val, bool def) +{ + if (val != def) { + fout_lens_field(token, val); + } +} + void Fred_mission_save::fout_raw_comment(const char* comment_start) { Assertion(comment_start <= raw_ptr, "This function assumes the beginning of the comment precedes the current raw pointer!"); @@ -3128,6 +3179,139 @@ int Fred_mission_save::save_mission_info() bypass_comment(";;FSO 23.1.0;; $Lighting Profile:"); } + // the-e's camera lens for physically-based flares. The token is written back + // verbatim: an empty one means the mission named no lens, and anything else -- + // a lens name or -- is a deliberate choice that has to survive the + // round trip even if it happens to match the current $Default Lens:. + if (!The_mission.camera_lens_name.empty()) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Camera Lens:")) { + parse_comments(2); + fout(" %s", The_mission.camera_lens_name.c_str()); + } else { + fout_version("\n\n$Camera Lens: %s", The_mission.camera_lens_name.c_str()); + } + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Camera Lens:"); + } + + // the-e's per-mission camera-lens overrides -- the iris, the anamorphic look + // and the flare's strength, the same things the set-lens-* sexps control but + // applied at mission load instead of by an event (see the field comment in + // missionparse.h). Each block is written only when the mission actually + // overrides that part, and within it each field only when it differs from its + // own default, so leaving (say) grating alone doesn't bloat every mission file + // with zeroes. + const graphics::lens_overrides& lens = The_mission.camera_lens_overrides; + + if (lens.aperture) { + const graphics::lens_aperture& ap = *lens.aperture; + const graphics::lens_aperture def; + + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Aperture:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Aperture:"); + } + + fout_lens_field("+Blades:", ap.blades, def.blades); + fout_lens_field("+Rotation:", ap.rotation, def.rotation); + fout_lens_field("+Curvature:", ap.curvature, def.curvature); + fout_lens_field("+Softness:", ap.softness, def.softness); + + fout_lens_field("+Grating Strength:", ap.grating.strength, def.grating.strength); + fout_lens_field("+Grating Density:", ap.grating.density, def.grating.density); + fout_lens_field("+Grating Length:", ap.grating.length, def.grating.length); + fout_lens_field("+Grating Width:", ap.grating.width, def.grating.width); + fout_lens_field("+Grating Softness:", ap.grating.softness, def.grating.softness); + + fout_lens_field("+Scratches Strength:", ap.scratches.strength, def.scratches.strength); + fout_lens_field("+Scratches Density:", ap.scratches.density, def.scratches.density); + fout_lens_field("+Scratches Length:", ap.scratches.length, def.scratches.length); + fout_lens_field("+Scratches Width:", ap.scratches.width, def.scratches.width); + fout_lens_field("+Scratches Rotation:", ap.scratches.rotation, def.scratches.rotation); + fout_lens_field("+Scratches Rotation Variation:", ap.scratches.rotation_variation, + def.scratches.rotation_variation); + fout_lens_field("+Scratches Softness:", ap.scratches.softness, def.scratches.softness); + + fout_lens_field("+Dust Strength:", ap.dust.strength, def.dust.strength); + fout_lens_field("+Dust Density:", ap.dust.density, def.dust.density); + fout_lens_field("+Dust Radius:", ap.dust.radius, def.dust.radius); + fout_lens_field("+Dust Softness:", ap.dust.softness, def.dust.softness); + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Aperture:"); + } + + if (lens.anamorphic) { + const graphics::lens_anamorphic& an = *lens.anamorphic; + const graphics::lens_anamorphic def; + + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Anamorphic:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Anamorphic:"); + } + + fout_lens_field("+Squeeze:", an.squeeze, def.squeeze); + fout_lens_field("+Streak Strength:", an.streak.strength, def.streak.strength); + fout_lens_field("+Streak Length:", an.streak.length, def.streak.length); + fout_lens_field("+Streak Thickness:", an.streak.thickness, def.streak.thickness); + + // The one field that isn't a single number, so it can't go through the + // helper: a tint is only meaningful as a whole triple. + if (an.streak.tint[0] != def.streak.tint[0] || an.streak.tint[1] != def.streak.tint[1] || + an.streak.tint[2] != def.streak.tint[2]) { + if (optional_string_fred("+Streak Tint:")) { + parse_comments(1); + fout(" ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1], an.streak.tint[2]); + } else { + fout_version("\n+Streak Tint: ( %f, %f, %f )", an.streak.tint[0], an.streak.tint[1], + an.streak.tint[2]); + } + } + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Anamorphic:"); + } + + // Unlike the two blocks above -- an iris and an anamorphic look are each one + // artifact, overridden whole -- these knobs are independent of each other, so + // each is written only if this mission overrode that one. + if (lens.intensity || lens.ghost_brightness || lens.starburst_brightness || lens.starburst || + lens.starburst_scale || lens.max_ghosts) { + fso_comment_push(";;FSO 26.1.0;;"); + if (optional_string_fred("$Lens Flare Strength:")) { + parse_comments(2); + } else { + fout_version("\n\n$Lens Flare Strength:"); + } + + // Written whenever the mission set it, default value or not: here the fact + // that it was overridden at all is the content. + if (lens.intensity) + fout_lens_field("+Intensity:", *lens.intensity); + if (lens.ghost_brightness) + fout_lens_field("+Ghost Brightness:", *lens.ghost_brightness); + if (lens.starburst_brightness) + fout_lens_field("+Starburst Brightness:", *lens.starburst_brightness); + if (lens.starburst) + fout_lens_field("+Starburst:", *lens.starburst); + if (lens.starburst_scale) + fout_lens_field("+Starburst Scale:", *lens.starburst_scale); + if (lens.max_ghosts) + fout_lens_field("+Max Ghosts:", *lens.max_ghosts); + + fso_comment_pop(); + } else { + bypass_comment(";;FSO 26.1.0;; $Lens Flare Strength:"); + } + // sound environment (EFX/EAX) - taylor sound_env* m_env = &The_mission.sound_environment; if ((m_env->id >= 0) && (m_env->id < static_cast(EFX_presets.size()))) { diff --git a/code/missioneditor/missionsave.h b/code/missioneditor/missionsave.h index 7d8f1558344..1da9d87e5b6 100644 --- a/code/missioneditor/missionsave.h +++ b/code/missioneditor/missionsave.h @@ -192,6 +192,29 @@ class Fred_mission_save { */ int fout_version(const char* format, ...); + /** + * @brief Writes one "+Token: value" line of a camera-lens override block + * + * Follows the usual round-trip convention: reuse the token's existing position + * and comments if the file already had one, otherwise append. + * + * @param[in] token The "+Something:" token, including the colon + * @param[in] val The value to write + */ + void fout_lens_field(const char* token, float val); + void fout_lens_field(const char* token, int val); + void fout_lens_field(const char* token, bool val); + + /** + * @brief As above, but emits nothing when the value still equals @a def + * + * For blocks that are overridden as a whole, where a field left at its default + * carries no information and would only bloat the file. + */ + void fout_lens_field(const char* token, float val, float def); + void fout_lens_field(const char* token, int val, int def); + void fout_lens_field(const char* token, bool val, bool def); + private: /** diff --git a/code/missioneditor/sexp_tree_opf.cpp b/code/missioneditor/sexp_tree_opf.cpp index dc29f09fa04..ba849078834 100644 --- a/code/missioneditor/sexp_tree_opf.cpp +++ b/code/missioneditor/sexp_tree_opf.cpp @@ -18,6 +18,7 @@ #include "model/model.h" #include "sound/ds.h" #include "hud/hud.h" +#include "graphics/lens_flare.h" #include "graphics/software/FontManager.h" #include "hud/hudsquadmsg.h" #include "controlconfig/controlsconfig.h" @@ -1154,6 +1155,21 @@ sexp_list_item *SexpTreeOPF::get_listing_opf_post_effect() return head.next; } +sexp_list_item *SexpTreeOPF::get_listing_opf_lens_system() +{ + sexp_list_item head; + + // the two magic values set-camera-lens accepts in place of a real lens + head.add_data(LENS_NAME_NONE); + head.add_data(LENS_NAME_DEFAULT); + + for (int i = 0; i < graphics::lens_flare_num_systems(); i++) { + head.add_data(graphics::lens_flare_get_system(i)->name.c_str()); + } + + return head.next; +} + sexp_list_item *SexpTreeOPF::get_listing_opf_turret_target_priorities() { sexp_list_item head; @@ -2318,6 +2334,10 @@ sexp_list_item *SexpTreeOPF::get_listing_opf(int opf, int parent_node, int arg_i list = get_listing_opf_post_effect(); break; + case OPF_LENS_SYSTEM: + list = get_listing_opf_lens_system(); + break; + case OPF_FONT: list = get_listing_opf_font(); break; @@ -2584,6 +2604,7 @@ int SexpTreeOPF::query_default_argument_available(int op, int i) const case OPF_TURRET_TARGET_ORDER: case OPF_TURRET_TYPE: case OPF_POST_EFFECT: + case OPF_LENS_SYSTEM: case OPF_TARGET_PRIORITIES: case OPF_ARMOR_TYPE: case OPF_DAMAGE_TYPE: @@ -3209,6 +3230,10 @@ int SexpTreeOPF::get_default_value(sexp_list_item* item, int op, int i) const str = ""; break; + case OPF_LENS_SYSTEM: + str = LENS_NAME_DEFAULT; + break; + case OPF_CUSTOM_HUD_GAUGE: str = ""; break; diff --git a/code/missioneditor/sexp_tree_opf.h b/code/missioneditor/sexp_tree_opf.h index 8c7b6ac09dd..d30b6744c4f 100644 --- a/code/missioneditor/sexp_tree_opf.h +++ b/code/missioneditor/sexp_tree_opf.h @@ -110,6 +110,7 @@ class SexpTreeOPF { static sexp_list_item* get_listing_opf_turret_target_order(); static sexp_list_item* get_listing_opf_turret_types(); static sexp_list_item* get_listing_opf_post_effect(); + static sexp_list_item* get_listing_opf_lens_system(); static sexp_list_item* get_listing_opf_turret_target_priorities(); static sexp_list_item* get_listing_opf_armor_type(); static sexp_list_item* get_listing_opf_damage_type(); diff --git a/code/parse/sexp.cpp b/code/parse/sexp.cpp index 48b59f5a53c..21495429049 100644 --- a/code/parse/sexp.cpp +++ b/code/parse/sexp.cpp @@ -42,6 +42,7 @@ #include "globalincs/version.h" #include "graphics/2d.h" #include "graphics/font.h" +#include "graphics/lens_flare.h" #include "graphics/light.h" #include "hud/hud.h" #include "hud/hudartillery.h" @@ -782,6 +783,12 @@ SCP_vector Operators = { { "set-skybox-orientation", OP_SET_SKYBOX_ORIENT, 3, 3, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-skybox-alpha", OP_SET_SKYBOX_ALPHA, 1, 1, SEXP_ACTION_OPERATOR, }, // Goober5000 { "set-ambient-light", OP_SET_AMBIENT_LIGHT, 3, 3, SEXP_ACTION_OPERATOR, }, // Karajorma + { "set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-grating", OP_SET_LENS_GRATING, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-dust", OP_SET_LENS_DUST, 1, 4, SEXP_ACTION_OPERATOR, }, // the-e + { "set-lens-flare-strength", OP_SET_LENS_FLARE_STRENGTH, 1, 5, SEXP_ACTION_OPERATOR, }, // the-e { "toggle-asteroid-field", OP_TOGGLE_ASTEROID_FIELD, 1, 1, SEXP_ACTION_OPERATOR, }, // MjnMixael { "set-asteroid-field", OP_SET_ASTEROID_FIELD, 1, INT_MAX, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated { "set-debris-field", OP_SET_DEBRIS_FIELD, 1, 12, SEXP_ACTION_OPERATOR, }, // MjnMixael - Deprecated @@ -1151,6 +1158,35 @@ int check_dynamic_value_node_type(int node, bool is_string, bool is_number); // hud-display-gauge magic values #define SEXP_HUD_GAUGE_WARPOUT "warpout" +// The set-lens-* operators warn when no lens is mounted, but a mission event +// without a guard re-evaluates every frame and Warning() is modal in debug +// builds. Shared by all four operators rather than one flag each: the cause is +// the same in every case, so one complaint per mission is enough even though the +// message names whichever operator hit it first. Reset by init_sexp(). +static bool Sexp_lens_aperture_warned = false; + +// Whether an OPF_LENS_SYSTEM argument names something the engine can resolve. +// +// This is checked at mission load, where a failure aborts the load, so it is +// only safe because lens_flares.tbl is an engine default: the built-in lenses +// always resolve no matter what is installed, stars_init() parses the table +// before any mission is loaded (in the game, the standalone server, FRED and +// qtFRED alike), and a mod adding lenses via *-lens.tbm ships that table +// alongside the missions that name them. +bool sexp_lens_name_is_valid(const char* lens_name) +{ + if (lens_name == nullptr) { + return false; + } + // set-camera-lens is the only operator taking a lens name, so the sentinels + // (shared with the mission field and the editors, see graphics/lens_flare.h) + // are always meaningful here + if (!stricmp(lens_name, LENS_NAME_NONE) || !stricmp(lens_name, LENS_NAME_DEFAULT)) { + return true; + } + return graphics::lens_flare_lookup(lens_name) >= 0; +} + // event log stuff SCP_vector *Current_event_log_buffer; SCP_vector *Current_event_log_variable_buffer; @@ -1379,6 +1415,7 @@ void init_sexp() // init data structures used by certain operators // (note, Sexp_music_handles are not cleared here because sexp_music_close() handled that at the end of the previous mission) Sexp_is_true_for_duration_times.clear(); + Sexp_lens_aperture_warned = false; } // done at the beginning of the game @@ -3930,6 +3967,14 @@ int check_sexp_syntax(int node, int desired_return_type, int recursive, int *bad } break; + case OPF_LENS_SYSTEM: + if (node_subtype != SEXP_ATOM_STRING) { + return SEXP_CHECK_TYPE_MISMATCH; + } else if (!sexp_lens_name_is_valid(CTEXT(node))) { + return SEXP_CHECK_INVALID_LENS_SYSTEM; + } + break; + case OPF_HUD_ELEMENT: if (node_subtype != SEXP_ATOM_STRING) { return SEXP_CHECK_TYPE_MISMATCH; @@ -16744,11 +16789,204 @@ void sexp_remove_background_bitmap(int n, bool is_sun) } } +// --- physically-based lens flares (see graphics/lens_flare.h) --------------- +// +// There is one camera lens for the whole mission, so these operators need no sun +// or lens argument: set-camera-lens swaps the mounted lens, and the four aperture +// operators restyle the iris of whatever is mounted. Both kinds of change are +// undone by lens_flare_reset_for_level() from stars_pre_level_init(), so a +// mission cannot leak its camera into the next one. Like the other +// background/visual operators (set-post-effect, the nebula ones) they are not +// packed for multiplayer, so in a networked game they only affect the host. + +void sexp_set_camera_lens(int n) +{ + // , , and the unknown-name warning are all resolved by + // lens_flare_switch_to() -- the same vocabulary the mission's "$Camera Lens:" + // and both editors use, so there is nothing to translate here + graphics::lens_flare_switch_to(CTEXT(n)); +} + +// Shared front end of the lens operators: check that there is a camera to +// restyle, hand the caller the settings currently in force so it can edit from +// there rather than from the tabled values, and publish the result. +// +// The edit starts from lens_flare_effective_settings() so that these operators +// compose: set-lens-grating after set-lens-dust keeps the dust, and either after +// a mission's own "$Lens Aperture:" keeps the rest of that block. +template +void sexp_edit_lens(const char* op_name, EditFunc&& edit) +{ + int lens_idx = graphics::lens_flare_active_lens(); + if (lens_idx < 0) { + // Once per mission: an unguarded event lands here every frame, and this + // warning is modal in debug builds (see Sexp_lens_aperture_warned) + if (!Sexp_lens_aperture_warned) { + Sexp_lens_aperture_warned = true; + Warning(LOCATION, "%s: this mission has no camera lens mounted; use set-camera-lens first.", op_name); + } + return; + } + + graphics::lens_settings settings = graphics::lens_flare_effective_settings(lens_idx); + edit(settings, graphics::lens_flare_overrides()); + + // Cheap to call even when nothing moved: only a genuinely changed iris costs + // anything, and the module works that out for itself. + graphics::lens_flare_overrides_changed(); +} + +// The four iris operators all edit the aperture and nothing else, so they share +// this wrapper on top of the above. +template +void sexp_edit_lens_aperture(const char* op_name, EditFunc&& edit) +{ + sexp_edit_lens(op_name, [&edit](graphics::lens_settings& settings, graphics::lens_overrides& overrides) { + edit(settings.aperture); + overrides.aperture = settings.aperture; + }); +} + +// Read an optional percentage argument into `dest` as a 0..1 fraction, leaving +// it alone when the argument was omitted or is nan. Advances the node. +void sexp_lens_next_pct(int& n, float& dest, float min_val, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f, min_val, max_val); + + n = CDR(n); +} + +// Same, for a plain integer argument. +void sexp_lens_next_int(int& n, int& dest, int min_val, int max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int val = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(val, min_val, max_val); + + n = CDR(n); +} + +void sexp_lens_next_degrees(int& n, float& dest) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int deg = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = i2fl(deg); + + n = CDR(n); +} + +void sexp_set_lens_aperture(int node) +{ + sexp_edit_lens_aperture("set-lens-aperture", [node](graphics::lens_aperture& ap) { + int n = node; + + sexp_lens_next_int(n, ap.blades, 0, 64); + sexp_lens_next_degrees(n, ap.rotation); + sexp_lens_next_pct(n, ap.curvature, -1.0f, 1.0f); + sexp_lens_next_pct(n, ap.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_grating(int node) +{ + sexp_edit_lens_aperture("set-lens-grating", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.grating.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.width, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.grating.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_scratches(int node) +{ + sexp_edit_lens_aperture("set-lens-scratches", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.scratches.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.length, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.width, 0.0f, 1.0f); + sexp_lens_next_degrees(n, ap.scratches.rotation); + sexp_lens_next_pct(n, ap.scratches.rotation_variation, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.scratches.softness, 0.0f, 1.0f); + }); +} + +void sexp_set_lens_dust(int node) +{ + sexp_edit_lens_aperture("set-lens-dust", [node](graphics::lens_aperture& ap) { + int n = node; + sexp_lens_next_pct(n, ap.dust.strength, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.density, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.radius, 0.0f, 1.0f); + sexp_lens_next_pct(n, ap.dust.softness, 0.0f, 1.0f); + }); +} + +// Same as sexp_lens_next_pct(), but scaling a percentage against a reference +// value rather than into 0..1 -- so 100 means "the engine's calibrated default" +// and a designer states these as a proportion of it instead of having to know +// that a ghost brightness of 64 is normal. +void sexp_lens_next_scaled_pct(int& n, float& dest, float reference, float max_val) +{ + if (n < 0) + return; + + bool is_nan, is_nan_forever; + int pct = eval_num(n, is_nan, is_nan_forever); + if (!is_nan && !is_nan_forever) + dest = std::clamp(pct / 100.0f * reference, 0.0f, max_val); + + n = CDR(n); +} + +// The cheap counterpart to the four iris operators: nothing here touches the +// aperture, so nothing here rebuilds a texture. Safe to drive from a repeating +// event, which is exactly what makes it the right operator for a flare that +// brightens or fades over time. +void sexp_set_lens_flare_strength(int node) +{ + sexp_edit_lens("set-lens-flare-strength", + [node](graphics::lens_settings& set, graphics::lens_overrides& overrides) { + int n = node; + + // Stated against the value in force rather than a fixed constant, so + // "50" halves whatever the mounted lens tables instead of jumping to + // half of some other lens's number. + sexp_lens_next_scaled_pct(n, set.intensity, set.intensity, 100.0f); + sexp_lens_next_scaled_pct(n, set.ghost_brightness, set.ghost_brightness, 10000.0f); + sexp_lens_next_scaled_pct(n, set.starburst_brightness, set.starburst_brightness, 1000.0f); + sexp_lens_next_scaled_pct(n, set.starburst_scale, set.starburst_scale, 100.0f); + sexp_lens_next_int(n, set.max_ghosts, 0, graphics::MAX_LENS_FLARE_GHOSTS); + + overrides.intensity = set.intensity; + overrides.ghost_brightness = set.ghost_brightness; + overrides.starburst_brightness = set.starburst_brightness; + overrides.starburst_scale = set.starburst_scale; + overrides.max_ghosts = set.max_ghosts; + }); +} + void sexp_nebula_change_storm(int n) { if (!(The_mission.flags[Mission::Mission_Flags::Fullneb])) return; - + nebl_set_storm(CTEXT(n)); } @@ -30286,8 +30524,38 @@ int eval_sexp(int cur_node, int referenced_node) sexp_val = SEXP_TRUE; break; - case OP_SET_AMBIENT_LIGHT: - sexp_set_ambient_light(node); + case OP_SET_AMBIENT_LIGHT: + sexp_set_ambient_light(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_CAMERA_LENS: + sexp_set_camera_lens(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_APERTURE: + sexp_set_lens_aperture(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_GRATING: + sexp_set_lens_grating(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_SCRATCHES: + sexp_set_lens_scratches(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_DUST: + sexp_set_lens_dust(node); + sexp_val = SEXP_TRUE; + break; + + case OP_SET_LENS_FLARE_STRENGTH: + sexp_set_lens_flare_strength(node); sexp_val = SEXP_TRUE; break; @@ -31989,6 +32257,12 @@ int query_operator_return_type(int op) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_SET_POST_EFFECT: case OP_RESET_POST_EFFECTS: case OP_CHANGE_IFF_COLOR: @@ -34301,7 +34575,24 @@ int query_operator_argument_type(int op_index, int argnum) case OP_SET_AMBIENT_LIGHT: return OPF_POSITIVE; - + + case OP_SET_CAMERA_LENS: + return OPF_LENS_SYSTEM; + + case OP_SET_LENS_APERTURE: + // blade count and rotation (both non-negative), then curvature, + // which may bow the blades inward + if (argnum == 2) + return OPF_NUMBER; + else + return OPF_POSITIVE; + + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: + return OPF_POSITIVE; + case OP_SET_POST_EFFECT: if (argnum == 0) return OPF_POST_EFFECT; @@ -35581,6 +35872,9 @@ const char *sexp_error_message(int num) case SEXP_CHECK_INVALID_ANIMATION_TYPE: return "Invalid animation type"; + case SEXP_CHECK_INVALID_LENS_SYSTEM: + return "Invalid lens system"; + case SEXP_CHECK_INVALID_MISSION_MOOD: return "Invalid mission mood"; @@ -37116,6 +37410,12 @@ int get_category(int op_id) case OP_SET_WEAPON_ENERGY: case OP_SET_SHIELD_ENERGY: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_CHANGE_IFF_COLOR: case OP_TURRET_SUBSYS_TARGET_DISABLE: case OP_TURRET_SUBSYS_TARGET_ENABLE: @@ -37731,6 +38031,12 @@ int get_subcategory(int op_id) case OP_NEBULA_SET_RANGE: case OP_VOLUMETRICS_TOGGLE: case OP_SET_AMBIENT_LIGHT: + case OP_SET_CAMERA_LENS: + case OP_SET_LENS_APERTURE: + case OP_SET_LENS_GRATING: + case OP_SET_LENS_SCRATCHES: + case OP_SET_LENS_DUST: + case OP_SET_LENS_FLARE_STRENGTH: case OP_TOGGLE_ASTEROID_FIELD: case OP_SET_ASTEROID_FIELD: case OP_SET_DEBRIS_FIELD: @@ -41874,6 +42180,114 @@ SCP_vector Sexp_help = { "\t3: Blue (0 - 255)." }, + { OP_SET_CAMERA_LENS, "set-camera-lens\r\n" + "\tMounts a physically-based camera lens, replacing the mission's $Camera Lens:.\r\n" + "\tThere is one lens for the whole mission, because there is one camera: every sun\r\n" + "\tin the background flares through the same glass, which is what keeps their\r\n" + "\tflares consistent with each other.\r\n\r\n" + "\tThe lens goes back to the mission's own when the mission ends. Not sent over\r\n" + "\tthe network, so in multiplayer this only affects the host.\r\n\r\n" + "\tTakes 1 argument...\r\n" + "\t1:\tLens system from lens_flares.tbl, or for no flares at all, or\r\n" + "\t\t for the one lens_flares.tbl declares as $Default Lens:." + }, + + { OP_SET_LENS_APERTURE, "set-lens-aperture\r\n" + "\tChanges the iris shape of the mounted camera lens. A lens has exactly one\r\n" + "\taperture and it drives both the ghosts and the starburst, so this restyles both\r\n" + "\tat once, for every sun.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. The iris goes back to its\r\n" + "\ttabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tCOST: changing the iris is expensive. The engine has to re-render the iris\r\n" + "\tmask and then take a 512x512 Fourier transform of it to get the new starburst,\r\n" + "\twhich takes long enough to be seen as a stutter. Use these operators for\r\n" + "\toccasional, deliberate changes -- a lens getting dirty over the course of a\r\n" + "\tmission, say. Do NOT drive them from a repeating event or an every-frame\r\n" + "\tcondition: the rebuild is coalesced so it cannot happen more than a few times\r\n" + "\ta second, but a value that keeps changing will keep paying for it. To vary the\r\n" + "\tflare continuously, use set-lens-flare-strength, which costs nothing.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tNumber of iris blades; fewer than 3 gives a round iris.\r\n" + "\t2:\t(optional) Blade rotation in degrees.\r\n" + "\t3:\t(optional) Blade curvature as a percentage: 0 leaves the blades straight,\r\n" + "\t\t100 bows them out into a circle, and negative values bow them inward.\r\n" + "\t4:\t(optional) Edge softness as a percentage of the iris radius. 0 keeps the\r\n" + "\t\tsharp default; even a few percent visibly weakens the starburst spikes,\r\n" + "\t\tsince those come from the sharpness of that edge." + }, + + { OP_SET_LENS_GRATING, "set-lens-grating\r\n" + "\tSets the diffraction grating of the mounted lens's iris: radial ridges around\r\n" + "\tthe rim that throw extra spikes into the starburst. See set-lens-aperture for\r\n" + "\thow iris edits behave.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tNote that the starburst is normalized against its own brightest value, so\r\n" + "\tadding grating dims the core spikes as it adds new ones.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the grating off.\r\n" + "\t2:\t(optional) Density as a percentage of the 360 possible ridges.\r\n" + "\t3:\t(optional) Length as a percentage: how far in from the rim they reach.\r\n" + "\t4:\t(optional) Width as a percentage of the spacing between ridges.\r\n" + "\t5:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_SCRATCHES, "set-lens-scratches\r\n" + "\tSets the scratches on the mounted lens's iris: randomly placed slivers, for a\r\n" + "\tworn or damaged lens. See set-lens-aperture for how iris edits behave, and\r\n" + "\tset-lens-grating for the note about starburst normalization.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tTakes 1 to 7 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the scratches off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible scratches.\r\n" + "\t3:\t(optional) Length as a percentage.\r\n" + "\t4:\t(optional) Width as a percentage.\r\n" + "\t5:\t(optional) Rotation in degrees.\r\n" + "\t6:\t(optional) Rotation variation as a percentage; 0 leaves every scratch\r\n" + "\t\tparallel, 100 scatters them completely.\r\n" + "\t7:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_DUST, "set-lens-dust\r\n" + "\tSets the dust on the mounted lens's iris: randomly placed specks, for a dirty\r\n" + "\tlens. See set-lens-aperture for how iris edits behave, and set-lens-grating\r\n" + "\tfor the note about starburst normalization.\r\n\r\n" + "\tCOST: like set-lens-aperture, this rebuilds the iris mask and its Fourier\r\n" + "\ttransform, which can stutter -- see the warning there. Not for repeating\r\n" + "\tevents.\r\n\r\n" + "\tTakes 1 to 4 arguments...\r\n" + "\t1:\tStrength as a percentage; 0 turns the dust off.\r\n" + "\t2:\t(optional) Density as a percentage of the 1000 possible specks.\r\n" + "\t3:\t(optional) Speck radius as a percentage.\r\n" + "\t4:\t(optional) Softness as a percentage." + }, + + { OP_SET_LENS_FLARE_STRENGTH, "set-lens-flare-strength\r\n" + "\tChanges how strongly the mounted camera lens flares, without changing the\r\n" + "\tshape of anything. Every value is a percentage of what is currently in force,\r\n" + "\tso 50 halves whatever the mounted lens tables and 100 leaves it alone --\r\n" + "\twhich means the same event does the same thing whichever lens is mounted.\r\n\r\n" + "\tUnlike set-lens-aperture and its relatives, this is cheap: it changes no\r\n" + "\ttexture, so there is nothing to rebuild and nothing to stutter. This is the\r\n" + "\toperator to use when the flare should brighten or fade over time -- drive it\r\n" + "\tfrom a repeating event as often as you like.\r\n\r\n" + "\tDoes nothing (with a warning) if no lens is mounted. Everything goes back to\r\n" + "\tits tabled values when the mission ends. Not sent over the network.\r\n\r\n" + "\tTakes 1 to 5 arguments...\r\n" + "\t1:\tOverall flare intensity, as a percentage of the current value.\r\n" + "\t2:\t(optional) Ghost brightness, as a percentage. Scales the ghost train --\r\n" + "\t\tthe row of iris images strung along the flare axis -- on its own.\r\n" + "\t3:\t(optional) Starburst brightness, as a percentage. Scales the spikes on\r\n" + "\t\tthe sun itself on its own.\r\n" + "\t4:\t(optional) Starburst size, as a percentage.\r\n" + "\t5:\t(optional) How many ghosts to draw at most. They are drawn brightest\r\n" + "\t\tfirst, so lowering this drops the faintest ones; 0 leaves only the\r\n" + "\t\tstarburst. Cheap either way -- fewer ghosts is also less to draw." + }, + { OP_SET_GRAVITY_ACCEL, "set-gravity-accel\r\n" "\tSets the gravity acceleration rate in units of 0.01 m/s^2\r\n" "\te.g. '981' would be earth gravity, 9.81 m/s^2.\r\n" diff --git a/code/parse/sexp.h b/code/parse/sexp.h index 456bc8b767f..68066d03f7a 100644 --- a/code/parse/sexp.h +++ b/code/parse/sexp.h @@ -150,6 +150,7 @@ enum sexp_opf_t : int { OPF_CHILD_LUA_ENUM, // MjnMixael - Used to let Lua Enums reference Enums OPF_MISSION_CUSTOM_STRING, // MjnMixael - The custom strings as defined in FRED OPF_MESSAGE_TYPE, // naomimyselfandi - A message type (Attack Target et al.) + OPF_LENS_SYSTEM, // the-e - a lens system from lens_flares.tbl, or / //Must always be at the end of the list First_available_opf_id @@ -941,7 +942,13 @@ enum : int { OP_SET_SKYBOX_ALPHA, // Goober5000 OP_NEBULA_SET_RANGE, // Goober5000 OP_SET_SQUADRON_WINGS, // Goober5000 - + OP_SET_CAMERA_LENS, // the-e + OP_SET_LENS_APERTURE, // the-e + OP_SET_LENS_GRATING, // the-e + OP_SET_LENS_SCRATCHES, // the-e + OP_SET_LENS_DUST, // the-e + OP_SET_LENS_FLARE_STRENGTH, // the-e + // OP_CATEGORY_AI // defined for AI goals @@ -1304,6 +1311,7 @@ enum sexp_error_check SEXP_CHECK_MUST_BE_INTEGER, SEXP_CHECK_INVALID_CUSTOM_STRING, SEXP_CHECK_INVALID_MESSAGE_TYPE, + SEXP_CHECK_INVALID_LENS_SYSTEM, SEXP_CHECK_POTENTIAL_ISSUE, }; @@ -1488,6 +1496,10 @@ extern bool map_opf_to_opr(sexp_opf_t opf_type, sexp_opr_t &opr_type); const char *opr_type_name(sexp_opr_t opr_type); extern int query_operator_return_type(int op); extern int query_operator_argument_type(int op, int argnum); + +// True if the string names a lens system from lens_flares.tbl, or one of the +// / values set-camera-lens accepts in place of one. +extern bool sexp_lens_name_is_valid(const char* lens_name); extern void update_sexp_references(const char *old_name, const char *new_name); extern void update_sexp_references(const char *old_name, const char *new_name, int format); extern std::pair query_referenced_in_sexp(sexp_ref_type type, const char *name, int &node); diff --git a/code/source_groups.cmake b/code/source_groups.cmake index 70dd601d39a..43c036b3992 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -251,6 +251,8 @@ add_file_folder("Default files\\\\data\\\\effects" def_files/data/effects/gamma.sdr def_files/data/effects/gamma-correct-f.sdr def_files/data/effects/irrmap-f.sdr + def_files/data/effects/lensflare-f.sdr + def_files/data/effects/lensflare-v.sdr def_files/data/effects/lighting.sdr def_files/data/effects/ls-f.sdr def_files/data/effects/main-f.sdr @@ -310,6 +312,7 @@ add_file_folder("Default files\\\\data\\\\tables" def_files/data/tables/fonts.tbl def_files/data/tables/game_settings.tbl def_files/data/tables/iff_defs.tbl + def_files/data/tables/lens_flares.tbl def_files/data/tables/objecttypes.tbl def_files/data/tables/post_processing.tbl def_files/data/tables/species_defs.tbl @@ -461,6 +464,14 @@ add_file_folder("Graphics" graphics/grbatch.h graphics/grinternal.cpp graphics/grinternal.h + graphics/lens_flare.cpp + graphics/lens_flare.h + graphics/lens_flare_aperture.cpp + graphics/lens_flare_beams.cpp + graphics/lens_flare_internal.h + graphics/lens_flare_optics.cpp + graphics/lens_flare_table.cpp + graphics/lens_flare_thrusters.cpp graphics/light.cpp graphics/light.h graphics/line_draw_list.cpp @@ -617,6 +628,7 @@ if (FSO_BUILD_WITH_VULKAN) graphics/vulkan/VulkanPostProcessing.cpp graphics/vulkan/VulkanPostProcessing.h graphics/vulkan/VulkanPostProcessingBloom.cpp + graphics/vulkan/VulkanPostProcessingLensFlare.cpp graphics/vulkan/VulkanPostProcessingCommon.cpp graphics/vulkan/VulkanPostProcessingDistortion.cpp graphics/vulkan/VulkanPostProcessingFog.cpp @@ -774,6 +786,7 @@ add_file_folder("Lab\\\\Dialogs" lab/dialogs/lab_ui.cpp lab/dialogs/lab_ui_helpers.h lab/dialogs/lab_ui_helpers.cpp + lab/dialogs/lab_ui_lens_flare.cpp ) add_file_folder("Lab\\\\Manager" diff --git a/code/species_defs/species_defs.cpp b/code/species_defs/species_defs.cpp index 90ea9e33a88..3c4298245a5 100644 --- a/code/species_defs/species_defs.cpp +++ b/code/species_defs/species_defs.cpp @@ -151,6 +151,41 @@ void parse_thrust_glows(species_info *species, bool no_create) generic_anim_init(&species->thruster_info.glow.afterburn, NULL); } +// How this species' engines flare through the camera lens (graphics/lens_flare.h). +// Wholly optional -- a table without the block leaves thruster_flare disabled, so +// nothing that predates this feature gains a flare -- which is also why there is +// no "!no_create" branch here: there are no defaults to warn about, only the +// struct's own. (Hence no `no_create` parameter, unlike its two neighbours.) +void parse_thrust_flare(species_info *species) +{ + if (!optional_string("$Thruster Flare:")) + return; + + auto &flare = species->thruster_flare; + flare.enabled = true; + + if (optional_string("+Intensity:")) + stuff_float(&flare.intensity); + + if (optional_string("+Afterburner Intensity:")) + stuff_float(&flare.afterburner_intensity); + + if (optional_string("+Color:") || optional_string("+Colour:")) + { + int rgb[3]; + stuff_int_list(rgb, 3, ParseLookupType::RAW_INTEGER_TYPE); + for (int i = 0; i < 3; i++) + flare.color.a1d[i] = i2fl(rgb[i]) / 255.0f; + } + + // A negative brightness would invert the flare rather than dim it, and a + // negative tint would subtract light from the frame + flare.intensity = MAX(flare.intensity, 0.0f); + flare.afterburner_intensity = MAX(flare.afterburner_intensity, 0.0f); + for (float & i : flare.color.a1d) + i = MAX(i, 0.0f); +} + void parse_species_tbl(const char *filename) { char species_name[NAME_LENGTH]; @@ -305,6 +340,9 @@ void parse_species_tbl(const char *filename) // Thruster Glow Anims parse_thrust_glows(species, no_create); + // Thruster lens flares + parse_thrust_flare(species); + // Goober5000 - AWACS multiplier if (optional_string("$AwacsMultiplier:")) diff --git a/code/species_defs/species_defs.h b/code/species_defs/species_defs.h index 24475923803..fe4e46dd617 100644 --- a/code/species_defs/species_defs.h +++ b/code/species_defs/species_defs.h @@ -16,6 +16,7 @@ #include "globalincs/globals.h" #include "globalincs/pstypes.h" #include "graphics/generic.h" +#include "graphics/lens_flare.h" #include "hud/hudparse.h" #include "mission/missionbriefcommon.h" @@ -59,6 +60,10 @@ class species_info generic_anim shield_anim; thrust_info thruster_info; + // How this species' engines flare through the camera lens; disabled unless + // species_defs.tbl says otherwise (graphics/lens_flare.h) + graphics::thruster_flare_info thruster_flare; + // Bobboau's thruster stuff thrust_pair_bitmap thruster_secondary_glow_info; thrust_pair_bitmap thruster_tertiary_glow_info; diff --git a/code/starfield/starfield.cpp b/code/starfield/starfield.cpp index a90ede83880..a6aba8dad55 100644 --- a/code/starfield/starfield.cpp +++ b/code/starfield/starfield.cpp @@ -16,6 +16,7 @@ #include "freespace.h" #include "cmdline/cmdline.h" #include "debugconsole/console.h" +#include "graphics/lens_flare.h" #include "graphics/matrix.h" #include "graphics/paths/PathRenderer.h" #include "hud/hud.h" @@ -79,6 +80,15 @@ typedef struct flare_bitmap { } flare_bitmap; +// Values of starfield_bitmap::camera_lens_flare, i.e. of "+Camera Lens Flare:". +// The unset default deliberately defers to $Flare:, which was the only way to say +// "this sun flares" before this option existed. +enum { + SUN_LENS_FLARE_FROM_FLARE = -1, // no "+Camera Lens Flare:"; follow $Flare: + SUN_LENS_FLARE_OFF = 0, + SUN_LENS_FLARE_ON = 1, +}; + // global info (not individual instances) typedef struct starfield_bitmap { char filename[MAX_FILENAME_LEN]; // bitmap filename @@ -93,6 +103,10 @@ typedef struct starfield_bitmap { float r, g, b, i; // only for suns int glare; // only for suns int flare; // Is there a lens-flare for this sun? + // Does this sun flare through the physically-based camera lens (graphics/lens_flare.h)? + // Tristate, from "+Camera Lens Flare:": SUN_LENS_FLARE_FROM_FLARE follows $Flare:, + // so a table written before this option existed behaves exactly as it did. + int camera_lens_flare; flare_info flare_infos[MAX_FLARE_COUNT]; // each flare can use a texture in flare_bmp, with different scale flare_bitmap flare_bitmaps[MAX_FLARE_BMP]; // bitmaps for different lens flares (can be re-used) int n_flares; // number of flares actually used @@ -399,6 +413,8 @@ static void starfield_bitmap_entry_init(starfield_bitmap *sbm) sbm->bitmap_id = -1; sbm->glow_bitmap = -1; sbm->glow_n_frames = 1; + // the memset above would otherwise read as SUN_LENS_FLARE_OFF + sbm->camera_lens_flare = SUN_LENS_FLARE_FROM_FLARE; for (i = 0; i < MAX_FLARE_BMP; i++) { sbm->flare_bitmaps[i].bitmap_id = -1; @@ -553,6 +569,18 @@ void parse_startbl(const char *filename) } } + // Opt this sun into (or out of) the physically-based camera-lens + // flare independently of the legacy sprite $Flare: block above, + // which otherwise doubles as the opt-in. Lets a sun flare through + // the camera lens without having to carry a full set of sprite + // flare fields it will never draw, and lets one that does carry + // them keep the sprites while sitting out the lens. + if (optional_string("+Camera Lens Flare:")) { + bool enabled = false; + stuff_boolean(&enabled); + sbm.camera_lens_flare = enabled ? SUN_LENS_FLARE_ON : SUN_LENS_FLARE_OFF; + } + sbm.glare = !optional_string("$NoGlare:"); sbm.xparent = 1; @@ -793,6 +821,9 @@ void stars_clear_instances() // call on game startup void stars_init() { + // lens systems must be known before a mission's $Camera Lens: names one + graphics::lens_flare_init(); + // parse stars.tbl parse_startbl("stars.tbl"); @@ -815,7 +846,7 @@ void stars_close() { stars_clear_instances(); - // any other code goes here + graphics::lens_flare_close(); } // called before mission parse so we can clear out all of the old stuff @@ -829,6 +860,11 @@ void stars_pre_level_init(bool clear_backgrounds) stars_clear_instances(); + // The camera lens and any aperture edits belong to the mission being left: + // unmount and restore the tabled irises so nothing carries into the next one. + // The mission's own $Camera Lens: is parsed after this (parse_mission_info). + graphics::lens_flare_reset_for_level(); + stars_set_background_model(nullptr, nullptr); stars_set_background_orientation(); @@ -972,6 +1008,10 @@ void stars_post_level_init() stars_preload_background(idx); } + // The mission's $Camera Lens: is mounted by now, so build its iris mask and + // starburst here rather than letting the first flaring frame pay for them + graphics::lens_flare_prime_textures(); + stars_set_background_model(The_mission.skybox_model, NULL, The_mission.skybox_flags); stars_set_background_orientation(&The_mission.skybox_orientation); @@ -1261,6 +1301,46 @@ void stars_get_sun_pos(int sun_n, vec3d *pos) vm_vec_unrotate(pos, &temp, &rot); } +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n) || Suns[sun_n].star_bitmap_index < 0) { + return std::nullopt; + } + + const starfield_bitmap* bm = &Sun_bitmaps[Suns[sun_n].star_bitmap_index]; + + sun_rgbi rgbi; + rgbi.color.xyz.x = bm->r; + rgbi.color.xyz.y = bm->g; + rgbi.color.xyz.z = bm->b; + rgbi.intensity = bm->i; + return rgbi; +} + +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx) +{ + if (!SCP_vector_inbounds(Sun_bitmaps, bitmap_idx)) { + return false; + } + const starfield_bitmap* bm = &Sun_bitmaps[bitmap_idx]; + + // "+Camera Lens Flare:" wins where it is given; otherwise $Flare: stands in for + // it, since that was the only way to say "this sun flares" before it existed + if (bm->camera_lens_flare != SUN_LENS_FLARE_FROM_FLARE) { + return bm->camera_lens_flare == SUN_LENS_FLARE_ON; + } + return bm->flare != 0; +} + +bool stars_sun_has_camera_lens_flare(int sun_n) +{ + if (!SCP_vector_inbounds(Suns, sun_n)) { + return false; + } + return stars_sun_bitmap_has_camera_lens_flare(Suns[sun_n].star_bitmap_index); +} + // draw sun void stars_draw_sun(int show_sun) { @@ -1341,9 +1421,15 @@ void stars_draw_sun(int show_sun) continue; } - material mat_params; - material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); - g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); + // When the flare pass is drawing this sun's starburst, skip the sprite so + // the two don't stack (the remaining suns are unaffected). Sun_drew is + // still counted: it means "a sun was on screen this frame", which drives + // the sunspot glare downstream and stays true either way. + if (!graphics::lens_flare_sun_starburst_drawn(idx)) { + material mat_params; + material_set_unlit(&mat_params, bitmap_id, 0.999f, true, false); + g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, true); + } Sun_drew++; // if ( !g3_draw_bitmap(&sun_vex, 0, 0.05f * Suns[idx].scale_x * local_scale, TMAP_FLAG_TEXTURED) ) @@ -1434,6 +1520,12 @@ void stars_draw_sun_glow(int sun_n) if (bm->glow_bitmap < 0) return; + // when the flare pass is drawing this sun's starburst, skip the bitmap glow so + // the two don't stack + if (graphics::lens_flare_sun_starburst_drawn(sun_n)) { + return; + } + memset( &sun_vex, 0, sizeof(vertex) ); // get sun pos @@ -1466,7 +1558,9 @@ void stars_draw_sun_glow(int sun_n) material_set_unlit(&mat_params, bitmap_id, 0.5f, true, false); g3_render_rect_screen_aligned_2d(&mat_params, &sun_vex, 0, 0.10f * Suns[sun_n].scale_x * local_scale, true); - if (bm->flare) { + // legacy sprite flares; suppressed while a physically-based camera lens is + // mounted, since that models the same artifact properly + if (bm->flare && graphics::lens_flare_active_lens() < 0) { vec3d light_dir; vec3d local_light_dir; light_get_global_dir(&light_dir, sun_n); @@ -1942,6 +2036,21 @@ void stars_draw(int show_stars, int show_suns, int /*show_nebulas*/, int show_s Rendering_to_env = env; + // Decide what the camera lens will flare for *this* render, before anything + // consults the answer: the sun sprites below step aside for a starburst the + // flare pass is drawing, and the post-processing pass draws exactly what is + // published here. + // + // Environment maps publish nothing, because they go straight to a render target + // without ever reaching that pass. Saying so here -- rather than having each + // consumer check where it is -- is what keeps "does this sun flare" a single + // answer that everything below can just read. + if (env) { + graphics::lens_flare_clear_frame(); + } else { + graphics::lens_flare_frame_update(); + } + if (show_subspace) subspace_render(); diff --git a/code/starfield/starfield.h b/code/starfield/starfield.h index 0e443a00b72..ccbf02a2d01 100644 --- a/code/starfield/starfield.h +++ b/code/starfield/starfield.h @@ -18,6 +18,8 @@ #include "model/model.h" #include "starfield/starfield_flags.h" +#include + #define DEFAULT_NMODEL_FLAGS (MR_NO_ZBUFFER | MR_NO_CULL | MR_ALL_XPARENT | MR_NO_LIGHTING) #define MAX_STARFIELD_BITMAP_LISTS 1 @@ -162,9 +164,41 @@ int stars_find_bitmap(const char *name); // lookup a sun by bitmap filename, return index or -1 on fail int stars_find_sun(const char *name); +// Parse a stars.tbl (or a *-str.tbm) into the bitmap/sun tables. Normally reached +// only through stars_init(), which also loads the bitmaps; declared here because +// parsing alone is meaningful on its own -- a sun's tabled properties are readable +// straight afterwards, before any bitmap exists. +void parse_startbl(const char *filename); + // get the world coords of the sun pos on the unit sphere. void stars_get_sun_pos(int sun_n, vec3d *pos); +// A sun's tabled light, as $SunRGBI: declares it in stars.tbl. +struct sun_rgbi { + vec3d color = vmd_zero_vector; // 0..1 per channel + float intensity = 0.0f; +}; + +// The sun's tabled light, or nothing if the sun instance itself is invalid. +std::optional stars_get_sun_rgbi(int sun_n); + +// True when this sun's stars.tbl entry asks to flare through the physically-based +// camera lens (graphics/lens_flare.h), when one is mounted. +// +// The content decides *whether* a sun flares; the mounted lens only decides *how* +// it is drawn, so mounting a lens never invents flares on suns tabled without one. +// A sun says so either with "+Camera Lens Flare:" or, for tables written before +// that existed, by carrying a legacy sprite "$Flare:" block -- the explicit option +// wins where both are present, and is the only way to have one without the other. +bool stars_sun_has_camera_lens_flare(int sun_n); + +// The same question keyed on a sun *bitmap* index (what stars_find_sun() returns) +// rather than on a placed sun instance. This is where the rule above actually +// lives; the instance form just looks up the bitmap. Separate because a sun's +// tabled answer is knowable straight after parsing, before any instance -- and so +// before any bitmap has to load, which is what lets it be tested. +bool stars_sun_bitmap_has_camera_lens_flare(int bitmap_idx); + // for SEXP stuff so that we can mark a bitmap as being used regardless of whether // or not there is an instance for it yet void stars_preload_background(const char *token); diff --git a/code/tracing/categories.cpp b/code/tracing/categories.cpp index d297bcda4b0..ae2dd7a9e9a 100644 --- a/code/tracing/categories.cpp +++ b/code/tracing/categories.cpp @@ -32,6 +32,7 @@ Category SMAACalculateBlendingWeights("SMAA Calculate BLending Weights", true); Category SMAANeighborhoodBlending("SMAA Neighborhood Blending", true); Category SMAAResolve("SMAA Resolve", true); Category Lightshafts("Lightshafts", true); +Category LensFlare("Lens flare", true); Category DrawPostEffects("Draw post effects", true); Category RenderBatchItem("Render batch item", true); diff --git a/code/tracing/categories.h b/code/tracing/categories.h index 892f9414a4f..71a75dad7e3 100644 --- a/code/tracing/categories.h +++ b/code/tracing/categories.h @@ -45,6 +45,7 @@ extern Category SMAACalculateBlendingWeights; extern Category SMAANeighborhoodBlending; extern Category SMAAResolve; extern Category Lightshafts; +extern Category LensFlare; extern Category DrawPostEffects; extern Category RenderBatchItem; diff --git a/code/weapon/beam.cpp b/code/weapon/beam.cpp index 0466130d365..cc9214eef1c 100644 --- a/code/weapon/beam.cpp +++ b/code/weapon/beam.cpp @@ -1911,7 +1911,7 @@ DCF(blight, "Sets the beam light scale factor (Default is 25.5f)") dc_stuff_float(&blight); } namespace ltp = lighting_profiles; -float beam_current_light_radius(beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise) +float beam_current_light_radius(const beam *bm, weapon_info *wip, beam_weapon_info *bwi, float noise) { auto lp = ltp::current(); float width = lp->beam_light_radius.handle(wip->light_radius); @@ -1972,6 +1972,61 @@ void beam_light_color(weapon_info *wip,hdr_color *to_fill ) to_fill->set_vecf(colors); } +// How strongly a beam's muzzle is emitting right now, 0..1: ramping up over the +// warmup, full while firing, and back down over the warmdown. +// +// Halved during both ramps, which is what the muzzle light has always done -- +// anything that wants to follow a beam's brightness follows this rather than +// writing a second curve that would drift out of step with it. +static float beam_muzzle_ramp(const beam *bm) +{ + if (bm->warmup_stamp != -1) { + return BEAM_WARMUP_PCT(bm) * 0.5f; + } + if (bm->warmdown_stamp != -1) { + return MAX(1.0f - BEAM_WARMDOWN_PCT(bm) * 1.3f, 0.0f) * 0.5f; + } + // otherwise the beam is really firing + return 1.0f; +} + +bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out) +{ + if (bm == nullptr || bm->weapon_info_index < 0) { + return false; + } + weapon_info *wip = &Weapon_info[bm->weapon_info_index]; + beam_weapon_info *bwi = &wip->b_info; + + const float pct = beam_muzzle_ramp(bm); + if (pct <= 0.0f) { + return false; + } + + // Deliberately without the flicker noise the muzzle light applies: this is + // read once per frame by the renderer rather than by the light code, and a + // fresh frand() per frame would make the flare jitter independently of the + // light it is supposed to be following. + const float radius = beam_current_light_radius(bm, wip, bwi, 1.0f); + if (radius <= 0.0f) { + return false; + } + + hdr_color light_color; + beam_light_color(wip, &light_color); + if (light_color.i() <= 0.0f) { + return false; + } + + out->pos = bm->last_start; + out->color.xyz.x = light_color.r(); + out->color.xyz.y = light_color.g(); + out->color.xyz.z = light_color.b(); + out->intensity = light_color.i() * pct; + out->radius = radius; + return true; +} + // call to add a light source to a small object void beam_add_light_small(beam *bm, object *objp, vec3d *pt) { @@ -1988,19 +2043,7 @@ void beam_add_light_small(beam *bm, object *objp, vec3d *pt) // get the width of the beam float light_rad = beam_current_light_radius(bm, wip, bwi, noise); - float pct = 0.0f; - - if (bm->warmup_stamp != -1) { // calculate muzzle light intensity - // get warmup pct - pct = BEAM_WARMUP_PCT(bm)*0.5f; - } else if (bm->warmdown_stamp != -1) { // if the beam is warming down - // get warmup pct - pct = MAX(1.0f - BEAM_WARMDOWN_PCT(bm)*1.3f,0.0f)*0.5f; - } - // otherwise the beam is really firing - else { - pct = 1.0f; - } + float pct = beam_muzzle_ramp(bm); // Color is a copy so that we can modify it the brigthness without side-effect hdr_color light_color; diff --git a/code/weapon/beam.h b/code/weapon/beam.h index c34181eaa0f..29c53e44e68 100644 --- a/code/weapon/beam.h +++ b/code/weapon/beam.h @@ -226,6 +226,27 @@ typedef struct beam { extern std::array Beams; // all beams extern int Beam_count; +// A beam's muzzle as a light source: where it is, the colour and strength of the +// light it throws, and how large that glow is. Filled by beam_get_muzzle_glow(). +struct beam_muzzle_glow { + vec3d pos; + vec3d color; // linear rgb, 0..1 + float intensity; // the light's own intensity, scaled by the warmup/warmdown ramp + float radius; // world radius of the glow +}; + +// What a beam's muzzle is emitting this frame, or false when it is emitting +// nothing. The intensity ramps up over the warmup, holds while firing, and ramps +// back down over the warmdown. +// +// Answered here rather than by the caller because it is the same question the +// muzzle light already asks -- beam_add_light_small() scales by the same ramp and +// uses the same radius -- and a second copy of it in another module would drift +// out of step with the light it is meant to be following. Unlike that light it is +// not gated on the lighting detail setting: a camera-lens flare is an artifact of +// the camera, not a light in the scene. +bool beam_get_muzzle_glow(const beam *bm, beam_muzzle_glow *out); + #define BEAM_INDEX(beam) (int)((beam) - Beams.data()) // ------------------------------------------------------------------------------------------------ diff --git a/documentation/qtfred-post-processing-viewport-resize.md b/documentation/qtfred-post-processing-viewport-resize.md new file mode 100644 index 00000000000..24e05f4ea36 --- /dev/null +++ b/documentation/qtfred-post-processing-viewport-resize.md @@ -0,0 +1,248 @@ +# qtFred post-processing viewport resize issues + +Notes on two bugs hit while adding the qtFred "Enable Post Processing" View-menu +toggle. Both are fixed; kept here so the next person who reopens this doesn't +have to re-derive the diagnosis from scratch, and so the dead ends are on record. + +## Background + +qtFred's `FredRenderer::render_frame()` calls `gr_screen_resize()` every frame +to match whatever size its dockable/resizable 3D viewport widget currently is. +The game does this too, but only on an SDL window-resize event +(`osapi.cpp`) — for most of its life `gr_screen` is fixed after `gr_init()`. +That difference in *frequency* is the root of everything below; the underlying +bug was reachable from the game's resizable window as well. + +## Bug 1: `u_scale`/`v_scale` copy-paste in the post-processing passes + +**Symptom:** with post-processing enabled, sun sprites and lens flares landed +at different screen positions depending on where in the viewport the sun was +— vertical-only offset top-left, both-axes top-right, horizontal-only +bottom-right, near-perfect bottom-left. Bloom was also misaligned the same way. + +**Cause:** `code/graphics/opengl/gropenglpostprocessing.cpp` had eight call +sites of the form: + +```cpp +opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_u_scale); +``` + +`u_scale` was passed for *both* the horizontal and vertical scale argument — +a copy-paste error. In the game this was a no-op (`u_scale == v_scale` there, +since the scene texture is always exactly screen-sized). In qtFred, where the +scene texture can be a different aspect ratio than the current viewport, this +silently applied the horizontal crop fraction to the vertical axis (or vice +versa) at each pass (tonemap, FXAA prepass, both SMAA passes, cockpit +lightshafts, and the final composite-to-screen blit), compounding into a +direction- and position-dependent drift. + +**Fix:** those sites now call `opengl_draw_full_screen_scene_texture()` +(`gropengldraw.cpp`), which supplies both scales itself. Open-coding the extents +is what allowed one axis to be wrong, so the helper exists to make the whole +class of bug unreachable — prefer it over literal extents in any new pass that +samples a scene or post-processing texture. + +Fixing bug 1 alone was **not** sufficient — the user confirmed misalignment +persisted afterwards, which is what led to bug 2. + +(Three further `u_scale`-twice sites survive at the bottom of +`gr_opengl_post_process_end()`; they are inside a `/* */` block of dead debug +code and were deliberately left alone.) + +### Related sites found later + +The same "sample the full [0,1] range of a partially-filled target" bug existed +outside the post-processing module and was fixed alongside the resize work: + +- `gropengldeferred.cpp` — the MSAA scene-colour copy, the MSAA resolve, and + the fog pass all passed literal `1.0f` extents while sampling scene targets. +- `deferred-f.sdr` reconstructs a G-buffer texture coordinate from + `gl_FragCoord` and `invScreenWidth`/`invScreenHeight`, which described + `gr_screen` rather than the G-buffer. Fixed in both the OpenGL + (`gropengldeferred.cpp`) and Vulkan (`VulkanPostProcessingLighting.cpp`) + backends. It is currently a no-op under Vulkan, whose `resize()` keeps the + scene extent equal to `gr_screen`, but it states the requirement rather than + depending on that staying true. +- `fxaa-v.sdr` derived its texcoord from `vertPosition` instead of the + `vertTexCoord` attribute, ignoring whatever sub-rectangle the draw call asked + for. Every other post-process vertex shader already used the attribute. + +**Deliberately left alone:** the volumetric nebula pass. `volumetric-f.sdr` uses +`fragTexCoord` for two incompatible purposes — reconstructing an eye-space ray +direction, which needs the full 0..1 range across the viewport, and sampling +composite/depth/emissive, which needs the rendered sub-rectangle. Scaling the +draw would fix the sampling and skew every ray. Separating them needs a second +varying (or a scale uniform) in the shader. Until then volumetrics are only +correct while the targets exactly match the viewport, which is the normal case. + +## Bug 2: stale scene-texture allocation when the viewport grows + +**Symptom:** after the bug-1 fix, misalignment (and bloom stretching) still +appeared, but only after the qtFred window had been resized/maximized/ +fullscreened *larger* than it was earlier in the session. A manual resize +cycle would often fix it; going fullscreen would reintroduce it. + +**Root cause:** `Scene_texture_width`/`Scene_texture_height` +(`opengl_setup_scene_textures()`, `gropengldraw.cpp`) and the post-processing +surfaces sized off `gr_screen.max_w`/`max_h` +(`opengl_post_init_framebuffer()`, `gropenglpostprocessing.cpp`) were allocated +exactly once, at `gr_init()`, and never revisited. When the viewport grows past +that original allocation the render still only writes into the old (smaller) +texture: everything past its edge is silently clipped, and the final blit — +unaware anything was clipped — stretches that smaller, cropped result back over +the new, larger viewport. That non-uniform stretch is what reads as a position- +and axis-dependent drift, magnified further at fullscreen. Shrinking the +viewport back below the allocation is unaffected, since +`Scene_texture_u_scale`/`v_scale` already crop correctly for a viewport smaller +than the allocation. + +Diagnosed empirically (no way to run the qtFred GUI directly) via two +throttled `mprintf` diagnostics temporarily added to `project_source()` +(`lens_flare.cpp`) and `gr_opengl_scene_texture_begin()` (`gropengldraw.cpp`), +comparing `Scene_texture_width/height` against `gr_screen.max_w/h` and +`Canvas_width/height`. Log evidence +(`Scene_texture=3072x1728 gr_screen.max=3512x1910`) confirmed the texture was +smaller than the live viewport, and the user's own manual testing (resize +fixes it, fullscreen re-breaks it) confirmed the allocate-once behavior. +Both diagnostics were removed once the root cause was confirmed; they are not +in the tree. + +### Fix: grow the render targets when the viewport outgrows them + +`gr_screen.gf_resize_render_targets` (`2d.h`) is a backend hook called from +`gr_screen_resize()` (`2d.cpp`) after `gr_setup_viewport()`. OpenGL implements +it as `gr_opengl_resize_render_targets()` (`gropengldraw.cpp`); Vulkan leaves it +unset, because `VulkanRenderer::recreateSwapChain()` already owns resizing its +extent-sized targets and a second entry point would risk double-resizing. + +The OpenGL implementation rebuilds only the resolution-dependent resources: + +- `opengl_scene_texture_shutdown()` + `opengl_setup_scene_textures(w, h)` for + the scene textures. The latter now takes explicit dimensions rather than + reading `gr_screen` itself, so the sizing policy lives in one named place. +- `opengl_post_resize_render_targets()` (`gropenglpostprocessing.cpp`) for the + bloom mip chain and the SMAA surfaces. It sizes `Post_texture_*` to match + `Scene_texture_*` — they consume those textures pass by pass, so the two + sizes diverging is what bug 1 looked like. + +The post-processing table, the compiled shaders and the SMAA area/search lookup +textures are all resolution-independent and stay alive. That is what keeps this +cheap enough to run off a window drag, and it mirrors what +`VulkanPostProcessor::resize()` has always done — the OpenGL backend was the +odd one out. + +Three properties the implementation depends on: + +- **Grow only.** `gr_screen_resize()` runs every frame in qtFred, and + `BriefingMapWidget` resizes down and back repeatedly. Tracking the high-water + mark avoids thrashing, and the shrunk state is already correct via + `Scene_texture_u_scale`/`v_scale`. +- **Clamp before comparing.** `GL_max_renderbuffer_size` is applied to the + requested size *inside* `gr_opengl_resize_render_targets()`, before it decides + whether anything changed. Clamping inside the allocator instead would leave a + viewport larger than the hardware limit requesting a resize that can never be + satisfied — a full teardown and rebuild every single frame. +- **Never resize mid-frame.** The function refuses (with an `Assertion`) while + `Scene_framebuffer_in_frame` is set. That flag covers the post-processing + passes too: they only run inside `gr_scene_texture_begin()`/`end()`, and it is + cleared at the very end of `gr_opengl_scene_texture_end()`. + +If the larger allocation fails outright — most likely precisely when growing — +`opengl_setup_scene_textures()` reports it by leaving `Scene_texture_initialized` +at 0, having already turned post-processing and soft particles off. The resize +stops there rather than rebuilding the post-processing targets on top of scene +textures that no longer exist. + +This replaced an earlier `Gr_min_render_target_w`/`_h` floor, which sized the +targets up front for the largest attached display. That worked, but every +qtFred user paid for it at launch whether or not they ever enabled +post-processing: on a 4K display at 2x scaling the floor was 7680x4320, which +across the nine scene textures (most of them `RGBA16F`) plus the post-processing +surfaces is on the order of a gigabyte of VRAM — and `-msaa 8` multiplied the +six multisample targets on top of that. AGENTS.md is explicit that FSO must run +across the whole hardware range, so an unconditional worst-case allocation for +an off-by-default feature was the wrong trade. + +### Verifying it from a log + +`opengl_setup_scene_textures()` reports each allocation: + +``` + Scene textures: 3840x2160 (screen 3840x2160, max renderbuffer 16384) +``` + +and `gr_opengl_resize_render_targets()` reports each growth: + +``` +Growing render targets from 1024x768 to 3840x2160 to cover the new 3840x2160 viewport. +``` + +Launch qtFred, enable post-processing, and drag the viewport dock larger. The +growth line should appear **once per growth step and never per frame** — a +per-frame stream means the clamp/early-out logic is wrong. The one-shot +`nprintf(("OpenGL", "Viewport (...) is larger than the scene texture backing +it ..."))` in `gr_opengl_scene_texture_begin()` should not appear at all; if it +does, the targets could not grow (`GL_max_renderbuffer_size` on low-end +hardware) and the old stretching is back. It must stay `nprintf` and stay +one-shot: that function runs every frame. + +**Not yet checked in-editor.** None of this has been exercised at runtime. Note +that the CLion `qtfred` run configuration passes `-vulkan`, which exercises +`VulkanPostProcessor::resize()` rather than any of the OpenGL code above — drop +that flag to test this path. Worth eyeballing once someone does: bloom radius +and SMAA quality, since the bright pass renders into the full +`Post_texture_width >> 1` viewport while sampling only the cropped +sub-rectangle, and SMAA's RT-metrics are likewise derived from `Post_texture_*`. +Worst case there is a cosmetic difference, not misalignment. + +### History: why reallocation was rejected twice before + +Two earlier attempts at exactly the approach now implemented both regressed to a +black viewport and were reverted: + +1. A size check inside `gr_opengl_scene_texture_begin()` that tore down and + rebuilt in place, every frame. +2. A cross-backend `gr_scene_texture_grow()` entry point wired through the + function-pointer table and called from `FredRenderer::render_frame()` after + `gr_screen_resize()` — structurally the same as the current hook. + +The second failed on *every* post-processing frame, not just grown ones, which +is consistent: qtFred's first post-processing frame is almost always already +larger than the `gr_init()` allocation. + +**Why it works now.** The previous version of this document identified the +prerequisite correctly, and it turned out to be the whole problem: +`opengl_scene_texture_shutdown()` did not delete or zero `Scene_ldr_texture`, +`Scene_composite_texture`, `Scene_luminance_texture`, `Cockpit_depth_texture`, +or any of the six `_ms` objects and `Scene_framebuffer_ms`, while +`opengl_setup_scene_textures()` re-`glGenTextures`'d over those handles. Any FBO +left attached to a stale or deleted texture is incomplete, and draws to an +incomplete FBO go nowhere — black viewport. (At shutdown this was merely a leak, +which is why it went unnoticed.) + +The teardown now releases everything setup allocates, and post-processing +shutdown releases the SMAA lookup textures it was also leaking. Two further +prerequisites had to be met: + +- **Deletion goes through the state cache.** `GL_state.Texture.Delete()` unbinds + a texture from every unit before `glDeleteTextures()`. Without it the cache + can still hold a freed name, and since the driver is free to hand that name + straight back out, a later `Enable()` of the recycled texture is silently + skipped. Use `opengl_delete_render_texture()` / + `opengl_delete_render_framebuffer()` (`gropengldraw.cpp`) rather than calling + the GL entry points directly. The framebuffer cache has no equivalent unbind, + so the resize path binds 0 before deleting anything. +- **Only the size-dependent work is redone.** `opengl_post_process_init()` + re-parses `post_processing.tbl` and rebuilds + `graphics::Post_processing_manager` from scratch, which is not something to do + mid-session; the resolution-dependent half was split out into + `opengl_post_resize_render_targets()` precisely so the resize does not touch + it. + +The earlier document also floated `GL_state`'s framebuffer-binding cache as the +cause of the black viewport and proposed an explicit +`GL_state.BindFrameBufferBoth(0, 0)`. That diagnosis did not hold up on its own — +both setup functions already end with `GL_state.BindFrameBuffer(0)` from a +non-zero cache, so the bind does get issued. The call is nonetheless present in +the resize path, for the different and real reason given above: to keep the +cache off a framebuffer name that is about to be deleted. diff --git a/fred2/bgbitmapdlg.cpp b/fred2/bgbitmapdlg.cpp index 593c2f42441..ea3317e9c61 100644 --- a/fred2/bgbitmapdlg.cpp +++ b/fred2/bgbitmapdlg.cpp @@ -19,6 +19,7 @@ #include "listitemchooser.h" #include "bmpman/bmpman.h" #include "graphics/light.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "math/bitarray.h" #include "mission/missionparse.h" @@ -82,6 +83,7 @@ bg_bitmap_dlg::bg_bitmap_dlg(CWnd* pParent) : CDialog(bg_bitmap_dlg::IDD, pParen m_sky_flag_5 = The_mission.skybox_flags & MR_NO_GLOWMAPS ? 1 : 0; m_sky_flag_6 = The_mission.skybox_flags & MR_FORCE_CLAMP ? 1 : 0; m_light_profile_index = 0; + m_camera_lens_index = 0; //}}AFX_DATA_INIT } @@ -150,6 +152,7 @@ void bg_bitmap_dlg::DoDataExchange(CDataExchange* pDX) DDX_Text(pDX, IDC_NEB2_FOG_SKYBOX_CLIP, m_neb_fog_skybox_clip); DDX_Text(pDX, IDC_NEB2_FOG_CLIP, m_neb_fog_clip); DDX_CBIndex(pDX, IDC_LIGHT_PROFILE, m_light_profile_index); + DDX_CBIndex(pDX, IDC_CAMERA_LENS, m_camera_lens_index); DDX_Text(pDX, IDC_NEB2_FOG_R, m_fog_r); DDV_MinMaxInt(pDX, m_fog_r, 0, 255); DDX_Text(pDX, IDC_NEB2_FOG_G, m_fog_g); @@ -425,6 +428,27 @@ void bg_bitmap_dlg::create() } box->SetCurSel(m_light_profile_index); + // The camera lens all sun flares are imaged through. "Default" and "None" are + // genuinely different answers -- the first leaves the mission silent so it + // follows lens_flares.tbl's $Default Lens:, the second says no flares even if + // one is declared -- so both get an entry ahead of the lenses themselves. + box = (CComboBox *) GetDlgItem(IDC_CAMERA_LENS); + box->AddString("Default"); + box->AddString("None"); + + // An unset (or explicitly ) mission lands on "Default" + m_camera_lens_index = CAMERA_LENS_IDX_DEFAULT; + if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) + m_camera_lens_index = CAMERA_LENS_IDX_NONE; + + for (int idx = 0; idx < graphics::lens_flare_num_systems(); idx++) { + const SCP_string &lens_name = graphics::lens_flare_get_system(idx)->name; + box->AddString(lens_name.c_str()); + if (The_mission.camera_lens_name == lens_name) + m_camera_lens_index = idx + CAMERA_LENS_IDX_FIRST_LENS; + } + box->SetCurSel(m_camera_lens_index); + background_flags_init(); UpdateData(FALSE); @@ -567,6 +591,18 @@ void bg_bitmap_dlg::OnClose() Neb2_fog_clip_distance = Default_max_draw_distance; The_mission.lighting_profile_name = lighting_profiles::list_profiles()[m_light_profile_index]; + + // Mirrors the combo built in create(): empty for "Default" so the mission stays + // silent, the token for "None" so the choice survives being saved + if (m_camera_lens_index == CAMERA_LENS_IDX_NONE) { + The_mission.camera_lens_name = LENS_NAME_NONE; + } else if (m_camera_lens_index >= CAMERA_LENS_IDX_FIRST_LENS) { + The_mission.camera_lens_name = + graphics::lens_flare_get_system(m_camera_lens_index - CAMERA_LENS_IDX_FIRST_LENS)->name; + } else { + The_mission.camera_lens_name.clear(); + } + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); // close sun data sun_data_close(); diff --git a/fred2/bgbitmapdlg.h b/fred2/bgbitmapdlg.h index d601bbd22d4..4bf2224bd60 100644 --- a/fred2/bgbitmapdlg.h +++ b/fred2/bgbitmapdlg.h @@ -96,8 +96,18 @@ class bg_bitmap_dlg : public CDialog CString m_neb_fog_skybox_clip; CString m_neb_fog_clip; int m_light_profile_index; + int m_camera_lens_index; //}}AFX_DATA + // Fixed head of the camera-lens combo: "Default" leaves the mission silent so + // it follows lens_flares.tbl, "None" is the explicit , and the tabled + // lenses follow. See create()/OnClose() in bgbitmapdlg.cpp. + enum { + CAMERA_LENS_IDX_DEFAULT = 0, + CAMERA_LENS_IDX_NONE = 1, + CAMERA_LENS_IDX_FIRST_LENS = 2, + }; + // Overrides // ClassWizard generated virtual function overrides //{{AFX_VIRTUAL(bg_bitmap_dlg) diff --git a/fred2/fred.rc b/fred2/fred.rc index bcb9b7ba576..388be9e455b 100644 --- a/fred2/fred.rc +++ b/fred2/fred.rc @@ -1530,7 +1530,7 @@ BEGIN PUSHBUTTON "Bottom",IDC_MESSAGE_MOVE_TO_BOTTOM,367,55,16,16,BS_ICON,WS_EX_STATICEDGE END -IDD_BG_BITMAP DIALOGEX 0, 0, 431, 470 +IDD_BG_BITMAP DIALOGEX 0, 0, 431, 486 STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Background Editor" FONT 8, "MS Sans Serif", 0, 0, 0x1 @@ -1653,6 +1653,8 @@ BEGIN "Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,450,195,10 COMBOBOX IDC_LIGHT_PROFILE,319,448,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP LTEXT "Lighting Profile",IDC_STATIC,227,451,88,8 + COMBOBOX IDC_CAMERA_LENS,319,464,93,140,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Camera Lens",IDC_STATIC,227,467,88,8 END IDD_REINFORCEMENT_EDITOR DIALOGEX 0, 0, 183, 119 @@ -2824,7 +2826,7 @@ BEGIN VERTGUIDE, 215 VERTGUIDE, 220 VERTGUIDE, 426 - BOTTOMMARGIN, 413 + BOTTOMMARGIN, 479 HORZGUIDE, 126 HORZGUIDE, 132 END diff --git a/fred2/resource.h b/fred2/resource.h index 27c6268578d..45ba7f0f14f 100644 --- a/fred2/resource.h +++ b/fred2/resource.h @@ -570,6 +570,7 @@ #define IDC_YES_MESSAGE_LIST 1208 #define IDC_ALT_CLASS_LIST 1208 #define IDC_LIGHT_PROFILE 1208 +#define IDC_CAMERA_LENS 1747 #define IDC_OPEN_CUSTOM_STRINGS 1208 #define IDC_COMMAND_SENDER 1209 #define IDC_COMMAND_PERSONA 1210 @@ -1626,7 +1627,7 @@ #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_3D_CONTROLS 1 #define _APS_NEXT_RESOURCE_VALUE 340 -#define _APS_NEXT_CONTROL_VALUE 1747 +#define _APS_NEXT_CONTROL_VALUE 1748 #define _APS_NEXT_COMMAND_VALUE 33113 #define _APS_NEXT_SYMED_VALUE 105 #endif diff --git a/qtfred/AGENTS.md b/qtfred/AGENTS.md new file mode 100644 index 00000000000..c54ef11c87a --- /dev/null +++ b/qtfred/AGENTS.md @@ -0,0 +1,10 @@ +# Module: qtfred + +Entry-point guide for this module lives at: +**`documentation/modules/qtfred.md`** (from repo root). + +It covers the editor's purpose, build requirements, layout, and how it relates to +the shared engine code in `code/`. + +For the engine-wide architecture overview see `documentation/ARCHITECTURE.md`. +For build/test/style conventions see the root `AGENTS.md`. diff --git a/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html b/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html index 3b79720ceec..53886dc907f 100644 --- a/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html +++ b/qtfred/help-src/doc/dialogs/BackgroundEditorDialog.html @@ -124,7 +124,45 @@

Misc

and lighting on ship surfaces. Lighting profileSelects the lighting profile from the tables to apply to the mission. + Camera LensThe physically-based camera lens every sun's flare is + imaged through. Default follows the tables' declared default lens; + None disables lens flares entirely even when a default exists. +

The Lens Aperture... button opens a dialog for overriding +everything about the camera except which lens it is – the same parameters the +set-lens-* sexps control, but applied for the whole mission instead of +by an event:

+ + + + + + +
GroupWhat it overrides
IrisBlade count, rotation, curvature and edge softness. One + iris drives both the ghosts and the starburst, so these restyle both.
Rim grating, Scratches, DustImperfections layered onto the + iris, each off at strength 0.
AnamorphicThe squeeze (how much wider than tall flare + footprints are) and the horizontal streak (strength, length, thickness and + colour tint).
StrengthOverall lens intensity, ghost and starburst + brightness, starburst on/off and size, and how many ghosts to draw at + most.
+

Every control applies live as you move it; use Reset to Lens +Default to drop every override and go back to what the lens's own table +entry declares. A control left at its default is not saved into the mission, so a +mission only carries what it actually restyles.

+
The Iris, Rim grating, +Scratches and Dust groups are the expensive ones: +changing any of them makes the engine re-render the iris mask and take a Fourier +transform of it to get the new starburst. That is fine for an editor and for +occasional mission events, but it is why set-lens-aperture and its +relatives should not be driven from a repeating event. The Anamorphic +and Strength groups cost nothing, and +set-lens-flare-strength is the operator to use for a flare that +brightens or fades over time.
+
Lens flares (and bloom/tonemapping generally) are only visible in +the 3D viewport when View › Enable Post Processing is +checked. With it off the viewport still renders normally, just without that +pipeline, so a lens or aperture change made here won't be visible until it's +turned on.

Old Nebula

A legacy FS1-style nebula system. These settings are read and written for diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index 8a8ca2e3e84..cb4bfca04ba 100644 --- a/qtfred/help-src/doc/general/PreferencesDialog.html +++ b/qtfred/help-src/doc/general/PreferencesDialog.html @@ -10,7 +10,9 @@

Preferences

Opens via File › Preferences.

Controls QtFRED editor settings. Changes take effect immediately; there is no -Apply button.

+Apply button. The exception is a handful of graphics settings that the renderer +can only read while it is starting up - those are marked below, and QtFRED +applies them the next time it is launched.

General

Miscellaneous editor options. Hover over each setting for a tooltip @@ -30,6 +32,50 @@

Autosave

being asked. +

Graphics

+

Controls how the 3D viewport is rendered. These settings affect the editor +only - they are stored separately from the game's own graphics options and +never change them.

+ +

Post-processing

+

Enable post-processing in the viewport routes the viewport +through the same HDR rendering pipeline the game uses, adding bloom, +tonemapping, and lightshafts. It is off by default, so missions look the way +they always have until you opt in. The same switch is available as +View › Enable Post Processing.

+

Every other setting in this section only changes what you see while +post-processing is on, because they are all consumed by that pipeline.

+ +

Shadows & anti-aliasing

+
    +
  • Shadow quality - detail level of the shadows cast by the + mission's sun. Higher settings cost more performance. Shadows are off + until you raise this above Disabled, so enabling post-processing + alone will not produce any. Requires a restart - including the + first time you turn it on.
  • +
  • Anti-aliasing - post-process anti-aliasing mode (FXAA or + SMAA, in increasing quality). Applies immediately.
  • +
  • MSAA - multisample anti-aliasing for the 3D scene, off + or 4x/8x. More expensive than the post-process modes above but higher + quality. Requires a restart.
  • +
+ +

Textures

+
    +
  • Texture filtering - bilinear or trilinear mipmap + filtering. Left alone, QtFRED uses the same default the game does. + Requires a restart.
  • +
  • Anisotropic filtering - sharpens textures viewed at a + steep angle. The list is limited to the levels your GPU reports, and the + control is greyed out if it reports nothing worth offering. Left alone, + QtFRED uses your hardware's maximum. Requires a restart.
  • +
+ +

Gamma

+

Brightness of the viewport, from 0.10 to 5.00. QtFRED defaults this to 3.00, +higher than the game, because the viewport is not tonemapped unless +post-processing is enabled. Applies immediately.

+

Grid

Configures the reference grid shown in the main viewport. You can set which world-space plane the grid lies on (XZ, XY, or YZ) and adjust the grid's center diff --git a/qtfred/source_groups.cmake b/qtfred/source_groups.cmake index 6718b68a21a..d432dfc5f8a 100644 --- a/qtfred/source_groups.cmake +++ b/qtfred/source_groups.cmake @@ -28,6 +28,8 @@ add_file_folder("Source/Mission" src/mission/EditorViewport.h src/mission/FredRenderer.cpp src/mission/FredRenderer.h + src/mission/GraphicsSettings.cpp + src/mission/GraphicsSettings.h src/mission/IDialogProvider.h src/mission/management.cpp src/mission/management.h @@ -167,6 +169,8 @@ add_file_folder("Source/UI/Dialogs" src/ui/dialogs/AsteroidEditorDialog.h src/ui/dialogs/BackgroundEditorDialog.h src/ui/dialogs/BackgroundEditorDialog.cpp + src/ui/dialogs/LensApertureDialog.h + src/ui/dialogs/LensApertureDialog.cpp src/ui/dialogs/BriefingEditorDialog.cpp src/ui/dialogs/BriefingEditorDialog.h src/ui/dialogs/CampaignEditorDialog.h @@ -347,6 +351,7 @@ add_file_folder("UI" ui/AboutDialog.ui ui/AsteroidEditorDialog.ui ui/BackgroundEditor.ui + ui/LensApertureDialog.ui ui/BriefingEditorDialog.ui ui/CampaignEditorDialog.ui ui/CheckBoxListDialog.ui diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index e91fcb7d67f..bc289f83833 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -122,6 +122,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); + view.Graphics.applyLive(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -180,6 +181,8 @@ void EditorViewport::loadSettings() { camera.setInvertOrbitX(settings.value("camera_invert_orbit_x", camera.getInvertOrbitX()).toBool()); camera.setInvertOrbitY(settings.value("camera_invert_orbit_y", camera.getInvertOrbitY()).toBool()); settings.endGroup(); + + view.Graphics = GraphicsSettings::load(); } void EditorViewport::saveSettings() const { @@ -228,7 +231,10 @@ void EditorViewport::saveSettings() const { settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); + + view.Graphics.save(); } + void EditorViewport::needsUpdate() { _renderer->scheduleUpdate(); } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 26962561525..4004f97f6b7 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -4,6 +4,7 @@ #include "CameraController.h" #include "FredRenderer.h" #include "Editor.h" +#include "GraphicsSettings.h" #include "IDialogProvider.h" #include "ui/ThemeMode.h" @@ -63,6 +64,9 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; + //! Preferences > Graphics. Owns its own persistence and apply rules; see GraphicsSettings. + GraphicsSettings Graphics; + ViewSettings(); }; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 5bd82493288..9ee0517f3a2 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,8 @@ #include #include +#include + #include "mission/object.h" #include "prop/prop.h" #include "weapon/weapon.h" @@ -56,6 +59,38 @@ void disable_htl() { gr_end_view_matrix(); } +//! Scoped gr_scene_texture_begin()/end(), so the two can't drift apart as render_frame() grows. +struct ScenePostProcessing { + ScenePostProcessing() { gr_scene_texture_begin(); } + ~ScenePostProcessing() { gr_scene_texture_end(); } + + ScenePostProcessing(const ScenePostProcessing&) = delete; + ScenePostProcessing& operator=(const ScenePostProcessing&) = delete; +}; + +/** + * @brief Render the shadow pass for the current camera. + * + * Only meaningful inside a ScenePostProcessing scope: the shadow pass writes into deferred + * G-buffer surfaces that only exist while the scene texture is bound. Mirrors game_render_frame(), + * which calls this right after its own gr_scene_texture_begin() + stars_draw(). + * + * shadows_render_all() works on the HTL proj/view matrix stack -- the same one enable_htl() and + * disable_htl() push and pop for the starfield -- and expects it to be open: it ends whatever is + * active (gr_end_view_matrix() asserts on modelview_matrix_depth) and restores it when done. The + * starfield's disable_htl() just popped that stack, hence the push here, and the matching pop + * afterwards so the next frame's enable_htl() doesn't assert on a stack left open. + */ +void render_shadows() { + gr_set_proj_matrix(Proj_fov, gr_screen.clip_aspect, Min_draw_distance, Max_draw_distance); + gr_set_view_matrix(&Eye_position, &Eye_matrix); + + shadows_render_all(Proj_fov, &Eye_matrix, &Eye_position, nullptr, nullptr, nullptr); + + gr_end_proj_matrix(); + gr_end_view_matrix(); +} + bool fred_colors_inited = false; color colour_white; color colour_green; @@ -994,6 +1029,15 @@ void FredRenderer::render_frame(int cur_object_index, g3_set_view_matrix(&_viewport->camera.eye_pos, &_viewport->camera.eye_orient, 0.5f); + // Optionally run the 3D world through the game's HDR post-processing pipeline (bloom, + // tonemapping, lightshafts, shadows) instead of drawing straight to the default framebuffer. + // Brackets only the 3D content, the same way game_render_frame() does; the 2D overlays further + // down (distances, ship info, tooltips) stay outside it. + std::optional postProcessing; + if (view().Graphics.enablePostProcessing) { + postProcessing.emplace(); + } + // Force max star detail so the editor always shows the full Num_stars count // regardless of the player's graphics quality setting (Detail.num_stars can be 0). int saved_detail_stars = Detail.num_stars; @@ -1003,6 +1047,10 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; + if (postProcessing) { + render_shadows(); + } + if (view().Show_horizon) { gr_set_color(128, 128, 64); g3_draw_horizon_line(); @@ -1019,6 +1067,8 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); + postProcessing.reset(); + if (view().Show_distances) { display_distances(); } diff --git a/qtfred/src/mission/GraphicsSettings.cpp b/qtfred/src/mission/GraphicsSettings.cpp new file mode 100644 index 00000000000..560c8f3b6dc --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.cpp @@ -0,0 +1,132 @@ +#include "mission/GraphicsSettings.h" + +#include +#include + +#include + +namespace fso::fred { + +namespace { + +const char* SETTINGS_GROUP = "Preferences"; + +const char* KEY_POST_PROCESSING = "view_enable_post_processing"; +const char* KEY_SHADOW_QUALITY = "view_graphics_shadow_quality"; +const char* KEY_AA_MODE = "view_graphics_aa_mode"; +const char* KEY_MSAA_SAMPLES = "view_graphics_msaa_samples"; +const char* KEY_TEXTURE_FILTER = "view_graphics_texture_filter"; +const char* KEY_ANISOTROPY = "view_graphics_anisotropy"; +const char* KEY_GAMMA = "view_graphics_gamma"; + +// A settings file can be hand-edited or left over from a build with a different set of values, so +// anything that ends up in an enum or a fixed value list gets checked rather than cast blindly. +template +T readEnum(const QSettings& settings, const char* key, T fallback, T highest) +{ + const int raw = settings.value(key, static_cast(fallback)).toInt(); + + if (raw < 0 || raw > static_cast(highest)) { + return fallback; + } + + return static_cast(raw); +} + +} // namespace + +bool GraphicsSettings::operator==(const GraphicsSettings& rhs) const +{ + return enablePostProcessing == rhs.enablePostProcessing && shadowQuality == rhs.shadowQuality && + aaMode == rhs.aaMode && msaaSamples == rhs.msaaSamples && gamma == rhs.gamma && + textureFilter == rhs.textureFilter && anisotropy == rhs.anisotropy; +} + +SCP_vector GraphicsSettings::validMsaaSampleCounts() +{ + return {0, 4, 8}; +} + +GraphicsSettings GraphicsSettings::load() +{ + GraphicsSettings out; + + QSettings settings; + settings.beginGroup(SETTINGS_GROUP); + + out.enablePostProcessing = settings.value(KEY_POST_PROCESSING, out.enablePostProcessing).toBool(); + out.shadowQuality = readEnum(settings, KEY_SHADOW_QUALITY, out.shadowQuality, ShadowQuality::Ultra); + out.aaMode = readEnum(settings, KEY_AA_MODE, out.aaMode, AntiAliasMode::SMAA_Ultra); + out.gamma = settings.value(KEY_GAMMA, out.gamma).toFloat(); + + if (settings.contains(KEY_TEXTURE_FILTER)) { + out.textureFilter = settings.value(KEY_TEXTURE_FILTER).toInt() != 0 ? 1 : 0; + } + + if (settings.contains(KEY_ANISOTROPY)) { + out.anisotropy = settings.value(KEY_ANISOTROPY).toFloat(); + } + + const int samples = settings.value(KEY_MSAA_SAMPLES, out.msaaSamples).toInt(); + const auto validSamples = validMsaaSampleCounts(); + if (std::find(validSamples.begin(), validSamples.end(), samples) != validSamples.end()) { + out.msaaSamples = samples; + } + + settings.endGroup(); + + return out; +} + +void GraphicsSettings::save() const +{ + QSettings settings; + settings.beginGroup(SETTINGS_GROUP); + + settings.setValue(KEY_POST_PROCESSING, enablePostProcessing); + settings.setValue(KEY_SHADOW_QUALITY, static_cast(shadowQuality)); + settings.setValue(KEY_AA_MODE, static_cast(aaMode)); + settings.setValue(KEY_MSAA_SAMPLES, msaaSamples); + settings.setValue(KEY_GAMMA, gamma); + + // Don't write the sentinels back out -- an absent key is what keeps the engine default in play. + if (textureFilter != NO_TEXTURE_FILTER_CHOICE) { + settings.setValue(KEY_TEXTURE_FILTER, textureFilter); + } + + if (anisotropy != NO_ANISOTROPY_CHOICE) { + settings.setValue(KEY_ANISOTROPY, anisotropy); + } + + settings.endGroup(); +} + +void GraphicsSettings::applyLive() const +{ + Gr_aa_mode = aaMode; + gr_set_gamma(gamma); +} + +GraphicsSettings GraphicsSettings::applyBeforeGrInit() +{ + const GraphicsSettings settings = load(); + + Cmdline_msaa_enabled = settings.msaaSamples; + Shadow_quality = settings.shadowQuality; + + auto* options = options::OptionsManager::instance(); + + // Overrides are in-memory only, so they steer this session's gr_init() without touching the + // config file the game reads its own graphics settings from. + if (settings.textureFilter != NO_TEXTURE_FILTER_CHOICE) { + options->setOverride("Graphics.TextureFilter", std::to_string(settings.textureFilter)); + } + + if (settings.anisotropy != NO_ANISOTROPY_CHOICE) { + options->setOverride("Graphics.Anisotropy", std::to_string(settings.anisotropy)); + } + + return settings; +} + +} // namespace fso::fred diff --git a/qtfred/src/mission/GraphicsSettings.h b/qtfred/src/mission/GraphicsSettings.h new file mode 100644 index 00000000000..8e85a368360 --- /dev/null +++ b/qtfred/src/mission/GraphicsSettings.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +namespace fso::fred { + +/** + * @brief qtFRED's Preferences > Graphics settings. + * + * Single owner of these values: the QSettings keys, their defaults, and the rules for when each + * one can be applied all live here. Two very different callers need them -- management.cpp before + * gr_init(), and EditorViewport once the editor is up -- and previously each read QSettings with + * its own copy of the key strings and default values. + * + * The settings split by *when* they can take effect, which is what the two apply functions below + * encode: + * + * - applyLive() settings can be changed at any time and are re-applied whenever the user hits + * Apply in Preferences. + * - applyBeforeGrInit() settings are baked into GPU resources during gr_init() and cannot be + * changed afterwards, so they are read straight out of QSettings before the editor exists. + * Shadow quality is the sharpest example: shadow_cascade_params_init() only runs during + * gr_init(), and only if Shadow_quality is already non-Disabled, so setting it later leaves the + * cascade buffers unsized and crashes the next frame that renders shadows. + */ +struct GraphicsSettings { + // Texture filtering and anisotropy reach the engine through the options system, whose defaults + // are better informed than anything qtFRED could hardcode -- the user's existing config for + // one, the hardware maximum for the other. Both therefore carry a sentinel meaning "the user + // has not chosen one", and applyBeforeGrInit() overrides only once there is a real choice. + static constexpr int NO_TEXTURE_FILTER_CHOICE = -1; + static constexpr float NO_ANISOTROPY_CHOICE = 0.0f; + + /** + * @brief Run the viewport through the game's HDR scene-texture + post-processing pipeline. + * + * Off by default so missions keep looking exactly as they do today unless a user opts in. + * Everything else on this struct is only visible in the viewport while this is on. + */ + bool enablePostProcessing = false; + + ShadowQuality shadowQuality = ShadowQuality::Disabled; + AntiAliasMode aaMode = AntiAliasMode::None; + + int msaaSamples = 0; //!< 0 (off), 4, or 8; see validMsaaSampleCounts() + float gamma = 3.0f; //!< matches the brightness qtFRED has always launched with + + int textureFilter = NO_TEXTURE_FILTER_CHOICE; //!< 0 = bilinear, 1 = trilinear + float anisotropy = NO_ANISOTROPY_CHOICE; //!< 1.0 = off, otherwise a power of two + + bool operator==(const GraphicsSettings& rhs) const; + bool operator!=(const GraphicsSettings& rhs) const { return !(*this == rhs); } + + /** + * @brief The MSAA sample counts the Preferences combo offers, in combo order. + */ + static SCP_vector validMsaaSampleCounts(); + + static GraphicsSettings load(); + void save() const; + + /** + * @brief Push the settings that can be changed without a restart into the engine. + */ + void applyLive() const; + + /** + * @brief Load and apply the settings that must be in place before gr_init() runs. + * + * The engine reads texture filtering and anisotropy through the options system, so those are + * pushed as in-memory-only overrides rather than written out -- qtFRED must never rewrite the + * config file the game reads its own graphics settings from. + * + * @return the settings that were loaded, so the caller can applyLive() them once gr_init() has + * returned and there is a renderer to apply them to. + */ + static GraphicsSettings applyBeforeGrInit(); +}; + +} // namespace fso::fred diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp index 5c1e47c175f..f6b48f3b11d 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.cpp @@ -7,6 +7,7 @@ #include "nebula/neb.h" #include "nebula/neblightning.h" #include "starfield/nebula.h" +#include "graphics/lens_flare.h" #include "lighting/lighting_profiles.h" #include "missioneditor/common.h" @@ -1388,4 +1389,138 @@ void BackgroundEditorDialogModel::setLightingProfileName(const SCP_string& name) modify(The_mission.lighting_profile_name, name); } +// The camera lens every sun's flare is imaged through. "Default" and "None" are +// genuinely different answers -- the first leaves the mission silent so it follows +// lens_flares.tbl's $Default Lens:, the second says no flares even when one is +// declared -- so both head the list, ahead of the lenses themselves. +SCP_vector BackgroundEditorDialogModel::getCameraLensOptions() +{ + SCP_vector out; + out.emplace_back(CAMERA_LENS_DEFAULT); + out.emplace_back(CAMERA_LENS_NONE); + for (int i = 0; i < graphics::lens_flare_num_systems(); i++) + out.emplace_back(graphics::lens_flare_get_system(i)->name); + return out; +} + +SCP_string BackgroundEditorDialogModel::getCameraLensName() +{ + // An unset mission (and one that spelled by hand) shows as "Default" + if (The_mission.camera_lens_name.empty() || + !stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_DEFAULT)) + return { CAMERA_LENS_DEFAULT }; + + if (!stricmp(The_mission.camera_lens_name.c_str(), LENS_NAME_NONE)) + return { CAMERA_LENS_NONE }; + + return The_mission.camera_lens_name; +} + +void BackgroundEditorDialogModel::setCameraLensName(const SCP_string& name) +{ + // Empty for "Default" so the mission stays silent, the token for "None" + // so the choice survives being saved + SCP_string lens_name; + if (name == CAMERA_LENS_NONE) + lens_name = LENS_NAME_NONE; + else if (name != CAMERA_LENS_DEFAULT) + lens_name = name; + + if (lens_name == The_mission.camera_lens_name) + return; + + modify(The_mission.camera_lens_name, lens_name); + + // mount it right away so the editor's viewport shows what the mission will + graphics::lens_flare_switch_to(The_mission.camera_lens_name.c_str()); + // Build this lens's textures now rather than paying for a 512^2 mask + FFT + // mid-frame the moment a sun next flares through it (see lens_flare.h). + graphics::lens_flare_prime_textures(); + refreshBackgroundPreview(); +} + +graphics::lens_settings BackgroundEditorDialogModel::getLensSettings() +{ + // The neutral baseline, with whatever this mission overrides laid over it -- + // the same resolution the engine does, against defaults rather than against a + // lens (see the header for why). + const graphics::lens_overrides& ov = The_mission.camera_lens_overrides; + graphics::lens_settings settings; + + if (ov.aperture) + settings.aperture = *ov.aperture; + if (ov.anamorphic) + settings.anamorphic = *ov.anamorphic; + if (ov.intensity) + settings.intensity = *ov.intensity; + if (ov.starburst) + settings.starburst = *ov.starburst; + if (ov.starburst_scale) + settings.starburst_scale = *ov.starburst_scale; + if (ov.max_ghosts) + settings.max_ghosts = *ov.max_ghosts; + if (ov.ghost_brightness) + settings.ghost_brightness = *ov.ghost_brightness; + if (ov.starburst_brightness) + settings.starburst_brightness = *ov.starburst_brightness; + + return settings; +} + +void BackgroundEditorDialogModel::setLensSettings(const graphics::lens_settings& settings) +{ + // A value that landed back on the neutral baseline -- every slider dragged + // down again without using Reset -- is indistinguishable from no override at + // all, and must be treated as one, or the mission keeps saving an empty block + // forever. + const graphics::lens_settings neutral; + graphics::lens_overrides ov; + + if (settings.aperture != neutral.aperture) + ov.aperture = settings.aperture; + if (settings.anamorphic != neutral.anamorphic) + ov.anamorphic = settings.anamorphic; + if (settings.intensity != neutral.intensity) + ov.intensity = settings.intensity; + if (settings.starburst != neutral.starburst) + ov.starburst = settings.starburst; + if (settings.starburst_scale != neutral.starburst_scale) + ov.starburst_scale = settings.starburst_scale; + if (settings.max_ghosts != neutral.max_ghosts) + ov.max_ghosts = settings.max_ghosts; + if (settings.ghost_brightness != neutral.ghost_brightness) + ov.ghost_brightness = settings.ghost_brightness; + if (settings.starburst_brightness != neutral.starburst_brightness) + ov.starburst_brightness = settings.starburst_brightness; + + modify(The_mission.camera_lens_overrides, ov); + applyLensOverridesToViewport(); +} + +void BackgroundEditorDialogModel::resetLensSettings() +{ + modify(The_mission.camera_lens_overrides, graphics::lens_overrides()); + applyLensOverridesToViewport(); +} + +bool BackgroundEditorDialogModel::getLensMounted() +{ + return graphics::lens_flare_active_lens() >= 0; +} + +// Push the mission's overrides at the running engine so the viewport shows what +// the mission will. Cheap to call on every slider tick: only a changed iris costs +// anything, and lens_flare_overrides_changed() coalesces that rebuild. +void BackgroundEditorDialogModel::applyLensOverridesToViewport() +{ + graphics::lens_flare_overrides() = The_mission.camera_lens_overrides; + graphics::lens_flare_overrides_changed(); + + // qtFred's viewport repaints on demand, not continuously (unlike the lab) -- + // without this, the edit above is real but invisible until some unrelated + // event (mouse move, resize) happens to trigger the next repaint. + if (_viewport) + _viewport->needsUpdate(); +} + } // namespace fso::fred::dialogs \ No newline at end of file diff --git a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h index 36118b3aa8d..02c9673236c 100644 --- a/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h +++ b/qtfred/src/mission/dialogs/BackgroundEditorDialogModel.h @@ -4,6 +4,7 @@ #include "AbstractDialogModel.h" +#include "graphics/lens_flare.h" #include "starfield/starfield.h" #include @@ -175,7 +176,39 @@ class BackgroundEditorDialogModel : public AbstractDialogModel { static SCP_string getLightingProfileName(); void setLightingProfileName(const SCP_string& name); + // Combo entries standing in for the two answers that aren't a lens name: + // "Default" leaves the mission silent so it follows lens_flares.tbl, + // "None" is the explicit token (see graphics/lens_flare.h). + static constexpr const char* CAMERA_LENS_DEFAULT = "Default"; + static constexpr const char* CAMERA_LENS_NONE = "None"; + static SCP_vector getCameraLensOptions(); + static SCP_string getCameraLensName(); + void setCameraLensName(const SCP_string& name); + + // How this mission restyles the camera lens -- iris, anamorphic look and flare + // strength -- as one value, which is what lets LensApertureDialog stay a plain + // form instead of tracking which parts the mission had already overridden. + // See The_mission.camera_lens_overrides in missionparse.h. + // + // A field the mission does not override reads back as its neutral default, and + // a field set back to that default stops being an override. Deliberately *not* + // the mounted lens's own tabled values: a mission-level override replaces the + // whole group it belongs to, so starting the sliders at (say) this lens's blade + // curvature would mean the first field a user touches quietly saves that too, + // frozen at one lens's numbers. + static graphics::lens_settings getLensSettings(); + void setLensSettings(const graphics::lens_settings& settings); + void resetLensSettings(); + + // Whether a lens is currently mounted at all ("Default" with no + // $Default Lens: in the tables, or "None", both leave nothing mounted). + // Edits still update the mission's stored override either way, but there is + // nothing for them to visibly change without a mounted lens -- the dialog + // uses this to warn instead of leaving the user wondering why nothing moved. + static bool getLensMounted(); + private: + void applyLensOverridesToViewport(); void initializeData(); void refreshBackgroundPreview(); static background_t& getActiveBackground(); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index c674f26744e..57681573958 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -26,6 +26,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) + , _graphics(viewport->view.Graphics) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -68,6 +69,9 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; + _viewport->view.Graphics = _graphics; + // Only some of these can take effect now; the rest need a restart (see GraphicsSettings). + _viewport->view.Graphics.applyLive(); _viewport->camera.setInvertOrbitX(_invertOrbitX); _viewport->camera.setInvertOrbitY(_invertOrbitY); @@ -180,6 +184,9 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); } +const GraphicsSettings& PreferencesDialogModel::getGraphics() const { return _graphics; } +void PreferencesDialogModel::setGraphics(const GraphicsSettings& value) { modify(_graphics, value); } + QKeySequence PreferencesDialogModel::getControlKey(ControlAction action) const { auto it = _controlKeys.find(action); Assertion(it != _controlKeys.end(), "Unknown control action!"); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index 9fcc5b50884..0d12bb0dcfe 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -1,5 +1,6 @@ #pragma once +#include "mission/GraphicsSettings.h" #include "mission/dialogs/AbstractDialogModel.h" #include "ui/ControlBindings.h" #include "ui/ThemeMode.h" @@ -67,6 +68,11 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int value); + // Graphics. The dialog edits a whole GraphicsSettings rather than a field at a time; the + // setter exists so edits still route through modify() and mark the model dirty. + const GraphicsSettings& getGraphics() const; + void setGraphics(const GraphicsSettings& value); + // Controls QKeySequence getControlKey(ControlAction action) const; void setControlKey(ControlAction action, const QKeySequence& sequence); @@ -109,6 +115,8 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; + GraphicsSettings _graphics; + // Controls std::map _controlKeys; bool _invertOrbitX; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index cc8cf8efb8d..db32e78246d 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -3,6 +3,8 @@ #include "object.h" +#include "mission/GraphicsSettings.h" + #include "cmdline/cmdline.h" #include @@ -125,9 +127,16 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; + // These have to be in place before gr_init() bakes them into GPU resources, which is well + // before EditorViewport (and its copy of the settings) exists. + const GraphicsSettings graphicsSettings = GraphicsSettings::applyBeforeGrInit(); + std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); - gr_set_gamma(3.0f); + + // The rest needs a live renderer. EditorViewport re-applies these once it exists, but the + // startup screens render before that. + graphicsSettings.applyLive(); io::mouse::CursorManager::get()->showCursor(false); diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 7c6aae8f482..4bbe97d6e41 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,6 +942,7 @@ void FredView::syncViewOptions() { connectActionToViewSetting(ui->actionLighting_from_Suns, &_viewport->view.Lighting_on); connectActionToViewSetting(ui->actionRender_Full_Detail, &_viewport->view.FullDetail); + connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp index 075274477ed..3df1d886596 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.cpp @@ -3,6 +3,7 @@ #include "ui/util/default_dir.h" #include "ui/util/SignalBlockers.h" #include "ui/dialogs/General/ImagePickerDialog.h" +#include "ui/dialogs/LensApertureDialog.h" #include "ui_BackgroundEditor.h" #include @@ -125,6 +126,10 @@ void BackgroundEditorDialog::initializeUi() ui->lightingProfileCombo->addItem(QString::fromStdString(s)); } + for (const auto& s : _model->getCameraLensOptions()) { + ui->cameraLensCombo->addItem(QString::fromStdString(s)); + } + updateMiscControls(); } @@ -406,6 +411,7 @@ void BackgroundEditorDialog::updateMiscControls() ui->subspaceCheckBox->setChecked(_model->getTakesPlaceInSubspace()); ui->envMapEdit->setText(QString::fromStdString(_model->getEnvironmentMapName())); ui->lightingProfileCombo->setCurrentIndex(ui->lightingProfileCombo->findText(QString::fromStdString(_model->getLightingProfileName()))); + ui->cameraLensCombo->setCurrentIndex(ui->cameraLensCombo->findText(QString::fromStdString(_model->getCameraLensName()))); } int BackgroundEditorDialog::pickBackgroundIndexDialog(QWidget* parent, int count, int defaultIndex) @@ -974,4 +980,19 @@ void BackgroundEditorDialog::on_lightingProfileCombo_currentIndexChanged(int ind _model->setLightingProfileName(text.toUtf8().constData()); } +void BackgroundEditorDialog::on_cameraLensCombo_currentIndexChanged(int index) +{ + if (index < 0) + return; + + const QString text = ui->cameraLensCombo->itemText(index); + _model->setCameraLensName(text.toUtf8().constData()); +} + +void BackgroundEditorDialog::on_lensApertureButton_clicked() +{ + LensApertureDialog dlg(this, _model.get()); + dlg.exec(); +} + } // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h index 42ea46fb77f..e6d9d20aefd 100644 --- a/qtfred/src/ui/dialogs/BackgroundEditorDialog.h +++ b/qtfred/src/ui/dialogs/BackgroundEditorDialog.h @@ -101,6 +101,8 @@ private slots: void on_envMapButton_clicked(); void on_envMapEdit_textChanged(const QString& arg1); void on_lightingProfileCombo_currentIndexChanged(int index); + void on_cameraLensCombo_currentIndexChanged(int index); + void on_lensApertureButton_clicked(); protected: void closeEvent(QCloseEvent* e) override; diff --git a/qtfred/src/ui/dialogs/LensApertureDialog.cpp b/qtfred/src/ui/dialogs/LensApertureDialog.cpp new file mode 100644 index 00000000000..ec22b03d3a4 --- /dev/null +++ b/qtfred/src/ui/dialogs/LensApertureDialog.cpp @@ -0,0 +1,295 @@ +#include "LensApertureDialog.h" +#include "ui/util/SignalBlockers.h" +#include "ui_LensApertureDialog.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace fso::fred::dialogs { + +namespace { + +using graphics::lens_settings; + +// One row of the dialog: what it is called, how it is presented, and where its +// value lives in a lens_settings. +// +// This table is the dialog. The controls are built from it, read from it and +// written back through it, so a knob added to lens_settings reaches the editor by +// adding one line here rather than by being spelled out in a .ui file, a build +// list, a load function and a save function that can all drift apart. +// +// The accessors are captureless lambdas (hence plain function pointers) rather +// than member pointers because most of these live inside a nested struct, and a +// pointer-to-member chain through lens_aperture::grating::density is far less +// readable than just naming it. +struct lens_field { + // Non-null starts a new group box, with this as its title. + const char* section; + const char* label; + + enum class kind { + Real, // a slider carrying a float, quantized to `decimals` places + Integer, // a slider carrying a whole number + Boolean, // a checkbox + }; + kind type; + + float min; + float max; + int decimals; // Real only; also fixes the slider's step size + + float (*get)(const lens_settings&); + void (*set)(lens_settings&, float); +}; + +// QSlider is integer-only, so a Real field rides a slider scaled by 10^decimals. +// Deriving the scale from the displayed precision rather than picking one per +// field is what keeps a value read back out of a slider equal to the value put +// in: anything the dialog can show, it can represent exactly. +float field_scale(const lens_field& f) +{ + return (f.type == lens_field::kind::Real) ? std::pow(10.0f, static_cast(f.decimals)) : 1.0f; +} + +// clang-format off +const lens_field Lens_fields[] = { + {"Iris", "Blades", lens_field::kind::Integer, 0.0f, 64.0f, 0, + [](const lens_settings& s) { return static_cast(s.aperture.blades); }, + [](lens_settings& s, float v) { s.aperture.blades = static_cast(std::lround(v)); }}, + {nullptr, "Blade rotation (deg)", lens_field::kind::Real, 0.0f, 180.0f, 1, + [](const lens_settings& s) { return s.aperture.rotation; }, + [](lens_settings& s, float v) { s.aperture.rotation = v; }}, + {nullptr, "Blade curvature", lens_field::kind::Real, -1.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.curvature; }, + [](lens_settings& s, float v) { s.aperture.curvature = v; }}, + {nullptr, "Edge softness", lens_field::kind::Real, 0.0f, 0.5f, 4, + [](const lens_settings& s) { return s.aperture.softness; }, + [](lens_settings& s, float v) { s.aperture.softness = v; }}, + + {"Rim grating", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.grating.strength; }, + [](lens_settings& s, float v) { s.aperture.grating.strength = v; }}, + {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.grating.density; }, + [](lens_settings& s, float v) { s.aperture.grating.density = v; }}, + {nullptr, "Length", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.grating.length; }, + [](lens_settings& s, float v) { s.aperture.grating.length = v; }}, + {nullptr, "Width", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.grating.width; }, + [](lens_settings& s, float v) { s.aperture.grating.width = v; }}, + {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4, + [](const lens_settings& s) { return s.aperture.grating.softness; }, + [](lens_settings& s, float v) { s.aperture.grating.softness = v; }}, + + {"Scratches", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.scratches.strength; }, + [](lens_settings& s, float v) { s.aperture.scratches.strength = v; }}, + {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.scratches.density; }, + [](lens_settings& s, float v) { s.aperture.scratches.density = v; }}, + {nullptr, "Length", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.scratches.length; }, + [](lens_settings& s, float v) { s.aperture.scratches.length = v; }}, + {nullptr, "Width", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.scratches.width; }, + [](lens_settings& s, float v) { s.aperture.scratches.width = v; }}, + {nullptr, "Rotation (deg)", lens_field::kind::Real, 0.0f, 180.0f, 1, + [](const lens_settings& s) { return s.aperture.scratches.rotation; }, + [](lens_settings& s, float v) { s.aperture.scratches.rotation = v; }}, + {nullptr, "Rotation variation", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.scratches.rotation_variation; }, + [](lens_settings& s, float v) { s.aperture.scratches.rotation_variation = v; }}, + {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4, + [](const lens_settings& s) { return s.aperture.scratches.softness; }, + [](lens_settings& s, float v) { s.aperture.scratches.softness = v; }}, + + {"Dust", "Strength", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.dust.strength; }, + [](lens_settings& s, float v) { s.aperture.dust.strength = v; }}, + {nullptr, "Density", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.dust.density; }, + [](lens_settings& s, float v) { s.aperture.dust.density = v; }}, + {nullptr, "Radius", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.aperture.dust.radius; }, + [](lens_settings& s, float v) { s.aperture.dust.radius = v; }}, + {nullptr, "Softness", lens_field::kind::Real, 0.0f, 0.5f, 4, + [](const lens_settings& s) { return s.aperture.dust.softness; }, + [](lens_settings& s, float v) { s.aperture.dust.softness = v; }}, + + {"Anamorphic", "Squeeze", lens_field::kind::Real, 1.0f, 3.0f, 2, + [](const lens_settings& s) { return s.anamorphic.squeeze; }, + [](lens_settings& s, float v) { s.anamorphic.squeeze = v; }}, + {nullptr, "Streak strength", lens_field::kind::Real, 0.0f, 2.0f, 2, + [](const lens_settings& s) { return s.anamorphic.streak.strength; }, + [](lens_settings& s, float v) { s.anamorphic.streak.strength = v; }}, + {nullptr, "Streak length", lens_field::kind::Real, 0.0f, 4.0f, 2, + [](const lens_settings& s) { return s.anamorphic.streak.length; }, + [](lens_settings& s, float v) { s.anamorphic.streak.length = v; }}, + {nullptr, "Streak thickness", lens_field::kind::Real, 0.0f, 0.2f, 4, + [](const lens_settings& s) { return s.anamorphic.streak.thickness; }, + [](lens_settings& s, float v) { s.anamorphic.streak.thickness = v; }}, + {nullptr, "Streak tint (red)", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.anamorphic.streak.tint[0]; }, + [](lens_settings& s, float v) { s.anamorphic.streak.tint[0] = v; }}, + {nullptr, "Streak tint (green)", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.anamorphic.streak.tint[1]; }, + [](lens_settings& s, float v) { s.anamorphic.streak.tint[1] = v; }}, + {nullptr, "Streak tint (blue)", lens_field::kind::Real, 0.0f, 1.0f, 2, + [](const lens_settings& s) { return s.anamorphic.streak.tint[2]; }, + [](lens_settings& s, float v) { s.anamorphic.streak.tint[2] = v; }}, + + // The knobs below cost nothing to change, unlike everything above the + // anamorphic section: none of them touches the iris mask or its transform. + {"Strength", "Lens intensity", lens_field::kind::Real, 0.0f, 10.0f, 3, + [](const lens_settings& s) { return s.intensity; }, + [](lens_settings& s, float v) { s.intensity = v; }}, + {nullptr, "Ghost brightness", lens_field::kind::Real, 0.0f, 500.0f, 1, + [](const lens_settings& s) { return s.ghost_brightness; }, + [](lens_settings& s, float v) { s.ghost_brightness = v; }}, + {nullptr, "Starburst brightness", lens_field::kind::Real, 0.0f, 10.0f, 2, + [](const lens_settings& s) { return s.starburst_brightness; }, + [](lens_settings& s, float v) { s.starburst_brightness = v; }}, + {nullptr, "Draw starburst", lens_field::kind::Boolean, 0.0f, 1.0f, 0, + [](const lens_settings& s) { return s.starburst ? 1.0f : 0.0f; }, + [](lens_settings& s, float v) { s.starburst = (v != 0.0f); }}, + {nullptr, "Starburst scale", lens_field::kind::Real, 0.0f, 4.0f, 2, + [](const lens_settings& s) { return s.starburst_scale; }, + [](lens_settings& s, float v) { s.starburst_scale = v; }}, + {nullptr, "Max ghosts", lens_field::kind::Integer, 0.0f, + static_cast(graphics::MAX_LENS_FLARE_GHOSTS), 0, + [](const lens_settings& s) { return static_cast(s.max_ghosts); }, + [](lens_settings& s, float v) { s.max_ghosts = static_cast(std::lround(v)); }}, +}; +// clang-format on + +constexpr int Num_lens_fields = static_cast(sizeof(Lens_fields) / sizeof(Lens_fields[0])); + +} // namespace + +LensApertureDialog::LensApertureDialog(QWidget* parent, BackgroundEditorDialogModel* model) + : QDialog(parent), ui(new Ui::LensApertureDialog()), _model(model) +{ + ui->setupUi(this); + + buildFields(); + + connect(ui->resetButton, &QPushButton::clicked, this, [this]() { + _model->resetLensSettings(); + updateUi(); + }); + + updateUi(); +} + +LensApertureDialog::~LensApertureDialog() = default; + +// Build one control per table row, grouping consecutive rows under whichever +// section header last named one. +void LensApertureDialog::buildFields() +{ + auto* outer = ui->scrollAreaContents->layout(); + QFormLayout* form = nullptr; + + _rows.resize(Num_lens_fields); + + for (int i = 0; i < Num_lens_fields; i++) { + const lens_field& f = Lens_fields[i]; + row& r = _rows[i]; + + if (f.section != nullptr) { + auto* group = new QGroupBox(QString::fromUtf8(f.section), ui->scrollAreaContents); + form = new QFormLayout(group); + outer->addWidget(group); + } + Assertion(form != nullptr, "The first lens field must open a section!"); + + if (f.type == lens_field::kind::Boolean) { + r.check = new QCheckBox(); + connect(r.check, &QCheckBox::toggled, this, [this]() { applyFromControls(); }); + form->addRow(QString::fromUtf8(f.label), r.check); + continue; + } + + const float scale = field_scale(f); + r.slider = new QSlider(Qt::Horizontal); + r.slider->setMinimum(static_cast(std::lround(f.min * scale))); + r.slider->setMaximum(static_cast(std::lround(f.max * scale))); + r.value = new QLabel(); + // Wide enough that the row doesn't jump about as digits come and go + r.value->setMinimumWidth(60); + r.value->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + connect(r.slider, &QSlider::valueChanged, this, [this]() { applyFromControls(); }); + + auto* rowLayout = new QHBoxLayout(); + rowLayout->addWidget(r.slider); + rowLayout->addWidget(r.value); + form->addRow(QString::fromUtf8(f.label), rowLayout); + } + + outer->addItem(new QSpacerItem(0, 0, QSizePolicy::Minimum, QSizePolicy::Expanding)); +} + +// The value label for a row, formatted at that row's own precision. +void LensApertureDialog::showValue(int index, float value) +{ + const lens_field& f = Lens_fields[index]; + if (_rows[index].value == nullptr) { + return; + } + _rows[index].value->setText(QString::number(value, 'f', f.decimals)); +} + +void LensApertureDialog::updateUi() +{ + util::SignalBlockers blockers(this); + + ui->noLensWarningLabel->setVisible(!BackgroundEditorDialogModel::getLensMounted()); + + const graphics::lens_settings settings = fso::fred::dialogs::BackgroundEditorDialogModel::getLensSettings(); + + for (int i = 0; i < Num_lens_fields; i++) { + const lens_field& f = Lens_fields[i]; + const float value = f.get(settings); + + if (f.type == lens_field::kind::Boolean) { + _rows[i].check->setChecked(value != 0.0f); + continue; + } + + _rows[i].slider->setValue(static_cast(std::lround(value * field_scale(f)))); + showValue(i, value); + } +} + +void LensApertureDialog::applyFromControls() +{ + graphics::lens_settings settings; + + for (int i = 0; i < Num_lens_fields; i++) { + const lens_field& f = Lens_fields[i]; + + if (f.type == lens_field::kind::Boolean) { + f.set(settings, _rows[i].check->isChecked() ? 1.0f : 0.0f); + continue; + } + + const float value = static_cast(_rows[i].slider->value()) / field_scale(f); + f.set(settings, value); + showValue(i, value); + } + + _model->setLensSettings(settings); +} + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/LensApertureDialog.h b/qtfred/src/ui/dialogs/LensApertureDialog.h new file mode 100644 index 00000000000..6cd64a55d40 --- /dev/null +++ b/qtfred/src/ui/dialogs/LensApertureDialog.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include "mission/dialogs/BackgroundEditorDialogModel.h" + +class QCheckBox; +class QLabel; +class QSlider; + +namespace fso::fred::dialogs { + +namespace Ui { +class LensApertureDialog; +} + +// Live editor for how the mission restyles the camera lens: the iris (shape plus +// grating/scratches/dust), the anamorphic look, and how strongly the flare draws +// -- everything about the camera except the lens prescription itself, which is +// what naming a lens in the Background Editor picks. See graphics/lens_flare.h's +// lens_settings for the whole set and the set-lens-* sexps this mirrors. +// +// Opened from the Background Editor's "Lens Aperture..." button; every control +// applies straight to BackgroundEditorDialogModel as it is dragged, the same way +// that dialog's own ambient light sliders do, so there is no separate Apply/OK +// step. +// +// The controls are not laid out in the .ui file. They are built from the field +// table at the top of LensApertureDialog.cpp, which is also what reads and writes +// them, so a knob added to lens_settings needs one line there rather than a +// widget, a build entry, a load line and a save line that can all drift apart. +class LensApertureDialog : public QDialog { + Q_OBJECT + + public: + explicit LensApertureDialog(QWidget* parent, BackgroundEditorDialogModel* model); + ~LensApertureDialog() override; + + private: + // The widgets built for one field: a slider plus its value readout, or a + // checkbox. Owned by the layout, like everything else Qt parents. + struct row { + QSlider* slider = nullptr; + QLabel* value = nullptr; + QCheckBox* check = nullptr; + }; + + void buildFields(); + void showValue(int index, float value); + void updateUi(); + void applyFromControls(); + + std::unique_ptr ui; + BackgroundEditorDialogModel* _model; + SCP_vector _rows; // parallel to the field table +}; + +} // namespace fso::fred::dialogs diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index 07db9e06d92..0067bb2b90c 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -8,9 +8,54 @@ #include "ui/util/SignalBlockers.h" #include "ui/widgets/sexp_tree_view.h" +#include +#include + namespace fso::fred::dialogs { namespace { +// The engine already describes each of these settings once, with its own value list and display +// names (Graphics.Shadows in shadows.cpp, Graphics.AAMode and Graphics.TextureFilter in 2d.cpp and +// gropengltexture.cpp). Reading the labels back out of those definitions keeps this dialog from +// carrying a second, silently-diverging copy of them. +// +// The values are enumerated in declaration order, which for these three is enum order, so a combo +// index is the enum value. Returns empty if the option isn't registered, in which case the caller +// leaves the combo alone rather than showing a half-populated list. +SCP_vector engineOptionValues(const char* configKey) +{ + for (const auto& option : options::OptionsManager::instance()->getOptions()) { + if (option->getConfigKey() == configKey && option->getType() == options::OptionType::Selection) { + return option->getValidValues(); + } + } + + return {}; +} + +void populateFromEngineOption(QComboBox* combo, const char* configKey) +{ + const auto values = engineOptionValues(configKey); + + for (const auto& value : values) { + combo->addItem(QString::fromStdString(value.display)); + } + + // Nothing to choose from means the option isn't registered in this build (a renderer compiled + // out, say). Grey the control out rather than leaving an empty combo that looks broken. + combo->setEnabled(!values.empty()); +} + +// The model stores graphics settings as one struct, so an edit is read-modify-write. Doing it this +// way keeps every control's slot to the one line that actually differs. +template +void editGraphics(PreferencesDialogModel* model, F&& mutate) +{ + GraphicsSettings graphics = model->getGraphics(); + mutate(graphics); + model->setGraphics(graphics); +} + int themeModeToIndex(ThemeMode mode) { switch (mode) { @@ -94,6 +139,27 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { + // setupUi() has already run connectSlotsByName(), so filling a combo here would fire its + // currentIndexChanged slot and mark the model modified before the user has touched anything. + // (Items declared in the .ui file were added before the connections existed and so were safe.) + util::SignalBlockers blockers(this); + + populateFromEngineOption(ui->shadowQualityCombo, "Graphics.Shadows"); + populateFromEngineOption(ui->aaModeCombo, "Graphics.AAMode"); + populateFromEngineOption(ui->textureFilterCombo, "Graphics.TextureFilter"); + + // Anisotropy levels are hardware-dependent, so ask the engine for the same list its own + // options screen offers. Empty means the hardware can't do it; leave the combo disabled. + _anisotropyLevels = gr_get_supported_anisotropy_levels(); + for (float level : _anisotropyLevels) { + ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0)); + } + ui->anisotropyCombo->setEnabled(!_anisotropyLevels.empty()); + + for (int samples : GraphicsSettings::validMsaaSampleCounts()) { + ui->msaaCombo->addItem(samples == 0 ? tr("Off") : tr("%1x").arg(samples)); + } + // Build the controls key-binding form dynamically from the registered bindings auto* form = new QFormLayout(ui->controlsFormWidget); auto& bindings = ControlBindings::instance(); @@ -125,6 +191,35 @@ void PreferencesDialog::updateUi() { const int iconSize = _model->getToolbarIconSize(); ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1); ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); + + // Graphics + const GraphicsSettings& graphics = _model->getGraphics(); + + ui->enablePostProcessing->setChecked(graphics.enablePostProcessing); + ui->shadowQualityCombo->setCurrentIndex(static_cast(graphics.shadowQuality)); + ui->aaModeCombo->setCurrentIndex(static_cast(graphics.aaMode)); + ui->gammaSpin->setValue(graphics.gamma); + + const auto msaaCounts = GraphicsSettings::validMsaaSampleCounts(); + ui->msaaCombo->setCurrentIndex( + static_cast(std::find(msaaCounts.begin(), msaaCounts.end(), graphics.msaaSamples) - msaaCounts.begin())); + + // Both of these can be "not chosen", in which case the engine picks: trilinear, and the + // hardware's maximum anisotropy. Show what will actually be used rather than a blank. + ui->textureFilterCombo->setCurrentIndex( + graphics.textureFilter == GraphicsSettings::NO_TEXTURE_FILTER_CHOICE ? 1 : graphics.textureFilter); + + if (!_anisotropyLevels.empty()) { + int index = static_cast(_anisotropyLevels.size()) - 1; + for (size_t i = 0; i < _anisotropyLevels.size(); ++i) { + if (_anisotropyLevels[i] == graphics.anisotropy) { + index = static_cast(i); + break; + } + } + ui->anisotropyCombo->setCurrentIndex(index); + } + ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); ui->showSexpHelpMissionCutscenes->setChecked(_model->getShowSexpHelpMissionCutscenes()); @@ -202,6 +297,43 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { _model->setThemeMode(themeModeFromIndex(index)); } +void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; }); +} + +void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast(index); }); +} + +void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast(index); }); +} + +void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { + const auto counts = GraphicsSettings::validMsaaSampleCounts(); + if (index < 0 || static_cast(index) >= counts.size()) { + return; + } + + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.msaaSamples = counts[index]; }); +} + +void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; }); +} + +void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { + if (index < 0 || static_cast(index) >= _anisotropyLevels.size()) { + return; + } + + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.anisotropy = _anisotropyLevels[index]; }); +} + +void PreferencesDialog::on_gammaSpin_valueChanged(double value) { + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.gamma = static_cast(value); }); +} + void PreferencesDialog::on_dataMenuStyleCombo_currentIndexChanged(int index) { _model->setDataMenuStyle(static_cast(index)); } diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index e4e8266866e..a4ed07e2614 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -34,6 +34,14 @@ private slots: void on_toolbarIconSizeCombo_currentIndexChanged(int index); void on_outlineLodCombo_currentIndexChanged(int index); void on_themeCombo_currentIndexChanged(int index); + // Graphics + void on_enablePostProcessing_toggled(bool checked); + void on_shadowQualityCombo_currentIndexChanged(int index); + void on_aaModeCombo_currentIndexChanged(int index); + void on_msaaCombo_currentIndexChanged(int index); + void on_textureFilterCombo_currentIndexChanged(int index); + void on_anisotropyCombo_currentIndexChanged(int index); + void on_gammaSpin_valueChanged(double value); void on_dataMenuStyleCombo_currentIndexChanged(int index); void on_showSexpHelpMissionEvents_toggled(bool checked); void on_showSexpHelpMissionGoals_toggled(bool checked); @@ -63,6 +71,8 @@ private slots: std::unique_ptr ui; std::unique_ptr _model; + //! Anisotropy levels backing the combo, in combo order. Queried once; hardware-dependent. + SCP_vector _anisotropyLevels; std::map _controlEditors; FredView* _fredView = nullptr; EditorViewport* _viewport = nullptr; diff --git a/qtfred/ui/BackgroundEditor.ui b/qtfred/ui/BackgroundEditor.ui index 779d8ab0311..df4aba447e5 100644 --- a/qtfred/ui/BackgroundEditor.ui +++ b/qtfred/ui/BackgroundEditor.ui @@ -1103,6 +1103,23 @@ + + + + Camera Lens + + + + + + + + + + Lens Aperture... + + + @@ -1188,6 +1205,8 @@ envMapButton envMapEdit lightingProfileCombo + cameraLensCombo + lensApertureButton oldNebulaPatternCombo oldNebulaColorCombo oldNebulaPitchSpinBox diff --git a/qtfred/ui/FredView.ui b/qtfred/ui/FredView.ui index 7d8cdc3e035..9cb356264bc 100644 --- a/qtfred/ui/FredView.ui +++ b/qtfred/ui/FredView.ui @@ -120,6 +120,8 @@ + + @@ -858,6 +860,17 @@ Render Full Detail + + + true + + + Enable Post Processing + + + Render the 3D viewport through the game's HDR post-processing pipeline (bloom, tonemapping, lens flares) + + true diff --git a/qtfred/ui/LensApertureDialog.ui b/qtfred/ui/LensApertureDialog.ui new file mode 100644 index 00000000000..3db12ffcc88 --- /dev/null +++ b/qtfred/ui/LensApertureDialog.ui @@ -0,0 +1,87 @@ + + + fso::fred::dialogs::LensApertureDialog + + + + 0 + 0 + 460 + 640 + + + + Camera Lens + + + + + + No camera lens is mounted (Camera Lens is "Default" with no $Default Lens: in the tables, or "None"), so these edits have nothing to visibly change. Pick a lens above first. + + + true + + + color: #cc8800; + + + + + + + true + + + + + + + + + + + + + Reset to Lens Default + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Close + + + + + + + + + + + closeButton + clicked() + fso::fred::dialogs::LensApertureDialog + close() + + + diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index c45e1a9ecb4..e06b97af410 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -360,6 +360,159 @@ + + + Graphics + + + + + + Route the 3D viewport through the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead of drawing straight to the screen. The other settings on this tab only have a visible effect while this is on. + + + Enable post-processing in the viewport + + + + + + + Shadows && Anti-Aliasing + + + + + + Shadow quality: + + + + + + + Quality of the shadows cast by the mission's sun in the viewport. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. + + + + + + + Anti-aliasing: + + + + + + + Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. + + + + + + + MSAA: + + + + + + + Multisample anti-aliasing used for the viewport's 3D scene. Requires post-processing to be enabled. Restarting qtFRED is required for a change to take effect. + + + + + + + + + + Textures + + + + + + Texture filtering: + + + + + + + Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. + + + + + + + Anisotropic filtering: + + + + + + + Anisotropic texture filtering level. Restarting qtFRED is required for a change to take effect. + + + + + + + + + + Brightness + + + + + + Gamma: + + + + + + + Brightness of the viewport. qtFRED defaults this higher than the game since the viewport isn't normally tonemapped. + + + 2 + + + 0.10 + + + 5.00 + + + 0.05 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + Grid diff --git a/test/src/graphics/test_lens_flare.cpp b/test/src/graphics/test_lens_flare.cpp new file mode 100644 index 00000000000..da2514e0065 --- /dev/null +++ b/test/src/graphics/test_lens_flare.cpp @@ -0,0 +1,1130 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace graphics; + +namespace { + +// Thick biconvex singlet (R1=100, R2=-100, n=1.5, d=2) with the stop embedded +// mid-glass so no extra air gaps skew the analytic reference values. +lens_system make_singlet() +{ + lens_system ls; + ls.name = "test_singlet"; + ls.coating_wavelength = 0.0f; // uncoated + + lens_surface front; + front.radius = 100.0f; + front.thickness = 1.0f; + front.n = 1.5f; + ls.surfaces.push_back(front); + + lens_surface stop; + stop.thickness = 1.0f; + stop.is_stop = true; + ls.surfaces.push_back(stop); + + lens_surface back; + back.radius = -100.0f; + back.thickness = 10.0f; // ignored; sensor distance is computed + back.n = 1.0f; + ls.surfaces.push_back(back); + + return ls; +} + +} // namespace + +TEST(LensFlare, FresnelUncoated) +{ + // Air -> glass at normal incidence: R = ((n1-n2)/(n1+n2))^2 = 0.04 + EXPECT_NEAR(lens_flare_fresnel_reflectance(1.0f, 1.5f, 0.0f, 550.0f), 0.04f, 1e-5f); + // Symmetric in the direction of travel + EXPECT_NEAR(lens_flare_fresnel_reflectance(1.5f, 1.0f, 0.0f, 550.0f), 0.04f, 1e-5f); +} + +TEST(LensFlare, FresnelIdealQuarterWaveCoating) +{ + // With nc = sqrt(n1*n2) exactly (here 1.38 = sqrt(1.9044)) a quarter-wave + // coating cancels reflection completely at its design wavelength + float r = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 550.0f, 550.0f); + EXPECT_NEAR(r, 0.0f, 1e-6f); + + // Away from the design wavelength some reflection returns, but still far + // below the uncoated value + float uncoated = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 0.0f, 400.0f); + float coated = lens_flare_fresnel_reflectance(1.0f, 1.9044f, 550.0f, 400.0f); + EXPECT_GT(coated, 0.0f); + EXPECT_LT(coated, uncoated); +} + +TEST(LensFlare, FftDeltaAndRoundtrip) +{ + const int size = 16; + + // FFT of a delta at the origin is a constant + SCP_vector> data(size * size, {0.0f, 0.0f}); + data[0] = {1.0f, 0.0f}; + lens_flare_fft2d(data, size, false); + for (const auto& v : data) { + EXPECT_NEAR(v.real(), 1.0f, 1e-4f); + EXPECT_NEAR(v.imag(), 0.0f, 1e-4f); + } + + // Forward + inverse returns the input + SCP_vector> orig(size * size); + for (int i = 0; i < size * size; i++) { + orig[i] = {sinf(i * 0.37f), cosf(i * 0.11f)}; + } + SCP_vector> work = orig; + lens_flare_fft2d(work, size, false); + lens_flare_fft2d(work, size, true); + for (int i = 0; i < size * size; i++) { + EXPECT_NEAR(work[i].real(), orig[i].real(), 1e-3f); + EXPECT_NEAR(work[i].imag(), orig[i].imag(), 1e-3f); + } +} + +TEST(LensFlare, SingletFocalLengthAndGhostCount) +{ + lens_system ls = make_singlet(); + ASSERT_TRUE(lens_flare_precompute(ls)); + + // Thick-lens references: 1/f = (n-1)*(1/R1 - 1/R2 + (n-1)*d/(n*R1*R2)), + // BFD = f*(1 - (n-1)*d/(n*R1)) + EXPECT_NEAR(ls.efl, 100.3344f, 0.01f); + EXPECT_NEAR(ls.bfd, 99.6656f, 0.01f); + + // Two refractive surfaces (the stop doesn't reflect) -> exactly one + // two-reflection ghost + ASSERT_EQ(ls.ghosts.size(), 1u); + EXPECT_EQ(ls.ghosts[0].surf_first, 2); + EXPECT_EQ(ls.ghosts[0].surf_second, 0); + + // Uncoated air/glass reflections: R = 0.04 each, product 1.6e-3 + EXPECT_NEAR(ls.ghosts[0].reflectance[1], 0.04f * 0.04f, 1e-5f); +} + +TEST(LensFlare, GhostMatricesAreUnimodular) +{ + // Ghost paths start and end in air, so det(Ms * Ma) == 1 must hold for + // every ghost and wavelength (ray-transfer matrix invariant) + lens_system ls = make_singlet(); + + // Add a cemented doublet behind the stop for more surfaces/ghost paths; + // the exit of the doublet flows into the original back surface (1.58 -> 1.0) + // so all four glass interfaces stay refractive + lens_surface extra1; + extra1.radius = 50.0f; + extra1.thickness = 3.0f; + extra1.n = 1.62f; + extra1.abbe = 36.0f; + lens_surface extra2; + extra2.radius = -75.0f; + extra2.thickness = 10.0f; + extra2.n = 1.58f; + ls.surfaces.insert(ls.surfaces.end() - 1, extra1); + ls.surfaces.insert(ls.surfaces.end() - 1, extra2); + + ASSERT_TRUE(lens_flare_precompute(ls)); + + // Four refractive surfaces -> C(4,2) = 6 ghost paths, but the pairing of + // the two faint cement interfaces (R ~ 1e-4 * 1e-3) falls below the + // reflectance culling floor, leaving 5 + EXPECT_EQ(ls.ghosts.size(), 5u); + + for (const auto& ghost : ls.ghosts) { + for (int wl = 0; wl < 3; wl++) { + const float* ma = ghost.ma[wl]; + const float* ms = ghost.ms[wl]; + float a = ms[0] * ma[0] + ms[1] * ma[2]; + float b = ms[0] * ma[1] + ms[1] * ma[3]; + float c = ms[2] * ma[0] + ms[3] * ma[2]; + float d = ms[2] * ma[1] + ms[3] * ma[3]; + EXPECT_NEAR(a * d - b * c, 1.0f, 1e-3f); + } + } +} + +class LensFlareTableTest : public test::FSTestFixture { + public: + LensFlareTableTest() : test::FSTestFixture(INIT_CFILE) {} + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +// Guards the shipped prescriptions in def_files/data/tables/lens_flares.tbl: a +// lens whose surface stack doesn't image onto a sensor is dropped at load with +// only a warning, so a bad transcription would otherwise go unnoticed. +TEST_F(LensFlareTableTest, ShippedLensesParseAndPrecompute) +{ + // Named individually because an unusable prescription is only warned about + // and then skipped -- a lens that stopped loading would still leave a + // well-formed (just smaller) table behind + static const char* const shipped[] = {"angenieux_100mm", "tessar_50mm", "canon_70_200mm", "kodak_100mm", + "leica_35mm", "nikon_50_135mm", "color_heliar_105mm", "zeiss_master_prime_50mm"}; + for (const char* name : shipped) { + EXPECT_GE(lens_flare_lookup(name), 0) << "lens '" << name << "' is missing from lens_flares.tbl"; + } + + const int count = lens_flare_num_systems(); + ASSERT_GE(count, static_cast(std::size(shipped))); + + for (int i = 0; i < count; i++) { + const lens_system* ls = lens_flare_get_system(i); + ASSERT_NE(ls, nullptr); + SCOPED_TRACE(ls->name); + + // precompute() already rejects these, but a rejected lens is simply + // absent, so assert on what did load + EXPECT_GT(ls->efl, 0.0f); + EXPECT_GT(ls->bfd, 0.0f); + EXPECT_FALSE(ls->ghosts.empty()); + // Every ghost the uniform block can hold is enumerated; $Max Ghosts: is + // applied when the quads are packed, so that it can be overridden without + // re-running this precompute. + EXPECT_LE(static_cast(ls->ghosts.size()), MAX_LENS_FLARE_GHOSTS); + EXPECT_GT(ls->max_ghosts, 0); + EXPECT_GT(ls->entrance_radius, 0.0f); + EXPECT_GT(ls->aperture_radius, 0.0f); + EXPECT_GT(ls->sensor_width, 0.0f); + // every shipped lens is spherical, so both anamorphic paths must be + // exactly off -- this is what keeps existing content identical + EXPECT_FLOAT_EQ(ls->anamorphic.squeeze, 1.0f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.strength, 0.0f); + + for (const auto& ghost : ls->ghosts) { + for (int wl = 0; wl < 3; wl++) { + const float* ma = ghost.ma[wl]; + const float* ms = ghost.ms[wl]; + for (int k = 0; k < 4; k++) { + ASSERT_TRUE(std::isfinite(ma[k])); + ASSERT_TRUE(std::isfinite(ms[k])); + } + // ghost paths start and end in air (see GhostMatricesAreUnimodular) + float a = ms[0] * ma[0] + ms[1] * ma[2]; + float b = ms[0] * ma[1] + ms[1] * ma[3]; + float c = ms[2] * ma[0] + ms[3] * ma[2]; + float d = ms[2] * ma[1] + ms[3] * ma[3]; + EXPECT_NEAR(a * d - b * c, 1.0f, 1e-2f); + EXPECT_GT(ghost.reflectance[wl], 0.0f); + EXPECT_LT(ghost.reflectance[wl], 1.0f); + } + } + } +} + +// The iris shape is the one definition behind both the ghosts and the +// starburst, so its geometry is worth pinning down: the polygon's corners sit +// on the iris radius, its blade midpoints pull in to the apothem, and curvature +// interpolates the midpoints out to a circle. (Mask only -- no FFT needed.) +TEST(LensFlareAperture, ShapeGeometry) +{ + lens_flare_textures tex; + + // probe the mask along a ray and report where transmission crosses 0.5 + auto boundary = [&tex](float angle_deg) { + const int size = tex.aperture_size; + float dx = cosf(fl_radians(angle_deg)); + float dy = sinf(fl_radians(angle_deg)); + float prev = 1.0f; + for (int i = 1; i < 2000; i++) { + float r = i / 2000.0f * 1.4f; + int x = static_cast((r * dx + 1.0f) * 0.5f * size); + int y = static_cast((r * dy + 1.0f) * 0.5f * size); + if (x < 0 || x >= size || y < 0 || y >= size) { + return r; + } + float v = tex.aperture[static_cast(y) * size + x] / 255.0f; + if (prev >= 0.5f && v < 0.5f) { + return r; + } + prev = v; + } + return -1.0f; + }; + + const float iris = 0.9f; + const float apothem = iris * cosf(PI / 6.0f); + + lens_aperture ap; + ap.blades = 6; + ap.rotation = 0.0f; + ap.curvature = 0.0f; + + // straight blades: corners on the +x axis (and every 60 deg from it), + // midpoints pulled in to the apothem + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(0.0f), iris, 0.01f); + EXPECT_NEAR(boundary(60.0f), iris, 0.01f); + EXPECT_NEAR(boundary(30.0f), apothem, 0.01f); + + // full curvature lifts the midpoints to the corner radius (a circle) + ap.curvature = 1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); + + // negative curvature bows them inward instead, past the straight-blade case + ap.curvature = -1.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_LT(boundary(30.0f), apothem - 0.05f); + + // rotation carries the corners with it + ap.curvature = 0.0f; + ap.rotation = 30.0f; + lens_flare_generate_aperture_mask(ap, &tex); + EXPECT_NEAR(boundary(30.0f), iris, 0.01f); +} + +// Every imperfection layer must actually occlude, and must leave the mask +// usable rather than blacking it out. +TEST(LensFlareAperture, ImperfectionLayers) +{ + lens_flare_textures tex; + auto transmission = [&tex](const lens_aperture& ap) { + lens_flare_generate_aperture_mask(ap, &tex); + double sum = 0.0; + for (auto v : tex.aperture) { + sum += v; + } + return sum / tex.aperture.size() / 255.0; + }; + + lens_aperture ap; // defaults: every layer off + const double base = transmission(ap); + EXPECT_GT(base, 0.1); + EXPECT_LT(base, 1.0); + + ap.grating.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.grating.strength = 0.0f; + + ap.scratches.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.scratches.strength = 0.0f; + + ap.dust.strength = 1.0f; + EXPECT_LT(transmission(ap), base); + ap.dust.strength = 0.0f; + + // strength 0 is exactly "off", whatever the other knobs say + ap.grating.density = 1.0f; + ap.scratches.density = 1.0f; + ap.dust.density = 1.0f; + EXPECT_NEAR(transmission(ap), base, 1e-9); +} + +// One aperture definition drives both outputs, so editing it has to rebuild the +// starburst as well as the ghost mask. +TEST_F(LensFlareTableTest, ApertureEditRebuildsStarburst) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + const lens_system* lens = lens_flare_get_system(idx); + ASSERT_NE(lens, nullptr); + + const auto* before = lens_flare_get_textures(idx); + ASSERT_NE(before, nullptr); + SCP_vector starburst_before = before->starburst; + ASSERT_FALSE(starburst_before.empty()); + + lens_aperture edited = lens->aperture; + edited.blades = 3; + edited.rotation = 40.0f; + lens_flare_overrides().aperture = edited; + lens_flare_overrides_changed(); + + const auto* after = lens_flare_get_textures(idx); + ASSERT_NE(after, nullptr); + ASSERT_EQ(after->starburst.size(), starburst_before.size()); + EXPECT_NE(after->starburst, starburst_before) << "starburst did not follow the aperture"; + + // and the lens itself was never touched, which is the whole point of an + // override: there is nothing to put back + EXPECT_NE(lens->aperture.blades, 3); +} + +// The backends cache their uploaded copy, so an edit has to change the +// generation counter or the new mask never reaches the GPU -- and, just as +// importantly, an edit that changes nothing must *not* bump it, or every slider +// tick would re-upload. +TEST_F(LensFlareTableTest, TextureInvalidationBumpsGeneration) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + const lens_system* lens = lens_flare_get_system(idx); + ASSERT_NE(lens, nullptr); + + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int before = lens_flare_get_texture_generation(); + + lens_aperture edited = lens->aperture; + edited.blades = 3; + lens_flare_overrides().aperture = edited; + lens_flare_overrides_changed(); + EXPECT_NE(lens_flare_get_texture_generation(), before); + + // An override that resolves to the same iris is not an edit. This is what + // keeps a repeating mission event from rebuilding the mask forever. + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int settled = lens_flare_get_texture_generation(); + lens_flare_overrides().aperture = edited; + lens_flare_overrides_changed(); + EXPECT_EQ(lens_flare_get_texture_generation(), settled); +} + +// Same engine, but reached through the table parser and the *-lens.tbm modular +// path rather than by setting the struct directly. Every aperture field is +// wired up by hand, so only running a table through it proves each one lands in +// the member it names. +// Uses a table that declares a $Default Lens:, so that "take the default" and +// "no flares" are distinguishable -- see the tbm's own comment. +class LensFlareDefaultLensTest : public test::FSTestFixture { + public: + LensFlareDefaultLensTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("default_lens"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +// lens_flare_switch_to() is the one place that resolves a camera-lens name, for +// the mission field, the set-camera-lens sexp and both editors alike. The two +// cases that matter are the ones a declared default separates: an empty name is +// "this mission says nothing" and must take the default, while LENS_NAME_NONE is +// "no flares" and must not. Collapsing those two is what silently overrode a +// mission that had deliberately asked for no flares. +TEST_F(LensFlareDefaultLensTest, NameVocabularyDistinguishesDefaultFromNone) +{ + const int declared = lens_flare_lookup("default_test_lens"); + ASSERT_GE(declared, 0) << "the modular *-lens.tbm was not picked up at all"; + ASSERT_STREQ(lens_flare_default_name(), "default_test_lens"); + + // no opinion -> the declared default + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), declared); + lens_flare_switch_to(nullptr); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // ...and the default asked for by name is the same thing + lens_flare_switch_to(LENS_NAME_DEFAULT); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // is the one answer that survives a declared default + lens_flare_switch_to(LENS_NAME_NONE); + EXPECT_EQ(lens_flare_active_lens(), -1) << " must not fall back to $Default Lens:"; + + // the sentinels are case-insensitive, like every other name here + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), -1); + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), declared); + + // (An unknown name also falls back to the default, but it warns on the way and + // the test harness turns Warning() into a thrown exception, so that path can't + // be exercised from here.) + + // leaving the mission re-arms the default, not "no lens" + lens_flare_switch_to(LENS_NAME_NONE); + ASSERT_EQ(lens_flare_active_lens(), -1); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_active_lens(), declared); +} + +// stars.tbl decides *whether* a sun flares; the camera lens only decides *how*. +// Parses a table covering both ways a sun can say so, and the case where they +// disagree. Only parses -- stars_init() would also load bitmaps this test has +// none of. +class SunCameraLensFlareTest : public test::FSTestFixture { + public: + SunCameraLensFlareTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("sun_flare_opt_in"); + } + + // The tabled answer, by sun name. stars_find_sun() gives the bitmap index the + // option was parsed into, which is knowable without placing an instance. + static bool sun_flares(const char* name) + { + const int idx = stars_find_sun(name); + EXPECT_GE(idx, 0) << "sun '" << name << "' did not parse out of the test stars.tbl"; + return stars_sun_bitmap_has_camera_lens_flare(idx); + } +}; + +TEST_F(SunCameraLensFlareTest, CameraLensFlareOptInOverridesTheFlareFallback) +{ + parse_startbl("stars.tbl"); + + // A sun that never asked to flare gets nothing from the camera lens. This is + // the whole point: mounting a lens must not invent flares on existing content. + EXPECT_FALSE(sun_flares("SunNeither")); + + // A legacy sprite $Flare: block still stands in for the opt-in, so tables + // written before "+Camera Lens Flare:" existed keep working unchanged + EXPECT_TRUE(sun_flares("SunLegacyFlareOnly")); + + // ...and the new option opts in on its own, with no sprite flare fields, which + // is the reason it exists + EXPECT_TRUE(sun_flares("SunLensOnly")); + + // Where the two disagree the explicit option wins -- otherwise a sun could not + // keep its sprite flare while sitting out the physically-based one + EXPECT_FALSE(sun_flares("SunFlareButNoLens")); + + // The option is parsed between the $Flare: block and $NoGlare:; if that + // ordering were wrong the option would silently never match, so check a sun + // that uses it alongside the option that follows it + EXPECT_TRUE(sun_flares("SunLensAndNoGlare")); +} + +class LensFlareApertureTbmTest : public test::FSTestFixture { + public: + LensFlareApertureTbmTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("aperture_fields"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(LensFlareApertureTbmTest, ParsesEveryApertureField) +{ + const int idx = lens_flare_lookup("aperture_test_lens"); + ASSERT_GE(idx, 0) << "the modular *-lens.tbm was not picked up at all"; + + const lens_system* ls = lens_flare_get_system(idx); + ASSERT_NE(ls, nullptr); + const lens_aperture& ap = ls->aperture; + + EXPECT_EQ(ap.blades, 11); + EXPECT_FLOAT_EQ(ap.rotation, 21.0f); + EXPECT_FLOAT_EQ(ap.curvature, 0.31f); + EXPECT_FLOAT_EQ(ap.softness, 0.041f); + + EXPECT_FLOAT_EQ(ap.grating.strength, 0.51f); + EXPECT_FLOAT_EQ(ap.grating.density, 0.52f); + EXPECT_FLOAT_EQ(ap.grating.length, 0.53f); + EXPECT_FLOAT_EQ(ap.grating.width, 0.54f); + EXPECT_FLOAT_EQ(ap.grating.softness, 0.055f); + + EXPECT_FLOAT_EQ(ap.scratches.strength, 0.61f); + EXPECT_FLOAT_EQ(ap.scratches.density, 0.62f); + EXPECT_FLOAT_EQ(ap.scratches.length, 0.63f); + EXPECT_FLOAT_EQ(ap.scratches.width, 0.64f); + EXPECT_FLOAT_EQ(ap.scratches.rotation, 65.0f); + EXPECT_FLOAT_EQ(ap.scratches.rotation_variation, 0.66f); + EXPECT_FLOAT_EQ(ap.scratches.softness, 0.067f); + + EXPECT_FLOAT_EQ(ap.dust.strength, 0.71f); + EXPECT_FLOAT_EQ(ap.dust.density, 0.72f); + EXPECT_FLOAT_EQ(ap.dust.radius, 0.73f); + EXPECT_FLOAT_EQ(ap.dust.softness, 0.074f); + + // the fields that follow the aperture block must still be reachable -- a + // mis-ordered optional_string would swallow the rest of the entry + EXPECT_FALSE(ls->starburst); + EXPECT_FLOAT_EQ(ls->intensity, 0.25f); + EXPECT_EQ(ls->max_ghosts, 7); + EXPECT_FLOAT_EQ(ls->entrance_radius, 20.0f); + EXPECT_FLOAT_EQ(ls->aperture_radius, 14.0f); + EXPECT_FLOAT_EQ(ls->anamorphic.squeeze, 1.75f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.strength, 0.81f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.length, 0.82f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.thickness, 0.083f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[0], 0.84f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[1], 0.85f); + EXPECT_FLOAT_EQ(ls->anamorphic.streak.tint[2], 0.86f); + + // and the tbm must not have disturbed the built-in table + EXPECT_GE(lens_flare_lookup("angenieux_100mm"), 0); +} + +// Overriding is what a mission, the lab and the set-lens-* sexps all do, and +// dropping those overrides is the whole of "one mission's camera cannot carry +// into the next". The lens itself is never written to, so there is nothing to +// restore and nothing that can be left half-restored. +TEST_F(LensFlareTableTest, ResetForLevelDropsOverrides) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + const lens_system* lens = lens_flare_get_system(idx); + ASSERT_NE(lens, nullptr); + lens_flare_switch_to("angenieux_100mm"); + ASSERT_EQ(lens_flare_active_lens(), idx); + + const lens_aperture tabled = lens->aperture; + ASSERT_EQ(lens_flare_effective_settings(idx).aperture, tabled) + << "with nothing overridden the camera must be exactly what the table declared"; + + // make sure the textures exist, so the reset has something to drop + ASSERT_NE(lens_flare_get_textures(idx), nullptr); + const unsigned int generation = lens_flare_get_texture_generation(); + + // what a mission's sexps would do + lens_aperture edited = tabled; + edited.blades = 3; + edited.dust.strength = 0.8f; + edited.scratches.strength = 0.4f; + ASSERT_NE(edited, tabled); + lens_flare_overrides().aperture = edited; + lens_flare_overrides_changed(); + + EXPECT_EQ(lens_flare_effective_settings(idx).aperture, edited); + EXPECT_EQ(lens->aperture, tabled) << "an override must never write through to the lens"; + + lens_flare_reset_for_level(); + + EXPECT_FALSE(lens_flare_overrides().any()); + EXPECT_EQ(lens_flare_effective_settings(idx).aperture, tabled); + EXPECT_NE(lens_flare_get_texture_generation(), generation) << "backends would keep the edited textures"; +} + +// Every knob resolves the same way -- the mounted lens's tabled value unless +// overridden -- so one test covers the whole set rather than one per field. What +// it actually guards is that lens_flare_effective_settings() reads each override +// into the member it names: a copy-paste slip there would silently make a knob +// unoverridable. +TEST_F(LensFlareTableTest, EveryOverrideReachesTheEffectiveSettings) +{ + const int idx = lens_flare_lookup("tessar_50mm"); + ASSERT_GE(idx, 0); + lens_flare_switch_to("tessar_50mm"); + ASSERT_EQ(lens_flare_active_lens(), idx); + + const lens_system* lens = lens_flare_get_system(idx); + ASSERT_NE(lens, nullptr); + const lens_settings tabled = lens_flare_effective_settings(idx); + + lens_overrides& ov = lens_flare_overrides(); + lens_aperture ap = tabled.aperture; + ap.blades = 3; + ap.rotation = 25.0f; + ap.dust.strength = 0.6f; + ov.aperture = ap; + + lens_anamorphic an; + an.squeeze = 2.5f; + an.streak.strength = 0.8f; + an.streak.length = 3.0f; + ov.anamorphic = an; + + ov.intensity = tabled.intensity + 1.0f; + ov.starburst = !tabled.starburst; + ov.starburst_scale = tabled.starburst_scale + 1.5f; + ov.max_ghosts = 5; + ov.ghost_brightness = 123.0f; + ov.starburst_brightness = 4.5f; + lens_flare_overrides_changed(); + + const lens_settings eff = lens_flare_effective_settings(idx); + EXPECT_EQ(eff.aperture, ap); + EXPECT_EQ(eff.anamorphic, an); + EXPECT_FLOAT_EQ(eff.intensity, tabled.intensity + 1.0f); + EXPECT_EQ(eff.starburst, !tabled.starburst); + EXPECT_FLOAT_EQ(eff.starburst_scale, tabled.starburst_scale + 1.5f); + EXPECT_EQ(eff.max_ghosts, 5); + EXPECT_FLOAT_EQ(eff.ghost_brightness, 123.0f); + EXPECT_FLOAT_EQ(eff.starburst_brightness, 4.5f); + + // None of it reached the lens, so all of it goes away at once + EXPECT_EQ(lens->aperture, tabled.aperture); + EXPECT_EQ(lens->anamorphic, tabled.anamorphic); + + lens_flare_reset_for_level(); + + const lens_settings after = lens_flare_effective_settings(idx); + EXPECT_EQ(after.aperture, tabled.aperture); + EXPECT_EQ(after.anamorphic, tabled.anamorphic); + EXPECT_FLOAT_EQ(after.intensity, tabled.intensity); + EXPECT_EQ(after.starburst, tabled.starburst); + EXPECT_FLOAT_EQ(after.starburst_scale, tabled.starburst_scale); + EXPECT_EQ(after.max_ghosts, tabled.max_ghosts); + EXPECT_FLOAT_EQ(after.ghost_brightness, tabled.ghost_brightness); + EXPECT_FLOAT_EQ(after.starburst_brightness, tabled.starburst_brightness); +} + +// An unmounted camera resolves to the plain defaults rather than to whatever +// lens happened to be looked up last -- the case a mission with "" hits, +// and the one where an out-of-range index would otherwise index the vector. +TEST_F(LensFlareTableTest, EffectiveSettingsWithoutALensAreTheDefaults) +{ + const lens_settings none = lens_flare_effective_settings(-1); + const lens_settings expected; + + EXPECT_EQ(none.aperture, expected.aperture); + EXPECT_EQ(none.anamorphic, expected.anamorphic); + EXPECT_FLOAT_EQ(none.intensity, expected.intensity); + EXPECT_EQ(none.max_ghosts, expected.max_ghosts); + EXPECT_FLOAT_EQ(none.ghost_brightness, expected.ghost_brightness); + + EXPECT_EQ(lens_flare_effective_settings(lens_flare_num_systems() + 5).max_ghosts, expected.max_ghosts); +} + +// The sexps apply their arguments to a copy and only rebuild when the result +// differs, so that an unguarded repeating mission event doesn't regenerate a +// 512^2 mask and its FFT every frame. That guard is only as good as this +// comparison. +TEST(LensFlareAperture, EqualityCoversEveryField) +{ + lens_aperture a; + EXPECT_EQ(a, lens_aperture()); + + // every field, including the ones nested in the imperfection layers + SCP_vector> mutators = { + [](lens_aperture& x) { x.blades += 1; }, + [](lens_aperture& x) { x.rotation += 1.0f; }, + [](lens_aperture& x) { x.curvature += 0.5f; }, + [](lens_aperture& x) { x.softness += 0.5f; }, + [](lens_aperture& x) { x.grating.strength += 0.5f; }, + [](lens_aperture& x) { x.grating.density += 0.25f; }, + [](lens_aperture& x) { x.grating.length += 0.25f; }, + [](lens_aperture& x) { x.grating.width += 0.25f; }, + [](lens_aperture& x) { x.grating.softness += 0.25f; }, + [](lens_aperture& x) { x.scratches.strength += 0.5f; }, + [](lens_aperture& x) { x.scratches.density += 0.25f; }, + [](lens_aperture& x) { x.scratches.length += 0.25f; }, + [](lens_aperture& x) { x.scratches.width += 0.25f; }, + [](lens_aperture& x) { x.scratches.rotation += 1.0f; }, + [](lens_aperture& x) { x.scratches.rotation_variation += 0.25f; }, + [](lens_aperture& x) { x.scratches.softness += 0.25f; }, + [](lens_aperture& x) { x.dust.strength += 0.5f; }, + [](lens_aperture& x) { x.dust.density += 0.25f; }, + [](lens_aperture& x) { x.dust.radius += 0.25f; }, + [](lens_aperture& x) { x.dust.softness += 0.25f; }, + }; + + for (size_t i = 0; i < mutators.size(); i++) { + lens_aperture changed; + mutators[i](changed); + SCOPED_TRACE("field index " + std::to_string(i)); + EXPECT_NE(changed, a) << "a changed field is not covered by operator=="; + EXPECT_FALSE(changed == a); + } +} + +// The mounted lens is the mission's, so it has to survive nothing but a level +// change: mounting is what parse_mission_info() does with "$Camera Lens:", and +// the reset before it is what stops the previous mission's camera from leaking. +TEST_F(LensFlareTableTest, MountingAndOverridingTheCameraLens) +{ + const int tessar = lens_flare_lookup("tessar_50mm"); + const int angenieux = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(tessar, 0); + ASSERT_GE(angenieux, 0); + + // The shipped table declares no default, so here "take the default" and "no + // flares" happen to coincide -- LensFlareDefaultLensTest pulls them apart with + // a table that does declare one. + EXPECT_STREQ(lens_flare_default_name(), ""); + lens_flare_switch_to(""); + EXPECT_EQ(lens_flare_active_lens(), -1); + lens_flare_switch_to(LENS_NAME_NONE); + EXPECT_EQ(lens_flare_active_lens(), -1); + + lens_flare_switch_to("tessar_50mm"); + EXPECT_EQ(lens_flare_active_lens(), tessar); + EXPECT_STREQ(lens_flare_mission_lens_name(), "tessar_50mm"); + + // the lab's override wins while it is set, and reveals the mission's lens + // again once cleared -- that is what its "Mission default (...)" entry means + lens_flare_set_lab_lens(angenieux); + EXPECT_EQ(lens_flare_active_lens(), angenieux); + EXPECT_STREQ(lens_flare_mission_lens_name(), "tessar_50mm"); + lens_flare_set_lab_lens(-1); + EXPECT_EQ(lens_flare_active_lens(), -1) << "a -1 override means 'no flares', not 'no override'"; + lens_flare_clear_lab_lens(); + EXPECT_EQ(lens_flare_active_lens(), tessar); + + // leaving the mission unmounts the lens and drops the lab override + lens_flare_set_lab_lens(angenieux); + lens_flare_reset_for_level(); + EXPECT_EQ(lens_flare_active_lens(), -1); + EXPECT_STREQ(lens_flare_mission_lens_name(), ""); +} + +// ---- thruster flares ---- + +namespace { + +// One nozzle, in model space. `norm` is the direction it fires (and so shines) +// in; a zero normal is legal and means "every way". +glow_point make_nozzle(vec3d pnt, vec3d norm, float radius) +{ + glow_point gpt; + gpt.pnt = pnt; + gpt.norm = norm; + gpt.radius = radius; + return gpt; +} + +// The scene every test below shares: a ship at the origin, unrotated, seen from +// far down -z -- so the eye looks along +z at the back of the ship, and a nozzle +// whose normal is (0, 0, -1) faces the camera squarely. +const matrix Unrotated = vmd_identity_matrix; +const vec3d Ship_pos = vmd_zero_vector; +const vec3d Eye = {{{0.0f, 0.0f, -100.0f}}}; + +// The apparent size of one nozzle in the shared scene, or -1 when it produces no +// source at all. +float nozzle_apparent(const glow_point& gpt, vec3d* world_pnt = nullptr) +{ + vec3d scratch; + float apparent = 0.0f; + if (!lens_flare_nozzle_apparent(gpt, Unrotated, Ship_pos, Eye, world_pnt ? world_pnt : &scratch, &apparent)) { + return -1.0f; + } + return apparent; +} + +const vec3d Toward_eye = {{{0.0f, 0.0f, -1.0f}}}; +const vec3d Away = {{{0.0f, 0.0f, 1.0f}}}; + +} // namespace + +// A nozzle pointed away from the camera produces no source, which is what keeps +// the far side of every ship out of the pass. +TEST(LensFlareThrusters, FacingAwayProducesNoSource) +{ + EXPECT_LT(nozzle_apparent(make_nozzle(vmd_zero_vector, Away, 1.0f)), 0.0f); + EXPECT_GT(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f)), 0.0f); +} + +// Apparent size is the solid angle the nozzle subtends: it grows with the square +// of its radius and falls with the square of its distance. The distance term is +// what keeps a battle's worth of distant engines from each drawing at full +// strength, so it is worth pinning exactly. +TEST(LensFlareThrusters, ApparentSizeIsSolidAngle) +{ + // squarely facing, 100 units away, radius 1 -> pi * 1^2 / 100^2 + EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f)), PI / 10000.0f, 1e-9f); + + // twice the radius is four times the apparent size + EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 2.0f)), 4.0f * PI / 10000.0f, 1e-9f); + + // half the distance is four times the apparent size + const vec3d halfway = {{{0.0f, 0.0f, -50.0f}}}; + EXPECT_NEAR(nozzle_apparent(make_nozzle(halfway, Toward_eye, 1.0f)), PI / 2500.0f, 1e-9f); +} + +// Each nozzle images where it actually is. A ship's engines are set far enough +// apart to read as separate points, which is the whole reason they are no longer +// averaged into one source at their centroid. +TEST(LensFlareThrusters, EachNozzleImagesAtItsOwnPosition) +{ + const vec3d port = {{{-40.0f, 0.0f, 0.0f}}}; + const vec3d starboard = {{{40.0f, 0.0f, 0.0f}}}; + + vec3d where; + nozzle_apparent(make_nozzle(port, Toward_eye, 1.0f), &where); + EXPECT_NEAR(where.xyz.x, -40.0f, 1e-3f); + nozzle_apparent(make_nozzle(starboard, Toward_eye, 1.0f), &where); + EXPECT_NEAR(where.xyz.x, 40.0f, 1e-3f); +} + +// The flare fades in over the same first third of the hemisphere the thruster +// glow itself does (modelrender.cpp's `d *= 3`), so a flare can never appear on +// an engine whose glow is still invisible. +TEST(LensFlareThrusters, FacingFalloffMatchesTheGlow) +{ + const float square = nozzle_apparent(make_nozzle(vmd_zero_vector, Toward_eye, 1.0f)); + + // a normal tipped so that its cosine against the view is exactly 0.2 + const float cos_view = 0.2f; + const vec3d tipped = {{{sqrtf(1.0f - (cos_view * cos_view)), 0.0f, -cos_view}}}; + EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, tipped, 1.0f)), 0.6f * square, 1e-6f); + + // and anything past a third of the hemisphere is simply full brightness + const float past_third = 0.5f; + const vec3d wide = {{{sqrtf(1.0f - (past_third * past_third)), 0.0f, -past_third}}}; + EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, wide, 1.0f)), square, 1e-6f); +} + +// Glowpoints are allowed to carry a zero normal, and the thruster renderer reads +// that as "shines every way". The flare has to agree, or such a nozzle would +// silently never flare. +TEST(LensFlareThrusters, NullNormalShinesEveryWay) +{ + EXPECT_NEAR(nozzle_apparent(make_nozzle(vmd_zero_vector, vmd_zero_vector, 1.0f)), PI / 10000.0f, 1e-9f); +} + +// lens_flare_point_visible() is the occlusion gate both the thruster and beam +// gathers run against a source's position before ever queuing it for a draw. +// A real occluding hit needs a scene with real ship geometry in it, which this +// test suite has no scaffolding for (the AI line-of-sight test it is built on, +// test_line_of_sight(), has none either) -- but the degenerate "eye sitting +// exactly on the source" case is pure vector math and worth pinning: a source +// coincident with the eye has no direction to test along, and must not be +// mistaken for occluded. +TEST(LensFlareVisibility, EyeExactlyOnSourceIsVisible) +{ + const vec3d saved_eye = Eye_position; + Eye_position = vmd_zero_vector; + + EXPECT_TRUE(graphics::lens_flare_point_visible(vmd_zero_vector)); + + Eye_position = saved_eye; +} + +// The lab overrides every species at once and leaves the tabled values alone, so +// switching it off restores them without anything having to be backed up -- and +// leaving a mission drops it, the same way a lab lens is dropped. +TEST(LensFlareThrusters, LabOverrideBeatsTheSpeciesTableAndIsDroppedOnLevelReset) +{ + const auto saved_species = Species_info; + Species_info.clear(); + + species_info tabled; + strcpy_s(tabled.species_name, "TestSpecies"); + tabled.thruster_flare.enabled = true; + tabled.thruster_flare.intensity = 3.0f; + Species_info.push_back(tabled); + + EXPECT_TRUE(lens_flare_thruster_settings(0).enabled); + EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 3.0f); + EXPECT_FALSE(lens_flare_thruster_settings(-1).enabled) << "an unknown species must not flare"; + + thruster_flare_info override_settings; + override_settings.enabled = true; + override_settings.intensity = 7.0f; + lens_flare_lab_thruster_flare() = override_settings; + + EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 7.0f); + EXPECT_FLOAT_EQ(Species_info[0].thruster_flare.intensity, 3.0f) << "the override must not write to the table"; + // it applies to every species, including ones with no table entry of their own + EXPECT_TRUE(lens_flare_thruster_settings(-1).enabled); + + lens_flare_reset_for_level(); + EXPECT_FLOAT_EQ(lens_flare_thruster_settings(0).intensity, 3.0f); + + Species_info = saved_species; +} + +// The species-table half of the feature. Parsed here rather than through +// species_init(), which would drag in iff_defs for the shipped table's +// "$Default IFF:"; every entry a mod actually writes is a +nocreate that needs +// none of that. +extern void parse_species_tbl(const char* filename); + +class ThrusterFlareTbmTest : public test::FSTestFixture { + public: + ThrusterFlareTbmTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("thruster_flare"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + + // Stand in for whatever table the mod underneath us defined + saved_species = Species_info; + Species_info.clear(); + + species_info existing; + strcpy_s(existing.species_name, "ThrusterFlareTestSpecies"); + Species_info.push_back(existing); + + species_info bare; + strcpy_s(bare.species_name, "ThrusterFlareDefaultsSpecies"); + Species_info.push_back(bare); + + parse_species_tbl("test-sdf.tbm"); + } + + void TearDown() override + { + Species_info = saved_species; + test::FSTestFixture::TearDown(); + } + + private: + SCP_vector saved_species; +}; + +// Guards the exact syntax a mod writes, "$Thruster Flare:" in a +nocreate entry +// with nothing else in it -- which only parses because every option ahead of it +// is optional under +nocreate. Reordering the parse would break real tables +// silently, since an option read out of order is skipped rather than diagnosed. +TEST_F(ThrusterFlareTbmTest, NocreateEntryAddsAFlareToAnExistingSpecies) +{ + const int idx = species_info_lookup("ThrusterFlareTestSpecies"); + ASSERT_GE(idx, 0); + + const auto& flare = Species_info[idx].thruster_flare; + EXPECT_TRUE(flare.enabled) << "the block was skipped entirely"; + EXPECT_FLOAT_EQ(flare.intensity, 6.0f); + EXPECT_FLOAT_EQ(flare.afterburner_intensity, 15.0f); + EXPECT_NEAR(flare.color.xyz.x, 170.0f / 255.0f, 1e-4f); + EXPECT_NEAR(flare.color.xyz.y, 205.0f / 255.0f, 1e-4f); + EXPECT_NEAR(flare.color.xyz.z, 255.0f / 255.0f, 1e-4f); +} + +// The block on its own is the opt-in; its fields are all optional and must fall +// back to the struct's defaults, not to zero. +TEST_F(ThrusterFlareTbmTest, EmptyBlockIsTheOptInAndKeepsTheDefaults) +{ + const int idx = species_info_lookup("ThrusterFlareDefaultsSpecies"); + ASSERT_GE(idx, 0); + + const auto& flare = Species_info[idx].thruster_flare; + const thruster_flare_info defaults; + EXPECT_TRUE(flare.enabled); + EXPECT_FLOAT_EQ(flare.intensity, defaults.intensity); + EXPECT_FLOAT_EQ(flare.afterburner_intensity, defaults.afterburner_intensity); +} + +// A table written for one mod has to be harmless in another: a +nocreate naming +// a species nothing defined is discarded rather than creating a stub. This is +// what lets one -sdf.tbm carry entries for several mods' species at once. +TEST_F(ThrusterFlareTbmTest, NocreateForAnUnknownSpeciesCreatesNothing) +{ + EXPECT_LT(species_info_lookup("NoSuchSpecies"), 0); + EXPECT_EQ(Species_info.size(), 2u); +} + +// ---- +override in a *-lens.tbm ---- + +class LensFlareOverrideTbmTest : public test::FSTestFixture { + public: + LensFlareOverrideTbmTest() : test::FSTestFixture(INIT_CFILE) + { + pushModDir("graphics"); + pushModDir("lens_flare"); + pushModDir("override"); + } + + void SetUp() override + { + test::FSTestFixture::SetUp(); + lens_flare_init(); + } + + void TearDown() override + { + lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +// The point of +override: restyle a shipped lens without transcribing its +// prescription. Everything the entry does not mention has to survive, which is +// the part a hand-written copy of lens_system would quietly get wrong every time +// a field was added. +TEST_F(LensFlareOverrideTbmTest, OverrideKeepsEverythingItDoesNotMention) +{ + const int idx = lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + const lens_system* ls = lens_flare_get_system(idx); + ASSERT_NE(ls, nullptr); + + // what the tbm set + EXPECT_FLOAT_EQ(ls->anamorphic.squeeze, 2.0f); + EXPECT_FLOAT_EQ(ls->intensity, 0.75f); + + // what it did not: these are the shipped table's values, and the twelve-surface + // prescription in particular must be intact + EXPECT_FLOAT_EQ(ls->entrance_radius, 22.0f); + EXPECT_FLOAT_EQ(ls->aperture_radius, 16.0f); + EXPECT_FLOAT_EQ(ls->sensor_width, 36.0f); + EXPECT_EQ(ls->aperture.blades, 6); + EXPECT_GT(ls->surfaces.size(), 8u) << "the override dropped the prescription it never touched"; + EXPECT_FALSE(ls->ghosts.empty()); + + // an override is still a tabled value, so it is what a mission's own overrides + // are laid over and what dropping them goes back to + EXPECT_EQ(lens_flare_effective_settings(idx).aperture, ls->aperture); +} + +// Opening a stack replaces the whole of it. A prescription is an ordered run +// whose focal length, ghost set and iris position all follow from the run as a +// whole, so a merged stack would be neither lens. +TEST_F(LensFlareOverrideTbmTest, OverridingTheStackReplacesItEntirely) +{ + const int idx = lens_flare_lookup("tessar_50mm"); + ASSERT_GE(idx, 0); + const lens_system* ls = lens_flare_get_system(idx); + ASSERT_NE(ls, nullptr); + + ASSERT_EQ(ls->surfaces.size(), 3u) << "the tbm's stack was merged into the shipped one instead of replacing it"; + EXPECT_FLOAT_EQ(ls->surfaces[0].radius, 60.0f); + EXPECT_FLOAT_EQ(ls->surfaces[0].abbe, 55.0f); + EXPECT_TRUE(ls->surfaces[1].is_stop); + EXPECT_FLOAT_EQ(ls->surfaces[2].radius, -60.0f); + + // the entry named no other option, so everything else is still the shipped + // lens -- and the new prescription was re-solved rather than left stale + EXPECT_FLOAT_EQ(ls->entrance_radius, 7.0f); + EXPECT_GT(ls->efl, 0.0f); + EXPECT_FALSE(ls->ghosts.empty()); +} + +// Without +override an entry is a complete definition, and replaces a lens of +// the same name outright -- the behaviour that predates +override, and the +// reason +override had to be opt-in rather than the default for a tbm. +TEST_F(LensFlareOverrideTbmTest, WithoutOverrideTheLensIsReplacedOutright) +{ + const int idx = lens_flare_lookup("kodak_100mm"); + ASSERT_GE(idx, 0); + const lens_system* ls = lens_flare_get_system(idx); + ASSERT_NE(ls, nullptr); + + EXPECT_EQ(ls->surfaces.size(), 3u); + EXPECT_FLOAT_EQ(ls->entrance_radius, 11.0f); + // the shipped kodak_100mm sets these; a replacement must be back on the + // struct's defaults rather than inheriting them + EXPECT_FLOAT_EQ(ls->sensor_width, 36.0f); + EXPECT_EQ(ls->aperture.blades, 6); +} diff --git a/test/src/parse/test_sexp_lens.cpp b/test/src/parse/test_sexp_lens.cpp new file mode 100644 index 00000000000..e7780477698 --- /dev/null +++ b/test/src/parse/test_sexp_lens.cpp @@ -0,0 +1,212 @@ +#include + +#include + +#include +#include + +#include + +// The six lens-flare operators are wired up by hand across five separate +// tables in sexp.cpp (the operator list, the argument-type switch, the category +// and subcategory switches, and the help text). Nothing makes those agree, and +// a desync is silent: an argument slot that returns the wrong OPF still parses, +// it just reads the designer's number into the wrong field. +namespace { + +struct lens_operator_expectation { + const char* name; + int op_const; + int min_args; + int max_args; + int first_arg_type; // OPF for argnum 0 +}; + +const lens_operator_expectation Lens_operators[] = { + // only set-camera-lens names a lens; the aperture operators restyle whatever + // lens the mission has mounted, so their first argument is already a value + {"set-camera-lens", OP_SET_CAMERA_LENS, 1, 1, OPF_LENS_SYSTEM}, + {"set-lens-aperture", OP_SET_LENS_APERTURE, 1, 4, OPF_POSITIVE}, + {"set-lens-grating", OP_SET_LENS_GRATING, 1, 5, OPF_POSITIVE}, + {"set-lens-scratches", OP_SET_LENS_SCRATCHES, 1, 7, OPF_POSITIVE}, + {"set-lens-dust", OP_SET_LENS_DUST, 1, 4, OPF_POSITIVE}, + {"set-lens-flare-strength", OP_SET_LENS_FLARE_STRENGTH, 1, 5, OPF_POSITIVE}, +}; + +const sexp_oper* find_lens_operator(const char* name) +{ + auto it = std::find_if(Operators.begin(), Operators.end(), [name](const sexp_oper& op) { + return op.text == name; + }); + return (it == Operators.end()) ? nullptr : &(*it); +} + +} // namespace + +TEST(SexpLens, OperatorsAreRegisteredWithExpectedArity) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const sexp_oper* op = find_lens_operator(expected.name); + ASSERT_NE(op, nullptr) << "operator missing from the Operators table"; + EXPECT_EQ(op->value, expected.op_const); + EXPECT_EQ(op->min, expected.min_args); + EXPECT_EQ(op->max, expected.max_args); + } +} + +TEST(SexpLens, EveryArgumentSlotHasAnArgumentType) +{ + // note that query_operator_argument_type() wants the operator's index in the + // Operators table, not its OP_ constant + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + const int op_index = find_operator_index(expected.op_const); + ASSERT_GE(op_index, 0); + + EXPECT_EQ(query_operator_argument_type(op_index, 0), expected.first_arg_type); + + // no slot may fall through to the switch default, which would hand the + // designer an argument the operator never reads + for (int argnum = 0; argnum < expected.max_args; argnum++) { + SCOPED_TRACE("argnum " + std::to_string(argnum)); + EXPECT_NE(query_operator_argument_type(op_index, argnum), OPF_NONE) + << "argument slot has no declared type"; + } + } + + // set-lens-aperture's curvature bows the blades inward at negative values, + // so that slot in particular must not be restricted to positive numbers + EXPECT_EQ(query_operator_argument_type(find_operator_index(OP_SET_LENS_APERTURE), 2), OPF_NUMBER); +} + +TEST(SexpLens, OperatorsAreCategorisedAndDocumented) +{ + for (const auto& expected : Lens_operators) { + SCOPED_TRACE(expected.name); + + // an uncategorised operator doesn't appear in FRED's menus at all + EXPECT_EQ(get_category(expected.op_const), OP_CATEGORY_CHANGE); + EXPECT_EQ(get_subcategory(expected.op_const), CHANGE_SUBCATEGORY_BACKGROUND_AND_NEBULA); + + EXPECT_EQ(query_operator_return_type(expected.op_const), OPR_NULL); + + auto help = std::find_if(Sexp_help.begin(), Sexp_help.end(), [&expected](const sexp_help_struct& h) { + return h.id == expected.op_const; + }); + ASSERT_NE(help, Sexp_help.end()) << "operator has no help text"; + EXPECT_NE(help->help.find(expected.name), SCP_string::npos) << "help text doesn't name the operator"; + } +} + +// OPF_LENS_SYSTEM arguments are validated at mission load, where a rejection +// aborts the load outright. That is only safe because the built-in lens table +// is an engine default, so these names resolve whatever is installed. +class SexpLensTableTest : public test::FSTestFixture { + public: + SexpLensTableTest() : test::FSTestFixture(INIT_CFILE) {} + + void SetUp() override + { + test::FSTestFixture::SetUp(); + graphics::lens_flare_init(); + } + + void TearDown() override + { + graphics::lens_flare_close(); + test::FSTestFixture::TearDown(); + } +}; + +TEST_F(SexpLensTableTest, ValidatesLensNames) +{ + // every lens the default table ships must be nameable from a mission + const int count = graphics::lens_flare_num_systems(); + ASSERT_GT(count, 0); + for (int i = 0; i < count; i++) { + const char* name = graphics::lens_flare_get_system(i)->name.c_str(); + SCOPED_TRACE(name); + EXPECT_TRUE(sexp_lens_name_is_valid(name)); + } + + // the set-camera-lens sentinels, case-insensitively + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + EXPECT_TRUE(sexp_lens_name_is_valid("")); + + // and a name no table defines, which is what stops a typo from silently + // leaving the flare unchanged at runtime + EXPECT_FALSE(sexp_lens_name_is_valid("no_such_lens")); + EXPECT_FALSE(sexp_lens_name_is_valid("")); + EXPECT_FALSE(sexp_lens_name_is_valid(nullptr)); + + // the error code has a message, or FRED and the mission loader print nothing + EXPECT_STRNE(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM), nullptr); + EXPECT_GT(strlen(sexp_error_message(SEXP_CHECK_INVALID_LENS_SYSTEM)), 0u); +} + +// The four iris operators rebuild a 512^2 mask and its Fourier transform, which +// is slow enough to be seen; set-lens-flare-strength deliberately does not. That +// distinction is the whole reason the sixth operator exists, so the help text +// has to actually make it -- a designer who drives the wrong one from a +// repeating event gets a stuttering mission and no clue why. +TEST(SexpLens, IrisOperatorsDocumentTheirCost) +{ + auto help_for = [](int op_const) -> SCP_string { + auto it = std::find_if(Sexp_help.begin(), Sexp_help.end(), [op_const](const sexp_help_struct& h) { + return h.id == op_const; + }); + return (it == Sexp_help.end()) ? SCP_string() : it->help; + }; + + for (int op : {OP_SET_LENS_APERTURE, OP_SET_LENS_GRATING, OP_SET_LENS_SCRATCHES, OP_SET_LENS_DUST}) { + SCOPED_TRACE(op); + const SCP_string help = help_for(op); + ASSERT_FALSE(help.empty()); + EXPECT_NE(help.find("COST:"), SCP_string::npos) << "no cost warning"; + EXPECT_NE(help.find("repeating"), SCP_string::npos) << "doesn't warn against repeating events"; + } + + const SCP_string cheap = help_for(OP_SET_LENS_FLARE_STRENGTH); + ASSERT_FALSE(cheap.empty()); + EXPECT_NE(cheap.find("cheap"), SCP_string::npos) << "doesn't say it is the cheap one"; + EXPECT_EQ(cheap.find("COST:"), SCP_string::npos) << "warns about a cost it doesn't have"; +} + +// set-lens-flare-strength must reach every knob it documents. Its arguments are +// written into lens_overrides by hand, so a copy-paste slip would silently make +// one of them do nothing -- and unlike a wrong OPF, nothing would even warn. +TEST_F(SexpLensTableTest, FlareStrengthOperatorTouchesEveryKnobItDocuments) +{ + const int idx = graphics::lens_flare_lookup("angenieux_100mm"); + ASSERT_GE(idx, 0); + graphics::lens_flare_switch_to("angenieux_100mm"); + ASSERT_EQ(graphics::lens_flare_active_lens(), idx); + + // Halving everything is the interesting case: it exercises the "percentage of + // whatever is in force" rule rather than landing on a default by accident. + const graphics::lens_settings before = graphics::lens_flare_effective_settings(idx); + + graphics::lens_overrides& ov = graphics::lens_flare_overrides(); + ov.intensity = before.intensity * 0.5f; + ov.ghost_brightness = before.ghost_brightness * 0.5f; + ov.starburst_brightness = before.starburst_brightness * 0.5f; + ov.starburst_scale = before.starburst_scale * 0.5f; + ov.max_ghosts = 5; + graphics::lens_flare_overrides_changed(); + + const graphics::lens_settings after = graphics::lens_flare_effective_settings(idx); + EXPECT_FLOAT_EQ(after.intensity, before.intensity * 0.5f); + EXPECT_FLOAT_EQ(after.ghost_brightness, before.ghost_brightness * 0.5f); + EXPECT_FLOAT_EQ(after.starburst_brightness, before.starburst_brightness * 0.5f); + EXPECT_FLOAT_EQ(after.starburst_scale, before.starburst_scale * 0.5f); + EXPECT_EQ(after.max_ghosts, 5); + + // None of it touches the iris, which is why this operator is the cheap one: + // the textures the aperture drives must be untouched. + EXPECT_EQ(after.aperture, before.aperture); + + graphics::lens_flare_reset_for_level(); +} diff --git a/test/src/source_groups.cmake b/test/src/source_groups.cmake index fa4c1c81d39..ef831b283aa 100644 --- a/test/src/source_groups.cmake +++ b/test/src/source_groups.cmake @@ -25,6 +25,7 @@ add_file_folder("Globalincs" add_file_folder("Graphics" graphics/test_font.cpp + graphics/test_lens_flare.cpp ) if (FSO_BUILD_WITH_VULKAN) @@ -52,6 +53,7 @@ add_file_folder("model" add_file_folder("Parse" parse/test_parselo.cpp parse/test_replace.cpp + parse/test_sexp_lens.cpp ) add_file_folder("Pilotfile" @@ -98,5 +100,6 @@ add_file_folder("Utils" ) add_file_folder("Weapon" + weapon/test_beam.cpp weapon/weapons.cpp ) diff --git a/test/src/weapon/test_beam.cpp b/test/src/weapon/test_beam.cpp new file mode 100644 index 00000000000..01c9545fbbb --- /dev/null +++ b/test/src/weapon/test_beam.cpp @@ -0,0 +1,109 @@ + +#include + +#include + +#include +#include +#include + +// beam_get_muzzle_glow() is what turns a beam's own muzzle light into a +// lens-flare source (graphics/lens_flare_beams.cpp), so its ramp is what the +// flare actually follows. These pin the exact shape of that ramp -- +// including the jump at fire onset and the early cutoff near the end of +// warmdown -- because both come straight from beam_add_light_small()'s +// pre-existing curve (the *0.5f halving and the *1.3f warmdown factor), and a +// future edit that "smoothed" them out would make the flare disagree with the +// light it is supposed to be tracking. +class BeamMuzzleGlowTest : public test::FSTestFixture { + public: + BeamMuzzleGlowTest() { pushModDir("beam"); } + + protected: + void SetUp() override + { + test::FSTestFixture::SetUp(); + + Weapon_info.emplace_back(); + weapon_info_index = static_cast(Weapon_info.size()) - 1; + weapon_info& wip = Weapon_info[weapon_info_index]; + wip.b_info.beam_warmup = 1000; + wip.b_info.beam_warmdown = 1000; + // Isolate the ramp from beam_current_light_radius()'s other multipliers, + // neither of which this test is about. + wip.b_info.beam_light_as_multiplier = false; + wip.b_info.beam_light_flicker = false; + wip.light_radius = 5.0f; + wip.light_color_set = true; + wip.light_color = hdr_color(1.0f, 1.0f, 1.0f, 1.0f, 4.0f); + + bm = beam(); + bm.weapon_info_index = weapon_info_index; + bm.warmup_stamp = -1; + bm.warmdown_stamp = -1; + bm.last_start = vmd_zero_vector; + } + + void TearDown() override + { + Weapon_info.pop_back(); + + test::FSTestFixture::TearDown(); + } + + int weapon_info_index = -1; + beam bm; +}; + +TEST_F(BeamMuzzleGlowTest, FiringHoldsAtFullIntensity) +{ + bm.warmup_stamp = -1; + bm.warmdown_stamp = -1; + + beam_muzzle_glow glow; + ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow)); + EXPECT_NEAR(glow.intensity, 4.0f, 1e-3f); +} + +TEST_F(BeamMuzzleGlowTest, WarmupStartsAtZero) +{ + bm.warmup_stamp = timestamp(1000); // the full warmup still ahead + bm.warmdown_stamp = -1; + + beam_muzzle_glow glow; + EXPECT_FALSE(beam_get_muzzle_glow(&bm, &glow)); +} + +TEST_F(BeamMuzzleGlowTest, WarmupRisesToHalfAtMidpoint) +{ + bm.warmup_stamp = timestamp(500); // halfway through a 1000ms warmup + bm.warmdown_stamp = -1; + + beam_muzzle_glow glow; + ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow)); + // BEAM_WARMUP_PCT ~= 0.5, halved by the legacy muzzle-light curve + EXPECT_NEAR(glow.intensity, 4.0f * 0.25f, 0.05f); +} + +TEST_F(BeamMuzzleGlowTest, WarmdownJumpsDownAtIgnition) +{ + bm.warmup_stamp = -1; + bm.warmdown_stamp = timestamp(1000); // the full warmdown still ahead + + beam_muzzle_glow glow; + ASSERT_TRUE(beam_get_muzzle_glow(&bm, &glow)); + // Firing was 1.0; warmdown's first instant is already halved -- the pop is + // inherited from beam_add_light_small(), not introduced by the flare. + EXPECT_NEAR(glow.intensity, 4.0f * 0.5f, 0.05f); +} + +TEST_F(BeamMuzzleGlowTest, WarmdownFadesBeforeItsNominalEnd) +{ + bm.warmup_stamp = -1; + bm.warmdown_stamp = timestamp(10); // almost fully warmed down + + beam_muzzle_glow glow; + // The *1.3f factor drives the ramp to zero before timestamp_elapsed() + // actually deletes the beam, so no flare should draw here. + EXPECT_FALSE(beam_get_muzzle_glow(&bm, &glow)); +} diff --git a/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm new file mode 100644 index 00000000000..58c814774b8 --- /dev/null +++ b/test/test_data/graphics/lens_flare/aperture_fields/data/tables/test-lens.tbm @@ -0,0 +1,54 @@ +; Exercises every aperture field of a lens entry (see the shipped +; lens_flares.tbl for what they mean). Fields are parsed sequentially, so this +; doubles as the reference for the order they have to appear in. +; +; Every value here is deliberately distinct and non-default so a mis-wired +; stuff_float() shows up as a specific field carrying the wrong number. + +#Lens Systems + +$Name: aperture_test_lens +$Entrance Pupil Radius: 20.0 +$Aperture Radius: 14.0 +$Sensor Width: 36.0 +$Anamorphic Squeeze: 1.75 +$Anamorphic Streak: 0.81 ++Length: 0.82 ++Thickness: 0.083 ++Tint: ( 0.84, 0.85, 0.86 ) +$Coating Wavelength: 500 +$Aperture Blades: 11 ++Blade Rotation: 21.0 ++Blade Curvature: 0.31 ++Edge Softness: 0.041 +$Aperture Grating: 0.51 ++Density: 0.52 ++Length: 0.53 ++Width: 0.54 ++Softness: 0.055 +$Aperture Scratches: 0.61 ++Density: 0.62 ++Length: 0.63 ++Width: 0.64 ++Rotation: 65.0 ++Rotation Variation: 0.66 ++Softness: 0.067 +$Aperture Dust: 0.71 ++Density: 0.72 ++Radius: 0.73 ++Softness: 0.074 +$Starburst: NO ++Starburst Scale: 0.9 +$Intensity: 0.25 +$Max Ghosts: 7 +$Lens Stack Start: +$Surface: ( 100.0, 5.0, 1.6 ) ++Abbe: 55.0 +$Surface: ( -200.0, 3.0, 1.0 ) +$Stop: ( 4.0 ) +$Surface: ( 150.0, 4.0, 1.62 ) ++Abbe: 45.0 +$Surface: ( -120.0, 40.0, 1.0 ) +$Lens Stack End + +#End diff --git a/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm new file mode 100644 index 00000000000..75526fe8eab --- /dev/null +++ b/test/test_data/graphics/lens_flare/default_lens/data/tables/test-lens.tbm @@ -0,0 +1,25 @@ +; A table that actually declares a $Default Lens:, which the shipped +; lens_flares.tbl deliberately does not. +; +; Without a declared default, "take the tabled default" and "no flares at all" +; both come out as "no lens mounted", so the two cannot be told apart. That is +; exactly the case where a mission's explicit used to be lost: it was +; stored as an empty string, an empty string was not written out, and on reload an +; absent $Camera Lens: became the default. This table is what lets a test see the +; difference. + +#Lens Systems + +$Default Lens: default_test_lens + +$Name: default_test_lens +$Entrance Pupil Radius: 12.0 +$Aperture Radius: 6.0 +$Sensor Width: 36.0 +$Lens Stack Start: +$Surface: ( 50.0, 4.0, 1.62 ) +$Stop: ( 3.0 ) +$Surface: ( -50.0, 40.0, 1.0 ) +$Lens Stack End + +#End diff --git a/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm b/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm new file mode 100644 index 00000000000..2a79ef656f1 --- /dev/null +++ b/test/test_data/graphics/lens_flare/override/data/tables/zz-override-lens.tbm @@ -0,0 +1,38 @@ +; A xxx-lens.tbm that edits lenses the engine's own lens_flares.tbl shipped, +; which is what +override exists for. Named "zz-" so it parses after any other +; *-lens.tbm a test mod dir might carry. + +#Lens Systems + +; Restyle a shipped lens without restating its twelve-surface prescription. +; Everything not mentioned here -- the surfaces, the entrance pupil, the sensor +; width, the blade count -- has to survive untouched. +$Name: angenieux_100mm ++override +$Anamorphic Squeeze: 2.0 +$Intensity: 0.75 + +; Overriding the prescription replaces it outright: this two-element lens must +; end up with exactly the surfaces below and none of the Tessar's. +$Name: tessar_50mm ++override +$Lens Stack Start: +$Surface: ( 60.0, 5.0, 1.62 ) ++Abbe: 55.0 +$Stop: ( 4.0 ) +$Surface: ( -60.0, 40.0, 1.0 ) +$Lens Stack End + +; No +override: a complete definition that replaces the shipped lens of this +; name outright, so its surface count must be this one's and not the shipped +; one's +$Name: kodak_100mm +$Entrance Pupil Radius: 11.0 +$Aperture Radius: 6.0 +$Lens Stack Start: +$Surface: ( 55.0, 4.0, 1.62 ) +$Stop: ( 3.0 ) +$Surface: ( -55.0, 35.0, 1.0 ) +$Lens Stack End + +#End diff --git a/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl b/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl new file mode 100644 index 00000000000..0ef8eddf234 --- /dev/null +++ b/test/test_data/graphics/lens_flare/sun_flare_opt_in/data/tables/stars.tbl @@ -0,0 +1,58 @@ +; Sun entries covering every combination of the two things that can declare a sun +; flares: the legacy sprite "$Flare:" block and the explicit +; "+Camera Lens Flare:" option. +; +; The option is parsed after the $Flare: block and before $NoGlare:, and a +; mis-ordered optional_string() is silent -- it would simply never match, and the +; sun would quietly keep the $Flare: fallback. That is what the accompanying test +; is really guarding. +; +; No bitmaps here are ever loaded: the test only parses. + +#Background Bitmaps + +$Sun: SunNeither +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 + +; the pre-existing way to opt in: a legacy sprite flare block, no explicit option +$Sun: SunLegacyFlareOnly +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 +$Flare: ++FlareCount: 1 +$FlareTexture1: nonexistent_flare +$FlareGlow1: ++FlareTexture: 0 ++FlarePos: 0.5 ++FlareScale: 0.5 + +; the new lightweight opt-in: camera lens flare with no sprite flare fields at all +$Sun: SunLensOnly +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 ++Camera Lens Flare: YES + +; explicitly opted out while still carrying a sprite flare block -- the option has +; to win over the $Flare: fallback, or there is no way to keep sprites without the +; physically-based flare +$Sun: SunFlareButNoLens +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 +$Flare: ++FlareCount: 1 +$FlareTexture1: nonexistent_flare +$FlareGlow1: ++FlareTexture: 0 ++FlarePos: 0.5 ++FlareScale: 0.5 ++Camera Lens Flare: NO + +; and the option must not swallow the $NoGlare: that follows it +$Sun: SunLensAndNoGlare +$Sunglow: nonexistent_glow +$SunRGBI: 1.0 1.0 1.0 1.0 ++Camera Lens Flare: YES +$NoGlare: + +#End diff --git a/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm b/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm new file mode 100644 index 00000000000..852986512ee --- /dev/null +++ b/test/test_data/graphics/lens_flare/thruster_flare/data/tables/test-sdf.tbm @@ -0,0 +1,26 @@ +; Mirrors the shape a mod actually writes: a +nocreate entry that adds nothing +; but a thruster flare to a species some earlier table defined. + +#SPECIES DEFS + +$Species_Name: ThrusterFlareTestSpecies ++nocreate +$Thruster Flare: + +Intensity: 6.0 + +Afterburner Intensity: 15.0 + +Color: ( 170, 205, 255 ) + +; A species nothing has defined: must be discarded, not created, so a table +; written for one mod is harmless in another +$Species_Name: NoSuchSpecies ++nocreate +$Thruster Flare: + +Intensity: 99.0 + +; An entry that opts in without setting anything, which must land on the +; defaults rather than on zero +$Species_Name: ThrusterFlareDefaultsSpecies ++nocreate +$Thruster Flare: + +#End