From bf67f2f04827d7d62d19fb1d0af4497f71aeb6d8 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 14:43:00 +0200 Subject: [PATCH 01/17] Size qtFRED's post-processing targets for its resizable viewport The offscreen render targets that back post-processing -- the scene textures and the post-processing surfaces -- are allocated once from gr_screen.max_w/max_h at renderer init and never resized. The game never notices, because its window size is fixed after gr_init(). qtFRED does: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, resizable widget, so growing the viewport past the startup size clipped the render to the old, smaller texture and then stretched it back over the new, larger viewport. Add Gr_min_render_target_w/h as a floor those allocations respect, and have qtFRED set it to the largest size its viewport can reach (the biggest screen the window could be maximized onto, in device pixels) before calling gr_init(). Left at 0 for the game, which keeps sizing them purely from gr_screen. That makes Scene_texture_u_scale/v_scale meaningful outside the game for the first time, which turns up two latent bugs: - Seven post-processing passes passed Scene_texture_u_scale for both axes. Harmless while the scene texture matches the screen exactly (u == v), wrong the moment it doesn't. - The bloom bright pass hardcoded 1.0/1.0. Unlike the passes after it, it reads the scene texture directly rather than an already-cropped intermediate, so it has to confine itself to the sub-rectangle that was actually rendered into. Finally, expose the pipeline in qtFRED at all: a View menu toggle brackets the 3D scene in gr_scene_texture_begin/end, the same way game_render_frame() brackets its own. Off by default, and it covers only the 3D world content -- the 2D overlays stay outside it. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 3 + code/graphics/2d.h | 12 ++++ code/graphics/opengl/gropengldraw.cpp | 67 ++++++++++++++----- .../opengl/gropenglpostprocessing.cpp | 35 ++++++---- qtfred/src/mission/EditorViewport.h | 6 ++ qtfred/src/mission/FredRenderer.cpp | 13 ++++ qtfred/src/mission/management.cpp | 18 +++++ qtfred/src/ui/FredView.cpp | 1 + qtfred/ui/FredView.ui | 13 ++++ 9 files changed, 141 insertions(+), 27 deletions(-) diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 2b0e6a556d2..4da3b6608dd 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -866,6 +866,9 @@ static void parse_post_processing_func() bool Gr_post_processing_enabled = true; +int Gr_min_render_target_w = 0; +int Gr_min_render_target_h = 0; + // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PostProcessOption __UNUSED = options::OptionBuilder("Graphics.PostProcessing", std::pair{"Post processing", 1726}, diff --git a/code/graphics/2d.h b/code/graphics/2d.h index 0f66ba3539b..be3ccdec745 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -73,6 +73,18 @@ bool gr_is_smaa_mode(AntiAliasMode mode); extern bool Gr_post_processing_enabled; +// Floor, in pixels, for the offscreen render targets that back post-processing (the scene textures +// and the post-processing surfaces). Those are sized once, from gr_screen.max_w/max_h at renderer +// init, and never revisited; anything drawn while gr_screen is *larger* than them is silently +// clipped to their edge and then stretched back over the full viewport. The game never hits that +// -- its window size is fixed after gr_init() -- but qtFred resizes its 3D viewport at runtime, so +// it sets this to the largest size that viewport can ever reach before calling gr_init(). Leave at +// 0 (the default) to size the targets purely from gr_screen, which is what the game does. The +// targets are still clamped to the hardware's maximum renderbuffer size, so this is a request, not +// a guarantee. +extern int Gr_min_render_target_w; +extern int Gr_min_render_target_h; + extern bool Gr_enable_vsync; // HDR10 (PQ/ST.2084 + BT.2020) output. Currently only honored by the Vulkan renderer. diff --git a/code/graphics/opengl/gropengldraw.cpp b/code/graphics/opengl/gropengldraw.cpp index 63ac62ffe6b..8ecfc3e82ec 100644 --- a/code/graphics/opengl/gropengldraw.cpp +++ b/code/graphics/opengl/gropengldraw.cpp @@ -113,10 +113,14 @@ void opengl_setup_scene_textures() return; } - // clamp size, if needed - Scene_texture_width = gr_screen.max_w; - Scene_texture_height = gr_screen.max_h; + // These textures are allocated once and never resized, so they have to be big enough for the + // largest gr_screen this session will ever see, not just the current one -- see + // Gr_min_render_target_w/h (2d.h) for why anything drawn outside them is stretched. In the game + // the floor is 0 and this is just gr_screen. + Scene_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); + Scene_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { Scene_texture_width = GL_max_renderbuffer_size; } @@ -125,6 +129,15 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } + mprintf((" Scene textures: %dx%d (screen %dx%d, floor %dx%d, max renderbuffer %d)\n", + Scene_texture_width, + Scene_texture_height, + gr_screen.max_w, + gr_screen.max_h, + Gr_min_render_target_w, + Gr_min_render_target_h, + GL_max_renderbuffer_size)); + // create framebuffer glGenFramebuffers(1, &Scene_framebuffer); GL_state.BindFrameBuffer(Scene_framebuffer); @@ -776,20 +789,44 @@ 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; + // In the game Scene_texture_width/height always equals gr_screen.max_w/h (the + // scene texture is sized once from the same resolution at gr_init() and the + // game never resizes around it), so this ratio is always exactly 1.0 there. + // qtFred is the exception: its viewport is resized every frame + // (gr_screen_resize() in FredRenderer::render_frame()) to match a dockable + // widget that is smaller than the scene texture's fixed allocation (which + // Gr_min_render_target_w/h floors at the largest size that widget can reach), + // so the ratio keeps the render (and this end-of-frame blit) confined to the + // sub-rectangle of the texture that was actually drawn into, instead of + // stretching the whole texture -- most of it never touched this frame -- + // over the viewport-sized quad. + 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); + + // A ratio above 1.0 means the viewport outgrew the allocation after the fact -- the scene + // texture can't be resized, so all we can do is render the part that fits and let the blit + // stretch it back out, which misaligns everything by a different amount on each axis. The + // floor above is meant to make this unreachable; if it does happen (a display hotplug or + // resolution change after gr_init(), or an allocation clamped by GL_max_renderbuffer_size on + // low-end hardware) 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); diff --git a/code/graphics/opengl/gropenglpostprocessing.cpp b/code/graphics/opengl/gropenglpostprocessing.cpp index ed9e14a995c..0f5056bc1d6 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -99,7 +99,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } void opengl_post_pass_bloom() @@ -134,7 +134,16 @@ 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); + // Unlike every other pass below, this reads directly from the scene + // texture rather than from an already-cropped intermediate (Bloom_textures + // is filled by this very call), so it has to confine itself to the + // sub-rectangle that was actually rendered into -- see the scale + // variables' own comment in gr_opengl_scene_texture_begin(). Hardcoding + // 1.0/1.0 here (as this used to) is only correct when the scene texture + // exactly matches the screen, which is not true of qtFred's dynamically + // resized viewport: it would smear/misplace the bloom halo relative to + // the scene it's supposed to be blooming. + opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } // ------ end bright pass ------ @@ -293,7 +302,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); // set and configure post shader .. opengl_shader_set_current(gr_opengl_maybe_create_shader(SDR_TYPE_POST_PROCESS_FXAA, 0)); @@ -310,7 +319,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); opengl_shader_set_current(); } @@ -333,7 +342,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } static void smaa_calculate_blending_weights() @@ -358,7 +367,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } static void smaa_neighborhood_blending() @@ -381,7 +390,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); } void smaa_resolve() @@ -491,7 +500,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); GL_state.Blend(GL_FALSE); break; @@ -625,7 +634,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_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1042,10 +1051,12 @@ static bool opengl_post_init_framebuffer() { bool rval = false; - // clamp size, if needed - Post_texture_width = gr_screen.max_w; - Post_texture_height = gr_screen.max_h; + // Sized once and never resized, like the scene textures they consume, so they get the same + // floor -- see Gr_min_render_target_w/h (2d.h). + Post_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); + Post_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + // clamp size, if needed if (Post_texture_width > GL_max_renderbuffer_size) { Post_texture_width = GL_max_renderbuffer_size; } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 26962561525..24ad6f92410 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -56,6 +56,12 @@ struct ViewSettings { bool Show_paths_fred = false; bool Lighting_on = false; bool FullDetail = false; + // Runs the viewport through the same HDR scene-texture + post-processing + // pipeline the game uses (bloom, tonemapping, lightshafts), instead of + // rendering straight to the default framebuffer. + // Off by default so existing missions keep looking exactly as they do today + // unless a FRED user opts in. + bool EnablePostProcessing = false; bool Show_waypoints = true; bool Show_props = true; bool Show_jump_nodes = true; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 5bd82493288..49d2ba750f3 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -994,6 +994,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 scene through the game's HDR post-processing pipeline + // (bloom, tonemapping, lightshafts) instead of drawing straight to the + // default framebuffer. Brackets only the 3D world content, + // the same way freespace.cpp's game_render_frame() brackets its own scene -- + // the 2D overlays below (distances, ship info, tooltips) stay outside it. + if (view().EnablePostProcessing) { + gr_scene_texture_begin(); + } + // 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; @@ -1019,6 +1028,10 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); + if (view().EnablePostProcessing) { + gr_scene_texture_end(); + } + if (view().Show_distances) { display_distances(); } diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index cc8cf8efb8d..f9ff1a2cce7 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,8 @@ #include #include +#include +#include #include extern bool Xstr_inited; @@ -125,6 +128,21 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; + // Unlike the game, whose window size is fixed after gr_init(), qtFred resizes its 3D viewport at + // runtime: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, + // resizable widget. The offscreen render targets that back post-processing are allocated once, + // from gr_screen as it is right now, and cannot grow afterwards -- so give them a floor big + // enough for any size that widget can reach, which is the largest screen the window could be + // maximized or fullscreened onto, in device pixels. Without this, growing the viewport past the + // startup size clips the render to the old, smaller texture and then stretches it back over the + // new, larger viewport, visibly misaligning sun sprites and bloom. + for (const QScreen* screen : QGuiApplication::screens()) { + const auto ratio = screen->devicePixelRatio(); + + Gr_min_render_target_w = MAX(Gr_min_render_target_w, qRound(screen->size().width() * ratio)); + Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); + } + std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); gr_set_gamma(3.0f); diff --git a/qtfred/src/ui/FredView.cpp b/qtfred/src/ui/FredView.cpp index 7c6aae8f482..29ffd80cbf7 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.EnablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/ui/FredView.ui b/qtfred/ui/FredView.ui index 7d8cdc3e035..dd9d82c97b9 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, lightshafts) + + true From 628aa09526d3aca1fb1aa1cc278b83acfe5acb04 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 18:51:09 +0200 Subject: [PATCH 02/17] Add configurable graphics settings to Preferences dialog Introduce post-processing, shadow quality, anti-aliasing, anisotropic filtering, texture filtering, MSAA, and gamma controls to qtFRED's Preferences. Ensures real-time application of certain settings and requires restart for others. Adjust graphics initialization to support baked settings before `gr_init()`. Update documentation to reflect these additions. --- code/def_files/data/effects/fxaa-v.sdr | 13 +- code/graphics/opengl/gropengltexture.cpp | 9 +- .../doc/dialogs/PreferencesDialog.html | 23 ++ qtfred/src/mission/EditorViewport.cpp | 37 +++ qtfred/src/mission/EditorViewport.h | 21 ++ qtfred/src/mission/FredRenderer.cpp | 21 ++ .../dialogs/PreferencesDialogModel.cpp | 54 ++++ .../mission/dialogs/PreferencesDialogModel.h | 34 +++ qtfred/src/mission/management.cpp | 31 +++ qtfred/src/ui/dialogs/PreferencesDialog.cpp | 69 +++++ qtfred/src/ui/dialogs/PreferencesDialog.h | 8 + qtfred/ui/PreferencesDialog.ui | 243 ++++++++++++++++++ 12 files changed, 561 insertions(+), 2 deletions(-) diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index 22a9053f2a1..d7134bb5972 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,16 @@ 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; + // Deriving this from vertPosition (clip-space -1..1 mapped to 0..1) instead of the actual + // texcoord attribute only samples the full [0,1] UV range, ignoring whatever sub-rectangle + // (u1,v1,u2,v2) the draw call actually asked for -- correct only when the source texture is + // the exact size of the destination viewport. qtFRED's post-processing targets are padded + // larger than its resizable viewport (see gr_opengl_scene_texture_begin()), so + // opengl_draw_full_screen_textured() is called with Scene_texture_u_scale/v_scale < 1 there; + // with the old formula FXAA re-samples that whole padded texture (valid content plus + // whatever's beyond it) squashed into the viewport, showing a shrunk duplicate of the scene + // in the corner matching the u/v scale. vertTexCoord already carries the correct (u1,v1).. + // (u2,v2) range, same as every other post-process pass's vertex shader (see post-v.sdr). + v_pos = vertTexCoord.xy; } #endif diff --git a/code/graphics/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index 9375b353468..be217c97452 100644 --- a/code/graphics/opengl/gropengltexture.cpp +++ b/code/graphics/opengl/gropengltexture.cpp @@ -189,7 +189,14 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear - GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + // Route through the option when in-game options are active, same as the anisotropy setting + // below, so that overriding "Graphics.TextureFilter" (e.g. via OptionsManager::setOverride()) + // actually has an effect instead of being silently bypassed by this direct config read. + if (Using_in_game_options) { + GL_mipmap_filter = TextureFilteringOption->getValue(); + } else { + GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); + } if (GL_mipmap_filter > 1) { GL_mipmap_filter = 1; diff --git a/qtfred/help-src/doc/dialogs/PreferencesDialog.html b/qtfred/help-src/doc/dialogs/PreferencesDialog.html index 88783286106..f4b2a5f043e 100644 --- a/qtfred/help-src/doc/dialogs/PreferencesDialog.html +++ b/qtfred/help-src/doc/dialogs/PreferencesDialog.html @@ -25,6 +25,29 @@

General

theme. +

Graphics

+ + + + + + + + + +
SettingDescription
Enable post-processing in the viewportRoutes the 3D viewport through + the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead + of drawing straight to the screen. Off by default. Every other setting on this tab + only has a visible effect while this is on.
Shadow qualityQuality of the shadows cast by the mission's sun in the + viewport.
Anti-aliasingPost-process anti-aliasing mode (FXAA/SMAA) used in the + viewport.
MSAAMultisample anti-aliasing for the viewport's 3D scene. Requires + restarting QtFRED to take effect.
Texture filteringMipmap filtering used for textures (bilinear or + trilinear). Requires restarting QtFRED to take effect.
Anisotropic filteringAnisotropic texture filtering level, up to the + maximum the current GPU supports. Requires restarting QtFRED to take + effect.
GammaBrightness of the viewport. QtFRED defaults this higher than the + game, since the viewport isn't normally tonemapped unless post-processing is + on.
+

Grid

diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index e91fcb7d67f..28b58a13f26 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace { @@ -122,6 +123,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); + applyGraphicsSettings(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -177,6 +179,21 @@ void EditorViewport::loadSettings() { view.Show_compass = settings.value("view_show_compass", view.Show_compass).toBool(); view.Highlight_selectable_subsys = settings.value("view_highlight_selectable_subsys", view.Highlight_selectable_subsys).toBool(); view.Outline_lod = settings.value("view_outline_lod", view.Outline_lod).toInt(); + view.EnablePostProcessing = settings.value("view_enable_post_processing", view.EnablePostProcessing).toBool(); + view.Graphics_shadow_quality = settings.value("view_graphics_shadow_quality", view.Graphics_shadow_quality).toInt(); + view.Graphics_aa_mode = settings.value("view_graphics_aa_mode", view.Graphics_aa_mode).toInt(); + view.Graphics_msaa_samples = settings.value("view_graphics_msaa_samples", view.Graphics_msaa_samples).toInt(); + view.Graphics_texture_filter = settings.value("view_graphics_texture_filter", view.Graphics_texture_filter).toInt(); + { + // Default to the hardware's max anisotropy the first time qtFRED runs, so the + // viewport looks the same as it always has until the user turns this control down. + float maxAnisotropy = 1.0f; + if (gr_get_property(gr_property::MAX_ANISOTROPY, &maxAnisotropy)) { + view.Graphics_anisotropy = maxAnisotropy; + } + view.Graphics_anisotropy = settings.value("view_graphics_anisotropy", view.Graphics_anisotropy).toFloat(); + } + view.Graphics_gamma = settings.value("view_graphics_gamma", view.Graphics_gamma).toFloat(); camera.setInvertOrbitX(settings.value("camera_invert_orbit_x", camera.getInvertOrbitX()).toBool()); camera.setInvertOrbitY(settings.value("camera_invert_orbit_y", camera.getInvertOrbitY()).toBool()); settings.endGroup(); @@ -225,10 +242,30 @@ void EditorViewport::saveSettings() const { settings.setValue("view_show_compass", view.Show_compass); settings.setValue("view_highlight_selectable_subsys", view.Highlight_selectable_subsys); settings.setValue("view_outline_lod", view.Outline_lod); + settings.setValue("view_enable_post_processing", view.EnablePostProcessing); + settings.setValue("view_graphics_shadow_quality", view.Graphics_shadow_quality); + settings.setValue("view_graphics_aa_mode", view.Graphics_aa_mode); + settings.setValue("view_graphics_msaa_samples", view.Graphics_msaa_samples); + settings.setValue("view_graphics_texture_filter", view.Graphics_texture_filter); + settings.setValue("view_graphics_anisotropy", view.Graphics_anisotropy); + settings.setValue("view_graphics_gamma", view.Graphics_gamma); settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); } + +void EditorViewport::applyGraphicsSettings() const { + // Shadow_quality is deliberately not set here. Turning shadows on requires allocating the + // shadow framebuffer and sizing the cascade-parameter buffers (shadow_cascade_params_init(), + // gropengltnl.cpp/gr_vulkan.cpp), which only happens once at gr_init() and only if + // Shadow_quality is already non-Disabled at that point -- flipping the global afterwards + // leaves those buffers empty/unsized and segfaults the next time a frame tries to render + // shadows. Applied before gr_init() instead (see management.cpp), same as MSAA samples, + // texture filter, and anisotropy; the Preferences UI notes it needs a restart. + Gr_aa_mode = static_cast(view.Graphics_aa_mode); + gr_set_gamma(view.Graphics_gamma); +} + void EditorViewport::needsUpdate() { _renderer->scheduleUpdate(); } diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index 24ad6f92410..c50ef0f9ccd 100644 --- a/qtfred/src/mission/EditorViewport.h +++ b/qtfred/src/mission/EditorViewport.h @@ -69,6 +69,19 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; + // Graphics (Preferences > Graphics tab). All quality/effect settings below are only + // visible in the viewport while EnablePostProcessing is on, since they're consumed by + // the same HDR scene-texture + post-processing pipeline that flag gates. + int Graphics_shadow_quality = 0; // ShadowQuality: 0=Disabled, 1=Low, 2=Medium, 3=High, 4=Ultra + int Graphics_aa_mode = 0; // AntiAliasMode: 0=None .. 7=SMAA Ultra + // The following three are baked into GPU resources at gr_init() time and can only be + // changed by restarting qtFRED; qtFRED applies the saved value before gr_init() runs + // (see management.cpp), and the Preferences UI notes this in each control's tooltip. + int Graphics_msaa_samples = 0; // 0, 4, or 8 + int Graphics_texture_filter = 1; // 0=Bilinear, 1=Trilinear + float Graphics_anisotropy = 1.0f; // populated from the hardware max the first time qtFRED runs + float Graphics_gamma = 3.0f; // matches the brightness qtFRED has always launched with + ViewSettings(); }; @@ -249,6 +262,14 @@ class EditorViewport { void saveSettings() const; + // Pushes the live-appliable subset of view.Graphics_* (shadow quality, AA mode, gamma) + // into the engine globals that actually drive rendering. Called once at startup, after + // loadSettings(), and again from PreferencesDialogModel::apply() whenever the user + // changes one of those controls. Graphics_msaa_samples/Graphics_texture_filter/ + // Graphics_anisotropy are deliberately not included here -- those can only be applied + // before gr_init() (see management.cpp) and need a restart to change. + void applyGraphicsSettings() const; + Editor* editor = nullptr; FredRenderer* renderer = nullptr; IDialogProvider* dialogProvider = nullptr; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 49d2ba750f3..760de615b6a 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -1012,6 +1013,26 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; + // Shadows piggyback on the same HDR scene-texture pipeline as post-processing above -- the + // shadow pass writes into deferred G-buffer surfaces that only exist while that's bound, so + // it can only run under EnablePostProcessing. Mirrors freespace.cpp's game_render_frame(), + // which calls this right after its own gr_scene_texture_begin()+stars_draw(). Eye_position/ + // Eye_matrix/Proj_fov were already set to FRED's camera by the g3_set_view_matrix() call + // above, but shadows_render_all() operates on the separate HTL proj/view matrix stack (the + // same one enable_htl()/disable_htl() push and pop for the starfield): it starts by ending + // whatever's currently active (Assert(modelview_matrix_depth == 2) in gr_end_view_matrix()), + // then restores it via gr_set_proj_matrix()/gr_set_view_matrix() when done. disable_htl() + // just popped that stack, so it has to be pushed again here first -- and popped again + // afterward, since FRED's grid/model rendering below never touches the HTL stack and the + // *next* frame's enable_htl() would itself assert if it were left open. + if (view().EnablePostProcessing) { + 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(); + } + if (view().Show_horizon) { gr_set_color(128, 128, 64); g3_draw_horizon_line(); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index c674f26744e..74ae5252400 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -4,6 +4,7 @@ #include "ui/Theme.h" #include "mission/missiongrid.h" #include "math/vecmat.h" +#include "graphics/2d.h" namespace fso::fred::dialogs { @@ -26,6 +27,13 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) + , _enablePostProcessing(viewport->view.EnablePostProcessing) + , _shadowQuality(viewport->view.Graphics_shadow_quality) + , _aaMode(viewport->view.Graphics_aa_mode) + , _msaaSamples(viewport->view.Graphics_msaa_samples) + , _textureFilter(viewport->view.Graphics_texture_filter) + , _anisotropy(viewport->view.Graphics_anisotropy) + , _gamma(viewport->view.Graphics_gamma) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -68,6 +76,16 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; + _viewport->view.EnablePostProcessing = _enablePostProcessing; + _viewport->view.Graphics_shadow_quality = _shadowQuality; + _viewport->view.Graphics_aa_mode = _aaMode; + _viewport->view.Graphics_msaa_samples = _msaaSamples; + _viewport->view.Graphics_texture_filter = _textureFilter; + _viewport->view.Graphics_anisotropy = _anisotropy; + _viewport->view.Graphics_gamma = _gamma; + // AA mode and gamma take effect immediately; shadow quality, MSAA samples, texture filter, + // and anisotropy are only applied at startup (see management.cpp) and need a restart. + _viewport->applyGraphicsSettings(); _viewport->camera.setInvertOrbitX(_invertOrbitX); _viewport->camera.setInvertOrbitY(_invertOrbitY); @@ -180,6 +198,42 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); } +bool PreferencesDialogModel::getEnablePostProcessing() const { return _enablePostProcessing; } +void PreferencesDialogModel::setEnablePostProcessing(bool value) { modify(_enablePostProcessing, value); } + +int PreferencesDialogModel::getShadowQuality() const { return _shadowQuality; } +void PreferencesDialogModel::setShadowQuality(int value) { modify(_shadowQuality, value); } + +int PreferencesDialogModel::getAAMode() const { return _aaMode; } +void PreferencesDialogModel::setAAMode(int value) { modify(_aaMode, value); } + +int PreferencesDialogModel::getMSAASamples() const { return _msaaSamples; } +void PreferencesDialogModel::setMSAASamples(int value) { modify(_msaaSamples, value); } + +int PreferencesDialogModel::getTextureFilter() const { return _textureFilter; } +void PreferencesDialogModel::setTextureFilter(int value) { modify(_textureFilter, value); } + +float PreferencesDialogModel::getAnisotropy() const { return _anisotropy; } +void PreferencesDialogModel::setAnisotropy(float value) { modify(_anisotropy, value); } + +SCP_vector PreferencesDialogModel::getAvailableAnisotropyLevels() const { + SCP_vector levels; + + float max = 1.0f; + if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max) || max <= 1.0f) { + levels.push_back(1.0f); + return levels; + } + + for (float level = 1.0f; level <= max; level *= 2.0f) { + levels.push_back(level); + } + return levels; +} + +float PreferencesDialogModel::getGamma() const { return _gamma; } +void PreferencesDialogModel::setGamma(float value) { modify(_gamma, 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..c46d915ef36 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.h +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.h @@ -67,6 +67,31 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int value); + // Graphics + bool getEnablePostProcessing() const; + void setEnablePostProcessing(bool value); + + int getShadowQuality() const; + void setShadowQuality(int value); + + int getAAMode() const; + void setAAMode(int value); + + int getMSAASamples() const; + void setMSAASamples(int value); + + int getTextureFilter() const; + void setTextureFilter(int value); + + float getAnisotropy() const; + void setAnisotropy(float value); + // Anisotropy levels the current hardware actually supports (1.0 = off), for populating the + // combo box: 1x, 2x, 4x, ... up to the hardware max. + SCP_vector getAvailableAnisotropyLevels() const; + + float getGamma() const; + void setGamma(float value); + // Controls QKeySequence getControlKey(ControlAction action) const; void setControlKey(ControlAction action, const QKeySequence& sequence); @@ -109,6 +134,15 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; + // Graphics + bool _enablePostProcessing; + int _shadowQuality; + int _aaMode; + int _msaaSamples; + int _textureFilter; + float _anisotropy; + float _gamma; + // Controls std::map _controlKeys; bool _invertOrbitX; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index f9ff1a2cce7..b10ee7a4d2a 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +44,7 @@ #include #include +#include #include extern bool Xstr_inited; @@ -143,6 +146,34 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); } + // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked + // into GPU resources during gr_init() and can't be changed afterwards without recreating + // them, so qtFRED has to apply its saved Preferences > Graphics values here, before gr_init() + // runs, rather than from EditorViewport (which doesn't exist yet -- it's created after the + // first viewport widget is up). Shadow quality specifically: shadow_cascade_params_init() + // (gropengltnl.cpp/gr_vulkan.cpp) only runs during gr_init(), and only if Shadow_quality is + // already non-Disabled at that point -- setting it afterwards leaves the cascade buffers + // unsized and segfaults the next time a frame renders shadows. Read directly out of QSettings + // under the same "Preferences" group EditorViewport uses, using an in-memory-only + // OptionsManager override for the two settings the engine reads through an Option + // (Graphics.TextureFilter, Graphics.Anisotropy) so this never rewrites the registry/config + // file the actual game reads its own graphics settings from. + { + QSettings settings; + settings.beginGroup("Preferences"); + Cmdline_msaa_enabled = settings.value("view_graphics_msaa_samples", Cmdline_msaa_enabled).toInt(); + Shadow_quality = static_cast(settings.value("view_graphics_shadow_quality", 0).toInt()); + + const int textureFilter = settings.value("view_graphics_texture_filter", 1).toInt(); + options::OptionsManager::instance()->setOverride("Graphics.TextureFilter", std::to_string(textureFilter)); + + if (settings.contains("view_graphics_anisotropy")) { + const float anisotropy = settings.value("view_graphics_anisotropy").toFloat(); + options::OptionsManager::instance()->setOverride("Graphics.Anisotropy", std::to_string(anisotropy)); + } + settings.endGroup(); + } + std::unique_ptr graphicsOps(new QtGraphicsOperations(editor)); gr_init(std::move(graphicsOps)); gr_set_gamma(3.0f); diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index 07db9e06d92..fd765fa548f 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.cpp +++ b/qtfred/src/ui/dialogs/PreferencesDialog.cpp @@ -94,6 +94,12 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { + // Populate the anisotropy combo with the levels the current hardware actually supports + // (1x/2x/4x/.../max), since that max varies by GPU. + for (float level : _model->getAvailableAnisotropyLevels()) { + ui->anisotropyCombo->addItem(level <= 1.0f ? tr("Off") : tr("%1x").arg(level, 0, 'g', 0)); + } + // Build the controls key-binding form dynamically from the registered bindings auto* form = new QFormLayout(ui->controlsFormWidget); auto& bindings = ControlBindings::instance(); @@ -125,6 +131,37 @@ void PreferencesDialog::updateUi() { const int iconSize = _model->getToolbarIconSize(); ui->toolbarIconSizeCombo->setCurrentIndex(iconSize <= 16 ? 0 : iconSize >= 32 ? 2 : 1); ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); + + // Graphics + ui->enablePostProcessing->setChecked(_model->getEnablePostProcessing()); + ui->shadowQualityCombo->setCurrentIndex(_model->getShadowQuality()); + ui->aaModeCombo->setCurrentIndex(_model->getAAMode()); + { + static constexpr int msaaSamples[] = { 0, 4, 8 }; + const int samples = _model->getMSAASamples(); + int index = 0; + for (int i = 0; i < 3; ++i) { + if (msaaSamples[i] == samples) { + index = i; + break; + } + } + ui->msaaCombo->setCurrentIndex(index); + } + ui->textureFilterCombo->setCurrentIndex(_model->getTextureFilter()); + { + const auto levels = _model->getAvailableAnisotropyLevels(); + const float anisotropy = _model->getAnisotropy(); + int index = 0; + for (size_t i = 0; i < levels.size(); ++i) { + if (levels[i] <= anisotropy) { + index = static_cast(i); + } + } + ui->anisotropyCombo->setCurrentIndex(index); + } + ui->gammaSpin->setValue(_model->getGamma()); + ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); ui->showSexpHelpMissionCutscenes->setChecked(_model->getShowSexpHelpMissionCutscenes()); @@ -202,6 +239,38 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { _model->setThemeMode(themeModeFromIndex(index)); } +void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { + _model->setEnablePostProcessing(checked); +} + +void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { + _model->setShadowQuality(index); +} + +void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { + _model->setAAMode(index); +} + +void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { + static constexpr int msaaSamples[] = { 0, 4, 8 }; + _model->setMSAASamples(msaaSamples[index]); +} + +void PreferencesDialog::on_textureFilterCombo_currentIndexChanged(int index) { + _model->setTextureFilter(index); +} + +void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { + const auto levels = _model->getAvailableAnisotropyLevels(); + if (index >= 0 && static_cast(index) < levels.size()) { + _model->setAnisotropy(levels[index]); + } +} + +void PreferencesDialog::on_gammaSpin_valueChanged(double value) { + _model->setGamma(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..cc1a5a6214b 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); diff --git a/qtfred/ui/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index c45e1a9ecb4..68b55ea5b3b 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -360,6 +360,249 @@ + + + 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. + + + + Disabled + + + + + Low (restart required) + + + + + Medium (restart required) + + + + + High (restart required) + + + + + Ultra (restart required) + + + + + + + + Anti-aliasing: + + + + + + + Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. + + + + None + + + + + FXAA Low + + + + + FXAA Medium + + + + + FXAA High + + + + + SMAA Low + + + + + SMAA Medium + + + + + SMAA High + + + + + SMAA Ultra + + + + + + + + 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. + + + + Off + + + + + 4x (restart required) + + + + + 8x (restart required) + + + + + + + + + + + Textures + + + + + + Texture filtering: + + + + + + + Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. + + + + Bilinear (restart required) + + + + + Trilinear (restart required) + + + + + + + + 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 From d6cfa2377918203c530edca1ae9edd366f984a00 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:07:16 +0200 Subject: [PATCH 03/17] Fix texture filtering silently defaulting to bilinear opengl_tcache_init() read GL_mipmap_filter out of TextureFilteringOption before seeding it from the legacy config key. That option's default_func returns GL_mipmap_filter itself, so with no persisted "Graphics.TextureFilter" value getValue() fell through to a still-zero-initialized global -- bilinear, where the config default is trilinear. Since in-game options are on by default, that affected every player who had never explicitly set the option, and it ignored the legacy TextureFilter key existing installs were configured through. Seed from config first, then let the option override, matching the order the anisotropy setting below already uses (its default_func queries the hardware directly, which is why it was never affected). Also route the option's value enumerator through the new shared gr_get_supported_anisotropy_levels() rather than a private copy. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 23 ++++++++++++++++ code/graphics/2d.h | 6 +++++ code/graphics/opengl/gropengltexture.cpp | 34 +++++------------------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index 4da3b6608dd..f9dd6285556 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 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 void parse_post_processing_func() { bool value; diff --git a/code/graphics/2d.h b/code/graphics/2d.h index be3ccdec745..2b2d9c8d5df 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -1415,6 +1415,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/opengl/gropengltexture.cpp b/code/graphics/opengl/gropengltexture.cpp index be217c97452..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,13 +167,13 @@ void opengl_tcache_init() // check what mipmap filter we should be using // 0 == Bilinear // 1 == Trilinear - // Route through the option when in-game options are active, same as the anisotropy setting - // below, so that overriding "Graphics.TextureFilter" (e.g. via OptionsManager::setOverride()) - // actually has an effect instead of being silently bypassed by this direct config read. + // 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(); - } else { - GL_mipmap_filter = os_config_read_uint(NULL, "TextureFilter", 1); } if (GL_mipmap_filter > 1) { From 7526893c93bece2484f6600deb719d4362015ce6 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:18:51 +0200 Subject: [PATCH 04/17] Grow the scene/post render targets when the viewport does The offscreen targets backing the scene texture and post-processing were allocated once from gr_screen at gr_init() and never revisited. Anything drawn while gr_screen was larger got clipped to their edge and stretched back over the viewport. The game hits this on the SDL window-resize path (osapi.cpp calls gr_screen_resize()); qtFRED hits it constantly, since its 3D viewport is a resizable dock widget. Add gf_resize_render_targets, called from gr_screen_resize(), and implement it for OpenGL: grow the targets to cover the new gr_screen, rebuilding only the resolution-dependent resources. The post-processing table, the compiled shaders and the SMAA lookup textures are resolution-independent and stay alive, which is what makes this cheap enough to run off a window drag. This mirrors what the Vulkan backend already does in VulkanPostProcessor::resize(); Vulkan leaves the hook unset because recreateSwapChain() owns it there. The allocation only ever grows: gr_screen_resize() is called every frame by qtFRED and repeatedly by the briefing map widget, so tracking the high-water mark avoids thrashing, and the shrunk state is already handled by Scene_texture_u_scale/v_scale. The size is also clamped to the hardware limit up front, so a viewport past that limit stops asking to be resized instead of rebuilding every frame. If the larger allocation fails outright -- most likely precisely when growing -- the resize stops before rebuilding the post-processing targets on top of scene textures that no longer exist. This replaces Gr_min_render_target_w/h, which sized the targets for the largest attached display up front -- on a 4K display at 2x scaling that was over a gigabyte of VRAM, allocated at launch, for a feature that is off by default and may never be switched on. Deletions now go through GL_state.Texture.Delete(): a freed texture name the state cache still holds would make a later Enable() of the recycled name a silent no-op. This mattered little when teardown only ran at shutdown. While there, the scene teardown now releases everything setup allocates -- it was leaking Scene_ldr/composite/luminance/Cockpit_depth and all six MSAA targets -- and post-processing shutdown releases the SMAA lookup textures. Separately, fix the sampling extents that the above makes reachable: - deferred-f.sdr turns gl_FragCoord into a G-buffer coordinate using invScreenWidth/Height, which described gr_screen rather than the G-buffer. Fixed in both backends; it is currently a no-op under Vulkan, where resize() keeps the extent equal to gr_screen, but states the requirement instead of relying on that. - the MSAA scene-colour copy, the MSAA resolve and the fog pass sampled the full [0,1] range of targets that are only filled to the u/v scale. - fxaa-v.sdr derived its texcoord from vertPosition, ignoring the sub-rectangle the draw call asked for. Those extents now come from opengl_draw_full_screen_scene_texture() rather than being open-coded, so a new pass cannot quietly reintroduce the bug. The volumetric nebula pass is deliberately left unscaled and commented: it uses fragTexCoord both to reconstruct a ray direction and to sample, which needs a shader change to separate. Co-Authored-By: Claude Opus 5 --- code/def_files/data/effects/fxaa-v.sdr | 12 +- code/graphics/2d.cpp | 10 +- code/graphics/2d.h | 18 +- code/graphics/opengl/gropengl.cpp | 3 +- code/graphics/opengl/gropengldeferred.cpp | 23 +- code/graphics/opengl/gropengldraw.cpp | 252 ++++++++++-------- code/graphics/opengl/gropengldraw.h | 14 +- .../opengl/gropenglpostprocessing.cpp | 145 +++++----- code/graphics/opengl/gropenglpostprocessing.h | 4 + .../vulkan/VulkanPostProcessingLighting.cpp | 8 +- qtfred/src/mission/management.cpp | 16 -- 11 files changed, 273 insertions(+), 232 deletions(-) diff --git a/code/def_files/data/effects/fxaa-v.sdr b/code/def_files/data/effects/fxaa-v.sdr index d7134bb5972..1b4e4d255c5 100644 --- a/code/def_files/data/effects/fxaa-v.sdr +++ b/code/def_files/data/effects/fxaa-v.sdr @@ -21,16 +21,8 @@ layout (std140) uniform genericData { void main() { gl_Position = vertPosition; v_rcpFrame = vec2(1.0/rt_w, 1.0/rt_h); - // Deriving this from vertPosition (clip-space -1..1 mapped to 0..1) instead of the actual - // texcoord attribute only samples the full [0,1] UV range, ignoring whatever sub-rectangle - // (u1,v1,u2,v2) the draw call actually asked for -- correct only when the source texture is - // the exact size of the destination viewport. qtFRED's post-processing targets are padded - // larger than its resizable viewport (see gr_opengl_scene_texture_begin()), so - // opengl_draw_full_screen_textured() is called with Scene_texture_u_scale/v_scale < 1 there; - // with the old formula FXAA re-samples that whole padded texture (valid content plus - // whatever's beyond it) squashed into the viewport, showing a shrunk duplicate of the scene - // in the corner matching the u/v scale. vertTexCoord already carries the correct (u1,v1).. - // (u2,v2) range, same as every other post-process pass's vertex shader (see post-v.sdr). + // 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/graphics/2d.cpp b/code/graphics/2d.cpp index f9dd6285556..f75ae3547ca 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -889,9 +889,6 @@ static void parse_post_processing_func() bool Gr_post_processing_enabled = true; -int Gr_min_render_target_w = 0; -int Gr_min_render_target_h = 0; - // coverity[GLOBAL_INIT_ORDER] -- safe; OptionBuilder::finish() uses Meyers singleton static auto PostProcessOption __UNUSED = options::OptionBuilder("Graphics.PostProcessing", std::pair{"Post processing", 1726}, @@ -1670,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 2b2d9c8d5df..e5f841e9ddb 100644 --- a/code/graphics/2d.h +++ b/code/graphics/2d.h @@ -73,18 +73,6 @@ bool gr_is_smaa_mode(AntiAliasMode mode); extern bool Gr_post_processing_enabled; -// Floor, in pixels, for the offscreen render targets that back post-processing (the scene textures -// and the post-processing surfaces). Those are sized once, from gr_screen.max_w/max_h at renderer -// init, and never revisited; anything drawn while gr_screen is *larger* than them is silently -// clipped to their edge and then stretched back over the full viewport. The game never hits that -// -- its window size is fixed after gr_init() -- but qtFred resizes its 3D viewport at runtime, so -// it sets this to the largest size that viewport can ever reach before calling gr_init(). Leave at -// 0 (the default) to size the targets purely from gr_screen, which is what the game does. The -// targets are still clamped to the hardware's maximum renderbuffer size, so this is a request, not -// a guarantee. -extern int Gr_min_render_target_w; -extern int Gr_min_render_target_h; - extern bool Gr_enable_vsync; // HDR10 (PQ/ST.2084 + BT.2020) output. Currently only honored by the Vulkan renderer. @@ -873,6 +861,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; 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 8ecfc3e82ec..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,12 +140,8 @@ void opengl_setup_scene_textures() return; } - // These textures are allocated once and never resized, so they have to be big enough for the - // largest gr_screen this session will ever see, not just the current one -- see - // Gr_min_render_target_w/h (2d.h) for why anything drawn outside them is stretched. In the game - // the floor is 0 and this is just gr_screen. - Scene_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); - Scene_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + Scene_texture_width = width; + Scene_texture_height = height; // clamp size, if needed if ( Scene_texture_width > GL_max_renderbuffer_size ) { @@ -129,13 +152,11 @@ void opengl_setup_scene_textures() Scene_texture_height = GL_max_renderbuffer_size; } - mprintf((" Scene textures: %dx%d (screen %dx%d, floor %dx%d, max renderbuffer %d)\n", + 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, - Gr_min_render_target_w, - Gr_min_render_target_h, GL_max_renderbuffer_size)); // create framebuffer @@ -346,32 +367,15 @@ void opengl_setup_scene_textures() if ( opengl_check_framebuffer() ) { GL_state.BindFrameBuffer(0); - glDeleteFramebuffers(1, &Scene_framebuffer); - Scene_framebuffer = 0; - - glDeleteTextures(1, &Scene_color_texture); - Scene_color_texture = 0; - - glDeleteTextures(1, &Scene_position_texture); - Scene_position_texture = 0; + opengl_delete_render_framebuffer(Scene_framebuffer); - 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; @@ -700,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; - } - - 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; - } + // 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 (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() @@ -789,26 +819,17 @@ void gr_opengl_scene_texture_begin() GL_state.PushFramebufferState(); GL_state.BindFrameBuffer(Scene_framebuffer); - // In the game Scene_texture_width/height always equals gr_screen.max_w/h (the - // scene texture is sized once from the same resolution at gr_init() and the - // game never resizes around it), so this ratio is always exactly 1.0 there. - // qtFred is the exception: its viewport is resized every frame - // (gr_screen_resize() in FredRenderer::render_frame()) to match a dockable - // widget that is smaller than the scene texture's fixed allocation (which - // Gr_min_render_target_w/h floors at the largest size that widget can reach), - // so the ratio keeps the render (and this end-of-frame blit) confined to the - // sub-rectangle of the texture that was actually drawn into, instead of - // stretching the whole texture -- most of it never touched this frame -- - // over the viewport-sized quad. + // 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); - // A ratio above 1.0 means the viewport outgrew the allocation after the fact -- the scene - // texture can't be resized, so all we can do is render the part that fits and let the blit - // stretch it back out, which misaligns everything by a different amount on each axis. The - // floor above is meant to make this unreachable; if it does happen (a display hotplug or - // resolution change after gr_init(), or an allocation clamped by GL_max_renderbuffer_size on - // low-end hardware) say so once rather than every frame. + // 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; @@ -1273,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 0f5056bc1d6..76dc1324709 100644 --- a/code/graphics/opengl/gropenglpostprocessing.cpp +++ b/code/graphics/opengl/gropenglpostprocessing.cpp @@ -28,6 +28,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 @@ -99,7 +102,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_v_scale); + opengl_draw_full_screen_scene_texture(); } void opengl_post_pass_bloom() @@ -134,16 +137,10 @@ void opengl_post_pass_bloom() GL_state.Texture.Enable(0, GL_TEXTURE_2D, Scene_color_texture); - // Unlike every other pass below, this reads directly from the scene - // texture rather than from an already-cropped intermediate (Bloom_textures - // is filled by this very call), so it has to confine itself to the - // sub-rectangle that was actually rendered into -- see the scale - // variables' own comment in gr_opengl_scene_texture_begin(). Hardcoding - // 1.0/1.0 here (as this used to) is only correct when the scene texture - // exactly matches the screen, which is not true of qtFred's dynamically - // resized viewport: it would smear/misplace the bloom halo relative to - // the scene it's supposed to be blooming. - opengl_draw_full_screen_textured(0.0f, 0.0f, Scene_texture_u_scale, Scene_texture_v_scale); + // 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 ------ @@ -302,7 +299,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_v_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)); @@ -319,7 +316,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_v_scale); + opengl_draw_full_screen_scene_texture(); opengl_shader_set_current(); } @@ -342,7 +339,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_v_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_calculate_blending_weights() @@ -367,7 +364,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_v_scale); + opengl_draw_full_screen_scene_texture(); } static void smaa_neighborhood_blending() @@ -390,7 +387,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_v_scale); + opengl_draw_full_screen_scene_texture(); } void smaa_resolve() @@ -500,7 +497,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_v_scale); + opengl_draw_full_screen_scene_texture(); GL_state.Blend(GL_FALSE); break; @@ -634,7 +631,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_v_scale); + opengl_draw_full_screen_scene_texture(); //Shadow Map debug window //#define SHADOW_DEBUG @@ -1029,79 +1026,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); - // Sized once and never resized, like the scene textures they consume, so they get the same - // floor -- see Gr_min_render_target_w/h (2d.h). - Post_texture_width = MAX(gr_screen.max_w, Gr_min_render_target_w); - Post_texture_height = MAX(gr_screen.max_h, Gr_min_render_target_h); + opengl_delete_render_texture(Smaa_blend_tex); + opengl_delete_render_framebuffer(Smaa_blending_weight_fb); - // clamp size, if needed - 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() @@ -1152,20 +1163,16 @@ 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); 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/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/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index b10ee7a4d2a..75a78e52619 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -42,8 +42,6 @@ #include #include -#include -#include #include #include @@ -131,20 +129,6 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; - // Unlike the game, whose window size is fixed after gr_init(), qtFred resizes its 3D viewport at - // runtime: FredRenderer::render_frame() calls gr_screen_resize() every frame to match a dockable, - // resizable widget. The offscreen render targets that back post-processing are allocated once, - // from gr_screen as it is right now, and cannot grow afterwards -- so give them a floor big - // enough for any size that widget can reach, which is the largest screen the window could be - // maximized or fullscreened onto, in device pixels. Without this, growing the viewport past the - // startup size clips the render to the old, smaller texture and then stretches it back over the - // new, larger viewport, visibly misaligning sun sprites and bloom. - for (const QScreen* screen : QGuiApplication::screens()) { - const auto ratio = screen->devicePixelRatio(); - - Gr_min_render_target_w = MAX(Gr_min_render_target_w, qRound(screen->size().width() * ratio)); - Gr_min_render_target_h = MAX(Gr_min_render_target_h, qRound(screen->size().height() * ratio)); - } // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked // into GPU resources during gr_init() and can't be changed afterwards without recreating From c40742842cbf635f6f6a213a49c77ce65fac9215 Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:20:42 +0200 Subject: [PATCH 05/17] Give qtFRED's graphics preferences a single owner The Preferences > Graphics settings were read from QSettings in two places -- management.cpp before gr_init(), and EditorViewport once the editor exists -- each with its own copy of the key strings and default values, so renaming a key in one would silently stop the other from working. The split between settings that apply live and settings that need a restart was recorded only in comments that had to stay in sync across five files. Move both into GraphicsSettings, which owns the keys, the defaults, and the two apply paths (applyLive() and applyBeforeGrInit()). ViewSettings holds one of these instead of seven loose fields. Behaviour fixes that fall out of having one reader: - Values are range-checked before being cast to ShadowQuality/AntiAliasMode and validated against the MSAA list, matching what the neighbouring DataMenuStyle load already did. A hand-edited settings file no longer produces an out-of-range enum. - The two OptionsManager overrides are now consistent. Texture filtering was overridden unconditionally with a hardcoded default while anisotropy was guarded, so a fresh install masked the engine's own texture-filter default for no reason. Both now use a sentinel for "the user has not chosen" and leave the engine option alone until there is a real choice. - gr_set_gamma(3.0f) is no longer duplicated as a literal next to the struct default. The dialog's value lists now come from the engine option definitions (Graphics.Shadows, Graphics.AAMode, Graphics.TextureFilter) instead of the hardcoded .ui items that duplicated them, and anisotropy uses the shared gr_get_supported_anisotropy_levels() rather than a near-verbatim copy of the engine's enumerator. Adding an AA mode upstream no longer silently desyncs qtFRED's dropdown. Note that the shadow-quality entries therefore lose the "(restart required)" suffix they carried in the .ui; the control's tooltip and the help page still say it. Populating combos now blocks signals: setupUi() has already run connectSlotsByName(), so filling them would otherwise fire the change slots and mark the model modified before the user touched anything. Finally, render_frame() gets its three correlated EnablePostProcessing branches replaced by a scoped ScenePostProcessing guard, and the shadow pass -- with the HTL matrix-stack dance it needs -- moves into its own named function. Co-Authored-By: Claude Opus 5 --- qtfred/AGENTS.md | 10 ++ qtfred/source_groups.cmake | 2 + qtfred/src/mission/EditorViewport.cpp | 39 +---- qtfred/src/mission/EditorViewport.h | 29 +--- qtfred/src/mission/FredRenderer.cpp | 72 ++++++---- qtfred/src/mission/GraphicsSettings.cpp | 132 +++++++++++++++++ qtfred/src/mission/GraphicsSettings.h | 81 +++++++++++ .../dialogs/PreferencesDialogModel.cpp | 59 +------- .../mission/dialogs/PreferencesDialogModel.h | 38 +---- qtfred/src/mission/management.cpp | 42 ++---- qtfred/src/ui/FredView.cpp | 2 +- qtfred/src/ui/dialogs/PreferencesDialog.cpp | 135 +++++++++++++----- qtfred/src/ui/dialogs/PreferencesDialog.h | 2 + qtfred/ui/PreferencesDialog.ui | 90 ------------ 14 files changed, 399 insertions(+), 334 deletions(-) create mode 100644 qtfred/AGENTS.md create mode 100644 qtfred/src/mission/GraphicsSettings.cpp create mode 100644 qtfred/src/mission/GraphicsSettings.h 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/source_groups.cmake b/qtfred/source_groups.cmake index 6718b68a21a..2bd6c19312c 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 diff --git a/qtfred/src/mission/EditorViewport.cpp b/qtfred/src/mission/EditorViewport.cpp index 28b58a13f26..bc289f83833 100644 --- a/qtfred/src/mission/EditorViewport.cpp +++ b/qtfred/src/mission/EditorViewport.cpp @@ -16,7 +16,6 @@ #include #include #include -#include namespace { @@ -123,7 +122,7 @@ EditorViewport::EditorViewport(Editor* in_editor, std::unique_ptr& syncMissionLayerNames(); loadSettings(); - applyGraphicsSettings(); + view.Graphics.applyLive(); fredApp->runAfterInit([this]() { initialSetup(); }); } @@ -179,24 +178,11 @@ void EditorViewport::loadSettings() { view.Show_compass = settings.value("view_show_compass", view.Show_compass).toBool(); view.Highlight_selectable_subsys = settings.value("view_highlight_selectable_subsys", view.Highlight_selectable_subsys).toBool(); view.Outline_lod = settings.value("view_outline_lod", view.Outline_lod).toInt(); - view.EnablePostProcessing = settings.value("view_enable_post_processing", view.EnablePostProcessing).toBool(); - view.Graphics_shadow_quality = settings.value("view_graphics_shadow_quality", view.Graphics_shadow_quality).toInt(); - view.Graphics_aa_mode = settings.value("view_graphics_aa_mode", view.Graphics_aa_mode).toInt(); - view.Graphics_msaa_samples = settings.value("view_graphics_msaa_samples", view.Graphics_msaa_samples).toInt(); - view.Graphics_texture_filter = settings.value("view_graphics_texture_filter", view.Graphics_texture_filter).toInt(); - { - // Default to the hardware's max anisotropy the first time qtFRED runs, so the - // viewport looks the same as it always has until the user turns this control down. - float maxAnisotropy = 1.0f; - if (gr_get_property(gr_property::MAX_ANISOTROPY, &maxAnisotropy)) { - view.Graphics_anisotropy = maxAnisotropy; - } - view.Graphics_anisotropy = settings.value("view_graphics_anisotropy", view.Graphics_anisotropy).toFloat(); - } - view.Graphics_gamma = settings.value("view_graphics_gamma", view.Graphics_gamma).toFloat(); 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 { @@ -242,28 +228,11 @@ void EditorViewport::saveSettings() const { settings.setValue("view_show_compass", view.Show_compass); settings.setValue("view_highlight_selectable_subsys", view.Highlight_selectable_subsys); settings.setValue("view_outline_lod", view.Outline_lod); - settings.setValue("view_enable_post_processing", view.EnablePostProcessing); - settings.setValue("view_graphics_shadow_quality", view.Graphics_shadow_quality); - settings.setValue("view_graphics_aa_mode", view.Graphics_aa_mode); - settings.setValue("view_graphics_msaa_samples", view.Graphics_msaa_samples); - settings.setValue("view_graphics_texture_filter", view.Graphics_texture_filter); - settings.setValue("view_graphics_anisotropy", view.Graphics_anisotropy); - settings.setValue("view_graphics_gamma", view.Graphics_gamma); settings.setValue("camera_invert_orbit_x", camera.getInvertOrbitX()); settings.setValue("camera_invert_orbit_y", camera.getInvertOrbitY()); settings.endGroup(); -} -void EditorViewport::applyGraphicsSettings() const { - // Shadow_quality is deliberately not set here. Turning shadows on requires allocating the - // shadow framebuffer and sizing the cascade-parameter buffers (shadow_cascade_params_init(), - // gropengltnl.cpp/gr_vulkan.cpp), which only happens once at gr_init() and only if - // Shadow_quality is already non-Disabled at that point -- flipping the global afterwards - // leaves those buffers empty/unsized and segfaults the next time a frame tries to render - // shadows. Applied before gr_init() instead (see management.cpp), same as MSAA samples, - // texture filter, and anisotropy; the Preferences UI notes it needs a restart. - Gr_aa_mode = static_cast(view.Graphics_aa_mode); - gr_set_gamma(view.Graphics_gamma); + view.Graphics.save(); } void EditorViewport::needsUpdate() { diff --git a/qtfred/src/mission/EditorViewport.h b/qtfred/src/mission/EditorViewport.h index c50ef0f9ccd..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" @@ -56,12 +57,6 @@ struct ViewSettings { bool Show_paths_fred = false; bool Lighting_on = false; bool FullDetail = false; - // Runs the viewport through the same HDR scene-texture + post-processing - // pipeline the game uses (bloom, tonemapping, lightshafts), instead of - // rendering straight to the default framebuffer. - // Off by default so existing missions keep looking exactly as they do today - // unless a FRED user opts in. - bool EnablePostProcessing = false; bool Show_waypoints = true; bool Show_props = true; bool Show_jump_nodes = true; @@ -69,18 +64,8 @@ struct ViewSettings { bool Highlight_selectable_subsys = false; int Outline_lod = 1; - // Graphics (Preferences > Graphics tab). All quality/effect settings below are only - // visible in the viewport while EnablePostProcessing is on, since they're consumed by - // the same HDR scene-texture + post-processing pipeline that flag gates. - int Graphics_shadow_quality = 0; // ShadowQuality: 0=Disabled, 1=Low, 2=Medium, 3=High, 4=Ultra - int Graphics_aa_mode = 0; // AntiAliasMode: 0=None .. 7=SMAA Ultra - // The following three are baked into GPU resources at gr_init() time and can only be - // changed by restarting qtFRED; qtFRED applies the saved value before gr_init() runs - // (see management.cpp), and the Preferences UI notes this in each control's tooltip. - int Graphics_msaa_samples = 0; // 0, 4, or 8 - int Graphics_texture_filter = 1; // 0=Bilinear, 1=Trilinear - float Graphics_anisotropy = 1.0f; // populated from the hardware max the first time qtFRED runs - float Graphics_gamma = 3.0f; // matches the brightness qtFRED has always launched with + //! Preferences > Graphics. Owns its own persistence and apply rules; see GraphicsSettings. + GraphicsSettings Graphics; ViewSettings(); }; @@ -262,14 +247,6 @@ class EditorViewport { void saveSettings() const; - // Pushes the live-appliable subset of view.Graphics_* (shadow quality, AA mode, gamma) - // into the engine globals that actually drive rendering. Called once at startup, after - // loadSettings(), and again from PreferencesDialogModel::apply() whenever the user - // changes one of those controls. Graphics_msaa_samples/Graphics_texture_filter/ - // Graphics_anisotropy are deliberately not included here -- those can only be applied - // before gr_init() (see management.cpp) and need a restart to change. - void applyGraphicsSettings() const; - Editor* editor = nullptr; FredRenderer* renderer = nullptr; IDialogProvider* dialogProvider = nullptr; diff --git a/qtfred/src/mission/FredRenderer.cpp b/qtfred/src/mission/FredRenderer.cpp index 760de615b6a..9ee0517f3a2 100644 --- a/qtfred/src/mission/FredRenderer.cpp +++ b/qtfred/src/mission/FredRenderer.cpp @@ -29,6 +29,8 @@ #include #include +#include + #include "mission/object.h" #include "prop/prop.h" #include "weapon/weapon.h" @@ -57,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; @@ -995,13 +1029,13 @@ 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 scene through the game's HDR post-processing pipeline - // (bloom, tonemapping, lightshafts) instead of drawing straight to the - // default framebuffer. Brackets only the 3D world content, - // the same way freespace.cpp's game_render_frame() brackets its own scene -- - // the 2D overlays below (distances, ship info, tooltips) stay outside it. - if (view().EnablePostProcessing) { - gr_scene_texture_begin(); + // 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 @@ -1013,24 +1047,8 @@ void FredRenderer::render_frame(int cur_object_index, disable_htl(); Detail.num_stars = saved_detail_stars; - // Shadows piggyback on the same HDR scene-texture pipeline as post-processing above -- the - // shadow pass writes into deferred G-buffer surfaces that only exist while that's bound, so - // it can only run under EnablePostProcessing. Mirrors freespace.cpp's game_render_frame(), - // which calls this right after its own gr_scene_texture_begin()+stars_draw(). Eye_position/ - // Eye_matrix/Proj_fov were already set to FRED's camera by the g3_set_view_matrix() call - // above, but shadows_render_all() operates on the separate HTL proj/view matrix stack (the - // same one enable_htl()/disable_htl() push and pop for the starfield): it starts by ending - // whatever's currently active (Assert(modelview_matrix_depth == 2) in gr_end_view_matrix()), - // then restores it via gr_set_proj_matrix()/gr_set_view_matrix() when done. disable_htl() - // just popped that stack, so it has to be pushed again here first -- and popped again - // afterward, since FRED's grid/model rendering below never touches the HTL stack and the - // *next* frame's enable_htl() would itself assert if it were left open. - if (view().EnablePostProcessing) { - 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(); + if (postProcessing) { + render_shadows(); } if (view().Show_horizon) { @@ -1049,9 +1067,7 @@ void FredRenderer::render_frame(int cur_object_index, render_models(cur_object_index); render_volumetric_overlay(); - if (view().EnablePostProcessing) { - gr_scene_texture_end(); - } + 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/PreferencesDialogModel.cpp b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp index 74ae5252400..57681573958 100644 --- a/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp +++ b/qtfred/src/mission/dialogs/PreferencesDialogModel.cpp @@ -4,7 +4,6 @@ #include "ui/Theme.h" #include "mission/missiongrid.h" #include "math/vecmat.h" -#include "graphics/2d.h" namespace fso::fred::dialogs { @@ -27,13 +26,7 @@ PreferencesDialogModel::PreferencesDialogModel(QObject* parent, EditorViewport* , _dataMenuStyle(viewport->Data_menu_style) , _toolbarIconSize(viewport->toolbar_icon_size) , _outlineLod(viewport->view.Outline_lod) - , _enablePostProcessing(viewport->view.EnablePostProcessing) - , _shadowQuality(viewport->view.Graphics_shadow_quality) - , _aaMode(viewport->view.Graphics_aa_mode) - , _msaaSamples(viewport->view.Graphics_msaa_samples) - , _textureFilter(viewport->view.Graphics_texture_filter) - , _anisotropy(viewport->view.Graphics_anisotropy) - , _gamma(viewport->view.Graphics_gamma) + , _graphics(viewport->view.Graphics) , _invertOrbitX(viewport->camera.getInvertOrbitX()) , _invertOrbitY(viewport->camera.getInvertOrbitY()) , _gridCenterX(static_cast(viewport->The_grid->center.xyz.x)) @@ -76,16 +69,9 @@ bool PreferencesDialogModel::apply() { _viewport->Data_menu_style = _dataMenuStyle; _viewport->toolbar_icon_size = _toolbarIconSize; _viewport->view.Outline_lod = _outlineLod; - _viewport->view.EnablePostProcessing = _enablePostProcessing; - _viewport->view.Graphics_shadow_quality = _shadowQuality; - _viewport->view.Graphics_aa_mode = _aaMode; - _viewport->view.Graphics_msaa_samples = _msaaSamples; - _viewport->view.Graphics_texture_filter = _textureFilter; - _viewport->view.Graphics_anisotropy = _anisotropy; - _viewport->view.Graphics_gamma = _gamma; - // AA mode and gamma take effect immediately; shadow quality, MSAA samples, texture filter, - // and anisotropy are only applied at startup (see management.cpp) and need a restart. - _viewport->applyGraphicsSettings(); + _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); @@ -198,41 +184,8 @@ void PreferencesDialogModel::setToolbarIconSize(int size) { modify(_toolbarIconS int PreferencesDialogModel::getOutlineLod() const { return _outlineLod; } void PreferencesDialogModel::setOutlineLod(int value) { modify(_outlineLod, value); } -bool PreferencesDialogModel::getEnablePostProcessing() const { return _enablePostProcessing; } -void PreferencesDialogModel::setEnablePostProcessing(bool value) { modify(_enablePostProcessing, value); } - -int PreferencesDialogModel::getShadowQuality() const { return _shadowQuality; } -void PreferencesDialogModel::setShadowQuality(int value) { modify(_shadowQuality, value); } - -int PreferencesDialogModel::getAAMode() const { return _aaMode; } -void PreferencesDialogModel::setAAMode(int value) { modify(_aaMode, value); } - -int PreferencesDialogModel::getMSAASamples() const { return _msaaSamples; } -void PreferencesDialogModel::setMSAASamples(int value) { modify(_msaaSamples, value); } - -int PreferencesDialogModel::getTextureFilter() const { return _textureFilter; } -void PreferencesDialogModel::setTextureFilter(int value) { modify(_textureFilter, value); } - -float PreferencesDialogModel::getAnisotropy() const { return _anisotropy; } -void PreferencesDialogModel::setAnisotropy(float value) { modify(_anisotropy, value); } - -SCP_vector PreferencesDialogModel::getAvailableAnisotropyLevels() const { - SCP_vector levels; - - float max = 1.0f; - if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max) || max <= 1.0f) { - levels.push_back(1.0f); - return levels; - } - - for (float level = 1.0f; level <= max; level *= 2.0f) { - levels.push_back(level); - } - return levels; -} - -float PreferencesDialogModel::getGamma() const { return _gamma; } -void PreferencesDialogModel::setGamma(float value) { modify(_gamma, 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); diff --git a/qtfred/src/mission/dialogs/PreferencesDialogModel.h b/qtfred/src/mission/dialogs/PreferencesDialogModel.h index c46d915ef36..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,30 +68,10 @@ class PreferencesDialogModel : public AbstractDialogModel { int getOutlineLod() const; void setOutlineLod(int value); - // Graphics - bool getEnablePostProcessing() const; - void setEnablePostProcessing(bool value); - - int getShadowQuality() const; - void setShadowQuality(int value); - - int getAAMode() const; - void setAAMode(int value); - - int getMSAASamples() const; - void setMSAASamples(int value); - - int getTextureFilter() const; - void setTextureFilter(int value); - - float getAnisotropy() const; - void setAnisotropy(float value); - // Anisotropy levels the current hardware actually supports (1.0 = off), for populating the - // combo box: 1x, 2x, 4x, ... up to the hardware max. - SCP_vector getAvailableAnisotropyLevels() const; - - float getGamma() const; - void setGamma(float 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; @@ -134,14 +115,7 @@ class PreferencesDialogModel : public AbstractDialogModel { int _toolbarIconSize; int _outlineLod; - // Graphics - bool _enablePostProcessing; - int _shadowQuality; - int _aaMode; - int _msaaSamples; - int _textureFilter; - float _anisotropy; - float _gamma; + GraphicsSettings _graphics; // Controls std::map _controlKeys; diff --git a/qtfred/src/mission/management.cpp b/qtfred/src/mission/management.cpp index 75a78e52619..db32e78246d 100644 --- a/qtfred/src/mission/management.cpp +++ b/qtfred/src/mission/management.cpp @@ -3,14 +3,14 @@ #include "object.h" +#include "mission/GraphicsSettings.h" + #include "cmdline/cmdline.h" #include #include #include #include -#include -#include #include #include #include @@ -27,7 +27,6 @@ #include #include #include -#include #include #include #include @@ -42,7 +41,6 @@ #include #include -#include #include extern bool Xstr_inited; @@ -129,38 +127,16 @@ initialize(const std::string& cfilepath, int argc, char* argv[], Editor* editor, // Cmdline_noglow = 1; Cmdline_window = 1; - - // MSAA sample count, shadow quality, and the texture filter/anisotropy defaults are baked - // into GPU resources during gr_init() and can't be changed afterwards without recreating - // them, so qtFRED has to apply its saved Preferences > Graphics values here, before gr_init() - // runs, rather than from EditorViewport (which doesn't exist yet -- it's created after the - // first viewport widget is up). Shadow quality specifically: shadow_cascade_params_init() - // (gropengltnl.cpp/gr_vulkan.cpp) only runs during gr_init(), and only if Shadow_quality is - // already non-Disabled at that point -- setting it afterwards leaves the cascade buffers - // unsized and segfaults the next time a frame renders shadows. Read directly out of QSettings - // under the same "Preferences" group EditorViewport uses, using an in-memory-only - // OptionsManager override for the two settings the engine reads through an Option - // (Graphics.TextureFilter, Graphics.Anisotropy) so this never rewrites the registry/config - // file the actual game reads its own graphics settings from. - { - QSettings settings; - settings.beginGroup("Preferences"); - Cmdline_msaa_enabled = settings.value("view_graphics_msaa_samples", Cmdline_msaa_enabled).toInt(); - Shadow_quality = static_cast(settings.value("view_graphics_shadow_quality", 0).toInt()); - - const int textureFilter = settings.value("view_graphics_texture_filter", 1).toInt(); - options::OptionsManager::instance()->setOverride("Graphics.TextureFilter", std::to_string(textureFilter)); - - if (settings.contains("view_graphics_anisotropy")) { - const float anisotropy = settings.value("view_graphics_anisotropy").toFloat(); - options::OptionsManager::instance()->setOverride("Graphics.Anisotropy", std::to_string(anisotropy)); - } - settings.endGroup(); - } + // 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 29ffd80cbf7..4bbe97d6e41 100644 --- a/qtfred/src/ui/FredView.cpp +++ b/qtfred/src/ui/FredView.cpp @@ -942,7 +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.EnablePostProcessing); + connectActionToViewSetting(ui->actionEnable_Post_Processing, &_viewport->view.Graphics.enablePostProcessing); connectActionToViewSetting(ui->actionShowDistances, &_viewport->view.Show_distances); diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.cpp b/qtfred/src/ui/dialogs/PreferencesDialog.cpp index fd765fa548f..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,11 +139,26 @@ void PreferencesDialog::applyChanges() { } void PreferencesDialog::initializeUi() { - // Populate the anisotropy combo with the levels the current hardware actually supports - // (1x/2x/4x/.../max), since that max varies by GPU. - for (float level : _model->getAvailableAnisotropyLevels()) { + // 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); @@ -133,34 +193,32 @@ void PreferencesDialog::updateUi() { ui->outlineLodCombo->setCurrentIndex(_model->getOutlineLod()); // Graphics - ui->enablePostProcessing->setChecked(_model->getEnablePostProcessing()); - ui->shadowQualityCombo->setCurrentIndex(_model->getShadowQuality()); - ui->aaModeCombo->setCurrentIndex(_model->getAAMode()); - { - static constexpr int msaaSamples[] = { 0, 4, 8 }; - const int samples = _model->getMSAASamples(); - int index = 0; - for (int i = 0; i < 3; ++i) { - if (msaaSamples[i] == samples) { - index = i; - break; - } - } - ui->msaaCombo->setCurrentIndex(index); - } - ui->textureFilterCombo->setCurrentIndex(_model->getTextureFilter()); - { - const auto levels = _model->getAvailableAnisotropyLevels(); - const float anisotropy = _model->getAnisotropy(); - int index = 0; - for (size_t i = 0; i < levels.size(); ++i) { - if (levels[i] <= anisotropy) { + 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->gammaSpin->setValue(_model->getGamma()); ui->showSexpHelpMissionEvents->setChecked(_model->getShowSexpHelpMissionEvents()); ui->showSexpHelpMissionGoals->setChecked(_model->getShowSexpHelpMissionGoals()); @@ -240,35 +298,40 @@ void PreferencesDialog::on_themeCombo_currentIndexChanged(int index) { } void PreferencesDialog::on_enablePostProcessing_toggled(bool checked) { - _model->setEnablePostProcessing(checked); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.enablePostProcessing = checked; }); } void PreferencesDialog::on_shadowQualityCombo_currentIndexChanged(int index) { - _model->setShadowQuality(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.shadowQuality = static_cast(index); }); } void PreferencesDialog::on_aaModeCombo_currentIndexChanged(int index) { - _model->setAAMode(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.aaMode = static_cast(index); }); } void PreferencesDialog::on_msaaCombo_currentIndexChanged(int index) { - static constexpr int msaaSamples[] = { 0, 4, 8 }; - _model->setMSAASamples(msaaSamples[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) { - _model->setTextureFilter(index); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.textureFilter = index; }); } void PreferencesDialog::on_anisotropyCombo_currentIndexChanged(int index) { - const auto levels = _model->getAvailableAnisotropyLevels(); - if (index >= 0 && static_cast(index) < levels.size()) { - _model->setAnisotropy(levels[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) { - _model->setGamma(static_cast(value)); + editGraphics(_model.get(), [=](GraphicsSettings& g) { g.gamma = static_cast(value); }); } void PreferencesDialog::on_dataMenuStyleCombo_currentIndexChanged(int index) { diff --git a/qtfred/src/ui/dialogs/PreferencesDialog.h b/qtfred/src/ui/dialogs/PreferencesDialog.h index cc1a5a6214b..a4ed07e2614 100644 --- a/qtfred/src/ui/dialogs/PreferencesDialog.h +++ b/qtfred/src/ui/dialogs/PreferencesDialog.h @@ -71,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/PreferencesDialog.ui b/qtfred/ui/PreferencesDialog.ui index 68b55ea5b3b..e06b97af410 100644 --- a/qtfred/ui/PreferencesDialog.ui +++ b/qtfred/ui/PreferencesDialog.ui @@ -393,31 +393,6 @@ 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. - - - Disabled - - - - - Low (restart required) - - - - - Medium (restart required) - - - - - High (restart required) - - - - - Ultra (restart required) - - @@ -432,46 +407,6 @@ Post-process anti-aliasing mode used in the viewport. Requires post-processing to be enabled. - - - None - - - - - FXAA Low - - - - - FXAA Medium - - - - - FXAA High - - - - - SMAA Low - - - - - SMAA Medium - - - - - SMAA High - - - - - SMAA Ultra - - @@ -486,21 +421,6 @@ 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. - - - Off - - - - - 4x (restart required) - - - - - 8x (restart required) - - @@ -524,16 +444,6 @@ Mipmap filtering used for textures. Restarting qtFRED is required for a change to take effect. - - - Bilinear (restart required) - - - - - Trilinear (restart required) - - From 3bdb63b3efa9385b009390ab90ed3d25052a962b Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:41:59 +0200 Subject: [PATCH 06/17] Document the Graphics preferences in the help that ships The Graphics tab was documented in help-src/doc/dialogs/PreferencesDialog.html, which is not listed in doc/qtfred.qhp and so is never compiled into qtfred_help.qch. It is a stale duplicate of general/PreferencesDialog.html -- the page the table of contents, the Preferences keyword, and Viewport.html all point at -- and one of only two orphaned files under help-src/doc. None of the new documentation reached the Help viewer. Document the tab on the page that actually ships, and revert the edit to the orphan so nothing is left stranded there. The orphan itself predates this branch and is left alone; it should probably be deleted, but that is a separate question from this branch. The content is also corrected against what the settings now do: - Shadow quality requires a restart. The orphan did not say so, and the dropdown entries no longer carry a "(restart required)" suffix now that their labels come from the engine's own option definition. - Texture filtering and anisotropy default to the engine's own choice (the config default, and the hardware maximum) until the user picks one. - Anisotropy is disabled outright on hardware that does not support it. - Gamma documents its actual range and default. - The page's "changes take effect immediately" claim is qualified, since it is not true of the four restart-only settings. Also notes the View > Enable Post Processing menu equivalent, which had no documentation anywhere -- no help page covers the View menu's display toggles. Co-Authored-By: Claude Opus 5 --- .../doc/dialogs/PreferencesDialog.html | 23 ---------- .../doc/general/PreferencesDialog.html | 46 ++++++++++++++++++- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/qtfred/help-src/doc/dialogs/PreferencesDialog.html b/qtfred/help-src/doc/dialogs/PreferencesDialog.html index f4b2a5f043e..88783286106 100644 --- a/qtfred/help-src/doc/dialogs/PreferencesDialog.html +++ b/qtfred/help-src/doc/dialogs/PreferencesDialog.html @@ -25,29 +25,6 @@

General

theme.
SettingDescription
-

Graphics

- - - - - - - - - -
SettingDescription
Enable post-processing in the viewportRoutes the 3D viewport through - the game's HDR post-processing pipeline (bloom, tonemapping, lightshafts) instead - of drawing straight to the screen. Off by default. Every other setting on this tab - only has a visible effect while this is on.
Shadow qualityQuality of the shadows cast by the mission's sun in the - viewport.
Anti-aliasingPost-process anti-aliasing mode (FXAA/SMAA) used in the - viewport.
MSAAMultisample anti-aliasing for the viewport's 3D scene. Requires - restarting QtFRED to take effect.
Texture filteringMipmap filtering used for textures (bilinear or - trilinear). Requires restarting QtFRED to take effect.
Anisotropic filteringAnisotropic texture filtering level, up to the - maximum the current GPU supports. Requires restarting QtFRED to take - effect.
GammaBrightness of the viewport. QtFRED defaults this higher than the - game, since the viewport isn't normally tonemapped unless post-processing is - on.
-

Grid

diff --git a/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index 8a8ca2e3e84..907ea9961a4 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,48 @@

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, lightshafts, and shadows. 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. Requires a + restart.
  • +
  • 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 is + disabled entirely if it does not support the feature. 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 From 46db0d0fffd8de35ba18a16dd4c2c0555942f92f Mon Sep 17 00:00:00 2001 From: the-e Date: Thu, 30 Jul 2026 20:46:31 +0200 Subject: [PATCH 07/17] Update the design notes and help for the resize rework documentation/qtfred-post-processing-viewport-resize.md still described the Gr_min_render_target_w/h floor, which no longer exists, and listed reallocate-on-resize as rejected -- which is now the implemented approach. Rewrite it around gf_resize_render_targets, and keep the two failed attempts as history, because the reason they failed is the reason the current one works. That document had already identified the prerequisite correctly: the scene teardown left FBOs attached to stale texture handles, and draws to an incomplete FBO go nowhere, hence the black viewport. Completing the teardown was what made reallocation viable. Also records the two properties that are easy to get wrong (clamp to the hardware limit before deciding whether to resize, or a viewport past that limit rebuilds every frame; never resize mid-frame), the sites fixed in the deferred and Vulkan paths, and why the volumetric nebula pass is deliberately left unscaled. Corrections to the Preferences help page from the same review: - Enabling post-processing does not by itself produce shadows. Shadow quality defaults to Disabled and needs a restart to change, so the page said the opposite of what a user will experience. - The anisotropy control also greys out when the GPU reports a maximum of 2x, not only when it lacks the feature outright. The help keyword index is left alone: keywords there are page-level, with multiple entries only ever used as synonyms for one page, so per-setting keywords would be inventing a convention rather than following one. Finally, note in the qtfred module guide that its viewport calls gr_screen_resize() every frame -- the assumption whose violation caused all of the above -- and where the graphics preferences now live. Co-Authored-By: Claude Opus 5 --- code/graphics/2d.cpp | 4 +- .../qtfred-post-processing-viewport-resize.md | 248 ++++++++++++++++++ .../doc/general/PreferencesDialog.html | 16 +- 3 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 documentation/qtfred-post-processing-viewport-resize.md diff --git a/code/graphics/2d.cpp b/code/graphics/2d.cpp index f75ae3547ca..1056434a463 100644 --- a/code/graphics/2d.cpp +++ b/code/graphics/2d.cpp @@ -860,11 +860,11 @@ SCP_vector gr_get_supported_anisotropy_levels() { float max; if (!gr_get_property(gr_property::MAX_ANISOTROPY, &max)) { - return SCP_vector(); + return {}; } if (max <= 2.0f) { - return SCP_vector(); + return {}; } SCP_vector out; 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/qtfred/help-src/doc/general/PreferencesDialog.html b/qtfred/help-src/doc/general/PreferencesDialog.html index 907ea9961a4..cb4bfca04ba 100644 --- a/qtfred/help-src/doc/general/PreferencesDialog.html +++ b/qtfred/help-src/doc/general/PreferencesDialog.html @@ -40,8 +40,8 @@

Graphics

Post-processing

Enable post-processing in the viewport routes the viewport through the same HDR rendering pipeline the game uses, adding bloom, -tonemapping, lightshafts, and shadows. It is off by default, so missions look -the way they always have until you opt in. The same switch is available as +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.

@@ -49,8 +49,10 @@

Post-processing

Shadows & anti-aliasing

  • Shadow quality - detail level of the shadows cast by the - mission's sun. Higher settings cost more performance. Requires a - restart.
  • + 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 @@ -64,9 +66,9 @@

    Textures

    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 is - disabled entirely if it does not support the feature. Left alone, QtFRED - uses your hardware's maximum. Requires a restart.
  • + 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

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